diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts new file mode 100644 index 000000000..d9bdf4d49 --- /dev/null +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -0,0 +1,437 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `enc(${value})`), + decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { encrypt } from "@/lib/crypto"; +import { auditLog } from "@/lib/auth/audit"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, +}; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/admin/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); +}); + +describe("GET /api/admin/settings", () => { + it("rejects with 401 when no session", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + await expect(GET()).rejects.toThrow("Not authenticated"); + }); + + it("rejects with 403 for non-admin", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect(GET()).rejects.toThrow("Admin access required"); + }); + + it("reads the singleton row (regression: not the default-typo)", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + await GET(); + expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1); + const args = vi.mocked(prisma.appSettings.findUnique).mock.calls[0]?.[0]; + // Must be "singleton" — the test-endpoints PR previously typo'd this as "default". + expect(args).toEqual({ where: { id: "singleton" } }); + }); + + it("returns sane defaults when no settings row exists", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data.registrationEnabled).toBe(true); + expect(body.data.defaultLocale).toBe("de"); + expect(body.data.webPushVapidConfigured).toBe(false); + expect(body.data.bugReportConfigured).toBe(false); + expect(body.data.glitchtipEnabled).toBe(false); + }); + + it("never leaks the encrypted private key fields", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + id: "singleton", + registrationEnabled: false, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: "pub", + webPushVapidSubject: "mailto:a@b.com", + webPushVapidPrivateKeyEncrypted: "secret-blob", + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: "production", + githubIssueRepo: "owner/repo", + githubIssueTokenEncrypted: "token-blob", + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + + const res = await GET(); + const body = (await res.json()) as { data: Record }; + // The two configured-flags must be true … + expect(body.data.webPushVapidConfigured).toBe(true); + expect(body.data.bugReportConfigured).toBe(true); + // … but raw encrypted blobs must never reach the client. + const serialized = JSON.stringify(body.data); + expect(serialized).not.toContain("secret-blob"); + expect(serialized).not.toContain("token-blob"); + expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted"); + expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted"); + }); +}); + +describe("PUT /api/admin/settings", () => { + it("rejects with 403 for non-admin", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect(PUT(jsonReq({}))).rejects.toThrow("Admin access required"); + }); + + it("returns 422 for an unknown field (strict schema)", async () => { + const res = await PUT(jsonReq({ totallyBogus: true })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("returns 422 for invalid Glitchtip DSN URL", async () => { + const res = await PUT(jsonReq({ glitchtipDsn: "not a url" })); + expect(res.status).toBe(422); + }); + + it("returns 422 when the body has no actionable fields", async () => { + const res = await PUT(jsonReq({})); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("No valid fields"); + }); + + it("encrypts the VAPID private key before persisting", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: "enc(plain-private)", + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: "production", + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + + const res = await PUT(jsonReq({ webPushVapidPrivateKey: "plain-private" })); + expect(res.status).toBe(200); + expect(encrypt).toHaveBeenCalledWith("plain-private"); + + const upsertArgs = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(upsertArgs?.where).toEqual({ id: "singleton" }); + expect(upsertArgs?.update).toEqual({ + webPushVapidPrivateKeyEncrypted: "enc(plain-private)", + }); + // Audit detail must NOT carry the plaintext private key. + expect(auditLog).toHaveBeenCalled(); + const auditDetails = vi.mocked(auditLog).mock.calls[0]?.[1] + ?.details as Record; + expect(JSON.stringify(auditDetails)).not.toContain("plain-private"); + expect(auditDetails.webPushVapidPrivateKeyUpdated).toBe(true); + }); + + it("clears the encrypted private key when clear flag is set", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + const res = await PUT(jsonReq({ clearWebPushVapidPrivateKey: true })); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ webPushVapidPrivateKeyEncrypted: null }); + }); + + it("normalises a Umami URL with no path to /script.js", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: "https://analytics.example.com/script.js", + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + + const res = await PUT( + jsonReq({ umamiScriptUrl: "https://analytics.example.com" }), + ); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ + umamiScriptUrl: "https://analytics.example.com/script.js", + }); + }); + + it("normalises and persists every Zod-allowed field in a single PUT", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: false, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + webPushVapidPublicKey: "vapid-pub", + webPushVapidSubject: "mailto:ops@healthlog.dev", + webPushVapidPrivateKeyEncrypted: "enc(secret)", + apiGlobal: false, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.com/custom.js", + umamiWebsiteId: "site-uuid", + glitchtipEnabled: true, + glitchtipDsn: "https://abc@glitchtip.example.com/1", + glitchtipEnvironment: "staging", + githubIssueRepo: "owner/repo", + githubIssueTokenEncrypted: "enc(gh-token)", + reminderLateMinutes: 30, + reminderMissedMinutes: 90, + moodLogGlobal: false, + } as never); + + const res = await PUT( + jsonReq({ + registrationEnabled: false, + defaultLocale: "en", + telegramGlobal: false, + ntfyGlobal: false, + webPushGlobal: false, + apiGlobal: false, + umamiEnabled: true, + moodLogGlobal: false, + glitchtipEnabled: true, + webPushVapidPublicKey: "vapid-pub", + webPushVapidSubject: "mailto:ops@healthlog.dev", + webPushVapidPrivateKey: "secret", + umamiScriptUrl: "https://analytics.example.com/custom.js", + umamiWebsiteId: "site-uuid", + glitchtipDsn: "https://abc@glitchtip.example.com/1", + glitchtipEnvironment: "staging", + bugReportRepo: "owner/repo", + bugReportToken: "gh-token", + reminderLateMinutes: 30, + reminderMissedMinutes: 90, + }), + ); + expect(res.status).toBe(200); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(upsert?.update).toMatchObject({ + registrationEnabled: false, + defaultLocale: "en", + telegramGlobal: false, + apiGlobal: false, + glitchtipEnabled: true, + umamiEnabled: true, + umamiScriptUrl: "https://analytics.example.com/custom.js", + umamiWebsiteId: "site-uuid", + glitchtipDsn: "https://abc@glitchtip.example.com/1", + glitchtipEnvironment: "staging", + githubIssueRepo: "owner/repo", + githubIssueTokenEncrypted: "enc(gh-token)", + webPushVapidPublicKey: "vapid-pub", + webPushVapidSubject: "mailto:ops@healthlog.dev", + webPushVapidPrivateKeyEncrypted: "enc(secret)", + reminderLateMinutes: 30, + reminderMissedMinutes: 90, + }); + expect(encrypt).toHaveBeenCalledWith("secret"); + expect(encrypt).toHaveBeenCalledWith("gh-token"); + }); + + it("clears nullable string fields when an empty string is submitted", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + + await PUT( + jsonReq({ + webPushVapidPublicKey: "", + webPushVapidSubject: "", + umamiScriptUrl: "", + umamiWebsiteId: "", + glitchtipDsn: "", + glitchtipEnvironment: "", + bugReportRepo: "", + }), + ); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(upsert?.update).toEqual({ + webPushVapidPublicKey: null, + webPushVapidSubject: null, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + }); + }); + + it("clears the encrypted bug-report token when clear flag is set", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "en", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + githubIssueRepo: null, + githubIssueTokenEncrypted: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + moodLogGlobal: true, + } as never); + await PUT(jsonReq({ clearBugReportToken: true })); + const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null }); + }); + + it("returns 415 when content-type is not JSON", async () => { + const r = new NextRequest("http://localhost/api/admin/settings", { + method: "PUT", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + const res = await PUT(r); + expect(res.status).toBe(415); + }); +}); diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts new file mode 100644 index 000000000..9a5466505 --- /dev/null +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { count: vi.fn() }, + measurement: { count: vi.fn() }, + medication: { count: vi.fn() }, + medicationIntakeEvent: { count: vi.fn() }, + apiToken: { count: vi.fn() }, + session: { count: vi.fn() }, + appSettings: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/jobs/worker-status", () => ({ + getWorkerStatus: vi.fn(() => ({ + running: true, + startedAt: new Date(Date.now() - 60_000).toISOString(), + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + errors: 0, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(prisma.user.count).mockResolvedValue(0); + vi.mocked(prisma.measurement.count).mockResolvedValue(0); + vi.mocked(prisma.medication.count).mockResolvedValue(0); + vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0); + vi.mocked(prisma.apiToken.count).mockResolvedValue(0); + vi.mocked(prisma.session.count).mockResolvedValue(0); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); +}); + +describe("GET /api/admin/status (integration health summary)", () => { + it("rejects non-admin callers with 403", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect(GET()).rejects.toThrow("Admin access required"); + }); + + it("returns null integration entries when no app settings exist", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { integrations: Record }; + }; + expect(body.data.integrations).toEqual({ + umami: null, + glitchtip: null, + webPush: null, + bugReport: null, + }); + }); + + it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + umamiScriptUrl: "https://x/script.js", + umamiWebsiteId: "site-1", + umamiEnabled: true, + glitchtipDsn: "https://abc@x/123", + glitchtipEnabled: false, + webPushVapidPublicKey: "pub", + webPushVapidPrivateKeyEncrypted: "secret-blob", + webPushVapidSubject: "mailto:a@b.com", + githubIssueRepo: "owner/repo", + githubIssueTokenEncrypted: "token-blob", + } as never); + + const res = await GET(); + const body = (await res.json()) as { + data: { integrations: Record | null> }; + }; + expect(body.data.integrations.umami).toEqual({ + configured: true, + enabled: true, + }); + // glitchtip is configured but the global toggle is off + expect(body.data.integrations.glitchtip).toEqual({ + configured: true, + enabled: false, + }); + expect(body.data.integrations.webPush).toEqual({ configured: true }); + expect(body.data.integrations.bugReport).toEqual({ configured: true }); + + // Regression: encrypted blobs MUST never appear in the response envelope. + const serialized = JSON.stringify(body); + expect(serialized).not.toContain("secret-blob"); + expect(serialized).not.toContain("token-blob"); + }); + + it("does not mark webPush configured when only the public key is present", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + webPushVapidPublicKey: "pub", + webPushVapidPrivateKeyEncrypted: null, + webPushVapidSubject: null, + } as never); + const res = await GET(); + const body = (await res.json()) as { + data: { integrations: { webPush: unknown } }; + }; + expect(body.data.integrations.webPush).toBeNull(); + }); + + it("returns aggregate counts and the worker status block", async () => { + vi.mocked(prisma.user.count).mockResolvedValue(7); + vi.mocked(prisma.measurement.count).mockResolvedValue(123); + vi.mocked(prisma.apiToken.count).mockResolvedValue(2); + + const res = await GET(); + const body = (await res.json()) as { + data: { + counts: Record; + worker: { running: boolean }; + database: string; + }; + }; + expect(body.data.counts).toEqual({ + users: 7, + measurements: 123, + medications: 0, + intakeEvents: 0, + activeTokens: 2, + activeSessions: 0, + }); + expect(body.data.worker.running).toBe(true); + expect(body.data.database).toBe("connected"); + + // Regression: only NON-revoked tokens are counted as "active". + const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0]; + expect(tokenCallArgs).toEqual({ where: { revoked: false } }); + }); +}); diff --git a/src/app/api/admin/users/[id]/__tests__/route.test.ts b/src/app/api/admin/users/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..c0ba351ea --- /dev/null +++ b/src/app/api/admin/users/[id]/__tests__/route.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + count: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, +}; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/admin/users/u1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function params(id: string) { + return { params: Promise.resolve({ id }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); +}); + +describe("PUT /api/admin/users/[id]", () => { + it("rejects with 401 when unauthenticated", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(401, "Not authenticated"), + ); + await expect( + PUT(jsonReq({ username: "marc" }), params("u1")), + ).rejects.toThrow("Not authenticated"); + }); + + it("rejects with 403 when caller is not admin", async () => { + vi.mocked(requireAdmin).mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect( + PUT(jsonReq({ username: "marc" }), params("u1")), + ).rejects.toThrow("Admin access required"); + }); + + it("returns 422 when payload fails Zod validation (short username)", async () => { + const res = await PUT(jsonReq({ username: "ab" }), params("u1")); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.findUnique)).not.toHaveBeenCalled(); + }); + + it("returns 422 when role is unknown", async () => { + const res = await PUT(jsonReq({ role: "SUPERUSER" }), params("u1")); + expect(res.status).toBe(422); + }); + + it("returns 404 when target user does not exist", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await PUT(jsonReq({ username: "marc" }), params("u-missing")); + expect(res.status).toBe(404); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("forbids demoting the last remaining admin", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "u1", + role: "ADMIN", + } as never); + vi.mocked(prisma.user.count).mockResolvedValue(1); + const res = await PUT(jsonReq({ role: "USER" }), params("u1")); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/last admin/i); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("allows demoting an admin while another admin exists", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "u1", + role: "ADMIN", + } as never); + vi.mocked(prisma.user.count).mockResolvedValue(2); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "u1", + username: "alice", + email: "a@b.com", + role: "USER", + createdAt: new Date("2024-01-01T00:00:00Z"), + } as never); + const res = await PUT(jsonReq({ role: "USER" }), params("u1")); + expect(res.status).toBe(200); + const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0]; + expect(updateArgs?.where).toEqual({ id: "u1" }); + expect(updateArgs?.data).toEqual({ role: "USER" }); + }); + + it("persists username + email + role and writes an audit-log entry", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "u1", + role: "USER", + } as never); + const updated = { + id: "u1", + username: "renamed", + email: "new@example.com", + role: "ADMIN" as const, + createdAt: new Date("2024-01-01T00:00:00Z"), + }; + vi.mocked(prisma.user.update).mockResolvedValue(updated as never); + + const res = await PUT( + jsonReq({ + username: "renamed", + email: "new@example.com", + role: "ADMIN", + }), + params("u1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(body.data.username).toBe("renamed"); + expect(body.data.role).toBe("ADMIN"); + expect(body.data).not.toHaveProperty("passwordHash"); + + const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0]; + expect(updateArgs?.data).toEqual({ + username: "renamed", + email: "new@example.com", + role: "ADMIN", + }); + expect(auditLog).toHaveBeenCalledWith( + "admin.user.update", + expect.objectContaining({ + userId: "admin-1", + details: expect.objectContaining({ targetUserId: "u1" }), + }), + ); + }); + + it("supports clearing email by passing null", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "u1", + role: "USER", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "u1", + username: "alice", + email: null, + role: "USER", + createdAt: new Date(), + } as never); + const res = await PUT(jsonReq({ email: null }), params("u1")); + expect(res.status).toBe(200); + const updateArgs = vi.mocked(prisma.user.update).mock.calls[0]?.[0]; + expect(updateArgs?.data).toEqual({ email: null }); + }); + + it("returns 415 when content-type is not JSON", async () => { + const r = new NextRequest("http://localhost/api/admin/users/u1", { + method: "PUT", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + const res = await PUT(r, params("u1")); + expect(res.status).toBe(415); + }); +}); diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts new file mode 100644 index 000000000..ed756306a --- /dev/null +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; + +const mockUserFindMany = vi.mocked(prisma.user.findMany); +const mockRequireAdmin = vi.mocked(requireAdmin); + +describe("GET /api/admin/users", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRequireAdmin.mockResolvedValue({ + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, + }); + mockUserFindMany.mockResolvedValue([] as never); + }); + + it("rejects with 401 when no session", async () => { + mockRequireAdmin.mockRejectedValue(new HttpError(401, "Not authenticated")); + await expect(GET()).rejects.toThrow("Not authenticated"); + }); + + it("rejects with 403 when caller is not admin", async () => { + mockRequireAdmin.mockRejectedValue( + new HttpError(403, "Admin access required"), + ); + await expect(GET()).rejects.toThrow("Admin access required"); + }); + + it("returns users sorted by createdAt ascending with passkey counts", async () => { + const created = new Date("2024-01-01T00:00:00Z"); + mockUserFindMany.mockResolvedValue([ + { + id: "u1", + username: "alice", + email: "alice@example.com", + role: "ADMIN", + createdAt: created, + _count: { passkeys: 2 }, + }, + { + id: "u2", + username: "bob", + email: null, + role: "USER", + createdAt: created, + _count: { passkeys: 0 }, + }, + ] as never); + + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array>; + }; + expect(body.data).toHaveLength(2); + expect(body.data[0]).toEqual({ + id: "u1", + username: "alice", + email: "alice@example.com", + role: "ADMIN", + createdAt: created.toISOString(), + passkeyCount: 2, + }); + expect(body.data[1].passkeyCount).toBe(0); + expect(body.data[1].email).toBeNull(); + }); + + it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => { + await GET(); + expect(mockUserFindMany).toHaveBeenCalledTimes(1); + const args = mockUserFindMany.mock.calls[0]?.[0] as { + select: Record; + orderBy: { createdAt: "asc" | "desc" }; + }; + // Regression: the route must NOT pull password hash / token columns. + expect(args.select).toEqual({ + id: true, + username: true, + email: true, + role: true, + createdAt: true, + _count: { select: { passkeys: true } }, + }); + expect(args.select).not.toHaveProperty("passwordHash"); + expect(args.orderBy).toEqual({ createdAt: "asc" }); + }); +}); diff --git a/src/app/api/monitoring/glitchtip/__tests__/route.test.ts b/src/app/api/monitoring/glitchtip/__tests__/route.test.ts new file mode 100644 index 000000000..ca5b1efbc --- /dev/null +++ b/src/app/api/monitoring/glitchtip/__tests__/route.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: Promise>(h: T): T => + h, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/monitoring-settings", () => ({ + getGlitchtipSettings: vi.fn(), +})); + +vi.mock("@/lib/monitoring/glitchtip", () => ({ + sendGlitchtipEvent: vi.fn(), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ addWarning: vi.fn() })), +})); + +import { POST } from "../route"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getGlitchtipSettings } from "@/lib/monitoring-settings"; +import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; + +function jsonReq(body: unknown, contentType = "application/json"): NextRequest { + return new NextRequest("http://localhost/api/monitoring/glitchtip", { + method: "POST", + headers: { "content-type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(getGlitchtipSettings).mockResolvedValue({ + glitchtipEnabled: true, + glitchtipDsn: "https://pub@glitchtip.example.com/1", + glitchtipEnvironment: "production", + }); + vi.mocked(sendGlitchtipEvent).mockResolvedValue({ + ok: true, + method: "envelope", + status: 200, + }); +}); + +describe("POST /api/monitoring/glitchtip", () => { + it("returns 429 when rate limit is exceeded", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + const res = await POST(jsonReq({ message: "x" })); + expect(res.status).toBe(429); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + }); + + it("short-circuits with skipped:true when GlitchTip is disabled", async () => { + vi.mocked(getGlitchtipSettings).mockResolvedValue({ + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + }); + const res = await POST(jsonReq({ message: "boom" })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { skipped: boolean } }; + expect(body.data.skipped).toBe(true); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + }); + + it("short-circuits when DSN is empty even if globally enabled", async () => { + vi.mocked(getGlitchtipSettings).mockResolvedValue({ + glitchtipEnabled: true, + glitchtipDsn: null, + glitchtipEnvironment: null, + }); + const res = await POST(jsonReq({ message: "boom" })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { skipped: boolean } }; + expect(body.data.skipped).toBe(true); + }); + + it("returns 422 when JSON parse fails", async () => { + const res = await POST(jsonReq("{not-json", "application/json")); + expect(res.status).toBe(422); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + }); + + it("returns 422 when payload fails schema (missing message)", async () => { + const res = await POST(jsonReq({ stack: "boom" })); + expect(res.status).toBe(422); + }); + + it("returns 422 when message exceeds size cap (2000 chars)", async () => { + // The Zod schema caps `message` at 2000 chars — bigger payloads must + // be rejected before they ever leave the box. + const huge = "x".repeat(2001); + const res = await POST(jsonReq({ message: huge })); + expect(res.status).toBe(422); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + }); + + it("returns 422 when stack exceeds 20 000 chars", async () => { + const res = await POST( + jsonReq({ message: "boom", stack: "x".repeat(20001) }), + ); + expect(res.status).toBe(422); + }); + + it("forwards a well-formed event with default level=error to the configured DSN", async () => { + const res = await POST( + jsonReq({ + message: "Boom!", + stack: "Error: Boom!\n at foo", + type: "ReferenceError", + url: "https://app.example.com/page", + userAgent: "Mozilla/5.0", + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { sent: boolean } }; + expect(body.data.sent).toBe(true); + + expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1); + const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0]; + expect(args?.dsn).toBe("https://pub@glitchtip.example.com/1"); + expect(args?.input).toMatchObject({ + environment: "production", + message: "Boom!", + level: "error", + type: "ReferenceError", + sourceTag: "healthlog-client", + }); + }); + + it("falls back to the literal 'production' env when settings.glitchtipEnvironment is null", async () => { + vi.mocked(getGlitchtipSettings).mockResolvedValue({ + glitchtipEnabled: true, + glitchtipDsn: "https://pub@glitchtip.example.com/1", + glitchtipEnvironment: null, + }); + await POST(jsonReq({ message: "boom" })); + const args = vi.mocked(sendGlitchtipEvent).mock.calls[0]?.[0]; + expect(args?.input.environment).toBe("production"); + }); + + it("returns 502 when the upstream delivery fails", async () => { + vi.mocked(sendGlitchtipEvent).mockResolvedValue({ + ok: false, + method: "envelope", + status: 500, + details: "internal", + }); + const res = await POST(jsonReq({ message: "boom" })); + expect(res.status).toBe(502); + }); + + it("rate-limits per IP — uses x-forwarded-for as the bucket key", async () => { + const r = new NextRequest("http://localhost/api/monitoring/glitchtip", { + method: "POST", + headers: { + "content-type": "application/json", + "x-forwarded-for": "10.0.0.42", + }, + body: JSON.stringify({ message: "boom" }), + }); + await POST(r); + const callArgs = vi.mocked(checkRateLimit).mock.calls[0]; + // First arg is the bucket key — must include the resolved client IP. + expect(callArgs?.[0]).toContain("10.0.0.42"); + // 20 requests / 60 000 ms — guard the documented sliding window. + expect(callArgs?.[1]).toBe(20); + expect(callArgs?.[2]).toBe(60_000); + }); +});