diff --git a/apps/aelis-backend/src/admin/http.test.ts b/apps/aelis-backend/src/admin/http.test.ts new file mode 100644 index 0000000..60ef7c8 --- /dev/null +++ b/apps/aelis-backend/src/admin/http.test.ts @@ -0,0 +1,162 @@ +import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" + +import { Hono } from "hono" +import { describe, expect, test } from "bun:test" + +import type { AdminMiddleware } from "../auth/admin-middleware.ts" +import type { AuthSession, AuthUser } from "../auth/session.ts" +import type { Database } from "../db/index.ts" +import type { FeedSourceProvider } from "../session/feed-source-provider.ts" + +import { UserSessionManager } from "../session/user-session-manager.ts" +import { registerAdminHttpHandlers } from "./http.ts" + +function createStubSource(id: string): FeedSource { + return { + id, + async listActions(): Promise> { + return {} + }, + async executeAction(): Promise { + return undefined + }, + async fetchContext(): Promise { + return null + }, + async fetchItems(): Promise { + return [] + }, + } +} + +function createStubProvider(sourceId: string): FeedSourceProvider { + return { + sourceId, + async feedSourceForUser() { + return createStubSource(sourceId) + }, + } +} + +/** Passthrough admin middleware for testing (assumes admin). */ +function passthroughAdminMiddleware(): AdminMiddleware { + const now = new Date() + return async (c, next) => { + c.set("user", { + id: "admin-1", + name: "Admin", + email: "admin@test.com", + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + role: "admin", + banned: false, + banReason: null, + banExpires: null, + } as AuthUser) + c.set("session", { id: "sess-1" } as AuthSession) + await next() + } +} + +const fakeDb = {} as Database + +function createApp(providers: FeedSourceProvider[]) { + const sessionManager = new UserSessionManager({ providers }) + const app = new Hono() + registerAdminHttpHandlers(app, { + sessionManager, + adminMiddleware: passthroughAdminMiddleware(), + db: fakeDb, + }) + return { app, sessionManager } +} + +const validWeatherConfig = { + credentials: { + privateKey: "pk-123", + keyId: "key-456", + teamId: "team-789", + serviceId: "svc-abc", + }, +} + +describe("PUT /api/admin/:sourceId/config", () => { + test("returns 404 for unknown provider", async () => { + const { app } = createApp([createStubProvider("aelis.location")]) + + const res = await app.request("/api/admin/aelis.nonexistent/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "value" }), + }) + + expect(res.status).toBe(404) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not found") + }) + + test("returns 404 for provider without runtime config support", async () => { + const { app } = createApp([createStubProvider("aelis.location")]) + + const res = await app.request("/api/admin/aelis.location/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: "value" }), + }) + + expect(res.status).toBe(404) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not found") + }) + + test("returns 400 for invalid JSON body", async () => { + const { app } = createApp([createStubProvider("aelis.weather")]) + + const res = await app.request("/api/admin/aelis.weather/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not json", + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("Invalid JSON") + }) + + test("returns 400 when weather config fails validation", async () => { + const { app } = createApp([createStubProvider("aelis.weather")]) + + const res = await app.request("/api/admin/aelis.weather/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ credentials: { privateKey: 123 } }), + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBeDefined() + }) + + test("returns 204 and applies valid weather config", async () => { + const { app, sessionManager } = createApp([createStubProvider("aelis.weather")]) + + const originalProvider = sessionManager.getProvider("aelis.weather") + + const res = await app.request("/api/admin/aelis.weather/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(validWeatherConfig), + }) + + expect(res.status).toBe(204) + + // Provider was replaced with a new instance + const provider = sessionManager.getProvider("aelis.weather") + expect(provider).toBeDefined() + expect(provider!.sourceId).toBe("aelis.weather") + expect(provider).not.toBe(originalProvider) + }) + +}) diff --git a/apps/aelis-backend/src/admin/http.ts b/apps/aelis-backend/src/admin/http.ts new file mode 100644 index 0000000..52921fc --- /dev/null +++ b/apps/aelis-backend/src/admin/http.ts @@ -0,0 +1,88 @@ +import type { Context, Hono } from "hono" + +import { type } from "arktype" +import { createMiddleware } from "hono/factory" + +import type { AdminMiddleware } from "../auth/admin-middleware.ts" +import type { Database } from "../db/index.ts" +import type { UserSessionManager } from "../session/index.ts" + +import { WeatherSourceProvider } from "../weather/provider.ts" + +type Env = { + Variables: { + sessionManager: UserSessionManager + db: Database + } +} + +interface AdminHttpHandlersDeps { + sessionManager: UserSessionManager + adminMiddleware: AdminMiddleware + db: Database +} + +export function registerAdminHttpHandlers( + app: Hono, + { sessionManager, adminMiddleware, db }: AdminHttpHandlersDeps, +) { + const inject = createMiddleware(async (c, next) => { + c.set("sessionManager", sessionManager) + c.set("db", db) + await next() + }) + + app.put("/api/admin/:sourceId/config", inject, adminMiddleware, handleUpdateProviderConfig) +} + +const WeatherKitSourceProviderConfig = type({ + credentials: { + privateKey: "string", + keyId: "string", + teamId: "string", + serviceId: "string", + }, +}) + +async function handleUpdateProviderConfig(c: Context) { + const sourceId = c.req.param("sourceId") + if (!sourceId) { + return c.body(null, 404) + } + + const sessionManager = c.get("sessionManager") + const db = c.get("db") + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: "Invalid JSON" }, 400) + } + + switch (sourceId) { + case "aelis.weather": { + const parsed = WeatherKitSourceProviderConfig(body) + if (parsed instanceof type.errors) { + return c.json({ error: parsed.summary }, 400) + } + + const updated = new WeatherSourceProvider({ + db, + credentials: parsed.credentials, + }) + + try { + await sessionManager.replaceProvider(updated) + } catch (err) { + console.error(`[admin] replaceProvider("${sourceId}") failed:`, err) + return c.json({ error: "Failed to apply config" }, 500) + } + + return c.body(null, 204) + } + + default: + return c.json({ error: `Provider "${sourceId}" not found` }, 404) + } +} diff --git a/apps/aelis-backend/src/auth/admin-middleware.test.ts b/apps/aelis-backend/src/auth/admin-middleware.test.ts new file mode 100644 index 0000000..1fa5547 --- /dev/null +++ b/apps/aelis-backend/src/auth/admin-middleware.test.ts @@ -0,0 +1,95 @@ +import { Hono } from "hono" +import { describe, expect, test } from "bun:test" + +import type { Auth } from "./index.ts" +import type { AuthSession, AuthUser } from "./session.ts" + +import { createRequireAdmin } from "./admin-middleware.ts" + +function makeUser(role: string | null): AuthUser { + const now = new Date() + return { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + role, + banned: false, + banReason: null, + banExpires: null, + } +} + +function makeSession(): AuthSession { + const now = new Date() + return { + id: "sess-1", + userId: "user-1", + token: "tok-1", + expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + ipAddress: "127.0.0.1", + userAgent: "test", + createdAt: now, + updatedAt: now, + } +} + +function mockAuth(sessionResult: { user: AuthUser; session: AuthSession } | null): Auth { + return { + api: { + getSession: async () => sessionResult, + }, + } as unknown as Auth +} + +function createApp(auth: Auth) { + const app = new Hono() + const middleware = createRequireAdmin(auth) + app.get("/api/admin/test", middleware, (c) => c.json({ ok: true })) + return app +} + +describe("createRequireAdmin", () => { + test("returns 401 when no session", async () => { + const app = createApp(mockAuth(null)) + + const res = await app.request("/api/admin/test") + + expect(res.status).toBe(401) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("Unauthorized") + }) + + test("returns 403 when user is not admin", async () => { + const app = createApp(mockAuth({ user: makeUser("user"), session: makeSession() })) + + const res = await app.request("/api/admin/test") + + expect(res.status).toBe(403) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("Forbidden") + }) + + test("returns 403 when role is null", async () => { + const app = createApp(mockAuth({ user: makeUser(null), session: makeSession() })) + + const res = await app.request("/api/admin/test") + + expect(res.status).toBe(403) + }) + + test("allows admin users through and sets context", async () => { + const user = makeUser("admin") + const session = makeSession() + const app = createApp(mockAuth({ user, session })) + + const res = await app.request("/api/admin/test") + + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean } + expect(body.ok).toBe(true) + }) +}) diff --git a/apps/aelis-backend/src/auth/admin-middleware.ts b/apps/aelis-backend/src/auth/admin-middleware.ts new file mode 100644 index 0000000..9b8ac5b --- /dev/null +++ b/apps/aelis-backend/src/auth/admin-middleware.ts @@ -0,0 +1,28 @@ +import type { Context, MiddlewareHandler, Next } from "hono" + +import type { Auth } from "./index.ts" +import type { AuthSessionEnv } from "./session-middleware.ts" + +export type AdminMiddleware = MiddlewareHandler + +/** + * Creates a middleware that requires a valid session with admin role. + * Returns 401 if not authenticated, 403 if not admin. + */ +export function createRequireAdmin(auth: Auth): AdminMiddleware { + return async (c: Context, next: Next): Promise => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }) + + if (!session) { + return c.json({ error: "Unauthorized" }, 401) + } + + if (session.user.role !== "admin") { + return c.json({ error: "Forbidden" }, 403) + } + + c.set("user", session.user) + c.set("session", session.session) + await next() + } +} diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index b1a05d5..4b4ca91 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -1,5 +1,7 @@ import { Hono } from "hono" +import { registerAdminHttpHandlers } from "./admin/http.ts" +import { createRequireAdmin } from "./auth/admin-middleware.ts" import { registerAuthHandlers } from "./auth/http.ts" import { createAuth } from "./auth/index.ts" import { createRequireSession } from "./auth/session-middleware.ts" @@ -50,6 +52,7 @@ function main() { app.get("/health", (c) => c.json({ status: "ok" })) const authSessionMiddleware = createRequireSession(auth) + const adminMiddleware = createRequireAdmin(auth) registerAuthHandlers(app, auth) @@ -58,6 +61,7 @@ function main() { authSessionMiddleware, }) registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware }) + registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db }) process.on("SIGTERM", async () => { await closeDb() diff --git a/apps/aelis-backend/src/session/user-session-manager.ts b/apps/aelis-backend/src/session/user-session-manager.ts index 79e2d63..e0d5612 100644 --- a/apps/aelis-backend/src/session/user-session-manager.ts +++ b/apps/aelis-backend/src/session/user-session-manager.ts @@ -23,6 +23,10 @@ export class UserSessionManager { this.feedEnhancer = config.feedEnhancer ?? null } + getProvider(sourceId: string): FeedSourceProvider | undefined { + return this.providers.get(sourceId) + } + async getOrCreate(userId: string): Promise { const existing = this.sessions.get(userId) if (existing) return existing.session