diff --git a/src/app/api/settings/__tests__/channel-enabled-routes.test.ts b/src/app/api/settings/__tests__/channel-enabled-routes.test.ts deleted file mode 100644 index c09dc31e9..000000000 --- a/src/app/api/settings/__tests__/channel-enabled-routes.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - notificationChannel: { - findUnique: vi.fn(), - updateMany: vi.fn(), - upsert: vi.fn(), - deleteMany: vi.fn(), - }, - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((value: string) => `encrypted:${value}`), - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); - -vi.mock("@/lib/telegram", () => ({ - setTelegramWebhook: vi.fn().mockResolvedValue(true), - deleteTelegramWebhook: vi.fn().mockResolvedValue(true), -})); - -vi.mock("@/lib/notifications/senders/email-config", () => ({ - isEmailConfigured: vi.fn(() => true), -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 59, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PUT as putWebhook } from "../webhook/route"; -import { PUT as putNtfy } from "../ntfy/route"; -import { PUT as putEmail } from "../email/route"; -import { PUT as putTelegram } from "../telegram/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { setTelegramWebhook } from "@/lib/telegram"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -const routes = { - webhook: putWebhook, - ntfy: putNtfy, - email: putEmail, - telegram: putTelegram, -}; - -type PutRoute = (request: Request) => Promise; - -function put(channel: keyof typeof routes, body: unknown) { - return (routes[channel] as PutRoute)( - new Request(`http://localhost/api/settings/${channel}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({ - count: 1, - } as never); - vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); -}); - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("notification channel enable-only routes", () => { - it.each(Object.keys(routes) as Array)( - "%s rejects unauthenticated toggle writes", - async (channel) => { - vi.mocked(getSession).mockResolvedValue(null); - - const response = await put(channel, { enabled: false }); - - expect(response.status).toBe(401); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - expect(prisma.user.update).not.toHaveBeenCalled(); - }, - ); - - it("updates only the owned webhook enabled field", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}', - } as never); - - const response = await put("webhook", { enabled: true }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: { saved: true }, - error: null, - }); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "WEBHOOK" }, - data: { enabled: true }, - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("updates only the owned ntfy enabled field", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}', - } as never); - - const response = await put("ntfy", { enabled: true }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: { saved: true }, - error: null, - }); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "NTFY" }, - data: { enabled: true }, - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("updates only the owned email enabled field", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: 'encrypted:{"recipient":"saved@example.com"}', - } as never); - - const response = await put("email", { enabled: true }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: { saved: true }, - error: null, - }); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "EMAIL" }, - data: { enabled: true }, - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("updates only Telegram enabled fields and leaves stored credentials untouched", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - telegramBotToken: "encrypted:saved-token", - telegramChatId: "saved-chat", - telegramEnabled: true, - } as never); - - const response = await put("telegram", { enabled: false }); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: { updated: true }, - error: null, - }); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { telegramEnabled: false }, - }); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "TELEGRAM" }, - data: { enabled: false }, - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - expect(prisma.notificationChannel.deleteMany).not.toHaveBeenCalled(); - }); - - it("registers Telegram on enable without rewriting stored credentials", async () => { - vi.stubEnv("APP_URL", "https://health.example"); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - telegramBotToken: "encrypted:saved-token", - telegramChatId: "saved-chat", - telegramEnabled: false, - } as never); - - const response = await put("telegram", { enabled: true }); - - expect(response.status).toBe(200); - expect(setTelegramWebhook).toHaveBeenCalledWith( - "saved-token", - "https://health.example/api/telegram/webhook", - undefined, - ); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { telegramEnabled: true }, - }); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "TELEGRAM" }, - data: { enabled: true }, - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it.each([ - [ - "webhook", - { - url: "https://saved.example/hook", - headerName: "X-Test", - headerValue: "secret", - enabled: false, - }, - ], - [ - "ntfy", - { - serverUrl: "https://ntfy.sh", - topic: "saved-topic", - authToken: "secret", - enabled: false, - }, - ], - ["email", { recipient: "saved@example.com", enabled: false }], - ] as const)( - "keeps the existing full %s save contract", - async (channel, body) => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null); - - const response = await put(channel, body); - - expect(response.status).toBe(200); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce(); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - }, - ); - - it("keeps the existing full Telegram save contract", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - telegramBotToken: null, - telegramChatId: null, - telegramEnabled: false, - } as never); - - const response = await put("telegram", { - botToken: "new-token", - chatId: "new-chat", - enabled: false, - }); - - expect(response.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { - telegramEnabled: false, - telegramBotToken: "encrypted:new-token", - telegramChatId: "new-chat", - }, - }); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledOnce(); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - }); - - it.each(["webhook", "ntfy", "email"] as const)( - "rejects enabling an unconfigured %s channel without creating configuration", - async (channel) => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null); - - const response = await put(channel, { enabled: true }); - - expect(response.status).toBe(422); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }, - ); -}); - -/** - * v1.32.1 — regression for iOS coordination issue #63: `PUT - * /api/settings/ntfy` reconstructed the entire stored config from the - * request body. `GET` never returns the write-only `authToken` (only - * `hasAuthToken: true/false`), so both native and web clients correctly - * OMIT — or round-trip an empty string through — the field on any - * unrelated edit. Rebuilding `config` without a preserve fallback - * silently dropped a working token on the next save. - * - * The fix mirrors the pre-existing `headerValue` preserve-on-empty - * contract in `src/app/api/settings/webhook/route.ts` (asserted here too, - * as a parity pin — that route was never broken, but any future drift - * between the two should fail the same suite). Covers exactly the four - * cases the report asks for. - */ -describe("PUT /api/settings/ntfy — write-only authToken preserve-on-omit contract", () => { - function upsertConfig() { - const call = vi.mocked(prisma.notificationChannel.upsert).mock - .calls[0]?.[0] as { - create: { config: string }; - update: { config: string }; - }; - // The route always upserts — assert both arms agree, then decode one. - expect(call.update.config).toBe(call.create.config); - return JSON.parse(call.update.config.replace(/^encrypted:/, "")) as { - serverUrl: string; - topic: string; - authToken?: string; - }; - } - - it("an omitted authToken preserves the stored secret", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}', - } as never); - - const response = await put("ntfy", { - serverUrl: "https://ntfy.sh", - topic: "saved-topic", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(upsertConfig().authToken).toBe("secret"); - }); - - it("an explicit empty-string authToken preserves the stored secret (matches current clients)", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"secret"}', - } as never); - - const response = await put("ntfy", { - serverUrl: "https://ntfy.sh", - topic: "saved-topic", - authToken: "", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(upsertConfig().authToken).toBe("secret"); - }); - - it("a non-empty authToken replaces the stored secret", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic","authToken":"old-secret"}', - } as never); - - const response = await put("ntfy", { - serverUrl: "https://ntfy.sh", - topic: "saved-topic", - authToken: "new-secret", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(upsertConfig().authToken).toBe("new-secret"); - }); - - it("creating without a token remains valid where anonymous ntfy is allowed", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue(null); - - const response = await put("ntfy", { - serverUrl: "https://ntfy.sh", - topic: "public-topic", - enabled: false, - }); - - expect(response.status).toBe(200); - const config = upsertConfig(); - expect(config.authToken).toBeUndefined(); - expect(config.topic).toBe("public-topic"); - }); - - it("parity pin: the webhook headerValue preserve-on-empty contract this fix mirrors is intact", async () => { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: - 'encrypted:{"url":"https://saved.example/hook","headerName":"Authorization","headerValue":"secret"}', - } as never); - - const response = await put("webhook", { - url: "https://saved.example/hook", - headerName: "Authorization", - enabled: true, - }); - - expect(response.status).toBe(200); - const call = vi.mocked(prisma.notificationChannel.upsert).mock - .calls[0]?.[0] as { update: { config: string } }; - const config = JSON.parse( - call.update.config.replace(/^encrypted:/, ""), - ) as { headerValue?: string }; - expect(config.headerValue).toBe("secret"); - }); -}); diff --git a/src/app/api/settings/__tests__/channel-global-switch-refusal.test.ts b/src/app/api/settings/__tests__/channel-global-switch-refusal.test.ts deleted file mode 100644 index 124a4a6cb..000000000 --- a/src/app/api/settings/__tests__/channel-global-switch-refusal.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Enabling a channel the operator switched off instance-wide is refused. - * - * The three switches (`AppSettings.telegramGlobal` / `.ntfyGlobal` / - * `.webPushGlobal`) now stop delivery in the dispatcher. Without a matching - * refusal on the way in, a user would still see the toggle flip, the row - * would still say `enabled = true`, and nothing would ever arrive. These - * tests pin the refusal and the absence of any write behind it. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - appSettings: { findUnique: vi.fn() }, - notificationChannel: { - findUnique: vi.fn(), - findFirst: vi.fn(), - create: vi.fn(), - updateMany: vi.fn(), - upsert: vi.fn(), - deleteMany: vi.fn(), - }, - pushSubscription: { upsert: vi.fn() }, - user: { findUnique: vi.fn(), update: vi.fn() }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((value: string) => `encrypted:${value}`), - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); - -const setTelegramWebhookMock = vi.fn().mockResolvedValue(true); -vi.mock("@/lib/telegram", () => ({ - setTelegramWebhook: (...args: unknown[]) => setTelegramWebhookMock(...args), - deleteTelegramWebhook: vi.fn().mockResolvedValue(true), -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 59, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PUT as putNtfy } from "../ntfy/route"; -import { PUT as putTelegram } from "../telegram/route"; -import { POST as postWebPush } from "../../notifications/web-push/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -const WEB_PUSH_SUBSCRIPTION = { - endpoint: "https://push.example.com/subscription/abc", - keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) }, -}; - -function stubSettings(over: Record) { - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - telegramGlobal: true, - ntfyGlobal: true, - webPushGlobal: true, - apiGlobal: true, - ...over, - } as never); -} - -function request(path: string, method: string, body: unknown) { - return new Request(`http://localhost${path}`, { - method, - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.notificationChannel.updateMany).mockResolvedValue({ - count: 1, - } as never); - vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: 'encrypted:{"serverUrl":"https://ntfy.sh","topic":"saved-topic"}', - } as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - telegramBotToken: "encrypted:saved-token", - telegramChatId: "saved-chat", - telegramEnabled: false, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue({} as never); - vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(null); -}); - -describe("ntfy enable against the instance-wide switch", () => { - it("refuses with 403 and writes nothing when the operator switched ntfy off", async () => { - stubSettings({ ntfyGlobal: false }); - - const response = await putNtfy( - request("/api/settings/ntfy", "PUT", { enabled: true }) as never, - ); - - expect(response.status).toBe(403); - await expect(response.json()).resolves.toMatchObject({ - data: null, - error: expect.stringContaining("disabled on this instance"), - }); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("still allows disabling the channel while the switch is off", async () => { - stubSettings({ ntfyGlobal: false }); - - const response = await putNtfy( - request("/api/settings/ntfy", "PUT", { enabled: false }) as never, - ); - - expect(response.status).toBe(200); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "NTFY" }, - data: { enabled: false }, - }); - }); - - it("allows enabling while the switch is on", async () => { - stubSettings({ ntfyGlobal: true }); - - const response = await putNtfy( - request("/api/settings/ntfy", "PUT", { enabled: true }) as never, - ); - - expect(response.status).toBe(200); - expect(prisma.notificationChannel.updateMany).toHaveBeenCalledWith({ - where: { userId: "user-1", type: "NTFY" }, - data: { enabled: true }, - }); - }); -}); - -describe("Telegram enable against the instance-wide switch", () => { - it("refuses with 403, registers no webhook, and writes nothing", async () => { - stubSettings({ telegramGlobal: false }); - - const response = await putTelegram( - request("/api/settings/telegram", "PUT", { enabled: true }) as never, - ); - - expect(response.status).toBe(403); - expect(setTelegramWebhookMock).not.toHaveBeenCalled(); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.notificationChannel.updateMany).not.toHaveBeenCalled(); - }); - - it("refuses a full-config save that enables the channel", async () => { - stubSettings({ telegramGlobal: false }); - - const response = await putTelegram( - request("/api/settings/telegram", "PUT", { - botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii", - chatId: "9876543", - enabled: true, - }) as never, - ); - - expect(response.status).toBe(403); - expect(setTelegramWebhookMock).not.toHaveBeenCalled(); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); - -describe("Web Push subscribe against the instance-wide switch", () => { - it("refuses with 403 and stores no subscription", async () => { - stubSettings({ webPushGlobal: false }); - - const response = await postWebPush( - request( - "/api/notifications/web-push", - "POST", - WEB_PUSH_SUBSCRIPTION, - ) as never, - ); - - expect(response.status).toBe(403); - expect(prisma.pushSubscription.upsert).not.toHaveBeenCalled(); - expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); - }); - - it("stores the subscription while the switch is on", async () => { - stubSettings({ webPushGlobal: true }); - - const response = await postWebPush( - request( - "/api/notifications/web-push", - "POST", - WEB_PUSH_SUBSCRIPTION, - ) as never, - ); - - expect(response.status).toBe(200); - expect(prisma.pushSubscription.upsert).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts deleted file mode 100644 index 0ee03d7c5..000000000 --- a/src/app/api/settings/__tests__/email-test-route.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * POST /api/settings/email/test — a failed send names its cause. - * - * Before this the route answered every failure with a bare 500, so the card - * read "Test failed" whether the mail server refused the login, timed out, - * or rejected the recipient. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { notificationChannel: { findUnique: vi.fn() } }, -})); -vi.mock("@/lib/crypto", () => ({ - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); -const sendViaEmailMock = vi.fn(); -vi.mock("@/lib/notifications/senders/email", () => ({ - sendViaEmail: (...args: unknown[]) => sendViaEmailMock(...args), -})); -vi.mock("@/lib/notifications/senders/email-config", () => ({ - isEmailConfigured: () => true, -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 4, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "../email/test/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function post(): Promise { - return (POST as (request: Request) => Promise)( - new Request("http://localhost/api/settings/email/test", { - method: "POST", - }), - ); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: 'encrypted:{"recipient":"you@example.com"}', - } as never); -}); - -describe("POST /api/settings/email/test", () => { - it.each([ - ["credentials_rejected", 535, "rejected the credentials (SMTP 535)"], - ["upstream_rejected", 550, "refused the message (SMTP 550)"], - ])( - "answers an SMTP rejection with 502, %s and the reply code", - async (failureCode, smtpCode, sentence) => { - sendViaEmailMock.mockResolvedValue({ - ok: false, - hardReject: smtpCode >= 500, - reason: "email_smtp_5xx", - failureCode, - smtpCode, - }); - - const response = await post(); - - expect(response.status).toBe(502); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: failureCode, - smtpCode, - }); - expect(json.error).toContain(sentence); - }, - ); - - it("answers a timeout with 502 and the timeout code", async () => { - sendViaEmailMock.mockResolvedValue({ - ok: false, - hardReject: false, - reason: "email_smtp_error", - failureCode: "timeout", - }); - - const response = await post(); - - expect(response.status).toBe(502); - await expect(response.json()).resolves.toMatchObject({ - meta: { errorCode: "timeout" }, - }); - }); - - it("keeps a 500 for a failure the sender could not name", async () => { - sendViaEmailMock.mockResolvedValue({ - ok: false, - hardReject: false, - reason: "email_smtp_error", - }); - - const response = await post(); - - expect(response.status).toBe(500); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "http.internal", - }); - }); - - it("answers a delivered test with 200", async () => { - sendViaEmailMock.mockResolvedValue({ ok: true }); - - const response = await post(); - - expect(response.status).toBe(200); - }); -}); diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index cd4bd86eb..c019e764a 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -1,510 +1,17 @@ /** - * #947 — a webhook or ntfy target on a private network, at the four routes - * that meet it. + * #947: the test button and the dispatcher must reach the SAME sender + * function for a webhook or ntfy target, so the private-origin decision + * cannot differ between the button that says "works" and the reminder that + * then does not arrive. A structural check over the sources. * - * The save routes evaluate the same policy the senders do, so a private - * target that is not listed in NOTIFICATION_PRIVATE_ORIGINS is a 422 that - * names the reason (`meta.errorCode = private_origin_not_approved`) instead - * of a generic "Invalid data", and a listed literal address saves although - * the plain public floor would refuse it. The test routes forward the - * sender's own refusal as a 422 with the same code instead of the bare 500 - * that made the reporter read the wide-event log to learn it was policy, and - * answer a relay's error status with a 502 that carries the status and what - * the relay said. - * - * The last block is the structural half: the test button and the dispatcher - * must reach the SAME sender function, so the decision cannot differ between - * the button that says "works" and the reminder that then does not arrive. + * The behaviour at the routes (save-time refusals, the test routes' 422 and + * 502 answers) is proven against the real database in + * `tests/integration/routes/settings-channels.test.ts` and + * `tests/integration/routes/settings-channel-tests.test.ts`. */ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - notificationChannel: { - findUnique: vi.fn(), - updateMany: vi.fn(), - upsert: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((value: string) => `encrypted:${value}`), - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); - -vi.mock("@/lib/app-settings", () => ({ - isChannelGloballyEnabled: vi.fn().mockResolvedValue(true), -})); - -const sendViaWebhookMock = vi.fn(); -const sendViaNtfyMock = vi.fn(); -vi.mock("@/lib/notifications/senders/webhook", () => ({ - sendViaWebhook: (...args: unknown[]) => sendViaWebhookMock(...args), -})); -vi.mock("@/lib/notifications/senders/ntfy", () => ({ - sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -const emitIfSampledMock = vi.fn(); -vi.mock("@/lib/logging/transports", () => ({ - emitIfSampled: (...args: unknown[]) => emitIfSampledMock(...args), -})); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 4, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PUT as putWebhook } from "../webhook/route"; -import { PUT as putNtfy } from "../ntfy/route"; -import { POST as testWebhook } from "../webhook/test/route"; -import { POST as testNtfy } from "../ntfy/test/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -type Route = (request: Request) => Promise; - -function put(path: string, body: unknown): Promise { - const route = (path === "webhook" ? putWebhook : putNtfy) as Route; - return route( - new Request(`http://localhost/api/settings/${path}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - ); -} - -function post(path: string): Promise { - const route = (path === "webhook" ? testWebhook : testNtfy) as Route; - return route( - new Request(`http://localhost/api/settings/${path}/test`, { - method: "POST", - }), - ); -} - -const ORIGINAL_PRIVATE_ORIGINS = process.env.NOTIFICATION_PRIVATE_ORIGINS; - -beforeEach(() => { - vi.clearAllMocks(); - delete process.env.NOTIFICATION_PRIVATE_ORIGINS; - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( - null as never, - ); - vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); -}); - -afterEach(() => { - if (ORIGINAL_PRIVATE_ORIGINS === undefined) { - delete process.env.NOTIFICATION_PRIVATE_ORIGINS; - } else { - process.env.NOTIFICATION_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; - } -}); - -describe("PUT /api/settings/webhook — private targets", () => { - it("refuses an unlisted private address with the reason, and stores nothing", async () => { - const response = await put("webhook", { - url: "http://10.0.0.5:8080/message", - enabled: true, - }); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.data).toBeNull(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_approved", - }); - expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("refuses a sub-host of a listed origin: a grant names one origin only", async () => { - process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; - - const response = await put("webhook", { - url: "https://push.gotify.example.com/message", - enabled: true, - }); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_approved", - }); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("saves a listed private address the public floor alone would refuse", async () => { - process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; - - const response = await put("webhook", { - url: "http://10.0.0.5:8080/message", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; - expect(call.create.config).toBe( - 'encrypted:{"url":"http://10.0.0.5:8080/message"}', - ); - }); - - it("keeps the plain shape refusal for a body that is not a private-origin case", async () => { - const response = await put("webhook", { url: 42, enabled: true }); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.error).toBe("Invalid data"); - // No private-origin code: only the status's generic one. - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "http.invalid", - }); - }); - - it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { - // Host networking: an exact loopback origin with its port is a grant like - // any other. Unlisted, it is refused with the code that says "list it", - // because listing it does open it. - process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; - - const refused = await put("webhook", { - url: "http://127.0.0.1:8080/message", - enabled: true, - }); - expect(refused.status).toBe(422); - await expect(refused.json()).resolves.toMatchObject({ - meta: { errorCode: "private_origin_not_approved" }, - }); - - const saved = await put("webhook", { - url: "http://localhost:8080/message", - enabled: true, - }); - expect(saved.status).toBe(200); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); - }); - - it("answers a metadata or unspecified target with the not-grantable code, listed or not (M1)", async () => { - // This used to pin a dead end: the route said "list it", the parser - // dropped the listing, and the next save said "list it" again. A target - // no grant can open gets its own code and a message that says so. - vi.spyOn(console, "warn").mockImplementation(() => {}); - process.env.NOTIFICATION_PRIVATE_ORIGINS = - "http://169.254.169.254, http://0.0.0.0:8080"; - - for (const url of [ - "http://169.254.169.254/latest", - "http://0.0.0.0:8080/message", - "http://[fe80::1]:8080/message", - ]) { - const response = await put("webhook", { url, enabled: true }); - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_grantable", - }); - expect(json.error).toMatch(/no operator grant can open/); - expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); - expect(json.details.issues.length).toBeGreaterThan(0); - } - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("saves an mDNS name the input floor refuses, once the operator lists it", async () => { - process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local"; - - const refused = await put("webhook", { - url: "http://ntfy.local/message", - enabled: true, - }); - expect(refused.status).toBe(422); - await expect(refused.json()).resolves.toMatchObject({ - meta: { errorCode: "private_origin_not_approved" }, - }); - - const saved = await put("webhook", { - url: "http://gotify.local/message", - enabled: true, - }); - expect(saved.status).toBe(200); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); - }); -}); - -describe("PUT /api/settings/ntfy — private servers", () => { - it("refuses an unlisted private server with the reason, and stores nothing", async () => { - const response = await put("ntfy", { - serverUrl: "http://192.168.1.20:8080", - topic: "health", - enabled: true, - }); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_approved", - }); - expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); - - it("saves a listed private server", async () => { - process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; - - const response = await put("ntfy", { - serverUrl: "http://192.168.1.20:8080", - topic: "health", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(prisma.notificationChannel.upsert).toHaveBeenCalledTimes(1); - }); - - it("refuses a sibling port of a listed server", async () => { - process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; - - const response = await put("ntfy", { - serverUrl: "http://192.168.1.20:9090", - topic: "health", - enabled: true, - }); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_approved", - }); - }); -}); - -describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", () => { - beforeEach(() => { - vi.mocked(prisma.notificationChannel.findUnique).mockImplementation( - (async (args: { where: { userId_type: { type: string } } }) => - args.where.userId_type.type === "WEBHOOK" - ? { config: 'encrypted:{"url":"https://gotify.lan/message"}' } - : { - config: - 'encrypted:{"serverUrl":"https://ntfy.lan","topic":"health"}', - }) as never, - ); - }); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])( - "%s: answers a private-origin refusal with 422 and the code, not a 500", - async (path, sender) => { - sender.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `${path}_private_origin_refused`, - errorCode: "private_origin_not_approved", - }); - - const response = await post(path); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_approved", - }); - expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); - }, - ); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])( - "%s: forwards the not-grantable code with a message that does not say list it (M1)", - async (path, sender) => { - sender.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `${path}_private_origin_refused`, - errorCode: "private_origin_not_grantable", - }); - - const response = await post(path); - - expect(response.status).toBe(422); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "private_origin_not_grantable", - }); - expect(json.error).toMatch(/no operator grant can open/); - expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); - }, - ); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])( - "%s: a relay that answered 400 is a 502 naming the status and what it said, not a bare 500 (#947: the card could only say Test failed)", - async (path, sender) => { - sender.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `${path}_400`, - statusCode: 400, - upstreamBody: '{"error":"Bad Request","errorCode":400}', - }); - - const response = await post(path); - - expect(response.status).toBe(502); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "upstream_rejected", - upstreamStatus: 400, - upstreamBody: '{"error":"Bad Request","errorCode":400}', - }); - expect(json.error).toContain("HTTP 400"); - // A user's relay refusing is not an operator error: the event is warn. - expect(emitIfSampledMock).toHaveBeenCalledWith( - expect.objectContaining({ level: "warn" }), - ); - }, - ); - - it.each([ - [401, "credentials_rejected"], - [403, "credentials_rejected"], - [404, "endpoint_not_found"], - [410, "endpoint_not_found"], - [415, "upstream_rejected"], - [422, "upstream_rejected"], - [429, "rate_limited"], - [503, "upstream_error"], - [307, "redirected"], - ])( - "webhook: an upstream %i maps to %s, without a body when the sender dropped it", - async (status, code) => { - sendViaWebhookMock.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `webhook_${status}`, - statusCode: status, - }); - - const response = await post("webhook"); - - expect(response.status).toBe(502); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: code, - upstreamStatus: status, - }); - }, - ); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])( - "%s: a timeout without a status is a 502 with the timeout code", - async (path, sender) => { - sender.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `${path}_network_error`, - failureCode: "timeout", - }); - - const response = await post(path); - - expect(response.status).toBe(502); - await expect(response.json()).resolves.toMatchObject({ - meta: { errorCode: "timeout" }, - }); - }, - ); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])( - "%s: only a failure the sender could not name stays a 500", - async (path, sender) => { - sender.mockResolvedValue({ - ok: false, - hardReject: false, - reason: `${path}_network_error`, - }); - - const response = await post(path); - - expect(response.status).toBe(500); - const json = await response.json(); - expect(json.meta).toEqual({ - requestId: expect.any(String), - errorCode: "http.internal", - }); - expect(emitIfSampledMock).toHaveBeenCalledWith( - expect.objectContaining({ level: "error" }), - ); - }, - ); - - it.each([ - ["webhook", sendViaWebhookMock], - ["ntfy", sendViaNtfyMock], - ])("%s: a delivered test is a 200", async (path, sender) => { - sender.mockResolvedValue({ ok: true, statusCode: 200 }); - - const response = await post(path); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - data: { sent: true }, - error: null, - }); - }); -}); +import { describe, expect, it } from "vitest"; describe("the test button and the dispatcher share one sender", () => { const SRC = join(process.cwd(), "src"); diff --git a/src/app/api/settings/__tests__/telegram-test-locale.test.ts b/src/app/api/settings/__tests__/telegram-test-locale.test.ts deleted file mode 100644 index 1256cf7d4..000000000 --- a/src/app/api/settings/__tests__/telegram-test-locale.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * v1.4.27 — settings/telegram/test locale path. - * - * Asserts the per-user Telegram test message renders in the user's - * persisted `User.locale` rather than the hardcoded English string - * the pre-fix route used. The actual translator behaviour is covered - * by `notifications/__tests__/admin-locale.test.ts`; this file is - * the route-level smoke test that confirms the wiring. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findUnique: vi.fn() }, - }, -})); - -vi.mock("@/lib/api-response", () => ({ - apiSuccess: (data: unknown) => ({ ok: true, data }), - apiError: (message: string, status: number) => ({ - ok: false, - error: message, - status, - }), -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: (s: string) => `decrypted(${s})`, -})); - -const sendTelegramMessageMock = vi.fn(); -vi.mock("@/lib/telegram", () => ({ - sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(), -})); - -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), -})); - -const tCalls: Array<{ locale: string; key: string }> = []; -vi.mock("@/lib/i18n/server-translator", () => ({ - getServerTranslator: (locale: string) => ({ - locale, - t: (key: string) => { - tCalls.push({ locale, key }); - const fakeStrings: Record> = { - de: { - "notifications.user.telegramTestBody": - "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", - }, - en: { - "notifications.user.telegramTestBody": - "HealthLog: connection successful. Telegram notifications are active.", - }, - fr: { - "notifications.user.telegramTestBody": - "HealthLog: connexion réussie. Les notifications Telegram sont actives.", - }, - }; - return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key; - }, - }), -})); - -import { POST } from "../telegram/test/route"; -import { prisma } from "@/lib/db"; -import { requireAuth } from "@/lib/api-handler"; -import { checkRateLimit } from "@/lib/rate-limit"; - -beforeEach(() => { - vi.resetAllMocks(); - tCalls.length = 0; - vi.mocked(requireAuth).mockResolvedValue({ - user: { id: "user-1" }, - } as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 5, - resetAt: Date.now() + 60_000, - } as never); - sendTelegramMessageMock.mockResolvedValue({ ok: true }); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe("POST /api/settings/telegram/test — locale-aware message", () => { - it("sends the German body when User.locale=de", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: "enc-token", - telegramChatId: "chat-1", - locale: "de", - } as never); - - const result = await (POST as () => Promise)(); - - expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1); - const [, , body] = sendTelegramMessageMock.mock.calls[0]; - expect(body).toBe( - "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", - ); - expect(tCalls).toEqual([ - { - locale: "de", - key: "notifications.user.telegramTestBody", - }, - ]); - expect((result as { ok: boolean }).ok).toBe(true); - }); - - it("sends the French body when User.locale=fr", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: "enc-token", - telegramChatId: "chat-1", - locale: "fr", - } as never); - - await (POST as () => Promise)(); - - const [, , body] = sendTelegramMessageMock.mock.calls[0]; - expect(body).toBe( - "HealthLog: connexion réussie. Les notifications Telegram sont actives.", - ); - }); - - it("falls back to the default locale (en) when User.locale is null", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: "enc-token", - telegramChatId: "chat-1", - locale: null, - } as never); - - await (POST as () => Promise)(); - - const [, , body] = sendTelegramMessageMock.mock.calls[0]; - expect(body).toBe( - "HealthLog: connection successful. Telegram notifications are active.", - ); - expect(tCalls[0].locale).toBe("en"); - }); - - it("falls back to the default locale (en) when User.locale is unsupported", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: "enc-token", - telegramChatId: "chat-1", - locale: "klingon", - } as never); - - await (POST as () => Promise)(); - - expect(tCalls[0].locale).toBe("en"); - }); - - it("returns a 422 error when bot token is not configured", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: null, - telegramChatId: null, - locale: "de", - } as never); - - const result = await (POST as () => Promise)(); - - expect(sendTelegramMessageMock).not.toHaveBeenCalled(); - expect((result as { status: number }).status).toBe(422); - }); - - it("returns a 429 error when rate-limited", async () => { - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - - const result = await (POST as () => Promise)(); - - expect(sendTelegramMessageMock).not.toHaveBeenCalled(); - expect((result as { status: number }).status).toBe(429); - }); - - it("returns a 422 error when the Telegram send fails", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ - telegramBotToken: "enc-token", - telegramChatId: "chat-1", - locale: "en", - } as never); - sendTelegramMessageMock.mockResolvedValueOnce({ ok: false }); - - const result = await (POST as () => Promise)(); - - expect((result as { status: number }).status).toBe(422); - }); -}); diff --git a/src/app/api/settings/__tests__/webhook-format-route.test.ts b/src/app/api/settings/__tests__/webhook-format-route.test.ts deleted file mode 100644 index d20ba38b8..000000000 --- a/src/app/api/settings/__tests__/webhook-format-route.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * The webhook payload format at the settings route (#947). - * - * Absent means generic, so a config saved before the choice existed keeps - * sending the body it always sent. Only the Gotify choice is written, an - * omitted format keeps the stored one, and the GET reports the resolved - * value rather than leaving the client to guess the default. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - notificationChannel: { - findUnique: vi.fn(), - updateMany: vi.fn(), - upsert: vi.fn(), - }, - }, -})); -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((value: string) => `encrypted:${value}`), - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 4, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PUT } from "../webhook/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -type Route = (request: Request) => Promise; - -function put(body: unknown): Promise { - return (PUT as Route)( - new Request("http://localhost/api/settings/webhook", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - ); -} - -function get(): Promise { - return (GET as Route)(new Request("http://localhost/api/settings/webhook")); -} - -function stored(): string { - const call = vi.mocked(prisma.notificationChannel.upsert).mock.calls[0][0]; - return call.create.config as string; -} - -function existing(config: Record) { - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - enabled: true, - config: `encrypted:${JSON.stringify(config)}`, - } as never); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue( - null as never, - ); - vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({} as never); -}); - -describe("PUT /api/settings/webhook — payload format", () => { - it("stores the Gotify choice", async () => { - const response = await put({ - url: "https://gotify.example.com/message", - headerName: "X-Gotify-Key", - headerValue: "AbCdEfGh123", - format: "gotify", - enabled: true, - }); - - expect(response.status).toBe(200); - expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toEqual({ - url: "https://gotify.example.com/message", - headerName: "X-Gotify-Key", - headerValue: "AbCdEfGh123", - format: "gotify", - }); - }); - - it("writes nothing for generic, so the stored config keeps its old shape", async () => { - await put({ - url: "https://relay.example.com/hook", - format: "generic", - enabled: true, - }); - - expect(stored()).toBe('encrypted:{"url":"https://relay.example.com/hook"}'); - }); - - it("keeps a stored Gotify choice when the body omits the field", async () => { - existing({ - url: "https://gotify.example.com/message", - headerValue: "AbCdEfGh123", - format: "gotify", - }); - - await put({ - url: "https://gotify.example.com/message", - headerValue: "NewToken456", - enabled: true, - }); - - expect(JSON.parse(stored().replace(/^encrypted:/, ""))).toMatchObject({ - headerValue: "NewToken456", - format: "gotify", - }); - }); - - it("switches a Gotify channel back to generic when asked", async () => { - existing({ url: "https://gotify.example.com/message", format: "gotify" }); - - await put({ - url: "https://gotify.example.com/message", - format: "generic", - enabled: true, - }); - - expect(stored()).toBe( - 'encrypted:{"url":"https://gotify.example.com/message"}', - ); - }); - - it("refuses a format it does not know", async () => { - const response = await put({ - url: "https://relay.example.com/hook", - format: "discord", - enabled: true, - }); - - expect(response.status).toBe(422); - expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); - }); -}); - -describe("GET /api/settings/webhook — payload format", () => { - it("reports generic for a config saved before the choice existed", async () => { - existing({ url: "https://relay.example.com/hook" }); - - const json = await (await get()).json(); - - expect(json.data.format).toBe("generic"); - }); - - it("reports the stored Gotify choice", async () => { - existing({ url: "https://gotify.example.com/message", format: "gotify" }); - - const json = await (await get()).json(); - - expect(json.data.format).toBe("gotify"); - }); - - it("reports generic when no channel exists", async () => { - const json = await (await get()).json(); - - expect(json.data).toEqual({ - enabled: false, - url: "", - headerName: "", - hasHeaderValue: false, - format: "generic", - }); - }); -}); diff --git a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts b/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts deleted file mode 100644 index b2f000a05..000000000 --- a/src/app/api/settings/reminder-thresholds/__tests__/route.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * v1.16.11 — reminder-thresholds GET: the operator-level late / missed - * minutes ride along unchanged; the new `lowStockRunwayDays` field is - * PER-USER (from `notificationPrefs.medication`), so the endpoint - * reflects whatever the notification-prefs PATCH persisted — the read - * half of the settings round-trip. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - }, - appSettings: { - findUnique: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function mkGet(): Request { - return new Request("http://localhost/api/settings/reminder-thresholds"); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ - reminderLateMinutes: 120, - reminderMissedMinutes: 240, - } as never); -}); - -describe("GET /api/settings/reminder-thresholds", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(401); - }); - - it("returns the default low-stock threshold (7) for a fresh user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - notificationPrefs: null, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { - lateMinutes: number; - missedMinutes: number; - lowStockRunwayDays: number | null; - reorderLeadDays: number; - }; - }; - expect(env.data).toEqual({ - lateMinutes: 120, - missedMinutes: 240, - lowStockRunwayDays: 7, - // v1.17.0 — the per-user reorder lead default rides along. - reorderLeadDays: 10, - }); - }); - - it("reflects the per-user threshold persisted by the prefs PATCH", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - notificationPrefs: { medication: { lowStockRunwayDays: 21 } }, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { lowStockRunwayDays: number | null }; - }; - expect(env.data.lowStockRunwayDays).toBe(21); - }); - - it("reflects the OFF state as null", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - notificationPrefs: { medication: { lowStockRunwayDays: null } }, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { lowStockRunwayDays: number | null }; - }; - expect(env.data.lowStockRunwayDays).toBe(null); - }); -}); diff --git a/src/app/api/settings/webhook/test/__tests__/route.test.ts b/src/app/api/settings/webhook/test/__tests__/route.test.ts deleted file mode 100644 index b317e1dd5..000000000 --- a/src/app/api/settings/webhook/test/__tests__/route.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * The webhook test button against a Discord webhook URL, through the real - * sender with only the network mocked. - * - * The mocked target answers the way Discord does: 400 with code 50006 - * ("Cannot send an empty message") for a body without `content` or `embeds`, - * 204 otherwise. The generic body used to carry neither, so the test button - * and every reminder failed against a Discord URL the settings card names. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - notificationChannel: { findUnique: vi.fn() }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - encrypt: vi.fn((value: string) => `encrypted:${value}`), - decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), -})); - -const safeFetchMock = vi.fn(); -vi.mock("@/lib/safe-fetch", () => ({ - safeFetch: (...args: unknown[]) => safeFetchMock(...args), - SafeFetchError: class SafeFetchError extends Error { - kind: string; - constructor(message: string, kind: string) { - super(message); - this.kind = kind; - } - }, -})); - -vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ - recordPushAttempt: vi.fn(), - recordPushAttemptForPayload: vi.fn(), -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 4, - resetAt: Date.now() + 60_000, - }), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST as postTest } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; - -const DISCORD_URL = - "https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"; - -/** Answers the way Discord's execute-webhook endpoint does. */ -function discordAnswer(_url: string, init: { body: string }): Response { - const body = JSON.parse(init.body) as Record; - const content = typeof body.content === "string" ? body.content : ""; - if (content.trim().length === 0 && body.embeds === undefined) { - return new Response( - JSON.stringify({ message: "Cannot send an empty message", code: 50006 }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - if (content.length > 2000) { - return new Response( - JSON.stringify({ message: "Invalid Form Body", code: 50035 }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - return new Response(null, { status: 204 }); -} - -const POST = postTest as (request: Request) => Promise; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue({ - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" }, - } as never); - vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ - config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`, - } as never); - safeFetchMock.mockImplementation( - async (url: string, init: { body: string }) => discordAnswer(url, init), - ); -}); - -describe("POST /api/settings/webhook/test — a Discord webhook URL", () => { - it("delivers: the generic body carries the content Discord requires", async () => { - const response = await POST( - new Request("http://localhost/api/settings/webhook/test", { - method: "POST", - }), - ); - - expect(response.status).toBe(200); - expect((await response.json()).data).toEqual({ sent: true }); - - expect(safeFetchMock).toHaveBeenCalledTimes(1); - const [url, init] = safeFetchMock.mock.calls[0] as [ - string, - { body: string }, - ]; - expect(url).toBe(DISCORD_URL); - const sent = JSON.parse(init.body); - expect(sent.content).toBe( - "HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.", - ); - expect(sent.allowed_mentions).toEqual({ parse: [] }); - }); - - it("passes Discord's own error through when the target still refuses", async () => { - safeFetchMock.mockResolvedValueOnce( - new Response( - JSON.stringify({ message: "Unknown Webhook", code: 10015 }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ), - ); - - const response = await POST( - new Request("http://localhost/api/settings/webhook/test", { - method: "POST", - }), - ); - - expect(response.status).toBe(502); - const json = await response.json(); - expect(json.meta.upstreamStatus).toBe(404); - expect(json.meta.upstreamBody).toContain("Unknown Webhook"); - }); -}); diff --git a/src/app/api/workouts/__tests__/batch-scope.test.ts b/src/app/api/workouts/__tests__/batch-scope.test.ts deleted file mode 100644 index 091b0ad8f..000000000 --- a/src/app/api/workouts/__tests__/batch-scope.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * Who may call `POST /api/workouts/batch` (#1054). - * - * The route names `workouts:write`, so the set of credentials it admits is: - * a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying - * exactly that scope. Every other narrow token — `measurements:write` - * included, however adjacent it looks — is refused 403 by the fail-closed - * default. - * - * The real `requireAuth` and the real `resolveBearerToken` run here; only the - * token row lookup is stubbed, so the admission decision under test is the - * production one rather than a re-implementation of it. - * - * The second half pins what a scoped caller writes: its rows are `EXTERNAL`, - * resolved from the credential, and a body naming any source is refused 422 - * rather than relabelled. A session and a wildcard token keep the source they - * send. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - apiToken: { findUnique: vi.fn(), update: vi.fn() }, - user: { findUnique: vi.fn() }, - workout: { - findMany: vi.fn(), - createManyAndReturn: vi.fn(), - }, - workoutRoute: { createMany: vi.fn() }, - workoutSamples: { createManyAndReturn: vi.fn() }, - $transaction: vi.fn(async (fn: unknown) => { - if (typeof fn === "function") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (fn as any)(prisma as unknown as { workout: unknown }); - } - }), - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); -vi.mock("@/lib/jobs/pr-detection", () => ({ - enqueuePrDetection: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/arrivals/emit-shared", () => ({ - emitDataArrival: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "../batch/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { headers } from "next/headers"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { auditLog } from "@/lib/auth/audit"; -import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; -import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes"; -import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; - -const USER = { - id: "user-1", - username: "tester", - role: "USER" as const, - sourcePriorityJson: null, -}; -const TOKEN = "hlk_" + "c".repeat(64); - -function makeRequest(body: unknown): NextRequest { - return new NextRequest("http://localhost/api/workouts/batch", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -function workout( - externalId: string, - overrides: Record = {}, -): Record { - return { - sportType: "running", - startedAt: "2026-09-20T06:30:00.000Z", - endedAt: "2026-09-20T07:15:00.000Z", - externalId, - ...overrides, - }; -} - -/** Present `TOKEN` as a Bearer whose row carries `permissions`. */ -function armToken(permissions: string[]) { - vi.mocked(getSession).mockResolvedValue(null as never); - vi.mocked(headers).mockResolvedValue({ - get: (name: string) => - name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null, - } as never); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - id: "tok-1", - userId: USER.id, - permissions, - revoked: false, - expiresAt: new Date(Date.now() + 86_400_000), - } as never); -} - -function armSession() { - vi.mocked(getSession).mockResolvedValue({ - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: USER, - } as never); -} - -/** The rows the route handed to `INSERT ... RETURNING`. */ -function insertedRows(): Array> { - return vi - .mocked(prisma.workout.createManyAndReturn) - .mock.calls.flatMap(([args]) => { - const data = (args as { data: unknown }).data; - return (Array.isArray(data) ? data : [data]) as Array< - Record - >; - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64)); - vi.mocked(headers).mockResolvedValue({ get: () => null } as never); - vi.mocked(getSession).mockResolvedValue(null as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 60, - resetAt: Date.now() + 60_000, - }); - vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); - // The refusal path chains `.catch` on the audit write, so the reset mock - // has to hand back a promise again. - vi.mocked(auditLog).mockResolvedValue(undefined as never); - // One row serves both lookups: the resolver's user fetch and the route's - // source-priority read. - vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never); - vi.mocked(prisma.workout.findMany).mockResolvedValue([]); - vi.mocked(prisma.workout.createManyAndReturn).mockImplementation( - (async (args: { data: Array> }) => - args.data.map((row, index) => ({ - id: `w-${index}`, - source: row.source, - externalId: row.externalId, - }))) as never, - ); -}); - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("POST /api/workouts/batch — which credentials it admits", () => { - it("admits a narrow workouts:write token", async () => { - armToken([WORKOUTS_WRITE_SCOPE]); - const res = await POST(makeRequest({ workouts: [workout("s-1")] })); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.inserted).toBe(1); - }); - - it("still admits a cookie session", async () => { - armSession(); - const res = await POST( - makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), - ); - expect(res.status).toBe(200); - }); - - it("still admits a wildcard token", async () => { - armToken(["*"]); - const res = await POST( - makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), - ); - expect(res.status).toBe(200); - }); - - it.each([ - ["measurements:write", [MEASUREMENTS_WRITE_SCOPE]], - ["documents:write", [DOCUMENTS_WRITE_SCOPE]], - ["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]], - ["health:read", ["health:read"]], - ])("refuses a %s token with 403 and writes nothing", async (_, perms) => { - armToken(perms); - const res = await POST(makeRequest({ workouts: [workout("s-1")] })); - expect(res.status).toBe(403); - expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled(); - }); -}); - -describe("POST /api/workouts/batch — rate limit buckets", () => { - function lastBucket(): string { - const calls = vi.mocked(checkRateLimit).mock.calls; - return calls[calls.length - 1][0] as string; - } - - it("counts a workouts:write token apart from the phone", async () => { - armToken([WORKOUTS_WRITE_SCOPE]); - await POST(makeRequest({ workouts: [workout("s-1")] })); - const bridge = lastBucket(); - - armToken(["*"]); - await POST( - makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }), - ); - const phone = lastBucket(); - - expect(bridge).not.toBe(phone); - expect(bridge).toContain(USER.id); - expect(phone).toContain(USER.id); - }); - - it("keeps a session and a wildcard token in the same bucket", async () => { - armSession(); - await POST( - makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), - ); - const session = lastBucket(); - armToken(["*"]); - await POST( - makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }), - ); - expect(lastBucket()).toBe(session); - }); -}); - -describe("POST /api/workouts/batch — what a scoped caller writes", () => { - it("attributes its rows to EXTERNAL", async () => { - armToken([WORKOUTS_WRITE_SCOPE]); - // Two sessions of the day: EXTERNAL ranks on the workout ladder, so two - // rows of one session would land once. - const res = await POST( - makeRequest({ - workouts: [ - workout("s-1"), - workout("s-2", { - startedAt: "2026-09-20T18:00:00.000Z", - endedAt: "2026-09-20T18:45:00.000Z", - }), - ], - }), - ); - expect(res.status).toBe(200); - const rows = insertedRows(); - expect(rows).toHaveLength(2); - expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]); - }); - - it("probes for duplicates in the EXTERNAL namespace", async () => { - armToken([WORKOUTS_WRITE_SCOPE]); - await POST(makeRequest({ workouts: [workout("s-1")] })); - const probe = vi - .mocked(prisma.workout.findMany) - .mock.calls.map(([args]) => args as { where?: { OR?: unknown } }) - .find((args) => args.where?.OR !== undefined); - expect(probe?.where?.OR).toEqual([ - { source: "EXTERNAL", externalId: "s-1" }, - ]); - }); - - it.each(["APPLE_HEALTH", "MANUAL"])( - "refuses a body naming %s with 422 and writes nothing", - async (source) => { - armToken([WORKOUTS_WRITE_SCOPE]); - const res = await POST( - makeRequest({ - workouts: [workout("s-1"), workout("s-2", { source })], - }), - ); - expect(res.status).toBe(422); - const body = await res.json(); - expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted"); - expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled(); - }, - ); - - it("leaves the source a wildcard caller sends alone", async () => { - armToken(["*"]); - await POST( - makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }), - ); - expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]); - }); - - it("leaves the source a session sends alone", async () => { - armSession(); - await POST( - makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }), - ); - expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]); - }); -}); diff --git a/src/app/api/workouts/__tests__/delete.test.ts b/src/app/api/workouts/__tests__/delete.test.ts deleted file mode 100644 index 8af47cf6a..000000000 --- a/src/app/api/workouts/__tests__/delete.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Unit suite for `DELETE /api/workouts/{id}`. - * - * Pins: - * - a workout entered by hand is removed, with the personal records keyed - * on it, and a silent detection pass re-derives the previous best; - * - a synced workout answers 409 and nothing is touched, because the next - * sync would write it back; - * - another user's row and a missing row answer 404; - * - no session answers 401. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -const tx = { - $queryRaw: vi.fn(async () => []), - personalRecord: { deleteMany: vi.fn() }, - workout: { delete: vi.fn() }, -}; - -vi.mock("@/lib/db", () => ({ - prisma: { - workout: { findUnique: vi.fn() }, - $transaction: vi.fn(async (fn: (client: typeof tx) => unknown) => fn(tx)), - }, -})); -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/cache/invalidate", () => ({ - invalidateUserMeasurements: vi.fn(), -})); -vi.mock("@/lib/jobs/pr-detection", () => ({ - enqueuePrDetection: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { DELETE } from "../[id]/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; -import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -const MANUAL_ROW = { - id: "w-1", - userId: "user-1", - source: "MANUAL" as const, - externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666", - startedAt: new Date("2026-09-01T07:00:00Z"), - sportType: "running", -}; - -function del(id = "w-1") { - return DELETE( - new NextRequest(`http://localhost/api/workouts/${id}`, { - method: "DELETE", - }), - { params: Promise.resolve({ id }) }, - ); -} - -describe("DELETE /api/workouts/{id}", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - tx.personalRecord.deleteMany.mockResolvedValue({ count: 0 }); - tx.workout.delete.mockResolvedValue({ id: "w-1" }); - }); - - it("removes a workout entered by hand", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); - - const res = await del(); - - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ data: { deleted: true }, error: null }); - expect(tx.workout.delete).toHaveBeenCalledWith({ where: { id: "w-1" } }); - expect(auditLog).toHaveBeenCalledWith( - "workout.delete", - expect.objectContaining({ userId: "user-1" }), - ); - expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1", { - evict: true, - }); - }); - - it("takes the personal records the workout set with it and re-derives the best", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); - tx.personalRecord.deleteMany.mockResolvedValue({ count: 2 }); - - const res = await del(); - - expect(res.status).toBe(200); - expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ - where: { - userId: "user-1", - metricSlot: { not: null }, - source: "MANUAL", - externalId: MANUAL_ROW.externalId, - }, - }); - expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", { - silent: true, - }); - }); - - it("keys a record without an external id on the session's start", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...MANUAL_ROW, - externalId: null, - } as never); - - await del(); - - expect(tx.personalRecord.deleteMany).toHaveBeenCalledWith({ - where: { - userId: "user-1", - source: "MANUAL", - // Only the running slots: a cycling record set at the same instant - // is not this workout's. - metricSlot: { - in: [ - "longest_run_duration", - "longest_distance_run", - "fastest_5km_time", - ], - }, - externalId: null, - achievedAt: MANUAL_ROW.startedAt, - }, - }); - // Nothing was removed, so nothing needs re-deriving. - expect(enqueuePrDetection).not.toHaveBeenCalled(); - }); - - it("takes the personal-records lock before it deletes anything", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue(MANUAL_ROW as never); - - await del(); - - expect(tx.$queryRaw).toHaveBeenCalledTimes(1); - const lockOrder = tx.$queryRaw.mock.invocationCallOrder[0]; - expect(lockOrder).toBeLessThan( - tx.personalRecord.deleteMany.mock.invocationCallOrder[0], - ); - expect(lockOrder).toBeLessThan( - tx.workout.delete.mock.invocationCallOrder[0], - ); - }); - - it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"])( - "refuses a %s workout with 409 and touches nothing", - async (source) => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...MANUAL_ROW, - source, - } as never); - - const res = await del(); - - expect(res.status).toBe(409); - const body = await res.json(); - expect(body.data).toBeNull(); - expect(prisma.$transaction).not.toHaveBeenCalled(); - expect(tx.workout.delete).not.toHaveBeenCalled(); - expect(auditLog).not.toHaveBeenCalled(); - }, - ); - - it("answers 404 for another user's workout", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...MANUAL_ROW, - userId: "someone-else", - } as never); - - const res = await del(); - - expect(res.status).toBe(404); - expect(prisma.$transaction).not.toHaveBeenCalled(); - }); - - it("answers 404 when there is no such workout", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue(null); - - const res = await del("missing"); - - expect(res.status).toBe(404); - }); - - it("answers 401 without a session", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await del(); - - expect(res.status).toBe(401); - expect(prisma.workout.findUnique).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts deleted file mode 100644 index 56af87362..000000000 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ /dev/null @@ -1,774 +0,0 @@ -/** - * Unit suite for `GET /api/workouts/{id}` — single-workout detail. - * - * v1.4.32 — paired with the list endpoint so the iOS detail screen + - * the `/insights/workouts/[id]` web page consume one envelope per - * workout. The test pins: - * - 200 + iOS-contract field names for a row the caller owns; - * - 401 without a session; - * - 404 (existence-channel sealed) when the row belongs to another - * user; - * - 404 when no row exists; - * - `canonicalId` resolves to the cluster winner — the requested id - * when the row is the canonical pick, the winner id when it is a - * non-canonical twin; - * - the optional `route` field flows through GeoJSON geometry when a - * `WorkoutRoute` row is attached, and is null when missing; - * - `aiInsight` is a pure READ: it serves a stored paragraph when one - * exists, null when none does, and never generates one; - * - `dayKey` dates the session in the user's timezone, so a session that - * starts late in the UTC evening lands on the local day the person - * actually trained on. - */ -import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -import { _resetCryptoCacheForTests } from "@/lib/crypto"; -import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; - -vi.mock("@/lib/db", () => ({ - prisma: { - workout: { - findUnique: vi.fn(), - findMany: vi.fn(), - }, - user: { - findUnique: vi.fn(), - }, - measurement: { - findMany: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -vi.mock("@/lib/modules/gate", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveModuleMap: vi.fn(async () => ({})), - requireModuleEnabled: vi.fn(async () => ({ enabled: true })), - }; -}); - -// The Activity Insight paragraph follows the `workoutInsights` capability. -vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe: vi.fn() })); - -import { GET } from "../[id]/route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { requireModuleEnabled } from "@/lib/modules/gate"; -import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; -import { - AI_AVAILABLE, - aiUnavailable, -} from "@/__tests__/helpers/ai-capability-fixtures"; -import { invalidateUserTimezone } from "@/lib/tz/resolver"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const }, -}; - -function makeRequest(): NextRequest { - return new NextRequest("http://localhost/api/workouts/w-1", { - method: "GET", - }); -} - -function makeParams(id: string) { - return { params: Promise.resolve({ id }) }; -} - -/** - * Session day, ten days before the test run. The detail route's HR-curve - * enrichment only reaches the pulse-window fallback inside the 90-day - * dense-intraday retention horizon (`DENSE_INTRADAY_RETENTION_DAYS`), so a - * fixed calendar day silently ages out of that branch and leaves the - * fallback assertions green without exercising it. - */ -const SESSION_DAY = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); - -const BASE_ROW = { - id: "w-1", - userId: "user-1", - sportType: "RUNNING", - startedAt: new Date(`${SESSION_DAY}T07:00:00Z`), - endedAt: new Date(`${SESSION_DAY}T07:30:00Z`), - durationSec: 1800, - totalDistanceM: 5000, - totalEnergyKcal: 320, - avgHeartRate: 145, - maxHeartRate: 170, - minHeartRate: 110, - stepCount: 5800, - elevationM: 12.5, - pauseDurationSec: null, - source: "APPLE_HEALTH" as const, - externalId: "ext-w-1", - externalSourceVersion: null, - metadata: { hkVersion: "1" }, - createdAt: new Date(`${SESSION_DAY}T07:30:00Z`), - updatedAt: new Date(`${SESSION_DAY}T07:30:00Z`), - route: null, - insight: null, -}; - -describe("GET /api/workouts/{id}", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - sourcePriorityJson: null, - dateOfBirth: null, - } as never); - // #67 — the enrichment reads: the HR-curve pulse-window fallback - // (`measurement.findMany`) and the sport-context average (a SECOND - // `workout.findMany`). Default both to empty; each test's - // `mockResolvedValueOnce` still wins for the cluster call. - vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - // clearAllMocks wipes the factory default; re-seed the gate to pass. - vi.mocked(requireModuleEnabled).mockResolvedValue({ - enabled: true, - } as never); - vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); - // The timezone resolver caches per user for 60 s in process; drop the - // entry so a test that pins a zone is not served the previous one. - invalidateUserTimezone(SESSION_OK.user.id); - }); - - it("returns the workout with iOS-contract field names", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( - BASE_ROW as never, - ); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.data.id).toBe("w-1"); - expect(body.data.distanceM).toBe(5000); - expect(body.data.activeEnergyKcal).toBe(320); - expect(body.data.avgHr).toBe(145); - expect(body.data.maxHr).toBe(170); - expect(body.data.canonicalId).toBe("w-1"); - expect(body.data.route).toBeNull(); - // Nothing older on record: absence, not a link to nowhere. - expect(body.data.previousWorkoutId).toBeNull(); - }); - - it("points previousWorkoutId at the canonical session before this one", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( - BASE_ROW as never, - ); - vi.mocked(prisma.workout.findMany) - // 1) the deep-link cluster - .mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never) - // 2) the sport-context baseline - .mockResolvedValueOnce([] as never) - // 3) the older sessions, newest first - .mockResolvedValueOnce([ - { - id: "w-0", - source: "APPLE_HEALTH", - startedAt: new Date("2026-05-13T07:00:00Z"), - sportType: "RUNNING", - durationSec: 1800, - avgHeartRate: null, - maxHeartRate: null, - metadata: null, - }, - // The same session as seen by the second watch. It must never win - // the link — the list would never show it. - { - id: "w-0-twin", - source: "WITHINGS", - startedAt: new Date("2026-05-13T07:01:00Z"), - sportType: "RUNNING", - durationSec: 1800, - avgHeartRate: null, - maxHeartRate: null, - metadata: null, - }, - { - id: "w-older", - source: "APPLE_HEALTH", - startedAt: new Date("2026-05-10T07:00:00Z"), - sportType: "RUNNING", - durationSec: 1800, - avgHeartRate: null, - maxHeartRate: null, - metadata: null, - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.data.previousWorkoutId).toBe("w-0"); - }); - - it("dates the session by the user's timezone, not by UTC", async () => { - // 20:00 UTC is already the NEXT calendar day in Auckland. A client that - // sliced the ISO timestamp would read the session's UTC day and pull the wrong - // day's pulse, sleep and mood around the session. - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - sourcePriorityJson: null, - dateOfBirth: null, - timezone: "Pacific/Auckland", - } as never); - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - startedAt: new Date(`${SESSION_DAY}T20:00:00Z`), - endedAt: new Date(`${SESSION_DAY}T20:30:00Z`), - } as never); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: new Date(`${SESSION_DAY}T20:00:00Z`), - sportType: "RUNNING", - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(res.status).toBe(200); - const nextLocalDay = new Date( - new Date(`${SESSION_DAY}T00:00:00Z`).getTime() + 24 * 60 * 60 * 1000, - ) - .toISOString() - .slice(0, 10); - expect(body.data.dayKey).toBe(nextLocalDay); - }); - - it("enriches the Apple winner with only matched WHOOP HR and zones", async () => { - const routeGeometry = { - type: "LineString", - coordinates: [ - [11.0, 49.0], - [11.01, 49.01], - ], - }; - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - avgHeartRate: null, - maxHeartRate: null, - metadata: { hkVersion: "1", owner: "apple" }, - route: { - id: "wr-apple", - geometry: routeGeometry, - sampleTimestamps: null, - createdAt: BASE_ROW.createdAt, - }, - } as never); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - avgHeartRate: null, - maxHeartRate: null, - metadata: { hkVersion: "1", owner: "apple" }, - }, - { - id: "w-whoop", - source: "WHOOP", - startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), - sportType: "RUNNING", - avgHeartRate: 148, - maxHeartRate: 181, - metadata: { - zoneDurations: { - zone_one_milli: 60_000, - zone_two_milli: 300_000, - }, - whoopWorkoutStrain: 12.3, - }, - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(body.data).toMatchObject({ - id: "w-1", - source: "APPLE_HEALTH", - canonicalId: "w-1", - avgHr: 148, - maxHr: 181, - route: { geometry: routeGeometry }, - metadata: { hkVersion: "1", owner: "apple" }, - }); - expect(body.data.metadata).not.toHaveProperty("whoopWorkoutStrain"); - expect(body.data.metadata).not.toHaveProperty("zoneDurations"); - expect(body.data.zones.model).toBe("whoop"); - expect(body.data.hrSeries).toBeNull(); - }); - - it("does not overwrite the canonical winner's existing HR values", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( - BASE_ROW as never, - ); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - avgHeartRate: BASE_ROW.avgHeartRate, - maxHeartRate: BASE_ROW.maxHeartRate, - metadata: BASE_ROW.metadata, - }, - { - id: "w-whoop", - source: "WHOOP", - startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), - sportType: "RUNNING", - avgHeartRate: 155, - maxHeartRate: 190, - metadata: null, - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(body.data.avgHr).toBe(145); - expect(body.data.maxHr).toBe(170); - }); - - it("returns 401 without a session", async () => { - vi.mocked(getSession).mockResolvedValueOnce(null); - const res = await GET(makeRequest(), makeParams("w-1")); - expect(res.status).toBe(401); - }); - - it("returns 403 module.disabled when the workouts module is off", async () => { - vi.mocked(requireModuleEnabled).mockResolvedValueOnce({ - enabled: false, - response: new Response( - JSON.stringify({ data: null, error: "Module disabled" }), - { status: 403 }, - ), - } as never); - const res = await GET(makeRequest(), makeParams("w-1")); - expect(res.status).toBe(403); - // The detail query never runs once the gate refuses. - expect(prisma.workout.findUnique).not.toHaveBeenCalled(); - }); - - it("returns 404 when the row belongs to another user", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - userId: "user-2", - } as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - expect(res.status).toBe(404); - }); - - it("returns 404 when no row exists", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce(null); - const res = await GET(makeRequest(), makeParams("w-1")); - expect(res.status).toBe(404); - }); - - it("resolves canonicalId to the Apple Health twin when the requested row is the Withings sibling", async () => { - // Same 5-minute slot + sportType, but the caller asked for the - // Withings row. The picker's default ladder makes Apple Health win - // the cluster, so canonicalId points at the Apple row. - const withingsRow = { - ...BASE_ROW, - id: "w-withings", - source: "WITHINGS" as const, - externalId: "withings-1", - }; - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( - withingsRow as never, - ); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-apple", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - { - id: "w-withings", - source: "WITHINGS", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-withings")); - const body = await res.json(); - - expect(res.status).toBe(200); - expect(body.data.id).toBe("w-withings"); - expect(body.data.canonicalId).toBe("w-apple"); - }); - - it("adds the #67 enrichment fields and strips raw blobs under compact=1", async () => { - const startMs = BASE_ROW.startedAt.getTime(); - const storedSamples = Array.from({ length: 40 }, (_, i) => ({ - t: new Date(startMs + i * 45_000).toISOString(), - hr: 140 + (i % 6), - })); - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - // WHOOP device zones win → zones render even without profile age. - metadata: { - zoneDurations: { - zone_one_milli: 60_000, - zone_two_milli: 300_000, - zone_three_milli: 600_000, - zone_four_milli: 200_000, - zone_five_milli: 40_000, - }, - }, - route: { - id: "wr-1", - geometry: { - type: "LineString", - coordinates: [ - [11, 49], - [11.01, 49.01], - ], - }, - sampleTimestamps: [ - `${SESSION_DAY}T07:00:00Z`, - `${SESSION_DAY}T07:01:00Z`, - ], - createdAt: BASE_ROW.createdAt, - }, - samples: { sampleCount: storedSamples.length, samples: storedSamples }, - } as never); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - - const res = await GET( - new NextRequest("http://localhost/api/workouts/w-1?compact=1", { - method: "GET", - }), - makeParams("w-1"), - ); - const body = await res.json(); - - expect(res.status).toBe(200); - // Stored series wins → provenance is the native workout series. - expect(body.data.hrSeries.source).toBe("workout_series"); - expect(body.data.hrSeries.points.length).toBeGreaterThanOrEqual(2); - // WHOOP device zones. - expect(body.data.zones.model).toBe("whoop"); - // Additive keys present; reserved seam is null. - expect(body.data).toHaveProperty("splits"); - expect(body.data).toHaveProperty("sportContext"); - expect(body.data.aiInsight).toBeNull(); - // compact=1 drops the raw blobs but keeps count + geometry. - expect(body.data.samples.sampleCount).toBe(40); - expect(body.data.samples.samples).toBeNull(); - expect(body.data.route.geometry).not.toBeNull(); - expect(body.data.route.sampleTimestamps).toBeNull(); - }); - - it("exposes the WorkoutRoute geometry when present", async () => { - const routeGeometry = { - type: "LineString", - coordinates: [ - [11.0, 49.0], - [11.01, 49.01], - ], - }; - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - route: { - id: "wr-1", - geometry: routeGeometry, - sampleTimestamps: [ - `${SESSION_DAY}T07:00:00Z`, - `${SESSION_DAY}T07:01:00Z`, - ], - createdAt: BASE_ROW.createdAt, - }, - } as never); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - - const res = await GET(makeRequest(), makeParams("w-1")); - const body = await res.json(); - - expect(body.data.route).not.toBeNull(); - expect(body.data.route.geometry).toEqual(routeGeometry); - expect(body.data.route.sampleTimestamps).toEqual([ - `${SESSION_DAY}T07:00:00Z`, - `${SESSION_DAY}T07:01:00Z`, - ]); - }); - - describe("v1.39.4 — sealed track", () => { - const TEST_KEY = "c".repeat(64); - const routeGeometry = { - type: "LineString", - coordinates: [ - [11.0, 49.0], - [11.005, 49.0], - [11.01, 49.0], - ], - }; - - beforeEach(() => { - vi.stubEnv("ENCRYPTION_KEYS", ""); - vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); - vi.stubEnv("ENCRYPTION_KEY", TEST_KEY); - _resetCryptoCacheForTests(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - _resetCryptoCacheForTests(); - }); - - function mockRoute(route: Record) { - vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ - ...BASE_ROW, - route: { - id: "wr-1", - sampleTimestamps: [ - `${SESSION_DAY}T07:00:00Z`, - `${SESSION_DAY}T07:02:00Z`, - `${SESSION_DAY}T07:04:00Z`, - ], - createdAt: BASE_ROW.createdAt, - ...route, - }, - } as never); - vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - } - - it("serves a sealed track in the same shape as a readable one, splits included", async () => { - mockRoute({ geometry: routeGeometry, geometryEncrypted: null }); - const legacy = ( - await (await GET(makeRequest(), makeParams("w-1"))).json() - ).data; - - mockRoute({ - geometry: null, - geometryEncrypted: encryptRouteGeometry(routeGeometry), - }); - const sealed = ( - await (await GET(makeRequest(), makeParams("w-1"))).json() - ).data; - - expect(sealed.route).toEqual(legacy.route); - expect(sealed.route.geometry).toEqual(routeGeometry); - expect(sealed.splits).toEqual(legacy.splits); - }); - - it("prefers the ciphertext over a readable column on the same row", async () => { - mockRoute({ - geometry: { type: "LineString", coordinates: [[0, 0]] }, - geometryEncrypted: encryptRouteGeometry(routeGeometry), - }); - const body = await (await GET(makeRequest(), makeParams("w-1"))).json(); - expect(body.data.route.geometry).toEqual(routeGeometry); - }); - - it("selects the sealed column", async () => { - mockRoute({ geometry: routeGeometry, geometryEncrypted: null }); - await GET(makeRequest(), makeParams("w-1")); - const args = vi.mocked(prisma.workout.findUnique).mock.calls.at(-1)?.[0]; - expect( - (args as { include: { route: { select: Record } } }) - .include.route.select.geometryEncrypted, - ).toBe(true); - }); - }); -}); - -/** - * The Activity-Insight read. - * - * The load-bearing property is what is ABSENT: this route has no enqueue, no - * provider resolution and no fallback. A workout with no stored row serves null - * and the page renders nothing — which is every workout that predates the - * feature, every re-synced one, and every one on a provider-less install. - */ -describe("GET /api/workouts/{id} — aiInsight", () => { - // A throwaway test key. The route decrypts a stored paragraph, so the suite - // needs a configured cipher; `_resetCryptoCacheForTests` keeps the stubbed - // key from leaking into the neighbouring describes. - const TEST_KEY = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - - beforeEach(() => { - vi.clearAllMocks(); - vi.stubEnv("ENCRYPTION_KEY", TEST_KEY); - _resetCryptoCacheForTests(); - vi.mocked(aiCapabilityToServe).mockResolvedValue(AI_AVAILABLE); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - sourcePriorityJson: null, - dateOfBirth: null, - } as never); - vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); - vi.mocked(prisma.workout.findMany).mockResolvedValue([ - { - id: "w-1", - source: "APPLE_HEALTH", - startedAt: BASE_ROW.startedAt, - sportType: "RUNNING", - }, - ] as never); - }); - - async function get() { - return GET( - new NextRequest("http://localhost/api/workouts/w-1?compact=1", { - method: "GET", - }), - makeParams("w-1"), - ); - } - - it("serves null when the workout has no stored paragraph", async () => { - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...BASE_ROW, - insight: null, - } as never); - - const body = await (await get()).json(); - expect(body.data.aiInsight).toBeNull(); - }); - - it("serves the decrypted paragraph when one exists", async () => { - const { encryptToBytes } = await import("@/lib/ai/coach/bytes-codec"); - const generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`); - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...BASE_ROW, - insight: { - paragraphEncrypted: encryptToBytes("A steady, aerobic-leaning run."), - generatedAt, - }, - } as never); - - const body = await (await get()).json(); - expect(body.data.aiInsight).toEqual({ - paragraph: "A steady, aerobic-leaning run.", - generatedAt: generatedAt.toISOString(), - }); - expect(aiCapabilityToServe).toHaveBeenCalledWith( - "user-1", - "workoutInsights", - ); - expect(body.data.ai).toEqual(AI_AVAILABLE); - }); - - it.each([ - "operator_disabled", - "user_disabled", - "module_disabled", - "consent_required", - "no_provider", - ] as const)( - "does not read or expose the stored paragraph while workoutInsights is %s", - async (reason) => { - vi.mocked(aiCapabilityToServe).mockResolvedValue(aiUnavailable(reason)); - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...BASE_ROW, - insight: { - paragraphEncrypted: new Uint8Array([1, 2, 3]), - generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`), - }, - } as never); - - const body = await (await get()).json(); - expect(body.data.aiInsight).toBeNull(); - expect(body.data.ai).toEqual(aiUnavailable(reason)); - expect(prisma.workout.findUnique).toHaveBeenCalledWith( - expect.objectContaining({ - include: expect.objectContaining({ insight: false }), - }), - ); - }, - ); - - it("degrades to no card rather than failing the page on an undecryptable row", async () => { - // `decrypt` is fail-closed by design. A key rotated away must not turn the - // whole workout-detail page into a 500 over a garnish field. - vi.mocked(prisma.workout.findUnique).mockResolvedValue({ - ...BASE_ROW, - insight: { - paragraphEncrypted: new Uint8Array([0, 1, 2, 3]), - generatedAt: new Date(`${SESSION_DAY}T07:35:00Z`), - }, - } as never); - - const res = await get(); - const body = await res.json(); - expect(res.status).toBe(200); - expect(body.data.aiInsight).toBeNull(); - // The rest of the payload is untouched — the failure is scoped to the field. - expect(body.data.id).toBe("w-1"); - expect(body.data.durationSec).toBe(1800); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - _resetCryptoCacheForTests(); - }); -}); diff --git a/tests/integration/routes/settings-channel-tests.test.ts b/tests/integration/routes/settings-channel-tests.test.ts new file mode 100644 index 000000000..b6dda936c --- /dev/null +++ b/tests/integration/routes/settings-channel-tests.test.ts @@ -0,0 +1,612 @@ +/** + * The channel self-tests, `POST /api/settings/{webhook,ntfy,email,telegram} + * /test`, against the real database, through the real API kit and the real + * senders. Only the far ends are replaced: outbound HTTP at the `safeFetch` + * seam (the relay, the ntfy server, Telegram's Bot API) and the SMTP + * transport at `nodemailer.createTransport`. What a test press sends is read + * off what those seams received. + * + * Pinned here: + * - a delivered test is 200 `{ sent: true }`, and the far end received the + * message for the caller's own stored config: the Discord-compatible + * generic webhook body, the ntfy JSON publish, the mail to the stored + * recipient, the Telegram text in the account's language (German, + * French, English for a missing or unknown locale); + * - a private target the operator has not listed is 422 + * `private_origin_not_approved`, one no grant can open is 422 + * `private_origin_not_grantable`, and in both cases nothing is dialled; + * - a relay that answered an error status is 502 with the status mapped to + * the card's code, the status itself, and what the relay said when it + * said anything; a timeout is 502 `timeout`; an SMTP rejection is 502 + * with the code and the SMTP reply code; only a failure the sender could + * not name is 500 `http.internal`; + * - Telegram: no saved credentials is 422 and nothing is sent, a refused + * send is 422, and the sixth test inside five minutes is 429. + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { SafeFetchError, type SafeFetchOptions } from "@/lib/safe-fetch"; +import { encrypt } from "@/lib/crypto"; +import { resetEmailConfigForTesting } from "@/lib/notifications/senders/email-config"; +import { resetEmailTransporterForTesting } from "@/lib/notifications/senders/email"; +import { POST as testWebhook } from "@/app/api/settings/webhook/test/route"; +import { POST as testNtfy } from "@/app/api/settings/ntfy/test/route"; +import { POST as testEmail } from "@/app/api/settings/email/test/route"; +import { POST as testTelegram } from "@/app/api/settings/telegram/test/route"; + +import { callRoute } from "../../kit/call-route"; +import { makeSession, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +interface SentRequest { + url: string; + method: string; + headers: Record; + body: string; +} + +interface SentMail { + from?: string; + to?: string; + subject?: string; + text?: string; +} + +const far = vi.hoisted(() => ({ + requests: [] as SentRequest[], + /** The far end's answer; a thrown value stands for the network failing. */ + answer: null as null | ((request: SentRequest) => Response), + mails: [] as SentMail[], + smtpFailure: null as null | Error, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: async ( + url: string | URL, + init: RequestInit = {}, + _options?: SafeFetchOptions, + ) => { + const request: SentRequest = { + url: String(url), + method: init.method ?? "GET", + headers: Object.fromEntries(new Headers(init.headers).entries()), + body: typeof init.body === "string" ? init.body : "", + }; + far.requests.push(request); + if (far.answer) return far.answer(request); + return new Response(JSON.stringify({ ok: true, result: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }; +}); + +vi.mock("nodemailer", async (importOriginal) => { + const actual = await importOriginal(); + const createTransport = () => ({ + sendMail: async (mail: SentMail) => { + far.mails.push(mail); + if (far.smtpFailure) throw far.smtpFailure; + return { messageId: "" }; + }, + }); + return { + ...actual, + createTransport, + default: { ...actual.default, createTransport }, + }; +}); + +const prisma = getPrismaClient(); + +const ENV_KEYS = [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_FROM", + "NOTIFICATION_PRIVATE_ORIGINS", +] as const; +const savedEnv = new Map(); + +beforeAll(() => { + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + process.env.SMTP_HOST = "smtp.example.test"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_FROM = "healthlog@example.test"; + resetEmailConfigForTesting(); + resetEmailTransporterForTesting(); +}); + +afterAll(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEmailConfigForTesting(); + resetEmailTransporterForTesting(); +}); + +beforeEach(async () => { + await truncateAllTables(prisma); + far.requests.length = 0; + far.answer = null; + far.mails.length = 0; + far.smtpFailure = null; + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +async function withChannel( + type: "WEBHOOK" | "NTFY" | "EMAIL", + config: Record, +) { + const user = await makeUser(); + await prisma.notificationChannel.create({ + data: { + userId: user.id, + type, + enabled: true, + config: encrypt(JSON.stringify(config)), + }, + }); + return user; +} + +const TEST_ROUTES = { + webhook: { handler: testWebhook, route: "/api/settings/webhook/test" }, + ntfy: { handler: testNtfy, route: "/api/settings/ntfy/test" }, + email: { handler: testEmail, route: "/api/settings/email/test" }, + telegram: { handler: testTelegram, route: "/api/settings/telegram/test" }, +} as const; + +async function press(name: keyof typeof TEST_ROUTES, userId: string) { + const { handler, route } = TEST_ROUTES[name]; + return callRoute<{ sent: boolean }>(handler, { + method: "POST", + route, + auth: await makeSession({ id: userId }), + }); +} + +function answerWith(status: number, body: string | null): void { + far.answer = () => + new Response(body, { + status, + headers: body ? { "content-type": "application/json" } : {}, + }); +} + +const PUBLIC_TARGETS = { + webhook: { + type: "WEBHOOK", + config: { url: "https://relay.example.com/hook" }, + }, + ntfy: { + type: "NTFY", + config: { serverUrl: "https://ntfy.example.com", topic: "health" }, + }, +} as const; + +const PRIVATE_TARGETS = { + webhook: { type: "WEBHOOK", config: { url: "http://10.0.0.5:8080/message" } }, + ntfy: { + type: "NTFY", + config: { serverUrl: "http://192.168.1.20:8080", topic: "health" }, + }, +} as const; + +const UNGRANTABLE_TARGETS = { + webhook: { + type: "WEBHOOK", + config: { url: "http://169.254.169.254/latest" }, + }, + ntfy: { + type: "NTFY", + config: { serverUrl: "http://169.254.169.254", topic: "health" }, + }, +} as const; + +const RELAYS = ["webhook", "ntfy"] as const; + +describe("POST /api/settings/webhook/test", () => { + const DISCORD_URL = + "https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"; + + it("delivers to a Discord webhook URL: the generic body carries the content Discord requires", async () => { + const user = await withChannel("WEBHOOK", { url: DISCORD_URL }); + // Answers the way Discord's execute-webhook endpoint does: 400 50006 for + // a body without `content` or `embeds`, 204 otherwise. + far.answer = (request) => { + const body = JSON.parse(request.body) as Record; + const content = typeof body.content === "string" ? body.content : ""; + if (content.trim().length === 0 && body.embeds === undefined) { + return new Response( + JSON.stringify({ + message: "Cannot send an empty message", + code: 50006, + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + } + return new Response(null, { status: 204 }); + }; + + const res = await press("webhook", user.id); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ sent: true }); + expect(far.requests).toHaveLength(1); + expect(far.requests[0].url).toBe(DISCORD_URL); + expect(far.requests[0].method).toBe("POST"); + const sent = JSON.parse(far.requests[0].body) as Record; + expect(sent.content).toBe( + "HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.", + ); + expect(sent.allowed_mentions).toEqual({ parse: [] }); + }); + + it("passes Discord's own error through when the target refuses", async () => { + const user = await withChannel("WEBHOOK", { url: DISCORD_URL }); + answerWith( + 404, + JSON.stringify({ message: "Unknown Webhook", code: 10015 }), + ); + + const res = await press("webhook", user.id); + + expect(res.status).toBe(502); + expect(res.meta?.errorCode).toBe("endpoint_not_found"); + expect(res.meta?.upstreamStatus).toBe(404); + expect(String(res.meta?.upstreamBody)).toContain("Unknown Webhook"); + }); + + it.each([ + [401, "credentials_rejected"], + [403, "credentials_rejected"], + [404, "endpoint_not_found"], + [410, "endpoint_not_found"], + [415, "upstream_rejected"], + [422, "upstream_rejected"], + [429, "rate_limited"], + [503, "upstream_error"], + [307, "redirected"], + ])( + "maps an upstream %i to %s, without a body when the relay sent none", + async (status, code) => { + const user = await withChannel("WEBHOOK", PUBLIC_TARGETS.webhook.config); + answerWith(status, null); + + const res = await press("webhook", user.id); + + expect(res.status).toBe(502); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: code, + upstreamStatus: status, + }); + }, + ); +}); + +describe("POST /api/settings/{webhook,ntfy}/test", () => { + it.each(RELAYS)( + "%s: refuses a private target the operator has not listed with 422 and the code, and dials nothing", + async (name) => { + const { type, config } = PRIVATE_TARGETS[name]; + const user = await withChannel(type, config); + + const res = await press(name, user.id); + + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); + expect(res.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(far.requests).toEqual([]); + }, + ); + + it.each(RELAYS)( + "%s: refuses a target no grant can open with its own code and a message that does not say list it", + async (name) => { + const { type, config } = UNGRANTABLE_TARGETS[name]; + const user = await withChannel(type, config); + + const res = await press(name, user.id); + + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); + expect(res.error).toMatch(/no operator grant can open/); + expect(res.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(far.requests).toEqual([]); + }, + ); + + it.each(RELAYS)( + "%s: a relay that answered 400 is a 502 naming the status and what it said", + async (name) => { + const { type, config } = PUBLIC_TARGETS[name]; + const user = await withChannel(type, config); + answerWith(400, '{"error":"Bad Request","errorCode":400}'); + + const res = await press(name, user.id); + + expect(res.status).toBe(502); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "upstream_rejected", + upstreamStatus: 400, + upstreamBody: '{"error":"Bad Request","errorCode":400}', + }); + expect(res.error).toContain("HTTP 400"); + }, + ); + + it.each(RELAYS)( + "%s: a timeout without a status is a 502 with the timeout code", + async (name) => { + const { type, config } = PUBLIC_TARGETS[name]; + const user = await withChannel(type, config); + far.answer = () => { + throw new SafeFetchError("The operation timed out", "timeout"); + }; + + const res = await press(name, user.id); + + expect(res.status).toBe(502); + expect(res.meta?.errorCode).toBe("timeout"); + }, + ); + + it.each(RELAYS)( + "%s: only a failure the sender could not name stays a 500", + async (name) => { + const { type, config } = PUBLIC_TARGETS[name]; + const user = await withChannel(type, config); + far.answer = () => { + throw new TypeError("something unexpected"); + }; + + const res = await press(name, user.id); + + expect(res.status).toBe(500); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.internal", + }); + }, + ); + + it("webhook: a delivered test is a 200 and the relay received the generic body", async () => { + const user = await withChannel("WEBHOOK", { + url: "https://relay.example.com/hook", + headerName: "X-Relay-Key", + headerValue: "relay-secret", + }); + + const res = await press("webhook", user.id); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: { sent: true }, error: null }); + expect(far.requests).toHaveLength(1); + expect(far.requests[0].url).toBe("https://relay.example.com/hook"); + expect(far.requests[0].headers["x-relay-key"]).toBe("relay-secret"); + expect(JSON.parse(far.requests[0].body)).toMatchObject({ + title: "HealthLog Test", + message: + "HealthLog: Connection successful! Webhook notifications are active.", + eventType: "SYSTEM_ALERT", + }); + }); + + it("ntfy: a delivered test is a 200 and the server received the JSON publish for the stored topic", async () => { + const user = await withChannel("NTFY", { + serverUrl: "https://ntfy.example.com", + topic: "health", + authToken: "tk_ntfy", + }); + + const res = await press("ntfy", user.id); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: { sent: true }, error: null }); + expect(far.requests).toHaveLength(1); + expect(far.requests[0].url).toBe("https://ntfy.example.com/"); + expect(far.requests[0].headers.authorization).toBe("Bearer tk_ntfy"); + expect(JSON.parse(far.requests[0].body)).toMatchObject({ + topic: "health", + title: "HealthLog Test", + message: + "HealthLog: Connection successful! ntfy notifications are active.", + }); + }); +}); + +describe("POST /api/settings/email/test", () => { + async function emailUser() { + return withChannel("EMAIL", { recipient: "you@example.com" }); + } + + it.each([ + [ + "credentials_rejected", + Object.assign(new Error("Invalid login: 535 Authentication failed"), { + code: "EAUTH", + responseCode: 535, + }), + 535, + "rejected the credentials (SMTP 535)", + ], + [ + "upstream_rejected", + Object.assign(new Error("Mailbox unavailable"), { + code: "EENVELOPE", + responseCode: 550, + }), + 550, + "refused the message (SMTP 550)", + ], + ])( + "answers an SMTP rejection with 502, %s and the reply code", + async (code, failure, smtpCode, sentence) => { + const user = await emailUser(); + far.smtpFailure = failure; + + const res = await press("email", user.id); + + expect(res.status).toBe(502); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: code, + smtpCode, + }); + expect(res.error).toContain(sentence); + }, + ); + + it("answers a timeout with 502 and the timeout code", async () => { + const user = await emailUser(); + far.smtpFailure = Object.assign(new Error("Connection timeout"), { + code: "ETIMEDOUT", + }); + + const res = await press("email", user.id); + + expect(res.status).toBe(502); + expect(res.meta?.errorCode).toBe("timeout"); + }); + + it("keeps a 500 for a failure the sender could not name", async () => { + const user = await emailUser(); + far.smtpFailure = new Error("something unexpected"); + + const res = await press("email", user.id); + + expect(res.status).toBe(500); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.internal", + }); + }); + + it("answers a delivered test with 200, and the mail went to the stored recipient", async () => { + const user = await emailUser(); + + const res = await press("email", user.id); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ sent: true }); + expect(far.mails).toEqual([ + { + from: "healthlog@example.test", + to: "you@example.com", + subject: "HealthLog Test", + text: "HealthLog: Connection successful! Email notifications are active.", + }, + ]); + }); +}); + +describe("POST /api/settings/telegram/test", () => { + async function telegramUser(locale: string | null) { + return makeUser({ + telegramBotToken: encrypt("123:bot-token"), + telegramChatId: "chat-1", + locale, + }); + } + + function sentText(): unknown { + expect(far.requests).toHaveLength(1); + expect(far.requests[0].url).toBe( + "https://api.telegram.org/bot123:bot-token/sendMessage", + ); + const body = JSON.parse(far.requests[0].body) as { + chat_id: string; + text: string; + }; + expect(body.chat_id).toBe("chat-1"); + return body.text; + } + + it.each([ + [ + "de", + "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + ], + [ + "fr", + "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + ], + [ + null, + "HealthLog: connection successful. Telegram notifications are active.", + ], + [ + "klingon", + "HealthLog: connection successful. Telegram notifications are active.", + ], + ])( + "sends the body in the account's language (locale %s)", + async (locale, text) => { + const user = await telegramUser(locale); + + const res = await press("telegram", user.id); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ sent: true }); + expect(sentText()).toBe(text); + }, + ); + + it("answers 422 and sends nothing when no bot token is saved", async () => { + const user = await makeUser({ locale: "de" }); + + const res = await press("telegram", user.id); + + expect(res.status).toBe(422); + expect(far.requests).toEqual([]); + }); + + it("answers 422 when Telegram refuses the send", async () => { + const user = await telegramUser("en"); + far.answer = () => + new Response( + JSON.stringify({ + ok: false, + description: "Bad Request: chat not found", + }), + { status: 400, headers: { "content-type": "application/json" } }, + ); + + const res = await press("telegram", user.id); + + expect(res.status).toBe(422); + }); + + it("answers the sixth test inside five minutes with 429 and sends nothing for it", async () => { + const user = await telegramUser("en"); + for (let i = 0; i < 5; i++) { + expect((await press("telegram", user.id)).status).toBe(200); + } + + const res = await press("telegram", user.id); + + expect(res.status).toBe(429); + expect(far.requests).toHaveLength(5); + }); +}); diff --git a/tests/integration/routes/settings-channels.test.ts b/tests/integration/routes/settings-channels.test.ts new file mode 100644 index 000000000..81254cf79 --- /dev/null +++ b/tests/integration/routes/settings-channels.test.ts @@ -0,0 +1,1063 @@ +/** + * The notification channel save routes, `/api/settings/{webhook,ntfy,email, + * telegram}` and the Web Push subscribe, against the real database and + * through the real API kit. Telegram's Bot API is replaced at the + * `safeFetch` seam, so what the bot is asked to do is read off the requests + * that seam received. + * + * Pinned here, read back from the stored rows: + * - every save route refuses an anonymous caller and a narrow token; + * - a toggle-only body (`{ enabled }`) flips the caller's own row and + * nothing else: the stored config is the same ciphertext afterwards, and + * another account's row of the same type is untouched; enabling a channel + * that has no stored config is 422 and stores nothing; + * - Telegram's toggle keeps the stored token and chat id, registers the + * bot's webhook at `APP_URL` on enable and removes it on disable; + * - a full body creates or replaces the config, encrypted; + * - the ntfy auth token and the webhook header value are write-only and + * survive a save that omits them or sends them empty; a non-empty value + * replaces them; + * - the webhook payload format: only the Gotify choice is written, an + * omitted format keeps the stored one, an unknown one is 422, and the + * read reports the resolved value; + * - a private webhook or ntfy target is refused at save time with a code + * that says whether the operator can list it, and a listed one saves; + * - enabling ntfy, Telegram or Web Push while the operator switched the + * channel off instance-wide is 403 and writes nothing; disabling still + * works. + */ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import type { SafeFetchOptions } from "@/lib/safe-fetch"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { resetEmailConfigForTesting } from "@/lib/notifications/senders/email-config"; +import { + GET as getWebhook, + PUT as putWebhook, +} from "@/app/api/settings/webhook/route"; +import { PUT as putNtfy } from "@/app/api/settings/ntfy/route"; +import { PUT as putEmail } from "@/app/api/settings/email/route"; +import { PUT as putTelegram } from "@/app/api/settings/telegram/route"; +import { POST as subscribeWebPush } from "@/app/api/notifications/web-push/route"; + +import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route"; +import { makeSession, makeToken, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const upstream = vi.hoisted(() => ({ + requests: [] as Array<{ url: string; body: string }>, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: async ( + url: string | URL, + init: RequestInit = {}, + _options?: SafeFetchOptions, + ) => { + upstream.requests.push({ + url: String(url), + body: typeof init.body === "string" ? init.body : "", + }); + return new Response(JSON.stringify({ ok: true, result: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, + }; +}); + +const prisma = getPrismaClient(); + +const ENV_KEYS = [ + "SMTP_HOST", + "SMTP_PORT", + "SMTP_FROM", + "APP_URL", + "NEXT_PUBLIC_APP_URL", + "TELEGRAM_WEBHOOK_SECRET", + "NOTIFICATION_PRIVATE_ORIGINS", +] as const; +const savedEnv = new Map(); + +beforeAll(() => { + for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]); + // The email routes refuse every write on an instance without SMTP. + process.env.SMTP_HOST = "smtp.example.test"; + process.env.SMTP_PORT = "587"; + process.env.SMTP_FROM = "healthlog@example.test"; + resetEmailConfigForTesting(); +}); + +afterAll(() => { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + resetEmailConfigForTesting(); +}); + +beforeEach(async () => { + await truncateAllTables(prisma); + upstream.requests.length = 0; + process.env.APP_URL = "https://health.example"; + delete process.env.NEXT_PUBLIC_APP_URL; + delete process.env.TELEGRAM_WEBHOOK_SECRET; + delete process.env.NOTIFICATION_PRIVATE_ORIGINS; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +type ChannelType = "WEBHOOK" | "NTFY" | "EMAIL" | "TELEGRAM" | "WEB_PUSH"; + +async function storeChannel( + userId: string, + type: ChannelType, + config: Record, + enabled: boolean, +) { + return prisma.notificationChannel.create({ + data: { + userId, + type, + enabled, + config: encrypt(JSON.stringify(config)), + }, + }); +} + +async function channel(userId: string, type: ChannelType) { + return prisma.notificationChannel.findUnique({ + where: { userId_type: { userId, type } }, + }); +} + +async function storedConfig( + userId: string, + type: ChannelType, +): Promise> { + const row = await prisma.notificationChannel.findUniqueOrThrow({ + where: { userId_type: { userId, type } }, + }); + return JSON.parse(decrypt(row.config)) as Record; +} + +async function switchGlobal(values: { + ntfyGlobal?: boolean; + telegramGlobal?: boolean; + webPushGlobal?: boolean; +}): Promise { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", ...values }, + update: values, + }); +} + +const SAVE_ROUTES = { + webhook: { handler: putWebhook, route: "/api/settings/webhook" }, + ntfy: { handler: putNtfy, route: "/api/settings/ntfy" }, + email: { handler: putEmail, route: "/api/settings/email" }, + telegram: { handler: putTelegram, route: "/api/settings/telegram" }, +} as const; + +type SaveRoute = keyof typeof SAVE_ROUTES; + +async function save(channelName: SaveRoute, body: unknown, auth: RouteAuth) { + const { handler, route } = SAVE_ROUTES[channelName]; + return callRoute<{ saved?: true; updated?: true }>(handler, { + method: "PUT", + route, + body, + auth, + }); +} + +const WEBHOOK_CONFIG = { + url: "https://saved.example/hook", + headerName: "Authorization", + headerValue: "secret", +}; +const NTFY_CONFIG = { + serverUrl: "https://ntfy.sh", + topic: "saved-topic", + authToken: "secret", +}; +const EMAIL_CONFIG = { recipient: "saved@example.com" }; + +const TOGGLE_FIXTURES = [ + ["webhook", "WEBHOOK", WEBHOOK_CONFIG], + ["ntfy", "NTFY", NTFY_CONFIG], + ["email", "EMAIL", EMAIL_CONFIG], +] as const; + +describe("who may save a channel", () => { + it.each(Object.keys(SAVE_ROUTES) as SaveRoute[])( + "%s refuses an anonymous caller with 401", + async (name) => { + const res = await save(name, { enabled: false }, anonymous); + + expect(res.status).toBe(401); + expect(res.meta?.errorCode).toBe("auth.missing"); + expect(await prisma.notificationChannel.count()).toBe(0); + }, + ); + + it.each(Object.keys(SAVE_ROUTES) as SaveRoute[])( + "%s refuses a narrow token with 403 and leaves the stored row alone", + async (name) => { + const user = await makeUser({ telegramEnabled: true }); + const type = name === "telegram" ? "TELEGRAM" : name.toUpperCase(); + const row = await prisma.notificationChannel.create({ + data: { + userId: user.id, + type, + enabled: true, + config: encrypt(JSON.stringify(EMAIL_CONFIG)), + }, + }); + + const res = await save( + name, + { enabled: false }, + await makeToken(user, { permissions: ["measurements:write"] }), + ); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + const after = await prisma.notificationChannel.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(after.enabled).toBe(true); + }, + ); +}); + +describe("the toggle-only body", () => { + it.each(TOGGLE_FIXTURES)( + "%s flips only the caller's enabled flag and keeps the stored config", + async (name, type, config) => { + const user = await makeUser(); + const other = await makeUser(); + const own = await storeChannel(user.id, type, config, false); + const theirs = await storeChannel(other.id, type, config, false); + + const res = await save(name, { enabled: true }, await makeSession(user)); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: { saved: true }, error: null }); + const after = await channel(user.id, type); + expect(after?.enabled).toBe(true); + expect(after?.config).toBe(own.config); + const untouched = await channel(other.id, type); + expect(untouched?.enabled).toBe(false); + expect(untouched?.config).toBe(theirs.config); + }, + ); + + it.each(TOGGLE_FIXTURES)( + "%s refuses to enable a channel that has no stored config, and stores none", + async (name, type) => { + const user = await makeUser(); + + const res = await save(name, { enabled: true }, await makeSession(user)); + + expect(res.status).toBe(422); + expect(await channel(user.id, type)).toBeNull(); + }, + ); + + it("Telegram disable keeps the stored credentials and removes the bot's webhook", async () => { + const user = await makeUser({ + telegramBotToken: encrypt("saved-token"), + telegramChatId: "saved-chat", + telegramEnabled: true, + }); + const row = await storeChannel( + user.id, + "TELEGRAM", + { botToken: "saved-token", chatId: "saved-chat" }, + true, + ); + + const res = await save( + "telegram", + { enabled: false }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: { updated: true }, error: null }); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored.telegramEnabled).toBe(false); + expect(stored.telegramBotToken).toBe(user.telegramBotToken); + expect(stored.telegramChatId).toBe("saved-chat"); + const after = await channel(user.id, "TELEGRAM"); + expect(after?.enabled).toBe(false); + expect(after?.config).toBe(row.config); + expect(upstream.requests.map((r) => r.url)).toEqual([ + "https://api.telegram.org/botsaved-token/deleteWebhook", + ]); + }); + + it("Telegram enable registers the webhook at APP_URL with the stored token, and rewrites no credential", async () => { + const user = await makeUser({ + telegramBotToken: encrypt("saved-token"), + telegramChatId: "saved-chat", + telegramEnabled: false, + }); + const row = await storeChannel( + user.id, + "TELEGRAM", + { botToken: "saved-token", chatId: "saved-chat" }, + false, + ); + + const res = await save( + "telegram", + { enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(upstream.requests).toHaveLength(1); + expect(upstream.requests[0].url).toBe( + "https://api.telegram.org/botsaved-token/setWebhook", + ); + expect(JSON.parse(upstream.requests[0].body)).toEqual({ + url: "https://health.example/api/telegram/webhook", + drop_pending_updates: false, + }); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored.telegramEnabled).toBe(true); + expect(stored.telegramBotToken).toBe(user.telegramBotToken); + expect(stored.telegramChatId).toBe("saved-chat"); + const after = await channel(user.id, "TELEGRAM"); + expect(after?.enabled).toBe(true); + expect(after?.config).toBe(row.config); + }); +}); + +describe("the full body", () => { + it.each([ + [ + "webhook", + "WEBHOOK", + { + url: "https://saved.example/hook", + headerName: "X-Test", + headerValue: "secret", + enabled: false, + }, + { + url: "https://saved.example/hook", + headerName: "X-Test", + headerValue: "secret", + }, + ], + [ + "ntfy", + "NTFY", + { + serverUrl: "https://ntfy.sh", + topic: "saved-topic", + authToken: "secret", + enabled: false, + }, + { + serverUrl: "https://ntfy.sh", + topic: "saved-topic", + authToken: "secret", + }, + ], + [ + "email", + "EMAIL", + { recipient: "saved@example.com", enabled: false }, + { recipient: "saved@example.com" }, + ], + ] as const)( + "%s creates the channel with the config encrypted", + async (name, type, body, expected) => { + const user = await makeUser(); + + const res = await save(name, body, await makeSession(user)); + + expect(res.status).toBe(200); + const row = await channel(user.id, type); + expect(row?.enabled).toBe(false); + expect(row?.config).not.toContain("saved"); + expect(await storedConfig(user.id, type)).toEqual(expected); + }, + ); + + it("Telegram stores the token encrypted, the chat id, and mirrors both into the channel row", async () => { + const user = await makeUser(); + + const res = await save( + "telegram", + { botToken: "new-token", chatId: "new-chat", enabled: false }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored.telegramEnabled).toBe(false); + expect(stored.telegramBotToken).not.toBe("new-token"); + expect(decrypt(stored.telegramBotToken ?? "")).toBe("new-token"); + expect(stored.telegramChatId).toBe("new-chat"); + const row = await channel(user.id, "TELEGRAM"); + expect(row?.enabled).toBe(false); + expect(await storedConfig(user.id, "TELEGRAM")).toEqual({ + botToken: "new-token", + chatId: "new-chat", + }); + }); +}); + +describe("write-only secrets survive a save that does not carry them", () => { + it("an omitted ntfy authToken keeps the stored one", async () => { + const user = await makeUser(); + await storeChannel(user.id, "NTFY", NTFY_CONFIG, true); + + const res = await save( + "ntfy", + { serverUrl: "https://ntfy.sh", topic: "saved-topic", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect((await storedConfig(user.id, "NTFY")).authToken).toBe("secret"); + }); + + it("an empty ntfy authToken keeps the stored one, as current clients send it", async () => { + const user = await makeUser(); + await storeChannel(user.id, "NTFY", NTFY_CONFIG, true); + + const res = await save( + "ntfy", + { + serverUrl: "https://ntfy.sh", + topic: "saved-topic", + authToken: "", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect((await storedConfig(user.id, "NTFY")).authToken).toBe("secret"); + }); + + it("a non-empty ntfy authToken replaces the stored one", async () => { + const user = await makeUser(); + await storeChannel( + user.id, + "NTFY", + { ...NTFY_CONFIG, authToken: "old-secret" }, + true, + ); + + const res = await save( + "ntfy", + { + serverUrl: "https://ntfy.sh", + topic: "saved-topic", + authToken: "new-secret", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect((await storedConfig(user.id, "NTFY")).authToken).toBe("new-secret"); + }); + + it("an ntfy channel without a token saves, for a server that allows anonymous publishing", async () => { + const user = await makeUser(); + + const res = await save( + "ntfy", + { serverUrl: "https://ntfy.sh", topic: "public-topic", enabled: false }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(await storedConfig(user.id, "NTFY")).toEqual({ + serverUrl: "https://ntfy.sh", + topic: "public-topic", + }); + }); + + it("an omitted webhook headerValue keeps the stored one", async () => { + const user = await makeUser(); + await storeChannel(user.id, "WEBHOOK", WEBHOOK_CONFIG, true); + + const res = await save( + "webhook", + { + url: "https://saved.example/hook", + headerName: "Authorization", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect((await storedConfig(user.id, "WEBHOOK")).headerValue).toBe("secret"); + }); +}); + +describe("the webhook payload format", () => { + async function readWebhook(userId: string) { + return callRoute<{ + enabled: boolean; + url: string; + headerName: string; + hasHeaderValue: boolean; + format: string; + }>(getWebhook, { + method: "GET", + route: "/api/settings/webhook", + auth: await makeSession({ id: userId }), + }); + } + + it("stores the Gotify choice", async () => { + const user = await makeUser(); + + const res = await save( + "webhook", + { + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + headerValue: "AbCdEfGh123", + format: "gotify", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + headerValue: "AbCdEfGh123", + format: "gotify", + }); + }); + + it("writes nothing for generic, so the stored config keeps its old shape", async () => { + const user = await makeUser(); + + await save( + "webhook", + { + url: "https://relay.example.com/hook", + format: "generic", + enabled: true, + }, + await makeSession(user), + ); + + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "https://relay.example.com/hook", + }); + }); + + it("keeps a stored Gotify choice when the body omits the field", async () => { + const user = await makeUser(); + await storeChannel( + user.id, + "WEBHOOK", + { + url: "https://gotify.example.com/message", + headerValue: "AbCdEfGh123", + format: "gotify", + }, + true, + ); + + await save( + "webhook", + { + url: "https://gotify.example.com/message", + headerValue: "NewToken456", + enabled: true, + }, + await makeSession(user), + ); + + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "https://gotify.example.com/message", + headerValue: "NewToken456", + format: "gotify", + }); + }); + + it("switches a Gotify channel back to generic when asked", async () => { + const user = await makeUser(); + await storeChannel( + user.id, + "WEBHOOK", + { url: "https://gotify.example.com/message", format: "gotify" }, + true, + ); + + await save( + "webhook", + { + url: "https://gotify.example.com/message", + format: "generic", + enabled: true, + }, + await makeSession(user), + ); + + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "https://gotify.example.com/message", + }); + }); + + it("refuses a format it does not know and stores nothing", async () => { + const user = await makeUser(); + + const res = await save( + "webhook", + { + url: "https://relay.example.com/hook", + format: "discord", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(await channel(user.id, "WEBHOOK")).toBeNull(); + }); + + it("reads generic for a config saved before the choice existed", async () => { + const user = await makeUser(); + await storeChannel( + user.id, + "WEBHOOK", + { url: "https://relay.example.com/hook" }, + true, + ); + + const res = await readWebhook(user.id); + + expect(res.status).toBe(200); + expect(res.data.format).toBe("generic"); + }); + + it("reads the stored Gotify choice, and the header value only as a flag", async () => { + const user = await makeUser(); + await storeChannel( + user.id, + "WEBHOOK", + { + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + headerValue: "AbCdEfGh123", + format: "gotify", + }, + true, + ); + + const res = await readWebhook(user.id); + + expect(res.data).toEqual({ + enabled: true, + url: "https://gotify.example.com/message", + headerName: "X-Gotify-Key", + hasHeaderValue: true, + format: "gotify", + }); + }); + + it("reads the defaults, generic included, when no channel exists", async () => { + const user = await makeUser(); + + const res = await readWebhook(user.id); + + expect(res.data).toEqual({ + enabled: false, + url: "", + headerName: "", + hasHeaderValue: false, + format: "generic", + }); + }); +}); + +describe("a private webhook target at save time", () => { + it("refuses an unlisted private address with the reason, and stores nothing", async () => { + const user = await makeUser(); + + const res = await save( + "webhook", + { url: "http://10.0.0.5:8080/message", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(res.data).toBeNull(); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); + expect(res.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(await channel(user.id, "WEBHOOK")).toBeNull(); + }); + + it("refuses a sub-host of a listed origin: a grant names one origin only", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "https://gotify.example.com"; + const user = await makeUser(); + + const res = await save( + "webhook", + { url: "https://push.gotify.example.com/message", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); + expect(await channel(user.id, "WEBHOOK")).toBeNull(); + }); + + it("saves a listed private address the public floor alone would refuse", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://10.0.0.5:8080"; + const user = await makeUser(); + + const res = await save( + "webhook", + { url: "http://10.0.0.5:8080/message", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "http://10.0.0.5:8080/message", + }); + }); + + it("keeps the plain shape refusal for a body that is not a private-origin case", async () => { + const user = await makeUser(); + + const res = await save( + "webhook", + { url: 42, enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(res.error).toBe("Invalid data"); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "http.invalid", + }); + }); + + it("saves a listed loopback origin and refuses an unlisted one with the listable code", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://localhost:8080"; + const user = await makeUser(); + const session = await makeSession(user); + + const refused = await save( + "webhook", + { url: "http://127.0.0.1:8080/message", enabled: true }, + session, + ); + expect(refused.status).toBe(422); + expect(refused.meta?.errorCode).toBe("private_origin_not_approved"); + expect(await channel(user.id, "WEBHOOK")).toBeNull(); + + const saved = await save( + "webhook", + { url: "http://localhost:8080/message", enabled: true }, + session, + ); + expect(saved.status).toBe(200); + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "http://localhost:8080/message", + }); + }); + + it("answers a metadata, unspecified or link-local target with the not-grantable code, listed or not", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.NOTIFICATION_PRIVATE_ORIGINS = + "http://169.254.169.254, http://0.0.0.0:8080"; + const user = await makeUser(); + const session = await makeSession(user); + + for (const url of [ + "http://169.254.169.254/latest", + "http://0.0.0.0:8080/message", + "http://[fe80::1]:8080/message", + ]) { + const res = await save("webhook", { url, enabled: true }, session); + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); + expect(res.error).toMatch(/no operator grant can open/); + expect(res.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); + const body = res.body as { details?: { issues: unknown[] } }; + expect(body.details?.issues.length).toBeGreaterThan(0); + } + expect(await channel(user.id, "WEBHOOK")).toBeNull(); + }); + + it("saves an mDNS name the input floor refuses, once the operator lists it", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://gotify.local"; + const user = await makeUser(); + const session = await makeSession(user); + + const refused = await save( + "webhook", + { url: "http://ntfy.local/message", enabled: true }, + session, + ); + expect(refused.status).toBe(422); + expect(refused.meta?.errorCode).toBe("private_origin_not_approved"); + + const saved = await save( + "webhook", + { url: "http://gotify.local/message", enabled: true }, + session, + ); + expect(saved.status).toBe(200); + expect(await storedConfig(user.id, "WEBHOOK")).toEqual({ + url: "http://gotify.local/message", + }); + }); +}); + +describe("a private ntfy server at save time", () => { + it("refuses an unlisted private server with the reason, and stores nothing", async () => { + const user = await makeUser(); + + const res = await save( + "ntfy", + { serverUrl: "http://192.168.1.20:8080", topic: "health", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); + expect(res.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); + expect(await channel(user.id, "NTFY")).toBeNull(); + }); + + it("saves a listed private server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + const user = await makeUser(); + + const res = await save( + "ntfy", + { serverUrl: "http://192.168.1.20:8080", topic: "health", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(await storedConfig(user.id, "NTFY")).toEqual({ + serverUrl: "http://192.168.1.20:8080", + topic: "health", + }); + }); + + it("refuses a sibling port of a listed server", async () => { + process.env.NOTIFICATION_PRIVATE_ORIGINS = "http://192.168.1.20:8080"; + const user = await makeUser(); + + const res = await save( + "ntfy", + { serverUrl: "http://192.168.1.20:9090", topic: "health", enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(422); + expect(res.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); + expect(await channel(user.id, "NTFY")).toBeNull(); + }); +}); + +describe("the operator's instance-wide channel switches", () => { + it("refuses to enable ntfy while the operator switched it off, and writes nothing", async () => { + await switchGlobal({ ntfyGlobal: false }); + const user = await makeUser(); + const row = await storeChannel(user.id, "NTFY", NTFY_CONFIG, false); + const session = await makeSession(user); + + const toggle = await save("ntfy", { enabled: true }, session); + expect(toggle.status).toBe(403); + expect(toggle.data).toBeNull(); + expect(toggle.error).toContain("disabled on this instance"); + + const full = await save( + "ntfy", + { serverUrl: "https://ntfy.sh", topic: "other-topic", enabled: true }, + session, + ); + expect(full.status).toBe(403); + + const after = await channel(user.id, "NTFY"); + expect(after?.enabled).toBe(false); + expect(after?.config).toBe(row.config); + }); + + it("still lets ntfy be disabled while the switch is off", async () => { + await switchGlobal({ ntfyGlobal: false }); + const user = await makeUser(); + await storeChannel(user.id, "NTFY", NTFY_CONFIG, true); + + const res = await save("ntfy", { enabled: false }, await makeSession(user)); + + expect(res.status).toBe(200); + expect((await channel(user.id, "NTFY"))?.enabled).toBe(false); + }); + + it("enables ntfy while the switch is on", async () => { + await switchGlobal({ ntfyGlobal: true }); + const user = await makeUser(); + await storeChannel(user.id, "NTFY", NTFY_CONFIG, false); + + const res = await save("ntfy", { enabled: true }, await makeSession(user)); + + expect(res.status).toBe(200); + expect((await channel(user.id, "NTFY"))?.enabled).toBe(true); + }); + + it("refuses to enable Telegram while the switch is off, registers no webhook, and writes nothing", async () => { + await switchGlobal({ telegramGlobal: false }); + const user = await makeUser({ + telegramBotToken: encrypt("saved-token"), + telegramChatId: "saved-chat", + telegramEnabled: false, + }); + const row = await storeChannel( + user.id, + "TELEGRAM", + { botToken: "saved-token", chatId: "saved-chat" }, + false, + ); + + const res = await save( + "telegram", + { enabled: true }, + await makeSession(user), + ); + + expect(res.status).toBe(403); + expect(upstream.requests).toEqual([]); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored.telegramEnabled).toBe(false); + const after = await channel(user.id, "TELEGRAM"); + expect(after?.enabled).toBe(false); + expect(after?.config).toBe(row.config); + }); + + it("refuses a full Telegram save that enables the channel while the switch is off", async () => { + await switchGlobal({ telegramGlobal: false }); + const user = await makeUser(); + + const res = await save( + "telegram", + { + botToken: "123456:AAaaBBbbCCccDDddEEeeFFffGGgg-HHhhIIii", + chatId: "9876543", + enabled: true, + }, + await makeSession(user), + ); + + expect(res.status).toBe(403); + expect(upstream.requests).toEqual([]); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored.telegramBotToken).toBeNull(); + expect(stored.telegramChatId).toBeNull(); + expect(stored.telegramEnabled).toBe(false); + expect(await channel(user.id, "TELEGRAM")).toBeNull(); + }); + + const SUBSCRIPTION = { + endpoint: "https://push.example.com/subscription/abc", + keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) }, + }; + + it("refuses a Web Push subscription while the switch is off, and stores none", async () => { + await switchGlobal({ webPushGlobal: false }); + const user = await makeUser(); + + const res = await callRoute(subscribeWebPush, { + method: "POST", + route: "/api/notifications/web-push", + body: SUBSCRIPTION, + auth: await makeSession(user), + }); + + expect(res.status).toBe(403); + expect( + await prisma.pushSubscription.count({ where: { userId: user.id } }), + ).toBe(0); + expect(await channel(user.id, "WEB_PUSH")).toBeNull(); + }); + + it("stores the subscription, keys encrypted, and the Web Push channel while the switch is on", async () => { + await switchGlobal({ webPushGlobal: true }); + const user = await makeUser(); + + const res = await callRoute<{ subscribed: boolean }>(subscribeWebPush, { + method: "POST", + route: "/api/notifications/web-push", + body: SUBSCRIPTION, + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ subscribed: true }); + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId: user.id }, + }); + expect(subscriptions).toHaveLength(1); + expect(subscriptions[0].endpoint).toBe(SUBSCRIPTION.endpoint); + expect(subscriptions[0].p256dh).not.toBe(SUBSCRIPTION.keys.p256dh); + expect(decrypt(subscriptions[0].p256dh)).toBe(SUBSCRIPTION.keys.p256dh); + expect(decrypt(subscriptions[0].auth)).toBe(SUBSCRIPTION.keys.auth); + expect((await channel(user.id, "WEB_PUSH"))?.enabled).toBe(true); + }); +}); diff --git a/tests/integration/routes/settings-reminder-thresholds.test.ts b/tests/integration/routes/settings-reminder-thresholds.test.ts new file mode 100644 index 000000000..2ffb4a9da --- /dev/null +++ b/tests/integration/routes/settings-reminder-thresholds.test.ts @@ -0,0 +1,117 @@ +/** + * `GET /api/settings/reminder-thresholds`, against the real database and + * through the real API kit. + * + * Pinned here: + * - an anonymous caller is refused; + * - the late and missed minutes are the operator's, read from the + * instance settings row, and fall back to 120 / 240 without one; + * - the low-stock runway and the reorder lead are the account's own: a + * fresh account reads the defaults (7 and 10), and the read reflects + * what `PATCH /api/auth/me/notification-prefs` stored, the OFF state + * (`null`) included. This is the read half of that settings round trip. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/settings/reminder-thresholds/route"; +import { PATCH as patchPrefs } from "@/app/api/auth/me/notification-prefs/route"; + +import { anonymous, callRoute } from "../../kit/call-route"; +import { + makeSession, + makeUser, + type SessionFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); +const ROUTE = "/api/settings/reminder-thresholds"; + +interface Thresholds { + lateMinutes: number; + missedMinutes: number; + lowStockRunwayDays: number | null; + reorderLeadDays: number; +} + +beforeEach(() => truncateAllTables(prisma)); + +function read(auth: SessionFixture) { + return callRoute(GET, { method: "GET", route: ROUTE, auth }); +} + +async function setRunway(auth: SessionFixture, days: number | null) { + const res = await callRoute(patchPrefs, { + method: "PATCH", + route: "/api/auth/me/notification-prefs", + body: { medication: { lowStockRunwayDays: days } }, + auth, + }); + expect(res.status).toBe(200); +} + +describe("GET /api/settings/reminder-thresholds", () => { + it("refuses an anonymous caller with 401", async () => { + const res = await callRoute(GET, { + method: "GET", + route: ROUTE, + auth: anonymous, + }); + + expect(res.status).toBe(401); + expect(res.meta?.errorCode).toBe("auth.missing"); + }); + + it("answers the built-in defaults for a fresh account on an instance without a settings row", async () => { + const res = await read(await makeSession(await makeUser())); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ + lateMinutes: 120, + missedMinutes: 240, + lowStockRunwayDays: 7, + reorderLeadDays: 10, + }); + }); + + it("carries the operator's late and missed minutes beside the account's own defaults", async () => { + await prisma.appSettings.create({ + data: { + id: "singleton", + reminderLateMinutes: 90, + reminderMissedMinutes: 300, + }, + }); + + const res = await read(await makeSession(await makeUser())); + + expect(res.data).toEqual({ + lateMinutes: 90, + missedMinutes: 300, + lowStockRunwayDays: 7, + reorderLeadDays: 10, + }); + }); + + it("reflects the runway the notification-prefs write stored, and only for that account", async () => { + const session = await makeSession(await makeUser()); + const otherSession = await makeSession(await makeUser()); + await setRunway(session, 21); + + const own = await read(session); + const other = await read(otherSession); + + expect(own.data.lowStockRunwayDays).toBe(21); + expect(other.data.lowStockRunwayDays).toBe(7); + }); + + it("reflects the OFF state as null", async () => { + const session = await makeSession(await makeUser()); + await setRunway(session, null); + + const res = await read(session); + + expect(res.status).toBe(200); + expect(res.data.lowStockRunwayDays).toBeNull(); + }); +}); diff --git a/tests/integration/routes/workouts-batch-scope.test.ts b/tests/integration/routes/workouts-batch-scope.test.ts new file mode 100644 index 000000000..5731715ba --- /dev/null +++ b/tests/integration/routes/workouts-batch-scope.test.ts @@ -0,0 +1,264 @@ +/** + * Who may call `POST /api/workouts/batch`, and what a narrow + * `workouts:write` credential writes, against the real database and + * through the real API kit (#1054). + * + * Pinned here, read back from the stored rows: + * - the route admits a cookie session, a wildcard token and a narrow token + * carrying exactly `workouts:write`; every other narrow token, however + * adjacent its scope looks, is 403 and writes nothing; + * - a `workouts:write` token counts in a rate-limit bucket of its own, apart + * from the phone's, while a session and a wildcard token share one; + * - a scoped caller's rows are `EXTERNAL`, resolved from the credential, + * and live in their own `(source, externalId)` namespace, so a bridge id + * equal to one the phone stored is a new row and its resend a duplicate; + * - a scoped body naming any source is 422 + * `workout.batch.source_not_permitted` and writes nothing, while a session + * and a wildcard token keep the source they send. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { POST } from "@/app/api/workouts/batch/route"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes"; +import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes"; + +import { callRoute, type RouteAuth } from "../../kit/call-route"; +import { + makeSession, + makeToken, + makeUser, + makeWorkout, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); +const ROUTE = "/api/workouts/batch"; + +interface BatchResult { + processed: number; + inserted: number; + duplicates: number; + entries: Array<{ index: number; status: string }>; +} + +beforeEach(() => truncateAllTables(prisma)); + +function workout( + externalId: string, + overrides: Record = {}, +): Record { + return { + sportType: "running", + startedAt: "2026-09-20T06:30:00.000Z", + endedAt: "2026-09-20T07:15:00.000Z", + externalId, + ...overrides, + }; +} + +/** A second session the same day, so the cross-source picker keeps both. */ +const EVENING = { + startedAt: "2026-09-20T18:00:00.000Z", + endedAt: "2026-09-20T18:45:00.000Z", +}; + +function post(workouts: Array>, auth: RouteAuth) { + return callRoute(POST, { + method: "POST", + route: ROUTE, + body: { workouts }, + auth, + }); +} + +async function storedSources(user: Pick): Promise { + const rows = await prisma.workout.findMany({ + where: { userId: user.id }, + orderBy: { startedAt: "asc" }, + select: { source: true }, + }); + return rows.map((r) => r.source); +} + +/** The rate-limit buckets this account's calls have opened, and their counts. */ +async function buckets(user: Pick) { + const rows = await prisma.rateLimit.findMany({ + where: { key: { contains: user.id } }, + orderBy: { key: "asc" }, + select: { key: true, count: true }, + }); + return rows; +} + +describe("POST /api/workouts/batch: which credentials it admits", () => { + it("admits a narrow workouts:write token", async () => { + const user = await makeUser(); + + const res = await post( + [workout("s-1")], + await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }), + ); + + expect(res.status).toBe(200); + expect(res.data.inserted).toBe(1); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(1); + }); + + it("admits a cookie session", async () => { + const user = await makeUser(); + + const res = await post( + [workout("s-1", { source: "APPLE_HEALTH" })], + await makeSession(user), + ); + + expect(res.status).toBe(200); + expect(res.data.inserted).toBe(1); + }); + + it("admits a wildcard token", async () => { + const user = await makeUser(); + + const res = await post( + [workout("s-1", { source: "APPLE_HEALTH" })], + await makeToken(user), + ); + + expect(res.status).toBe(200); + expect(res.data.inserted).toBe(1); + }); + + it.each([ + ["measurements:write", [MEASUREMENTS_WRITE_SCOPE]], + ["documents:write", [DOCUMENTS_WRITE_SCOPE]], + ["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]], + ["health:read", ["health:read"]], + ])("refuses a %s token with 403 and writes nothing", async (_, perms) => { + const user = await makeUser(); + + const res = await post( + [workout("s-1")], + await makeToken(user, { permissions: perms }), + ); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(0); + }); +}); + +describe("POST /api/workouts/batch: rate-limit buckets", () => { + it("counts a workouts:write token apart from the phone", async () => { + const user = await makeUser(); + const bridge = await makeToken(user, { + permissions: [WORKOUTS_WRITE_SCOPE], + }); + const phone = await makeToken(user); + + expect((await post([workout("s-1")], bridge)).status).toBe(200); + expect( + (await post([workout("s-2", { source: "APPLE_HEALTH" })], phone)).status, + ).toBe(200); + + const opened = await buckets(user); + expect(opened).toHaveLength(2); + expect(opened.map((b) => b.count)).toEqual([1, 1]); + }); + + it("keeps a session and a wildcard token in the same bucket", async () => { + const user = await makeUser(); + + await post( + [workout("s-1", { source: "APPLE_HEALTH" })], + await makeSession(user), + ); + await post( + [workout("s-2", { source: "APPLE_HEALTH", ...EVENING })], + await makeToken(user), + ); + + const opened = await buckets(user); + expect(opened).toHaveLength(1); + expect(opened[0].count).toBe(2); + }); +}); + +describe("POST /api/workouts/batch: what a scoped caller writes", () => { + it("attributes its rows to EXTERNAL", async () => { + const user = await makeUser(); + + const res = await post( + [workout("s-1"), workout("s-2", EVENING)], + await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }), + ); + + expect(res.status).toBe(200); + expect(res.data.inserted).toBe(2); + expect(await storedSources(user)).toEqual(["EXTERNAL", "EXTERNAL"]); + }); + + it("dedups in the EXTERNAL namespace: the phone's id is no collision, its own resend is", async () => { + const user = await makeUser(); + // The phone already stored a different session under the same id. + await makeWorkout(user, { + source: "APPLE_HEALTH", + externalId: "s-1", + startedAt: new Date("2026-09-18T06:30:00.000Z"), + endedAt: new Date("2026-09-18T07:15:00.000Z"), + durationSec: 2700, + }); + const bridge = await makeToken(user, { + permissions: [WORKOUTS_WRITE_SCOPE], + }); + + const first = await post([workout("s-1")], bridge); + const resend = await post([workout("s-1")], bridge); + + expect(first.data.entries).toEqual([ + expect.objectContaining({ index: 0, status: "inserted" }), + ]); + expect(resend.data.entries).toEqual([ + expect.objectContaining({ index: 0, status: "duplicate" }), + ]); + expect(await storedSources(user)).toEqual(["APPLE_HEALTH", "EXTERNAL"]); + }); + + it.each(["APPLE_HEALTH", "MANUAL"])( + "refuses a body naming %s with 422 and writes nothing", + async (source) => { + const user = await makeUser(); + + const res = await post( + [workout("s-1"), workout("s-2", { source, ...EVENING })], + await makeToken(user, { permissions: [WORKOUTS_WRITE_SCOPE] }), + ); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("workout.batch.source_not_permitted"); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe( + 0, + ); + }, + ); + + it("keeps the source a wildcard caller sends", async () => { + const user = await makeUser(); + + await post( + [workout("s-1", { source: "APPLE_HEALTH" })], + await makeToken(user), + ); + + expect(await storedSources(user)).toEqual(["APPLE_HEALTH"]); + }); + + it("keeps the source a session sends", async () => { + const user = await makeUser(); + + await post([workout("s-1", { source: "MANUAL" })], await makeSession(user)); + + expect(await storedSources(user)).toEqual(["MANUAL"]); + }); +}); diff --git a/tests/integration/routes/workouts.test.ts b/tests/integration/routes/workouts.test.ts new file mode 100644 index 000000000..bd3601a9c --- /dev/null +++ b/tests/integration/routes/workouts.test.ts @@ -0,0 +1,803 @@ +/** + * `GET` and `DELETE /api/workouts/{id}`, against the real database, through + * the real API kit, with the personal-records re-derivation queued on a real + * pg-boss queue. + * + * The read, pinned here: + * - answers the caller's own workout under the native client's field + * names, and 404 for another account's row or a missing one; 401 without + * a session, 403 `module.disabled` while the workouts module is off; + * - `canonicalId` names the cluster winner (the Apple Health twin when the + * Withings sibling was asked for), the winner borrows heart rate and + * zones only where it has none of its own, and `previousWorkoutId` is the + * canonical session before this one, never a twin of it; + * - `dayKey` is the session's day in the account's timezone; + * - the route geometry, the stored heart-rate series, zones, splits and + * sport context; `compact=1` drops the raw sample and timestamp blobs; a + * sealed track reads exactly like a readable one and wins over a readable + * column on the same row; + * - `aiInsight` serves the stored paragraph while `workoutInsights` is + * available, null when none is stored or the paragraph cannot be + * decrypted, and never while the capability is unavailable, with `ai` + * naming the reason. + * + * The delete, pinned here: + * - removes a workout entered by hand, with the personal records it set, + * leaves an audit row, and queues a silent detection pass when a record + * went; a legacy record without an external id goes only when it is this + * sport's and set at this workout's start; + * - a synced workout is 409 `workout.delete.synced_source` and nothing is + * touched; another account's row and a missing row are 404; anonymous is + * 401 and a narrow token is 403. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { PgBoss } from "pg-boss"; + +import { Prisma, type User, type Workout } from "@/generated/prisma/client"; +import { DELETE, GET } from "@/app/api/workouts/[id]/route"; +import { setGlobalBoss } from "@/lib/jobs/boss-instance"; +import { PR_DETECTION_QUEUE } from "@/lib/jobs/pr-detection"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher"; + +import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route"; +import { + makeSession, + makeToken, + makeUser, + makeWorkout, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); +const ROUTE = "/api/workouts/{id}"; +const DAY_MS = 24 * 60 * 60 * 1000; + +let boss: PgBoss; + +beforeAll(async () => { + boss = new PgBoss({ connectionString: process.env.DATABASE_URL }); + await boss.start(); + await boss.createQueue(PR_DETECTION_QUEUE); + setGlobalBoss(boss); +}, 120_000); + +afterAll(async () => { + setGlobalBoss(null); + await boss?.stop({ graceful: false }); +}); + +beforeEach(() => truncateAllTables(prisma)); + +/** + * Ten days back: the heart-rate curve's pulse-window fallback only reaches + * inside the dense-intraday retention horizon, so a fixed calendar day would + * age out of it. + */ +const SESSION_DAY = new Date(Date.now() - 10 * DAY_MS) + .toISOString() + .slice(0, 10); +const START = new Date(`${SESSION_DAY}T07:00:00Z`); +const END = new Date(`${SESSION_DAY}T07:30:00Z`); + +const GEOMETRY = { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.005, 49.0], + [11.01, 49.0], + ], +}; +const TIMESTAMPS = [ + `${SESSION_DAY}T07:00:00Z`, + `${SESSION_DAY}T07:02:00Z`, + `${SESSION_DAY}T07:04:00Z`, +]; + +interface Detail { + id: string; + dayKey: string; + durationSec: number; + distanceM: number | null; + activeEnergyKcal: number | null; + avgHr: number | null; + maxHr: number | null; + source: string; + metadata: unknown; + route: { geometry: unknown; sampleTimestamps: unknown } | null; + samples: { sampleCount: number; samples: unknown } | null; + hrSeries: { source: string; points: unknown[] } | null; + zones: { model: string } | null; + splits: unknown; + sportContext: unknown; + aiInsight: { paragraph: string; generatedAt: string } | null; + ai: { available: boolean; reason: string | null; onDeviceAllowed: boolean }; + canonicalId: string; + previousWorkoutId: string | null; +} + +function appleRun( + user: Pick, + overrides: Partial> = {}, +): Promise { + return makeWorkout(user, { + sportType: "running", + startedAt: START, + endedAt: END, + durationSec: 1800, + totalDistanceM: 5000, + totalEnergyKcal: 320, + avgHeartRate: 145, + maxHeartRate: 170, + minHeartRate: 110, + stepCount: 5800, + elevationM: 12.5, + source: "APPLE_HEALTH", + externalId: `ext-${Math.random().toString(36).slice(2)}`, + metadata: { hkVersion: "1" }, + ...overrides, + }); +} + +async function read( + workoutId: string, + auth: RouteAuth, + query: Record = {}, +) { + return callRoute(GET, { + method: "GET", + route: ROUTE, + params: { id: workoutId }, + query, + auth, + }); +} + +/** An account for which `workoutInsights` is available: own key and receipt. */ +async function makeAiUser( + overrides: Partial = {}, +) { + const user = await makeUser({ + aiProvider: "ANTHROPIC", + aiModel: "claude-sonnet-4-6", + // Presence only: the capability probe never decrypts. + aiAnthropicKeyEncrypted: "v1:presence-only", + locale: "en", + ...overrides, + }); + await prisma.consentReceipt.create({ + data: { + userId: user.id, + kind: "ai_full", + artefact: "test", + signedAt: new Date(), + }, + }); + return user; +} + +describe("GET /api/workouts/{id}", () => { + it("returns the caller's workout under the native client's field names", async () => { + const user = await makeUser(); + const workout = await appleRun(user); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + id: workout.id, + distanceM: 5000, + activeEnergyKcal: 320, + avgHr: 145, + maxHr: 170, + canonicalId: workout.id, + route: null, + // Nothing older on record: absence, not a link to nowhere. + previousWorkoutId: null, + }); + }); + + it("points previousWorkoutId at the canonical session before this one, never at a twin", async () => { + const user = await makeUser(); + const workout = await appleRun(user); + const fiveDaysBefore = new Date(START.getTime() - 5 * DAY_MS); + const previous = await appleRun(user, { + startedAt: fiveDaysBefore, + endedAt: new Date(fiveDaysBefore.getTime() + 1800_000), + }); + // The same session as seen by a second device, a minute later. + await appleRun(user, { + source: "WITHINGS", + startedAt: new Date(fiveDaysBefore.getTime() + 60_000), + endedAt: new Date(fiveDaysBefore.getTime() + 1860_000), + }); + const older = new Date(START.getTime() - 8 * DAY_MS); + await appleRun(user, { + startedAt: older, + endedAt: new Date(older.getTime() + 1800_000), + }); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.status).toBe(200); + expect(res.data.previousWorkoutId).toBe(previous.id); + }); + + it("dates the session by the account's timezone, not by UTC", async () => { + // 20:00 UTC is already the next calendar day in Auckland. + const user = await makeUser({ timezone: "Pacific/Auckland" }); + const workout = await appleRun(user, { + startedAt: new Date(`${SESSION_DAY}T20:00:00Z`), + endedAt: new Date(`${SESSION_DAY}T20:30:00Z`), + }); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.status).toBe(200); + const nextDay = new Date( + new Date(`${SESSION_DAY}T00:00:00Z`).getTime() + DAY_MS, + ) + .toISOString() + .slice(0, 10); + expect(res.data.dayKey).toBe(nextDay); + }); + + it("fills the Apple winner's missing heart rate and zones from its WHOOP twin, and nothing else", async () => { + const user = await makeUser(); + const apple = await appleRun(user, { + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + }); + await prisma.workoutRoute.create({ + data: { workoutId: apple.id, geometry: GEOMETRY }, + }); + await appleRun(user, { + source: "WHOOP", + startedAt: new Date(START.getTime() + 30_000), + endedAt: new Date(END.getTime() + 30_000), + avgHeartRate: 148, + maxHeartRate: 181, + metadata: { + zoneDurations: { zone_one_milli: 60_000, zone_two_milli: 300_000 }, + whoopWorkoutStrain: 12.3, + }, + }); + + const res = await read(apple.id, await makeSession(user)); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + id: apple.id, + source: "APPLE_HEALTH", + canonicalId: apple.id, + avgHr: 148, + maxHr: 181, + route: { geometry: GEOMETRY }, + metadata: { hkVersion: "1", owner: "apple" }, + }); + expect(res.data.metadata).not.toHaveProperty("whoopWorkoutStrain"); + expect(res.data.metadata).not.toHaveProperty("zoneDurations"); + expect(res.data.zones?.model).toBe("whoop"); + expect(res.data.hrSeries).toBeNull(); + }); + + it("keeps the winner's own heart rate when its twin carries different values", async () => { + const user = await makeUser(); + const apple = await appleRun(user); + await appleRun(user, { + source: "WHOOP", + startedAt: new Date(START.getTime() + 30_000), + endedAt: new Date(END.getTime() + 30_000), + avgHeartRate: 155, + maxHeartRate: 190, + metadata: Prisma.JsonNull, + }); + + const res = await read(apple.id, await makeSession(user)); + + expect(res.data.avgHr).toBe(145); + expect(res.data.maxHr).toBe(170); + }); + + it("returns 401 without a session", async () => { + const user = await makeUser(); + const workout = await appleRun(user); + + const res = await read(workout.id, anonymous); + + expect(res.status).toBe(401); + expect(res.meta?.errorCode).toBe("auth.missing"); + }); + + it("returns 403 module.disabled while the workouts module is off", async () => { + const user = await makeUser({ modulePreferencesJson: { workouts: false } }); + const workout = await appleRun(user); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("module.disabled"); + expect(res.meta?.module).toBe("workouts"); + }); + + it("returns 404 for another account's workout", async () => { + const owner = await makeUser(); + const workout = await appleRun(owner); + + const res = await read(workout.id, await makeSession(await makeUser())); + + expect(res.status).toBe(404); + expect(res.data).toBeNull(); + }); + + it("returns 404 when no such workout exists", async () => { + const res = await read( + "no-such-workout", + await makeSession(await makeUser()), + ); + + expect(res.status).toBe(404); + }); + + it("resolves canonicalId to the Apple Health twin when the Withings sibling is asked for", async () => { + const user = await makeUser(); + const apple = await appleRun(user); + const withings = await appleRun(user, { source: "WITHINGS" }); + + const res = await read(withings.id, await makeSession(user)); + + expect(res.status).toBe(200); + expect(res.data.id).toBe(withings.id); + expect(res.data.canonicalId).toBe(apple.id); + }); + + it("adds the stored series, device zones, splits and sport context, and drops the raw blobs under compact=1", async () => { + const user = await makeUser(); + const storedSamples = Array.from({ length: 40 }, (_, i) => ({ + t: new Date(START.getTime() + i * 45_000).toISOString(), + hr: 140 + (i % 6), + })); + const workout = await appleRun(user, { + // WHOOP device zones win, so zones render without a profile age. + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: 300_000, + zone_three_milli: 600_000, + zone_four_milli: 200_000, + zone_five_milli: 40_000, + }, + }, + }); + await prisma.workoutRoute.create({ + data: { + workoutId: workout.id, + geometry: GEOMETRY, + sampleTimestamps: TIMESTAMPS, + }, + }); + await prisma.workoutSamples.create({ + data: { + workoutId: workout.id, + samples: storedSamples, + sampleCount: storedSamples.length, + }, + }); + + const res = await read(workout.id, await makeSession(user), { + compact: "1", + }); + + expect(res.status).toBe(200); + expect(res.data.hrSeries?.source).toBe("workout_series"); + expect(res.data.hrSeries?.points.length).toBeGreaterThanOrEqual(2); + expect(res.data.zones?.model).toBe("whoop"); + expect(res.data).toHaveProperty("splits"); + expect(res.data).toHaveProperty("sportContext"); + expect(res.data.aiInsight).toBeNull(); + expect(res.data.samples).toEqual({ sampleCount: 40, samples: null }); + expect(res.data.route?.geometry).toEqual(GEOMETRY); + expect(res.data.route?.sampleTimestamps).toBeNull(); + }); + + it("exposes the route geometry and its timestamps without compact", async () => { + const user = await makeUser(); + const workout = await appleRun(user); + await prisma.workoutRoute.create({ + data: { + workoutId: workout.id, + geometry: GEOMETRY, + sampleTimestamps: TIMESTAMPS, + }, + }); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.data.route).toEqual({ + geometry: GEOMETRY, + sampleTimestamps: TIMESTAMPS, + }); + }); + + it("serves a sealed track in the same shape as a readable one, splits included", async () => { + const user = await makeUser(); + const session = await makeSession(user); + const readable = await appleRun(user); + await prisma.workoutRoute.create({ + data: { + workoutId: readable.id, + geometry: GEOMETRY, + sampleTimestamps: TIMESTAMPS, + }, + }); + const otherDay = new Date(START.getTime() - 2 * DAY_MS); + const sealed = await appleRun(user, { + startedAt: otherDay, + endedAt: new Date(otherDay.getTime() + 1800_000), + }); + await prisma.workoutRoute.create({ + data: { + workoutId: sealed.id, + geometryEncrypted: encryptRouteGeometry(GEOMETRY), + sampleTimestamps: TIMESTAMPS, + }, + }); + + const legacy = await read(readable.id, session); + const fromCipher = await read(sealed.id, session); + + expect(fromCipher.data.route).toEqual(legacy.data.route); + expect(fromCipher.data.route?.geometry).toEqual(GEOMETRY); + expect(fromCipher.data.splits).toEqual(legacy.data.splits); + }); + + it("prefers the sealed track over a readable column on the same row", async () => { + const user = await makeUser(); + const workout = await appleRun(user); + await prisma.workoutRoute.create({ + data: { + workoutId: workout.id, + geometry: { type: "LineString", coordinates: [[0, 0]] }, + geometryEncrypted: encryptRouteGeometry(GEOMETRY), + sampleTimestamps: TIMESTAMPS, + }, + }); + + const res = await read(workout.id, await makeSession(user)); + + expect(res.data.route?.geometry).toEqual(GEOMETRY); + }); +}); + +describe("GET /api/workouts/{id}: aiInsight", () => { + async function withInsight( + user: Pick, + paragraphEncrypted: Uint8Array, + generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`), + ): Promise { + const workout = await appleRun(user); + await prisma.workoutInsight.create({ + data: { + userId: user.id, + workoutId: workout.id, + paragraphEncrypted, + inputHash: "hash", + promptVersion: "v1", + providerType: "anthropic", + locale: "en", + generatedAt, + }, + }); + return workout; + } + + function sealed(text: string): Uint8Array { + return encryptToBytes(text); + } + + it("serves null when the workout has no stored paragraph", async () => { + const user = await makeAiUser(); + const workout = await appleRun(user); + + const res = await read(workout.id, await makeSession(user), { + compact: "1", + }); + + expect(res.status).toBe(200); + expect(res.data.ai.available).toBe(true); + expect(res.data.aiInsight).toBeNull(); + }); + + it("serves the decrypted paragraph while workoutInsights is available", async () => { + const user = await makeAiUser(); + const generatedAt = new Date(`${SESSION_DAY}T07:35:00Z`); + const workout = await withInsight( + user, + sealed("A steady, aerobic-leaning run."), + generatedAt, + ); + + const res = await read(workout.id, await makeSession(user), { + compact: "1", + }); + + expect(res.data.aiInsight).toEqual({ + paragraph: "A steady, aerobic-leaning run.", + generatedAt: generatedAt.toISOString(), + }); + expect(res.data.ai).toEqual({ + available: true, + reason: null, + onDeviceAllowed: true, + }); + }); + + it.each([ + [ + "operator_disabled", + async () => { + await prisma.appSettings.create({ + data: { id: "singleton", assistantEnabled: false }, + }); + return makeAiUser(); + }, + ], + [ + "user_disabled", + () => makeAiUser({ modulePreferencesJson: { insights: false } }), + ], + [ + "consent_required", + async () => { + await prisma.appSettings.create({ + data: { + id: "singleton", + adminAiKeyEncrypted: "v1:presence-only", + adminAiModel: "gpt-4o", + }, + }); + return makeUser({ locale: "en" }); + }, + ], + ["no_provider", () => makeUser({ locale: "en" })], + ] as const)( + "does not serve the stored paragraph while workoutInsights is %s", + async (reason, arrange) => { + const user = await arrange(); + const workout = await withInsight(user, sealed("Model-written.")); + + const res = await read(workout.id, await makeSession(user), { + compact: "1", + }); + + expect(res.status).toBe(200); + expect(res.data.aiInsight).toBeNull(); + expect(res.data.ai.available).toBe(false); + expect(res.data.ai.reason).toBe(reason); + }, + ); + + it("degrades to no paragraph rather than failing the read on an undecryptable row", async () => { + const user = await makeAiUser(); + const workout = await withInsight(user, new Uint8Array([0, 1, 2, 3])); + + const res = await read(workout.id, await makeSession(user), { + compact: "1", + }); + + expect(res.status).toBe(200); + expect(res.data.aiInsight).toBeNull(); + expect(res.data.id).toBe(workout.id); + expect(res.data.durationSec).toBe(1800); + }); +}); + +describe("DELETE /api/workouts/{id}", () => { + async function del(workoutId: string, auth: RouteAuth) { + return callRoute<{ deleted: true }>(DELETE, { + method: "DELETE", + route: ROUTE, + params: { id: workoutId }, + auth, + }); + } + + function manualRun( + user: Pick, + overrides: Partial> = {}, + ) { + return appleRun(user, { + source: "MANUAL", + externalId: "manual:5b0f3c1e-1111-4222-8333-444455556666", + ...overrides, + }); + } + + function record( + user: Pick, + overrides: Partial< + Omit + > = {}, + ) { + return prisma.personalRecord.create({ + data: { + userId: user.id, + metricType: "WALKING_RUNNING_DISTANCE", + metricSlot: "longest_run_duration", + direction: "MAX", + value: 1800, + unit: "s", + achievedAt: START, + source: "MANUAL", + ...overrides, + }, + }); + } + + async function detectionJobs(userId: string) { + return prisma.$queryRaw< + Array<{ data: { userId: string; silent: boolean } }> + >` + SELECT data FROM pgboss.job + WHERE name = ${PR_DETECTION_QUEUE} AND data->>'userId' = ${userId} + `; + } + + it("removes a workout entered by hand, audits it, and the next read is 404", async () => { + const user = await makeUser(); + const session = await makeSession(user); + const workout = await manualRun(user); + + const res = await del(workout.id, session); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ data: { deleted: true }, error: null }); + expect( + await prisma.workout.findUnique({ where: { id: workout.id } }), + ).toBeNull(); + const audit = await prisma.auditLog.findMany({ + where: { userId: user.id, action: "workout.delete" }, + }); + expect(audit).toHaveLength(1); + expect(JSON.parse(audit[0].details ?? "null")).toMatchObject({ + workoutId: workout.id, + sportType: "running", + personalRecordsRemoved: 0, + }); + expect((await read(workout.id, session)).status).toBe(404); + // Nothing was removed, so nothing needs re-deriving. + expect(await detectionJobs(user.id)).toEqual([]); + }); + + it("takes the personal records the workout set with it and queues a silent re-derivation", async () => { + const user = await makeUser(); + const workout = await manualRun(user); + const set = await record(user, { externalId: workout.externalId }); + const unrelated = await record(user, { + externalId: "manual:another-workout", + achievedAt: new Date(START.getTime() - DAY_MS), + }); + + const res = await del(workout.id, await makeSession(user)); + + expect(res.status).toBe(200); + expect( + await prisma.personalRecord.findUnique({ where: { id: set.id } }), + ).toBeNull(); + expect( + await prisma.personalRecord.findUnique({ where: { id: unrelated.id } }), + ).not.toBeNull(); + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { userId: user.id, action: "workout.delete" }, + }); + expect(JSON.parse(audit.details ?? "null")).toMatchObject({ + personalRecordsRemoved: 1, + }); + const jobs = await detectionJobs(user.id); + expect(jobs.map((j) => j.data.silent)).toEqual([true]); + }); + + it("removes a legacy record without an external id only when it is this sport's and set at this start", async () => { + const user = await makeUser(); + const workout = await manualRun(user, { externalId: null }); + const running = await record(user, { externalId: null }); + // A cycling record set at the same instant is not this workout's. + const cycling = await record(user, { + externalId: null, + metricSlot: "longest_cycle_duration", + }); + const otherStart = await record(user, { + externalId: null, + metricSlot: "longest_distance_run", + achievedAt: new Date(START.getTime() - DAY_MS), + }); + + const res = await del(workout.id, await makeSession(user)); + + expect(res.status).toBe(200); + const left = await prisma.personalRecord.findMany({ + where: { userId: user.id }, + select: { id: true }, + }); + expect(left.map((r) => r.id).sort()).toEqual( + [cycling.id, otherStart.id].sort(), + ); + expect(left.map((r) => r.id)).not.toContain(running.id); + }); + + it.each(["APPLE_HEALTH", "WITHINGS", "WHOOP", "EXTERNAL"] as const)( + "refuses a %s workout with 409 and touches nothing", + async (source) => { + const user = await makeUser(); + const workout = await appleRun(user, { source }); + const set = await record(user, { + source, + externalId: workout.externalId, + }); + + const res = await del(workout.id, await makeSession(user)); + + expect(res.status).toBe(409); + expect(res.data).toBeNull(); + expect(res.meta?.errorCode).toBe("workout.delete.synced_source"); + expect( + await prisma.workout.findUnique({ where: { id: workout.id } }), + ).not.toBeNull(); + expect( + await prisma.personalRecord.findUnique({ where: { id: set.id } }), + ).not.toBeNull(); + expect( + await prisma.auditLog.count({ + where: { userId: user.id, action: "workout.delete" }, + }), + ).toBe(0); + }, + ); + + it("answers 404 for another account's workout and leaves it in place", async () => { + const owner = await makeUser(); + const workout = await manualRun(owner); + + const res = await del(workout.id, await makeSession(await makeUser())); + + expect(res.status).toBe(404); + expect( + await prisma.workout.findUnique({ where: { id: workout.id } }), + ).not.toBeNull(); + }); + + it("answers 404 when there is no such workout", async () => { + const res = await del("missing", await makeSession(await makeUser())); + + expect(res.status).toBe(404); + }); + + it("answers 401 without a session", async () => { + const user = await makeUser(); + const workout = await manualRun(user); + + const res = await del(workout.id, anonymous); + + expect(res.status).toBe(401); + expect( + await prisma.workout.findUnique({ where: { id: workout.id } }), + ).not.toBeNull(); + }); + + it("refuses a narrow token with 403 and leaves the workout in place", async () => { + const user = await makeUser(); + const workout = await manualRun(user); + + const res = await del( + workout.id, + await makeToken(user, { permissions: ["workouts:write"] }), + ); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + expect( + await prisma.workout.findUnique({ where: { id: workout.id } }), + ).not.toBeNull(); + }); +});