From 43b86e46da7ac72dbc4cd57a7aaf4ae58f2ad239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:32:22 +0200 Subject: [PATCH] test(integration): move notification channel and workout route tests onto Postgres The channel settings routes, their test-message routes, the reminder thresholds and the workout detail, delete and batch-scope tests run through the real handlers against the database. Only the far ends are faked (the relay, the ntfy server, the Telegram Bot API, SMTP), and the tests assert what those received. The private-origin source guard stays a unit test and now points at the two files that carry its runtime proof. --- .../__tests__/channel-enabled-routes.test.ts | 418 ------- .../channel-global-switch-refusal.test.ts | 226 ---- .../__tests__/email-test-route.test.ts | 138 --- .../__tests__/private-origin-routes.test.ts | 511 +------- .../__tests__/telegram-test-locale.test.ts | 202 ---- .../__tests__/webhook-format-route.test.ts | 194 --- .../__tests__/route.test.ts | 119 -- .../webhook/test/__tests__/route.test.ts | 150 --- .../workouts/__tests__/batch-scope.test.ts | 311 ----- src/app/api/workouts/__tests__/delete.test.ts | 213 ---- src/app/api/workouts/__tests__/detail.test.ts | 774 ------------ .../routes/settings-channel-tests.test.ts | 612 ++++++++++ .../routes/settings-channels.test.ts | 1063 +++++++++++++++++ .../settings-reminder-thresholds.test.ts | 117 ++ .../routes/workouts-batch-scope.test.ts | 264 ++++ tests/integration/routes/workouts.test.ts | 803 +++++++++++++ 16 files changed, 2868 insertions(+), 3247 deletions(-) delete mode 100644 src/app/api/settings/__tests__/channel-enabled-routes.test.ts delete mode 100644 src/app/api/settings/__tests__/channel-global-switch-refusal.test.ts delete mode 100644 src/app/api/settings/__tests__/email-test-route.test.ts delete mode 100644 src/app/api/settings/__tests__/telegram-test-locale.test.ts delete mode 100644 src/app/api/settings/__tests__/webhook-format-route.test.ts delete mode 100644 src/app/api/settings/reminder-thresholds/__tests__/route.test.ts delete mode 100644 src/app/api/settings/webhook/test/__tests__/route.test.ts delete mode 100644 src/app/api/workouts/__tests__/batch-scope.test.ts delete mode 100644 src/app/api/workouts/__tests__/delete.test.ts delete mode 100644 src/app/api/workouts/__tests__/detail.test.ts create mode 100644 tests/integration/routes/settings-channel-tests.test.ts create mode 100644 tests/integration/routes/settings-channels.test.ts create mode 100644 tests/integration/routes/settings-reminder-thresholds.test.ts create mode 100644 tests/integration/routes/workouts-batch-scope.test.ts create mode 100644 tests/integration/routes/workouts.test.ts 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(); + }); +});