From a6787cfc0301920eee84f9b4bae95f1997dc9a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:30:00 +0200 Subject: [PATCH 01/20] test(integration): run files in parallel, one cloned database per worker Global setup migrates one template database and reads what a fresh install holds: its schemas, its tables and the rows the migrations seed. Each Vitest worker clones the template on its first file with CREATE DATABASE ... TEMPLATE and keeps the clone for the rest of the run, so files run in parallel without seeing each other's rows, advisory locks or queue schema. Schemas created at runtime (pg-boss, migration probes) are dropped when the next file starts. The truncate list is the catalogue now, every table in public except the migration bookkeeping, instead of a hand-kept list of 42 of 166 tables. The seeded catalogues are put back after each truncate in foreign-key order, so a test starts from what a fresh install has. The settings singleton is the one seeded table a test starts without, declared with its reason. next/headers and the boot compatibility probe are mocked once in the setup file. Two lock-wait probes now look at their own database only, since other workers' backends share pg_stat_activity. --- ...ntegration-truncate-deadlock-retry.test.ts | 64 +++++-- tests/integration/environment-setup.ts | 47 ++++- tests/integration/global-setup.ts | 171 ++++++++++++++++-- tests/integration/mock-next-headers.ts | 23 +-- .../restore-consolidation-deadlock.test.ts | 3 +- .../rollup-concurrent-recompute.test.ts | 1 + tests/integration/setup.ts | 146 +++++---------- tests/integration/worker-database.ts | 129 +++++++++++++ vitest.integration.config.mts | 28 ++- 9 files changed, 451 insertions(+), 161 deletions(-) create mode 100644 tests/integration/worker-database.ts diff --git a/src/__tests__/integration-truncate-deadlock-retry.test.ts b/src/__tests__/integration-truncate-deadlock-retry.test.ts index a5b423a11..1852ef3f0 100644 --- a/src/__tests__/integration-truncate-deadlock-retry.test.ts +++ b/src/__tests__/integration-truncate-deadlock-retry.test.ts @@ -12,6 +12,18 @@ import { describe, expect, it, vi } from "vitest"; import { truncateAllTables } from "../../tests/integration/setup"; +import type { IntegrationDatabasePlan } from "../../tests/integration/worker-database"; + +/** What global setup reads off the migrated template, cut down to the shape. */ +const PLAN: IntegrationDatabasePlan = { + templateSchemas: ["public"], + tables: ["app_settings", "mood_tag_categories", "mood_tags", "users"], + reference: [ + { table: "app_settings", rowsJson: '[{"id":"singleton"}]' }, + { table: "mood_tag_categories", rowsJson: '[{"id":"c1"}]' }, + { table: "mood_tags", rowsJson: '[{"id":"t1","category_id":"c1"}]' }, + ], +}; function deadlock(): Error & { code: string } { const error = new Error("deadlock detected") as Error & { code: string }; @@ -19,45 +31,61 @@ function deadlock(): Error & { code: string } { return error; } -function fakeClient(executeRaw: ReturnType) { - const empty = vi.fn().mockResolvedValue([]); - return { - $executeRawUnsafe: executeRaw, - moodTag: { findMany: empty, createMany: vi.fn() }, - moodTagCategory: { findMany: empty, createMany: vi.fn() }, - } as unknown as Parameters[0]; +type Execute = (query: string, ...values: unknown[]) => Promise; + +function fakeClient(executeRaw: Execute) { + return { $executeRawUnsafe: executeRaw }; } describe("truncateAllTables", () => { it("retries a deadlocked truncate and succeeds once the lock clears", async () => { const executeRaw = vi - .fn() + .fn() .mockRejectedValueOnce(deadlock()) .mockRejectedValueOnce(deadlock()) .mockResolvedValue(0); - await truncateAllTables(fakeClient(executeRaw)); + await truncateAllTables(fakeClient(executeRaw), PLAN); - expect(executeRaw).toHaveBeenCalledTimes(3); + // Three truncate attempts, then the two seeded tables put back. + expect(executeRaw).toHaveBeenCalledTimes(5); + }); + + it("empties every catalogued table and puts the seeded rows back in key order", async () => { + const executeRaw = vi.fn().mockResolvedValue(0); + + await truncateAllTables(fakeClient(executeRaw), PLAN); + + const statements = executeRaw.mock.calls.map((call) => String(call[0])); + expect(statements[0]).toBe( + 'TRUNCATE TABLE "app_settings", "mood_tag_categories", "mood_tags", "users" RESTART IDENTITY CASCADE;', + ); + // The settings singleton starts empty; the catalogues come back, the + // category before the tag that references it. + expect(statements.slice(1)).toEqual([ + expect.stringContaining('INSERT INTO "mood_tag_categories"'), + expect.stringContaining('INSERT INTO "mood_tags"'), + ]); + expect(executeRaw.mock.calls[2][1]).toBe(PLAN.reference[2].rowsJson); }); it("gives up on an error that is not a deadlock, on the first attempt", async () => { const other = new Error("permission denied") as Error & { code: string }; other.code = "42501"; - const executeRaw = vi.fn().mockRejectedValue(other); + const executeRaw = vi.fn().mockRejectedValue(other); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "permission denied", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("permission denied"); expect(executeRaw).toHaveBeenCalledTimes(1); }); it("stops retrying a deadlock that never clears", async () => { - const executeRaw = vi.fn().mockRejectedValue(deadlock()); + const executeRaw = vi.fn().mockRejectedValue(deadlock()); - await expect(truncateAllTables(fakeClient(executeRaw))).rejects.toThrow( - "deadlock detected", - ); + await expect( + truncateAllTables(fakeClient(executeRaw), PLAN), + ).rejects.toThrow("deadlock detected"); expect(executeRaw).toHaveBeenCalledTimes(5); }); }); diff --git a/tests/integration/environment-setup.ts b/tests/integration/environment-setup.ts index 2613d151e..9adf0e3c7 100644 --- a/tests/integration/environment-setup.ts +++ b/tests/integration/environment-setup.ts @@ -1,12 +1,14 @@ import { afterEach, inject, vi } from "vitest"; -declare module "vitest" { - export interface ProvidedContext { - integrationDatabaseUrl: string; - } -} +import { prepareWorkerDatabase } from "./worker-database"; -process.env.DATABASE_URL = inject("integrationDatabaseUrl"); +// Each worker runs against its own clone of the migrated template (see +// `worker-database.ts`). The URL is final before any application module is +// imported, so the Prisma singleton is built against the clone. +process.env.DATABASE_URL = await prepareWorkerDatabase( + inject("integrationDatabaseUrl"), + inject("integrationDatabasePlan"), +); process.env.ENCRYPTION_KEY = "0000000000000000000000000000000000000000000000000000000000000000"; delete process.env.ENCRYPTION_KEYS; @@ -33,6 +35,39 @@ afterEach(async () => { await settleBackgroundTasks(); }); +/** + * Route handlers read cookies and headers through `next/headers`, which only + * works inside a Next.js request scope. Every file gets the same stand-in, + * backed by the shared jars in `mock-next-headers.ts`, and the same no-op for + * the boot-time compatibility probe. A file that needs different behaviour + * still declares its own `vi.mock`, which takes precedence over this one. + */ +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + has: (name: string) => headerJar.has(name.toLowerCase()), + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + /** * The same pin the unit setup applies (see `vitest.setup.ts`): the Fitbit * suites run before the Web API turndown, whatever the real date, and a test diff --git a/tests/integration/global-setup.ts b/tests/integration/global-setup.ts index 5ee724dd9..31fdc912b 100644 --- a/tests/integration/global-setup.ts +++ b/tests/integration/global-setup.ts @@ -1,47 +1,84 @@ /** * Vitest globalSetup — boots ONE Postgres testcontainer for the whole - * integration run, applies migrations, and exposes the connection URL - * via process.env.DATABASE_URL. The teardown returned at the end stops - * the container after all test files have finished. + * integration run, migrates ONE template database, and hands the workers + * what they need to clone it. The teardown returned at the end stops the + * container after all test files have finished. * - * Why globalSetup instead of beforeAll/afterAll per file: + * Why one container and a template: + * - Applying the migration history takes seconds; copying a migrated + * database with `CREATE DATABASE … TEMPLATE` takes a fraction of one. + * Each Vitest worker therefore gets its own clone (see + * `worker-database.ts`), cloned once per worker rather than once per + * file or test, and files run in parallel without seeing each other's + * rows, advisory locks or queue schema. * - The application's Prisma singleton in `src/lib/db.ts` is built at - * module load time using the current `process.env.DATABASE_URL`. If - * the container is rotated between test files, the singleton keeps - * pointing at the dead container and queries fail. Booting once, - * keeping the URL stable, and truncating between tests is both - * faster and safer. - * - Truncate-between-tests still gives per-test isolation; see - * `setup.ts -> truncateAllTables`. + * module load time from `process.env.DATABASE_URL`. A worker's URL is + * fixed before any application module loads (`environment-setup.ts`), + * so the singleton always points at that worker's clone. + * + * What the template carries besides the schema is recorded here as well: + * the rows the migrations seed (the mood-tag and symptom catalogues, the + * settings singleton). `setup.ts -> truncateAllTables` empties every table + * and puts exactly these rows back, so a test starts from the state a fresh + * install has, and the truncate list is the catalogue rather than a list + * somebody has to remember to extend. */ import { execSync } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { Client } from "pg"; import type { TestProject } from "vitest/node"; import { PostgreSqlContainer, type StartedPostgreSqlContainer, } from "@testcontainers/postgresql"; +import { + TEMPLATE_DATABASE, + type IntegrationDatabasePlan, + type ReferenceTable, +} from "./worker-database"; + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = resolve(__dirname, "..", ".."); let container: StartedPostgreSqlContainer | null = null; +declare module "vitest" { + export interface ProvidedContext { + integrationDatabaseUrl: string; + integrationDatabasePlan: IntegrationDatabasePlan; + } +} + export default async function setup( project: TestProject, ): Promise<() => Promise> { container = await new PostgreSqlContainer("postgres:16-alpine") - .withDatabase("healthlog_test") + .withDatabase(TEMPLATE_DATABASE) .withUsername("healthlog") .withPassword("healthlog") + // A throwaway cluster: durability buys nothing here and costs every + // commit a flush. Several workers each hold a Prisma pool and, in the + // queue tests, a pg-boss pool, so the default of 100 connections is + // raised well past what parallel workers can open. + .withCommand([ + "postgres", + "-c", + "fsync=off", + "-c", + "synchronous_commit=off", + "-c", + "full_page_writes=off", + "-c", + "max_connections=400", + ]) .start(); const url = container.getConnectionUri() + "?schema=public&pgbouncer=false"; process.env.DATABASE_URL = url; - project.provide("integrationDatabaseUrl", url); // The explicit child environment keeps Prisma CLI on the same container. execSync("pnpm db:migrate:deploy", { @@ -50,6 +87,9 @@ export default async function setup( env: { ...process.env, DATABASE_URL: url }, }); + project.provide("integrationDatabaseUrl", url); + project.provide("integrationDatabasePlan", await readTemplate(url)); + return async () => { if (container) { await container.stop({ remove: true }); @@ -57,3 +97,108 @@ export default async function setup( } }; } + +/** + * Read what a fresh install holds: its schemas, its tables, and the rows the + * migrations seed, in an order that satisfies the foreign keys between them. + * The connection is closed before any worker clones the template, because + * Postgres refuses to copy a database somebody is connected to. + */ +async function readTemplate(url: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + const schemas = ( + await client.query<{ nspname: string }>( + `SELECT nspname FROM pg_namespace + WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema' + ORDER BY nspname`, + ) + ).rows.map((r) => r.nspname); + + const tables = ( + await client.query<{ tablename: string }>( + `SELECT tablename FROM pg_tables + WHERE schemaname = 'public' AND tablename <> '_prisma_migrations' + ORDER BY tablename`, + ) + ).rows.map((r) => r.tablename); + if (tables.length < 100) { + throw new Error( + `the migrated template has only ${tables.length} tables; the migration step did not run`, + ); + } + + const seeded: string[] = []; + for (const table of tables) { + const { rows } = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM "${table}") AS present`, + ); + if (rows[0]?.present) seeded.push(table); + } + + // A seeded table whose key comes from a sequence would collide with the + // next insert once `RESTART IDENTITY` rewinds it. None does today; the + // check keeps it that way rather than leaving a silent collision. + const sequenced = await client.query<{ table: string }>( + `SELECT DISTINCT c.relname AS table + FROM pg_depend d + JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S' + JOIN pg_class c ON c.oid = d.refobjid + WHERE c.relname = ANY($1::text[])`, + [seeded], + ); + if (sequenced.rows.length > 0) { + throw new Error( + `seeded tables with sequence-backed keys are not supported by the reseed: ${sequenced.rows.map((r) => r.table).join(", ")}`, + ); + } + + const edges = ( + await client.query<{ child: string; parent: string }>( + `SELECT conrelid::regclass::text AS child, confrelid::regclass::text AS parent + FROM pg_constraint + WHERE contype = 'f' + AND conrelid::regclass::text = ANY($1::text[]) + AND confrelid::regclass::text = ANY($1::text[]) + AND conrelid <> confrelid`, + [seeded], + ) + ).rows; + + const reference: ReferenceTable[] = []; + for (const table of topologicalOrder(seeded, edges)) { + const { rows } = await client.query<{ rows: string }>( + `SELECT COALESCE(json_agg(t), '[]'::json)::text AS rows FROM "${table}" t`, + ); + reference.push({ table, rowsJson: rows[0].rows }); + } + + return { templateSchemas: schemas, tables, reference }; + } finally { + await client.end(); + } +} + +function topologicalOrder( + tables: readonly string[], + edges: ReadonlyArray<{ child: string; parent: string }>, +): string[] { + const ordered: string[] = []; + const pending = new Set(tables); + while (pending.size > 0) { + const ready = [...pending].filter( + (t) => !edges.some((e) => e.child === t && pending.has(e.parent)), + ); + if (ready.length === 0) { + throw new Error( + `seeded tables reference each other in a cycle: ${[...pending].join(", ")}`, + ); + } + for (const t of ready.sort()) { + ordered.push(t); + pending.delete(t); + } + } + return ordered; +} diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts index f6de28c2e..2a012bdf6 100644 --- a/tests/integration/mock-next-headers.ts +++ b/tests/integration/mock-next-headers.ts @@ -1,20 +1,17 @@ /** * Shared `next/headers` mock state for the integration suite. * - * Background: vitest runs the integration suite with `isolate: false` - * (one worker, one container). The factory passed to - * `vi.mock("next/headers", ...)` is resolved ONCE per worker — - * whichever test file loads first wins. If two files each declare a - * top-level `const cookieJar = new Map()` and reference it from their - * own `vi.mock("next/headers", ...)` factory, only the first file's - * Map is ever read; the second file's writes silently disappear and - * its tests flake based on import order. + * `environment-setup.ts` mocks `next/headers` for every file with a stand-in + * that reads these jars, so a test sets a cookie or a header here and the + * route handler under test sees it. Files are isolated (`isolate: true`), so + * each file gets fresh jars; clear them in `beforeEach` to keep cases + * independent. The route test kit (`tests/kit/`) fills them from the `auth` + * option of `callRoute`, so a kit test never touches them directly. * - * Fix: every integration file imports `cookieJar` + `headerJar` from - * THIS module (a singleton) and clears them in `beforeEach`. The - * `vi.mock` factory inside each test file resolves the Maps via - * `await import()` so it sees the same Map instances regardless of - * which file's mock factory ran first. + * Many files still declare their own identical `vi.mock("next/headers")`. + * That predates the shared one, is harmless (a file's own mock wins), and is + * only needed where a file wants different behaviour, such as consuming + * `queuedSessionIds` below. */ export const cookieJar = new Map(); export const headerJar = new Map(); diff --git a/tests/integration/restore-consolidation-deadlock.test.ts b/tests/integration/restore-consolidation-deadlock.test.ts index c07e422de..8f9df2686 100644 --- a/tests/integration/restore-consolidation-deadlock.test.ts +++ b/tests/integration/restore-consolidation-deadlock.test.ts @@ -153,7 +153,8 @@ async function untilHeldInTrigger() { const prisma = getPrismaClient(); for (let i = 0; i < 100; i++) { const rows = await prisma.$queryRaw>` - SELECT count(*) AS n FROM pg_stat_activity WHERE wait_event = 'PgSleep' + SELECT count(*) AS n FROM pg_stat_activity + WHERE wait_event = 'PgSleep' AND datname = current_database() `; if (Number(rows[0]?.n ?? 0) > 0) return; await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/tests/integration/rollup-concurrent-recompute.test.ts b/tests/integration/rollup-concurrent-recompute.test.ts index 552ccdab3..d9ea3d6b3 100644 --- a/tests/integration/rollup-concurrent-recompute.test.ts +++ b/tests/integration/rollup-concurrent-recompute.test.ts @@ -68,6 +68,7 @@ async function waitForRollupLockWaiter(timeoutMs = 5_000): Promise { SELECT count(*)::bigint AS n FROM pg_stat_activity WHERE wait_event_type = 'Lock' + AND datname = current_database() AND query ILIKE '%measurement_rollups%' `; if (Number(rows[0]?.n ?? 0) > 0) return true; diff --git a/tests/integration/setup.ts b/tests/integration/setup.ts index fca0c34da..bca5f427f 100644 --- a/tests/integration/setup.ts +++ b/tests/integration/setup.ts @@ -3,8 +3,14 @@ * is started once in global-setup.ts and bridged into each worker by * environment-setup.ts before application modules load. */ +import { inject } from "vitest"; + import { prisma } from "@/lib/db"; import { assertRecordContext } from "./mock-next-headers"; +import { + START_EMPTY_TABLES, + type IntegrationDatabasePlan, +} from "./worker-database"; import type { PrismaClient } from "@/generated/prisma/client"; /** @@ -17,101 +23,37 @@ export function getPrismaClient(): PrismaClient { return prisma; } -/** Lazily-captured global mood-tag catalogue (NULL user_id rows). */ -let catalogueSnapshot: Awaited< - ReturnType -> | null = null; - /** - * v1.17.0 — same treatment for the seeded mood-tag categories: - * `mood_tag_categories.user_id` now FKs to `users` (custom groups), so the - * users TRUNCATE cascades into the category table too. The categories must - * restore BEFORE the tags (tags FK into categories). + * Return the database to the state a fresh install has: every table in + * `public` emptied, then the rows the migrations seed put back (the mood-tag, + * cycle-symptom and illness-symptom catalogues). Call from beforeEach() to + * make tests independent. + * + * Both lists come from the migrated template, read once in + * `global-setup.ts`: the tables from the catalogue (`pg_tables` minus the + * migration bookkeeping), the seeded rows from the tables that hold any + * before a test has run. The list used to be written by hand and covered 42 + * of 166 tables; a table outside it kept whatever the previous file left, + * which is how a case came to pass or fail depending on file order. Now a new + * table is covered by existing, and the one exception is declared in + * `START_EMPTY_TABLES` with its reason. + * + * Instance-wide schemas the application creates at runtime (pg-boss) are not + * touched between tests of one file, because a file may hold a running queue + * across them; they are dropped when the next file starts in the worker (see + * `worker-database.ts`). */ -let categorySnapshot: Awaited< - ReturnType -> | null = null; +export async function truncateAllTables( + client: { + $executeRawUnsafe(query: string, ...values: unknown[]): Promise; + }, + plan: IntegrationDatabasePlan = inject("integrationDatabasePlan"), +): Promise { + const quoted = plan.tables.map((t) => `"${t}"`).join(", "); + const reseed = plan.reference.filter( + (r) => !START_EMPTY_TABLES.includes(r.table), + ); -/** - * Truncate every user-facing table in dependency-safe order using - * `TRUNCATE … RESTART IDENTITY CASCADE`. Call from beforeEach() to make - * tests independent. The list mirrors `prisma/schema.prisma` minus - * prisma-internal tables (_prisma_migrations). - */ -export async function truncateAllTables(client: PrismaClient): Promise { - // CASCADE means we only need to enumerate the tables; FK chains are - // handled by Postgres. Listed alphabetically for diff stability. - const tables = [ - "api_tokens", - "app_settings", - "audit_logs", - "auth_challenges", - "coach_conversations", - "coach_messages", - "coach_usage", - "data_backups", - "devices", - // Instance-wide, keyed by encryption key id: a canary left by one test - // would make the next test's boot check verify instead of write. - "encryption_key_canaries", - "host_metrics", - "idempotency_keys", - "integration_statuses", - "measurement_rollups", - "measurements", - "medication_intake_events", - "medication_intake_import_jobs", - "medication_schedules", - "medications", - "mood_entries", - "notification_channels", - "notification_preferences", - // Instance-wide OAuth callback claims, keyed on a hash of the state: not - // reached by the users cascade. - "oauth_callback_claims", - // Host-wide off-host backup state: not keyed to a user, so the users - // cascade does not reach it, and a file that reads it whole (the purge - // job, the encryption view) saw what earlier files left behind. - "offhost_backup_key_use", - "offhost_purge_requests", - "passkeys", - "personal_records", - "push_subscriptions", - "rate_limits", - "refresh_tokens", - "reminder_phase_configs", - "sessions", - "step_up_elevations", - "telegram_reminder_messages", - "telegram_scheduled_deletions", - "user_achievements", - "users", - "withings_connections", - "withings_oauth_states", - "workout_routes", - "workouts", - ]; - - // v1.13.0 — the mood-tag catalogue (`mood_tags` rows with NULL user_id) is - // global reference data seeded by migrations and is intentionally NOT in the - // truncate list, so tests can rely on it. But `mood_tags.user_id` now FKs to - // `users`, so `TRUNCATE users CASCADE` cascades into `mood_tags` and wipes - // the catalogue too (TRUNCATE CASCADE truncates dependent tables wholesale, - // regardless of the ON DELETE action). Snapshot the catalogue once (before - // the first truncate clears it) and restore it after each truncate so the - // migration-seeded catalogue persists across tests as it did before the FK. - if (catalogueSnapshot === null) { - catalogueSnapshot = await client.moodTag.findMany({ - where: { userId: null }, - }); - } - if (categorySnapshot === null) { - categorySnapshot = await client.moodTagCategory.findMany({ - where: { userId: null }, - }); - } - - const quoted = tables.map((t) => `"${t}"`).join(", "); // A truncate takes the strictest lock there is on every table at once, so it // loses to anything still holding a row lock. Thirty-nine places in the // server write without awaiting the result (a last-used stamp, a delivery @@ -136,18 +78,14 @@ export async function truncateAllTables(client: PrismaClient): Promise { } } - // Categories first — the tag rows FK into them. - if (categorySnapshot.length > 0) { - await client.moodTagCategory.createMany({ - data: categorySnapshot, - skipDuplicates: true, - }); - } - if (catalogueSnapshot.length > 0) { - await client.moodTag.createMany({ - data: catalogueSnapshot, - skipDuplicates: true, - }); + // In foreign-key order, as global setup sorted them. The rows go back + // exactly as the template holds them, so a test that edited a catalogue + // entry does not hand the edit to the next one. + for (const { table, rowsJson } of reseed) { + await client.$executeRawUnsafe( + `INSERT INTO "${table}" SELECT * FROM json_populate_recordset(NULL::"${table}", $1::json)`, + rowsJson, + ); } } diff --git a/tests/integration/worker-database.ts b/tests/integration/worker-database.ts new file mode 100644 index 000000000..86e600231 --- /dev/null +++ b/tests/integration/worker-database.ts @@ -0,0 +1,129 @@ +/** + * One database per Vitest worker, cloned from the migrated template. + * + * `global-setup.ts` migrates `TEMPLATE_DATABASE` once. Each worker, on its + * first file, copies it into `healthlog_test_w` with + * `CREATE DATABASE … TEMPLATE`, and every later file in that worker reuses + * the copy. Rows, advisory locks (scoped per database), `LISTEN` channels and + * the pg-boss schema therefore never cross between files running at the same + * time in different workers. + * + * Postgres refuses to copy a database while anyone is connected to it, + * including another copy in progress, so clones are serialised with an + * advisory lock held on the maintenance database. Nothing connects to the + * template after global setup has read it. + * + * This module deliberately imports nothing from the application: it runs + * from `environment-setup.ts` before `DATABASE_URL` is final. + */ +import { Client } from "pg"; + +export const TEMPLATE_DATABASE = "healthlog_test"; + +/** + * Tables the migrations seed but a test starts without. + * + * `app_settings` holds the instance singleton. The application creates it on + * first read, and tests create it with the values they mean to test; a seeded + * copy would make every such `create` collide. Every other seeded table (the + * mood-tag, cycle-symptom and illness-symptom catalogues) is reference data a + * fresh install always has, so it is put back after each truncate. + */ +export const START_EMPTY_TABLES: readonly string[] = ["app_settings"]; + +export interface ReferenceTable { + table: string; + /** The template's rows as a JSON array, in insert order across tables. */ + rowsJson: string; +} + +export interface IntegrationDatabasePlan { + /** Schemas a fresh install has; anything else is runtime state. */ + templateSchemas: string[]; + /** Every table in `public` except the migration bookkeeping. */ + tables: string[]; + /** Seeded tables in foreign-key order, with their seeded rows. */ + reference: ReferenceTable[]; +} + +/** The worker's database name. `VITEST_POOL_ID` runs from 1 to maxWorkers. */ +export function workerDatabaseName( + poolId: string | undefined = process.env.VITEST_POOL_ID, +): string { + const id = Number.parseInt(poolId ?? "1", 10); + if (!Number.isInteger(id) || id < 1) { + throw new Error(`unusable VITEST_POOL_ID: ${String(poolId)}`); + } + return `${TEMPLATE_DATABASE}_w${id}`; +} + +function withDatabase(url: string, database: string): string { + const parsed = new URL(url); + parsed.pathname = `/${database}`; + return parsed.toString(); +} + +/** + * Make sure this worker's clone exists and carries no runtime schema left by + * an earlier file, then return its URL. + * + * Runtime schemas are the ones a fresh install does not have: pg-boss creates + * `pgboss` the first time a queue starts, and a few migration tests create + * probe schemas. A file starts without them, so whether a queue table exists + * never depends on which file happened to run earlier in the same worker. + */ +export async function prepareWorkerDatabase( + templateUrl: string, + plan: IntegrationDatabasePlan, +): Promise { + const name = workerDatabaseName(); + const maintenance = new Client({ + connectionString: withDatabase(templateUrl, "postgres"), + }); + await maintenance.connect(); + try { + const exists = async () => + ( + await maintenance.query( + `SELECT 1 FROM pg_database WHERE datname = $1`, + [name], + ) + ).rowCount === 1; + if (!(await exists())) { + await maintenance.query( + `SELECT pg_advisory_lock(hashtext('healthlog-integration-clone'))`, + ); + try { + if (!(await exists())) { + await maintenance.query( + `CREATE DATABASE "${name}" TEMPLATE "${TEMPLATE_DATABASE}"`, + ); + } + } finally { + await maintenance.query( + `SELECT pg_advisory_unlock(hashtext('healthlog-integration-clone'))`, + ); + } + } + } finally { + await maintenance.end(); + } + + const url = withDatabase(templateUrl, name); + const db = new Client({ connectionString: url }); + await db.connect(); + try { + const { rows } = await db.query<{ nspname: string }>( + `SELECT nspname FROM pg_namespace + WHERE nspname NOT LIKE 'pg\\_%' AND nspname <> 'information_schema' + AND nspname <> ALL($1::text[])`, + [plan.templateSchemas], + ); + for (const { nspname } of rows) { + await db.query(`DROP SCHEMA "${nspname.replace(/"/g, '""')}" CASCADE`); + } + } finally { + await db.end(); + } + return url; +} diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 50770dc21..41512022d 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -1,10 +1,21 @@ import { defineConfig } from "vitest/config"; import { dirname, resolve } from "node:path"; +import { availableParallelism } from "node:os"; import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +function integrationWorkers(): number { + const pinned = Number.parseInt( + process.env.HEALTHLOG_INTEGRATION_WORKERS ?? "", + 10, + ); + if (Number.isInteger(pinned) && pinned > 0) return pinned; + // Half the cores: the other half runs Postgres and the container runtime. + return Math.max(1, Math.min(8, Math.floor(availableParallelism() / 2))); +} + // Integration suite — boots a real Postgres in a testcontainer and runs // migrations against it. Slow (container boot ~5–15s on a cold pull), // so excluded from the default `pnpm test` and triggered explicitly via @@ -30,11 +41,16 @@ export default defineConfig({ // budget and beforeAll/afterAll twice that. testTimeout: 60_000, hookTimeout: 120_000, - // Run sequentially in a single fork so files reuse one container and - // amortise the migration-apply cost. Vitest 4 moved these to the top - // level (the old `poolOptions.forks.singleFork` was removed). + // Files run in parallel, one database per worker: global setup migrates a + // template once and each worker clones it on its first file (see + // `tests/integration/worker-database.ts`). The worker count follows the + // host unless `HEALTHLOG_INTEGRATION_WORKERS` pins it; CI pins it to what + // a runner shared with the Postgres container can carry. pool: "forks", - fileParallelism: false, + fileParallelism: true, + maxWorkers: integrationWorkers(), + // Order dependence is looked for on purpose by shuffling file order + // (`--sequence.shuffle`), not by keeping the suite serial. // v1.4.37 — switched from `isolate: false` to `isolate: true`. // The previous setting reused the module graph across files inside // one fork; mock declarations (`vi.mock("@/lib/notifications/...")`, @@ -48,8 +64,8 @@ export default defineConfig({ // expensive boot cost is unaffected. isolate: true, // One container for the whole run; tests truncate between them. - // Bridge the global Testcontainers URL into each isolated worker before - // application modules read DATABASE_URL at import time. + // Point each isolated worker at its own clone before application modules + // read DATABASE_URL at import time. setupFiles: ["./tests/integration/environment-setup.ts"], globalSetup: ["./tests/integration/global-setup.ts"], }, From 3d837cdd77f689dff7ccc5e7721253cf7fe10ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:30:31 +0200 Subject: [PATCH 02/20] test(kit): call route handlers through the real API kit against Postgres tests/kit/ holds typed fixture factories and callRoute(handler, options). The factories write through the application's Prisma client, so a fixture has the shape production stores and the compiler checks it; sessions and Bearer tokens are minted by createSession and issueApiToken, so a fixture cannot drift into a shape authentication no longer accepts. callRoute builds a real NextRequest, fills the next/headers stand-in from the auth fixture and runs the exported handler, apiHandler included. Every call is also a contract check: the status must be declared for that path and method in the OpenAPI registry, a JSON body must parse with the registered schema, and a key the schema does not declare fails the call. The scope gate's generic 403, documented on the shared 401, is the one answer accepted without its own entry. kit-contract.test.ts proves each refusal can fire. The token list and revoke move onto the kit. The mocked unit test asserted the list's where clause and proved the API switch is not consulted by reading api-handler.ts for a string; the kit test authenticates a Bearer token while the switch is off, revokes it, and shows it refused on the next request. --- src/app/api/tokens/__tests__/route.test.ts | 180 --------- tests/integration/routes/kit-contract.test.ts | 111 ++++++ tests/integration/routes/tokens.test.ts | 187 +++++++++ tests/kit/call-route.ts | 363 ++++++++++++++++++ tests/kit/factories.ts | 219 +++++++++++ 5 files changed, 880 insertions(+), 180 deletions(-) delete mode 100644 src/app/api/tokens/__tests__/route.test.ts create mode 100644 tests/integration/routes/kit-contract.test.ts create mode 100644 tests/integration/routes/tokens.test.ts create mode 100644 tests/kit/call-route.ts create mode 100644 tests/kit/factories.ts diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts deleted file mode 100644 index 2c757c67d..000000000 --- a/src/app/api/tokens/__tests__/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * `/api/tokens` — the instance-wide API switch must not hide live credentials. - * - * `AppSettings.apiGlobal` gates the surfaces a token is FOR — external - * medication ingest, the MCP bridge — and not a token's ability to - * authenticate: nothing on the `requireAuth` path consults it, which the last - * case here asserts directly rather than assuming. So a token minted before an - * operator flipped the switch stays a live credential while it is off, and the - * LIST used to answer 403 — hiding exactly the credentials their owner most - * needed to see, including the wildcard access token their own phone signs in - * with. - * - * The read is ungated now. The revoke is not, and that asymmetry is pinned - * below rather than left to drift: it is a decision about what the switch - * means, and a change to it should be a visible diff here. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: (fn: unknown) => fn, - requireAuth: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - apiToken: { - findMany: vi.fn(), - findUnique: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); - -vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: vi.fn() })); - -import { GET } from "../route"; -import { DELETE } from "../[id]/route"; -import { requireAuth } from "@/lib/api-handler"; -import { prisma } from "@/lib/db"; -import { isApiGloballyEnabled } from "@/lib/app-settings"; - -const USER = { id: "user-1", username: "u" }; - -const ROW = { - id: "tok-1", - name: "iPhone", - permissions: ["*"], - lastUsedAt: new Date("2026-08-01T10:00:00Z"), - expiresAt: null, - createdAt: new Date("2026-01-01T00:00:00Z"), - revoked: false, -}; - -type ListEnvelope = { - data: Array<{ id: string }> | null; - error: string | null; -}; - -const params = Promise.resolve({ id: "tok-1" }); - -type DeleteHandler = ( - r: Request, - ctx: { params: Promise<{ id: string }> }, -) => Promise; - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never); - vi.mocked(prisma.apiToken.findMany).mockResolvedValue([ROW] as never); -}); - -describe("GET /api/tokens", () => { - it("lists the caller's tokens while the operator has the API switched off", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - const res = await (GET as unknown as () => Promise)(); - - expect(res.status).toBe(200); - const env = (await res.json()) as ListEnvelope; - expect(env.error).toBeNull(); - expect(env.data?.[0]?.id).toBe("tok-1"); - expect(prisma.apiToken.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { userId: USER.id } }), - ); - }); - - it("does not consult the switch at all", async () => { - // Stronger than the status assertion above and the reason it is separate: - // a future re-gating that happened to answer 200 anyway would still be a - // read whose behaviour depends on an operator toggle it has no business - // reading. - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - await (GET as unknown as () => Promise)(); - - expect(isApiGloballyEnabled).not.toHaveBeenCalled(); - }); - - it("still scopes the list to the caller", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - - await (GET as unknown as () => Promise)(); - - const args = vi.mocked(prisma.apiToken.findMany).mock.calls[0][0]; - expect(args?.where).toEqual({ userId: USER.id }); - // No `revoked: false` filter — revoked rows stay in the list, which is - // what the published contract says and what the UI relies on. - expect(JSON.stringify(args?.where)).not.toContain("revoked"); - }); -}); - -describe("DELETE /api/tokens/{id}", () => { - it("keeps refusing while the operator has the API switched off", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(false); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(403); - expect(prisma.apiToken.update).not.toHaveBeenCalled(); - }); - - it("revokes when the switch is on", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - ...ROW, - userId: USER.id, - } as never); - vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(200); - expect(prisma.apiToken.update).toHaveBeenCalledWith({ - where: { id: "tok-1" }, - data: { revoked: true }, - }); - }); - - it("does not reach another account's token", async () => { - vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - ...ROW, - userId: "someone-else", - } as never); - - const res = await (DELETE as unknown as DeleteHandler)( - new Request("http://localhost/api/tokens/tok-1", { method: "DELETE" }), - { params }, - ); - - expect(res.status).toBe(404); - expect(prisma.apiToken.update).not.toHaveBeenCalled(); - }); -}); - -describe("the switch and the authentication path", () => { - it("is not consulted by requireAuth, which is why the read must not gate on it", async () => { - // The premise the fix rests on, asserted rather than assumed: if the auth - // kit ever started refusing Bearer callers while `apiGlobal` is off, then - // a token would NOT be live during the outage and hiding the list would - // stop being wrong. This reads the source because the behaviour is an - // absence, and an absence has no call to spy on. - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const source = readFileSync( - join(process.cwd(), "src/lib/api-handler.ts"), - "utf8", - ); - expect(source).not.toContain("isApiGloballyEnabled"); - expect(source).not.toContain("apiGlobal"); - }); -}); diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts new file mode 100644 index 000000000..99e0106e1 --- /dev/null +++ b/tests/integration/routes/kit-contract.test.ts @@ -0,0 +1,111 @@ +/** + * The route kit's contract check, proven able to fail. + * + * Every kit test leans on `callRoute` refusing a response the published + * contract does not describe. A check that passes whatever it is handed would + * turn every one of them into a weaker test than it claims to be, so each + * refusal is exercised here against a handler that answers exactly the wrong + * thing, wrapped in the real `apiHandler`. + */ +import { NextResponse } from "next/server"; +import { describe, expect, it } from "vitest"; + +import { apiHandler } from "@/lib/api-handler"; + +import { ContractViolation, anonymous, callRoute } from "../../kit/call-route"; + +const token = { + id: "tok-1", + name: "phone", + permissions: ["*"], + lastUsedAt: null, + expiresAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + revoked: false, +}; + +function answering(status: number, body: unknown) { + return apiHandler(async () => NextResponse.json(body, { status })); +} + +describe("callRoute's contract check", () => { + it("accepts a body the registered schema describes", async () => { + const res = await callRoute( + answering(200, { data: [token], error: null }), + { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }, + ); + expect(res.status).toBe(200); + }); + + it("refuses a key the registered schema does not declare", async () => { + await expect( + callRoute( + answering(200, { + data: [{ ...token, tokenHash: "abc" }], + error: null, + }), + { method: "GET", route: "/api/tokens", auth: anonymous }, + ), + ).rejects.toThrow(/does not declare: data\[0\]\.tokenHash/); + }); + + it("refuses a body of the wrong shape", async () => { + await expect( + callRoute( + answering(200, { data: [{ ...token, revoked: "no" }], error: null }), + { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }, + ), + ).rejects.toBeInstanceOf(ContractViolation); + }); + + it("refuses a status the operation does not declare", async () => { + await expect( + callRoute(answering(409, { data: null, error: "conflict" }), { + method: "GET", + route: "/api/tokens", + auth: anonymous, + }), + ).rejects.toThrow(/answered 409, which the contract does not declare/); + }); + + it("refuses a 403 that is not the generic scope refusal", async () => { + await expect( + callRoute( + answering(403, { + data: null, + error: "nope", + meta: { errorCode: "http.forbidden" }, + }), + { method: "GET", route: "/api/tokens", auth: anonymous }, + ), + ).rejects.toThrow(/answered 403/); + }); + + it("refuses a route the registry does not know", async () => { + await expect( + callRoute(answering(200, { data: null, error: null }), { + method: "GET", + route: "/api/not-a-route", + auth: anonymous, + }), + ).rejects.toThrow(/is not in the OpenAPI registry/); + }); + + it("skips the check only when told why", async () => { + const res = await callRoute(answering(200, { anything: true }), { + method: "GET", + route: "/api/not-a-route", + auth: anonymous, + contract: { skip: "exercising the escape hatch itself" }, + }); + expect(res.body).toEqual({ anything: true }); + }); +}); diff --git a/tests/integration/routes/tokens.test.ts b/tests/integration/routes/tokens.test.ts new file mode 100644 index 000000000..a8bdc6877 --- /dev/null +++ b/tests/integration/routes/tokens.test.ts @@ -0,0 +1,187 @@ +/** + * `/api/tokens` and `/api/tokens/{id}` — list and revoke the caller's API + * tokens, against the real database and through the real API kit. + * + * The decisions pinned here are the ones a person relies on when they go + * looking for a credential to kill: + * - the list carries every token the account holds, revoked ones included, + * newest first, and never anyone else's; + * - the list is not gated on the operator's instance-wide API switch, + * because that switch does not stop a token authenticating, which the + * Bearer case below proves by authenticating with one while it is off; + * - the revoke is gated on the switch, reaches only the caller's own rows, + * and a revoked token stops authenticating on the very next request. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/tokens/route"; +import { DELETE } from "@/app/api/tokens/[id]/route"; + +import { callRoute } from "../../kit/call-route"; +import { makeSession, makeToken, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +type TokenRow = { + id: string; + name: string; + permissions: string[]; + revoked: boolean; + createdAt: string; +}; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +async function switchApi(enabled: boolean): Promise { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", apiGlobal: enabled }, + update: { apiGlobal: enabled }, + }); +} + +describe("GET /api/tokens", () => { + it("lists the caller's tokens newest first, revoked ones included, and nobody else's", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const older = await makeToken(owner, { name: "older" }); + const newer = await makeToken(owner, { name: "newer" }); + await prisma.apiToken.update({ + where: { id: older.tokenId }, + data: { revoked: true, createdAt: new Date("2026-01-01T00:00:00Z") }, + }); + await makeToken(other, { name: "not yours" }); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data.map((t) => [t.id, t.name, t.revoked])).toEqual([ + [newer.tokenId, "newer", false], + [older.tokenId, "older", true], + ]); + }); + + it("answers a Bearer caller while the operator has the API switched off", async () => { + // The premise of the ungated list: the switch does not stop a token from + // authenticating. Proven by authenticating with one while it is off. + const owner = await makeUser(); + const phone = await makeToken(owner, { name: "phone" }); + await switchApi(false); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + + expect(res.status).toBe(200); + expect(res.data.map((t) => t.id)).toEqual([phone.tokenId]); + }); + + it("refuses a narrow token that does not name this route's scope", async () => { + const owner = await makeUser(); + const ingest = await makeToken(owner, { + permissions: ["measurements:write"], + }); + + const res = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: ingest, + }); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + expect(res.data).toBeNull(); + }); +}); + +describe("DELETE /api/tokens/{id}", () => { + it("revokes the caller's token, keeps the row listed, and the token stops authenticating", async () => { + const owner = await makeUser(); + const session = await makeSession(owner); + const phone = await makeToken(owner, { name: "phone" }); + + const before = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + expect(before.status).toBe(200); + + const res = await callRoute<{ revoked: boolean }>(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: phone.tokenId }, + auth: session, + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ revoked: true }); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: phone.tokenId }, + }); + expect(row.revoked).toBe(true); + + const listed = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: session, + }); + expect(listed.data).toEqual([ + expect.objectContaining({ id: phone.tokenId, revoked: true }), + ]); + + const after = await callRoute(GET, { + method: "GET", + route: "/api/tokens", + auth: phone, + }); + expect(after.status).toBe(401); + }); + + it("answers 404 for another account's token and leaves it live", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeToken(other); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: theirs.tokenId }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: theirs.tokenId }, + }); + expect(row.revoked).toBe(false); + }); + + it("refuses while the operator has the API switched off and revokes nothing", async () => { + const owner = await makeUser(); + const phone = await makeToken(owner); + await switchApi(false); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: phone.tokenId }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(403); + const row = await prisma.apiToken.findUniqueOrThrow({ + where: { id: phone.tokenId }, + }); + expect(row.revoked).toBe(false); + }); +}); diff --git a/tests/kit/call-route.ts b/tests/kit/call-route.ts new file mode 100644 index 000000000..1634c3e87 --- /dev/null +++ b/tests/kit/call-route.ts @@ -0,0 +1,363 @@ +/** + * Call a route handler the way Next.js would, through the real `apiHandler`, + * against the worker's real database, and check the answer against the + * published contract. + * + * What runs: the exported handler (`GET`, `POST`, …) of a `route.ts`, which + * every API route wraps in `apiHandler`. Authentication (cookie session or + * Bearer token, scope checks, the acting-account resolver), the error + * envelope, Zod validation, idempotency, rate limits and the handler body are + * all the production code. What does not run is the edge in `src/proxy.ts` + * (security headers, the public-path allowlist, demo mode), which has its own + * tests. + * + * The contract check: the response status must be one the OpenAPI registry + * (`src/lib/openapi/routes`) declares for that path and method, and a JSON + * body must parse with the schema registered for that status. Keys the + * schema does not declare fail too, because a client generated from the + * contract cannot see them and a field nobody declared is a field nobody + * promised to keep. Every kit test is therefore also a consumer contract + * check for the native client, at no extra cost to whoever writes it. A test + * that deliberately calls an unregistered route, or reaches a body the + * registry cannot describe, says so with `contract: { skip: "" }`. + */ +import { NextRequest } from "next/server"; +import type { z } from "zod"; + +import { openApiComponents, openApiPaths } from "@/lib/openapi/routes"; +import { SESSION_COOKIE_NAME } from "@/lib/auth/session"; + +import { cookieJar, headerJar } from "../integration/mock-next-headers"; +import type { BearerFixture, SessionFixture } from "./factories"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +export type RouteAuth = SessionFixture | BearerFixture | { kind: "anonymous" }; + +export const anonymous: RouteAuth = { kind: "anonymous" }; + +type RouteParams = Record; + +/** + * The shape Next.js hands a route handler. Written as a method so a handler + * whose context names its own params (`{ params: Promise<{ id: string }> }`) + * is accepted without a cast; static handlers that take only the request, or + * nothing at all, fit as they are. + */ +type RouteHandler = { + handle( + request: NextRequest, + context: { params: Promise }, + ): Promise; +}["handle"]; + +export interface CallRouteOptions { + method: HttpMethod; + /** The path as the contract names it, e.g. `/api/tokens/{id}`. */ + route: string; + /** Values for the `{…}` segments of `route`. */ + params?: RouteParams; + query?: Record; + /** + * Serialised as JSON with `content-type: application/json`. A `FormData` + * goes out as a multipart body instead, with the boundary the platform + * picks. + */ + body?: unknown; + auth: RouteAuth; + headers?: Record; + contract?: { skip: string }; +} + +export interface RouteResult { + status: number; + headers: Headers; + /** The parsed JSON body, or the text of a non-JSON one. */ + body: unknown; + /** `body.data` of the envelope; typed by the caller, checked by the contract. */ + data: T; + error: string | null; + meta: Record | undefined; +} + +export class ContractViolation extends Error { + constructor(message: string) { + super(message); + this.name = "ContractViolation"; + } +} + +export async function callRoute( + handler: RouteHandler, + options: CallRouteOptions, +): Promise> { + const path = concretePath(options.route, options.params ?? {}); + const url = new URL(path, "http://localhost:3000"); + for (const [key, value] of Object.entries(options.query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + + const headers = new Headers(options.headers); + const multipart = options.body instanceof FormData; + if ( + options.body !== undefined && + !multipart && + !headers.has("content-type") + ) { + headers.set("content-type", "application/json"); + } + if (options.auth.kind === "bearer") { + headers.set("authorization", `Bearer ${options.auth.token}`); + } + if (options.auth.kind === "session") { + headers.set("cookie", `${SESSION_COOKIE_NAME}=${options.auth.cookie}`); + } + + // `next/headers` is served from the shared jars; fill them for this call + // and put them back afterwards, so a record-context assertion a test set + // with `switchSessionTo` survives and nothing of this call leaks into the + // next one. + const savedCookies = new Map(cookieJar); + const savedHeaders = new Map(headerJar); + cookieJar.clear(); + if (options.auth.kind === "session") { + cookieJar.set(SESSION_COOKIE_NAME, options.auth.cookie); + } + headerJar.delete("authorization"); + headers.forEach((value, name) => headerJar.set(name.toLowerCase(), value)); + + let response: Response; + try { + const request = new NextRequest(url, { + method: options.method, + headers, + body: + options.body === undefined + ? undefined + : options.body instanceof FormData + ? options.body + : JSON.stringify(options.body), + }); + response = await handler(request, { + params: Promise.resolve(options.params ?? {}), + }); + } finally { + cookieJar.clear(); + for (const [name, value] of savedCookies) cookieJar.set(name, value); + headerJar.clear(); + for (const [name, value] of savedHeaders) headerJar.set(name, value); + } + + const contentType = response.headers.get("content-type") ?? ""; + const text = await response.text(); + const isJson = contentType.includes("json") && text.length > 0; + const body: unknown = isJson ? JSON.parse(text) : text; + + if (!options.contract?.skip) { + checkContract( + options.method, + options.route, + response.status, + contentType, + body, + isJson, + ); + } + + const envelope = isEnvelope(body) ? body : null; + return { + status: response.status, + headers: response.headers, + body, + // The one cast in the kit: the contract check above has already parsed + // the body against the registered schema, and the caller names the type + // it expects. + data: (envelope?.data ?? null) as T, + error: + envelope && typeof envelope.error === "string" ? envelope.error : null, + meta: + envelope && isRecord(envelope.meta) + ? (envelope.meta as Record) + : undefined, + }; +} + +function concretePath(route: string, params: RouteParams): string { + const used = new Set(); + const path = route.replace(/\{([^}]+)\}/g, (_, name: string) => { + const value = params[name]; + if (value === undefined) { + throw new Error(`callRoute: no value for {${name}} in ${route}`); + } + used.add(name); + return Array.isArray(value) + ? value.map(encodeURIComponent).join("/") + : encodeURIComponent(value); + }); + for (const name of Object.keys(params)) { + if (!used.has(name)) { + throw new Error(`callRoute: ${route} has no {${name}} segment`); + } + } + return path; +} + +interface ResponseObject { + content?: Record; + $ref?: string; +} + +/** + * The generic scope gate answers 403 `auth.scope.insufficient` on any route a + * narrow Bearer token does not reach. The registry documents that refusal in + * the prose of the shared 401 (`stdResponses`) rather than as a 403 entry on + * every operation, so an operation that declares the shared 401 is taken to + * declare this 403 as well, and nothing else is. + */ +const SCOPE_REFUSAL_CODE = "auth.scope.insufficient"; + +function lookupResponse( + method: HttpMethod, + route: string, + status: number, + body: unknown, +): ResponseObject { + const pathItem = (openApiPaths as Record)[route]; + if (!isRecord(pathItem)) { + throw new ContractViolation( + `${method} ${route} is not in the OpenAPI registry. Register the route in src/lib/openapi/routes, or pass contract: { skip: "" }.`, + ); + } + const operation = pathItem[method.toLowerCase()]; + if (!isRecord(operation)) { + throw new ContractViolation( + `${route} is registered, but not for ${method}.`, + ); + } + const responses = isRecord(operation.responses) ? operation.responses : {}; + let declared = responses[String(status)] ?? responses.default; + if ( + !isRecord(declared) && + status === 403 && + isRecord(responses["401"]) && + isRecord(body) && + isRecord(body.meta) && + body.meta.errorCode === SCOPE_REFUSAL_CODE + ) { + declared = responses["401"]; + } + if (!isRecord(declared)) { + throw new ContractViolation( + `${method} ${route} answered ${status}, which the contract does not declare (declared: ${Object.keys(responses).join(", ")}).`, + ); + } + if (typeof declared.$ref === "string") { + const name = declared.$ref.split("/").pop() ?? ""; + const resolved = ( + openApiComponents.responses as Record | undefined + )?.[name]; + if (!isRecord(resolved)) { + throw new ContractViolation( + `unresolvable response reference ${declared.$ref}`, + ); + } + return resolved as ResponseObject; + } + return declared as ResponseObject; +} + +function checkContract( + method: HttpMethod, + route: string, + status: number, + contentType: string, + body: unknown, + isJson: boolean, +): void { + const declared = lookupResponse(method, route, status, body); + const content = declared.content ?? {}; + const mediaTypes = Object.keys(content); + if (!isJson) { + if (status === 204 || mediaTypes.length === 0) return; + const mediaType = contentType.split(";")[0]?.trim() ?? ""; + if (!mediaTypes.some((m) => m === mediaType || m === "*/*")) { + throw new ContractViolation( + `${method} ${route} ${status} answered ${mediaType || "no content type"}; the contract declares ${mediaTypes.join(", ")}.`, + ); + } + return; + } + const mediaType = contentType.split(";")[0]?.trim() ?? ""; + const media = content[mediaType] ?? content["application/json"]; + if (!media) { + throw new ContractViolation( + `${method} ${route} ${status} answered JSON; the contract declares ${mediaTypes.join(", ") || "no body"}.`, + ); + } + if (!isZodSchema(media.schema)) return; + const parsed = media.schema.safeParse(body); + // A file download (a FHIR bundle, an export) is often declared as the + // string it is served as; check it as that string before calling it a + // mismatch. + if (!parsed.success && typeof body !== "string") { + const asText = media.schema.safeParse(JSON.stringify(body)); + if (asText.success) return; + } + if (!parsed.success) { + const issues = parsed.error.issues + .slice(0, 8) + .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`) + .join("\n"); + throw new ContractViolation( + `${method} ${route} ${status} does not match its registered schema:\n${issues}`, + ); + } + const undeclared = undeclaredKeys(body, parsed.data, ""); + if (undeclared.length > 0) { + throw new ContractViolation( + `${method} ${route} ${status} carries keys its registered schema does not declare: ${undeclared.slice(0, 12).join(", ")}`, + ); + } +} + +/** + * Keys present in what the handler sent and absent from what the schema + * parsed out of it. A closed `z.object` drops keys it does not declare, a + * loose object or a record keeps them, so the difference is exactly the set + * of fields the contract never mentions. + */ +function undeclaredKeys(input: unknown, output: unknown, at: string): string[] { + if (Array.isArray(input) && Array.isArray(output)) { + return input.flatMap((item, i) => + undeclaredKeys(item, output[i], `${at}[${i}]`), + ); + } + if (!isRecord(input) || !isRecord(output)) return []; + const found: string[] = []; + for (const key of Object.keys(input)) { + const path = at ? `${at}.${key}` : key; + if (!(key in output)) { + found.push(path); + continue; + } + found.push(...undeclaredKeys(input[key], output[key], path)); + } + return found; +} + +function isZodSchema(value: unknown): value is z.ZodType { + return ( + isRecord(value) && + typeof (value as { safeParse?: unknown }).safeParse === "function" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isEnvelope( + value: unknown, +): value is { data?: unknown; error?: unknown; meta?: unknown } { + return isRecord(value) && ("data" in value || "error" in value); +} diff --git a/tests/kit/factories.ts b/tests/kit/factories.ts new file mode 100644 index 000000000..1510314f6 --- /dev/null +++ b/tests/kit/factories.ts @@ -0,0 +1,219 @@ +/** + * Typed fixture factories for tests that run against the real database. + * + * Every factory writes through the application's Prisma client and returns + * the stored row, so a fixture has the shape production stores, checked by + * the compiler: a renamed or retyped column is a type error here, once, and + * not a silent mismatch in a hundred hand-written row literals cast past the + * type checker. Overrides are the Prisma create input for that model, so a + * test names only what it is about and takes a valid default for the rest. + * + * Credentials are minted the way the server mints them: a session through + * `createSession` and a Bearer token through `issueApiToken`, so a fixture + * cannot drift into a shape the authentication path no longer accepts. + * + * Only for files under `tests/integration/`; importing this anywhere else + * opens a connection to whatever `DATABASE_URL` points at. + */ +import { randomBytes } from "node:crypto"; + +import type { + AccountGrant, + Medication, + MedicationSchedule, + Measurement, + Prisma, + User, + Workout, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { createSession, SESSION_COOKIE_NAME } from "@/lib/auth/session"; +import { issueApiToken } from "@/lib/auth/issue-token"; + +import { cookieJar } from "../integration/mock-next-headers"; + +let counter = 0; + +/** A short unique suffix, so fixtures never collide on a unique column. */ +export function uniqueSuffix(): string { + counter += 1; + return `${counter.toString(36)}${randomBytes(3).toString("hex")}`; +} + +type UserOverrides = Partial; + +/** + * An onboarded account with a password-less sign-in identity. Onboarding is + * marked complete because an account that has not finished it is redirected + * by the proxy and refused by a handful of write routes; a test about + * onboarding sets `onboardingCompletedAt: null` itself. + */ +export async function makeUser(overrides: UserOverrides = {}): Promise { + const suffix = uniqueSuffix(); + return prisma.user.create({ + data: { + username: `user-${suffix}`, + email: `user-${suffix}@example.test`, + onboardingCompletedAt: new Date("2026-01-01T00:00:00Z"), + ...overrides, + }, + }); +} + +/** An administrator account; admin routes also need a cookie session. */ +export async function makeAdmin(overrides: UserOverrides = {}): Promise { + return makeUser({ role: "ADMIN", ...overrides }); +} + +export interface SessionFixture { + kind: "session"; + userId: string; + sessionId: string; + /** The cookie value: the secret, never stored, exactly as a browser holds it. */ + cookie: string; +} + +/** + * A browser session minted by the real `createSession`. The secret is read + * off the cookie the function sets, and the jar is left as it was found, so + * minting a second session for another account does not sign the first one + * out of a request in flight. + */ +export async function makeSession( + user: Pick, + options: { mfaVerifiedAt?: Date | null } = {}, +): Promise { + const before = new Map(cookieJar); + const sessionId = await createSession( + user.id, + false, + "203.0.113.10", + "healthlog-kit", + options.mfaVerifiedAt ?? null, + ); + const cookie = cookieJar.get(SESSION_COOKIE_NAME); + cookieJar.clear(); + for (const [name, value] of before) cookieJar.set(name, value); + if (!cookie) { + throw new Error("createSession did not set the session cookie"); + } + return { kind: "session", userId: user.id, sessionId, cookie }; +} + +export interface BearerFixture { + kind: "bearer"; + userId: string; + tokenId: string; + /** The raw `hlk_…` value, which the server stores only as an HMAC. */ + token: string; + permissions: string[]; +} + +/** A Bearer token minted by the real `issueApiToken`. Wildcard by default. */ +export async function makeToken( + user: Pick, + options: { + permissions?: string[]; + name?: string; + expiresInDays?: number; + } = {}, +): Promise { + const permissions = options.permissions ?? ["*"]; + const issued = await issueApiToken({ + userId: user.id, + name: options.name ?? `kit-${uniqueSuffix()}`, + permissions, + expiresInDays: options.expiresInDays, + }); + return { + kind: "bearer", + userId: user.id, + tokenId: issued.tokenId, + token: issued.token, + permissions, + }; +} + +export async function makeMeasurement( + user: Pick, + overrides: Partial< + Omit + > = {}, +): Promise { + return prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 72.5, + unit: "kg", + measuredAt: new Date("2026-03-02T07:30:00Z"), + ...overrides, + }, + }); +} + +export async function makeMedication( + user: Pick, + overrides: Partial< + Omit + > = {}, + schedules: ReadonlyArray< + Partial> + > = [{}], +): Promise { + return prisma.medication.create({ + data: { + userId: user.id, + name: `Medication ${uniqueSuffix()}`, + dose: "10mg", + ...overrides, + schedules: { + create: schedules.map((schedule) => ({ + windowStart: "08:00", + windowEnd: "10:00", + ...schedule, + })), + }, + }, + include: { schedules: true }, + }); +} + +export async function makeWorkout( + user: Pick, + overrides: Partial> = {}, +): Promise { + const startedAt = new Date("2026-03-02T17:00:00Z"); + return prisma.workout.create({ + data: { + userId: user.id, + sportType: "running", + startedAt, + endedAt: new Date(startedAt.getTime() + 30 * 60 * 1000), + durationSec: 30 * 60, + ...overrides, + }, + }); +} + +/** + * A grant from `grantor` to `grantee`, accepted unless the override says + * otherwise. Read access by default; `access: "WRITE"` for a delegate who + * edits. + */ +export async function makeGrant( + grantor: Pick, + grantee: Pick, + overrides: Partial< + Omit + > = {}, +): Promise { + return prisma.accountGrant.create({ + data: { + grantorId: grantor.id, + granteeId: grantee.id, + acceptedAt: new Date(), + ...overrides, + }, + }); +} From b179e34f581442306ecd9ab640caea02279f784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:30:40 +0200 Subject: [PATCH 03/20] test(integration): round-trip every account setting through the API and a backup One table holds an entry per write route, and every User column the backup classifies as a setting appears in exactly one entry; a new setting column fails the inventory case until it has one. For each entry the file sets a non-default value through its write route, reads it back through its read route and through /api/auth/me where that publishes it, and checks the stored column moved off what a fresh account holds. The backup leg runs through the routes too: the portable full backup a person downloads, the operator's upload, and the restore into an account recreated under the same id. Every setting must read back identically on the restored account, through the same routes and the account payload, and every stored column must come back with its value. --- tests/integration/settings-round-trip.test.ts | 1196 +++++++++++++++++ 1 file changed, 1196 insertions(+) create mode 100644 tests/integration/settings-round-trip.test.ts diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts new file mode 100644 index 000000000..d89a6a4e2 --- /dev/null +++ b/tests/integration/settings-round-trip.test.ts @@ -0,0 +1,1196 @@ +/** + * Every setting a person can choose survives the whole trip: set through the + * API, read back through the API and `/api/auth/me`, carried out in a backup, + * restored into a fresh account, and read back there. + * + * The table below has one entry per write, and every `User` column the backup + * classifies as a `SETTING` (`USER_COLUMN_BACKUP_CLASS` in + * `src/lib/export/backup-plan.ts`) appears in exactly one entry. A new setting + * column fails the inventory case until it is given an entry, which is the + * point: the class of bug this file exists for is a setting that is stored but + * not read back, read back but not published, or published but dropped by the + * backup, and each of those used to be found by somebody restoring an account. + * + * Every request runs through the real API kit against the real database with + * the kit's contract check, so each write and read is also checked against + * the OpenAPI registry the native client is generated from. + * + * The backup leg goes through the routes too: the portable full backup a + * person downloads, the operator's upload, and the operator's restore into an + * account recreated under the same id, the way an account is brought back on + * a new host. Only the restore's queue hop is replaced (see + * `restore-job-driver.ts`). + */ +import { isDeepStrictEqual } from "node:util"; + +import { beforeAll, describe, expect, it } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { + USER_COLUMN_BACKUP_CLASS, + type AccountSettingColumn, +} from "@/lib/export/backup-plan"; + +import { GET as getMe } from "@/app/api/auth/me/route"; +import * as profile from "@/app/api/user/profile/route"; +import * as diabetes from "@/app/api/auth/me/diabetes/route"; +import * as autoAiRead from "@/app/api/auth/me/documents-auto-ai-read/route"; +import * as labsOcr from "@/app/api/auth/me/labs-local-ocr/route"; +import * as workoutRoutes from "@/app/api/auth/me/workout-routes/route"; +import * as centralCodex from "@/app/api/auth/me/use-central-codex/route"; +import * as disableCoach from "@/app/api/auth/me/disable-coach/route"; +import * as unitPreference from "@/app/api/auth/me/unit-preference/route"; +import * as glucoseUnit from "@/app/api/auth/me/glucose-unit/route"; +import * as injectionSites from "@/app/api/auth/me/injection-site-prefs/route"; +import * as modules from "@/app/api/auth/me/modules/route"; +import * as healthScore from "@/app/api/auth/me/health-score-config/route"; +import * as sourcePriority from "@/app/api/auth/me/source-priority/route"; +import * as notificationPrefs from "@/app/api/auth/me/notification-prefs/route"; +import * as reportSelection from "@/app/api/auth/me/report-selection/route"; +import * as coachPrefs from "@/app/api/auth/me/coach-prefs/route"; +import { PUT as putAiUsage } from "@/app/api/auth/me/ai-usage/route"; +import { POST as dismissPasskeyNudge } from "@/app/api/auth/me/passkey-nudge/dismiss/route"; +import { GET as getMfa } from "@/app/api/auth/me/mfa/route"; +import { POST as uploadAvatar } from "@/app/api/user/avatar/route"; +import { GET as getAvatar } from "@/app/api/user/avatar/[id]/route"; +import * as aiProvider from "@/app/api/user/ai-provider/route"; +import * as providerChain from "@/app/api/insights/provider-chain/route"; +import * as insightsSettings from "@/app/api/insights/settings/route"; +import { POST as completeOnboarding } from "@/app/api/onboarding/complete/route"; +import { POST as onboardingTour } from "@/app/api/onboarding/tour/route"; +import { POST as onboardingDisclaimer } from "@/app/api/onboarding/disclaimer/route"; +import { GET as getEnvironment } from "@/app/api/environment/route"; +import { PUT as putHome } from "@/app/api/environment/home/route"; +import { PATCH as patchEnvironmentPrefs } from "@/app/api/environment/preferences/route"; +import * as thresholds from "@/app/api/user/thresholds/route"; +import * as dashboardWidgets from "@/app/api/dashboard/widgets/route"; +import * as insightsLayout from "@/app/api/insights/layout/route"; +import * as medicationLayout from "@/app/api/medications/layout/route"; +import * as moodTagLayout from "@/app/api/mood/tags/layout/route"; +import * as documentsLayout from "@/app/api/documents/inbound/layout/route"; +import * as moduleListLayout from "@/app/api/settings/module-list-layout/route"; +import * as previewFeatures from "@/app/api/settings/preview-features/route"; +import * as activityGoals from "@/app/api/activity/goals/route"; +import * as healthKit from "@/app/api/integrations/healthkit/route"; +import { POST as exportHealthRecord } from "@/app/api/export/health-record/route"; +import { GET as exportFullBackup } from "@/app/api/export/full-backup/route"; +import { POST as uploadBackup } from "@/app/api/admin/backups/upload/route"; + +import { + callRoute, + type CallRouteOptions, + type HttpMethod, + type RouteResult, +} from "../kit/call-route"; +import { + makeAdmin, + makeSession, + makeUser, + type SessionFixture, +} from "../kit/factories"; +import { POST as restoreBackup } from "./restore-job-driver"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +type Handler = Parameters[0]; + +interface Call { + handler: Handler; + method: HttpMethod; + route: string; + params?: Record; + body?: unknown; + contract?: CallRouteOptions["contract"]; +} + +interface Read { + call: Call; + /** What a client reads off the answer. */ + pick: (res: RouteResult) => unknown; +} + +interface Entry { + /** The `User` columns this write sets. */ + columns: readonly AccountSettingColumn[]; + write: Call; + /** + * How the value is read back. Every entry has one, so a setting that can + * be written but not read is a missing route, not a missing check. + */ + read: Read; + /** The value `read.pick` must return after the write. */ + expected?: unknown; + /** Where `/api/auth/me` publishes it, when it does. */ + me?: { pick: (me: unknown) => unknown; expected?: unknown }; +} + +/** `data.a.b[0].c` of a response, by a dotted path. */ +function at(value: unknown, path: string): unknown { + let current: unknown = value; + for (const key of path.split(".")) { + if (current === null || current === undefined) return undefined; + if (Array.isArray(current) && /^\d+$/.test(key)) { + current = current[Number(key)]; + } else if (typeof current === "object") { + current = (current as Record)[key]; + } else { + return undefined; + } + } + return current; +} + +const data = + (path: string) => + (res: RouteResult): unknown => + at(res.data, path); + +const get = (handler: Handler, route: string, path: string): Read => ({ + call: { handler, method: "GET", route }, + pick: data(path), +}); + +/** A 1×1 PNG: the smallest image the avatar route's sniffing accepts. */ +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +function avatarForm(): FormData { + const form = new FormData(); + form.append("file", new File([PNG], "avatar.png", { type: "image/png" })); + return form; +} + +/** + * In the order they run. Onboarding first, because a fresh account starts + * without it; the doctor report before the report selection, because the + * export also stores the selection it was given. + */ +function entries(user: Pick): Entry[] { + return [ + { + columns: ["onboardingCompletedAt"], + write: { + handler: completeOnboarding, + method: "POST", + route: "/api/onboarding/complete", + body: {}, + }, + read: { + call: { handler: getMe, method: "GET", route: "/api/auth/me" }, + pick: data("onboardingCompletedAt"), + }, + me: { pick: (me) => typeof at(me, "onboardingCompletedAt") }, + }, + { + columns: ["onboardingTourCompleted", "onboardingTourProgressJson"], + write: { + handler: onboardingTour, + method: "POST", + route: "/api/onboarding/tour", + body: { + completed: true, + outcome: "completed", + progress: { + lastStopId: "mood", + completedStopIds: ["dashboard"], + status: "in_progress", + updatedAt: "2026-10-22T10:00:00.000Z", + }, + }, + }, + read: get(getMe, "/api/auth/me", "onboardingTourProgress.lastStopId"), + expected: "mood", + me: { pick: (me) => at(me, "onboardingTourCompleted"), expected: true }, + }, + { + columns: ["disclaimerAcknowledgedAt", "disclaimerAcknowledgedVersion"], + write: { + handler: onboardingDisclaimer, + method: "POST", + route: "/api/onboarding/disclaimer", + body: { version: "2026-06-18" }, + }, + read: get(getMe, "/api/auth/me", "disclaimerAcknowledgedVersion"), + expected: "2026-06-18", + me: { + pick: (me) => typeof at(me, "disclaimerAcknowledgedAt"), + expected: "string", + }, + }, + { + columns: [ + "heightCm", + "dateOfBirth", + "gender", + "displayName", + "fullName", + "insurerName", + "insuranceNumberEncrypted", + "insurerIkNumber", + "locale", + "timezone", + "timeFormat", + "dateFormat", + "moodReminderEnabled", + ], + write: { + handler: profile.PATCH, + method: "PATCH", + route: "/api/user/profile", + body: { + heightCm: 182, + dateOfBirth: "1985-04-12", + gender: "FEMALE", + displayName: "Kit Person", + fullName: "Kit Example Person", + insurerName: "Example Insurer", + insuranceNumber: "A123456780", + insurerIkNumber: "123456789", + locale: "en", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + moodReminderEnabled: true, + }, + }, + read: { + call: { + handler: profile.GET, + method: "GET", + route: "/api/user/profile", + }, + pick: (res) => { + const d = res.data as Record; + return { + heightCm: d.heightCm, + dateOfBirth: String(d.dateOfBirth).slice(0, 10), + gender: d.gender, + displayName: d.displayName, + fullName: d.fullName, + insurerName: d.insurerName, + insuranceNumber: d.insuranceNumber, + insurerIkNumber: d.insurerIkNumber, + locale: d.locale, + timezone: d.timezone, + timeFormat: d.timeFormat, + dateFormat: d.dateFormat, + moodReminderEnabled: d.moodReminderEnabled, + }; + }, + }, + expected: { + heightCm: 182, + dateOfBirth: "1985-04-12", + gender: "FEMALE", + displayName: "Kit Person", + fullName: "Kit Example Person", + insurerName: "Example Insurer", + insuranceNumber: "A123456780", + insurerIkNumber: "123456789", + locale: "en", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + moodReminderEnabled: true, + }, + me: { + pick: (me) => ({ + heightCm: at(me, "heightCm"), + gender: at(me, "gender"), + displayName: at(me, "displayName"), + insuranceNumber: at(me, "insuranceNumber"), + timezone: at(me, "timezone"), + timeFormat: at(me, "timeFormat"), + dateFormat: at(me, "dateFormat"), + }), + expected: { + heightCm: 182, + gender: "FEMALE", + displayName: "Kit Person", + insuranceNumber: "A123456780", + timezone: "America/New_York", + timeFormat: "H12", + dateFormat: "YMD", + }, + }, + }, + { + columns: ["hasDiabetes"], + write: { + handler: diabetes.PATCH, + method: "PATCH", + route: "/api/auth/me/diabetes", + body: { hasDiabetes: true }, + }, + read: get(diabetes.GET, "/api/auth/me/diabetes", "hasDiabetes"), + expected: true, + }, + { + columns: ["documentsAutoAiRead"], + write: { + handler: autoAiRead.PATCH, + method: "PATCH", + route: "/api/auth/me/documents-auto-ai-read", + body: { documentsAutoAiRead: true }, + }, + read: get( + autoAiRead.GET, + "/api/auth/me/documents-auto-ai-read", + "documentsAutoAiRead", + ), + expected: true, + }, + { + columns: ["labsLocalOcrEnabled"], + write: { + handler: labsOcr.PATCH, + method: "PATCH", + route: "/api/auth/me/labs-local-ocr", + body: { labsLocalOcrEnabled: true }, + }, + read: get( + labsOcr.GET, + "/api/auth/me/labs-local-ocr", + "labsLocalOcrEnabled", + ), + expected: true, + }, + { + columns: ["storeWorkoutRoutes"], + write: { + handler: workoutRoutes.PATCH, + method: "PATCH", + route: "/api/auth/me/workout-routes", + body: { storeWorkoutRoutes: true }, + }, + read: get( + workoutRoutes.GET, + "/api/auth/me/workout-routes", + "storeWorkoutRoutes", + ), + expected: true, + }, + { + columns: ["useCentralCodex"], + write: { + handler: centralCodex.PATCH, + method: "PATCH", + route: "/api/auth/me/use-central-codex", + body: { useCentralCodex: true }, + }, + read: get( + centralCodex.GET, + "/api/auth/me/use-central-codex", + "useCentralCodex", + ), + expected: true, + }, + { + columns: ["disableCoach"], + write: { + handler: disableCoach.PATCH, + method: "PATCH", + route: "/api/auth/me/disable-coach", + body: { disableCoach: true }, + }, + read: get(disableCoach.GET, "/api/auth/me/disable-coach", "disableCoach"), + expected: true, + me: { pick: (me) => at(me, "disableCoach"), expected: true }, + }, + { + columns: ["unitPreference"], + write: { + handler: unitPreference.PATCH, + method: "PATCH", + route: "/api/auth/me/unit-preference", + body: { unitPreference: "imperial" }, + }, + read: get( + unitPreference.GET, + "/api/auth/me/unit-preference", + "unitPreference", + ), + expected: "imperial", + me: { pick: (me) => at(me, "unitPreference"), expected: "imperial" }, + }, + { + columns: ["glucoseUnit"], + write: { + handler: glucoseUnit.PATCH, + method: "PATCH", + route: "/api/auth/me/glucose-unit", + body: { glucoseUnit: "mmol/L" }, + }, + read: get(glucoseUnit.GET, "/api/auth/me/glucose-unit", "glucoseUnit"), + expected: "mmol/L", + me: { pick: (me) => at(me, "glucoseUnit"), expected: "mmol/L" }, + }, + { + columns: ["globalExcludedInjectionSites"], + write: { + handler: injectionSites.PATCH, + method: "PATCH", + route: "/api/auth/me/injection-site-prefs", + body: { + globalExcludedInjectionSites: ["THIGH_LEFT", "UPPER_ARM_RIGHT"], + }, + }, + read: get( + injectionSites.GET, + "/api/auth/me/injection-site-prefs", + "globalExcludedInjectionSites", + ), + }, + { + columns: ["modulePreferencesJson"], + write: { + handler: modules.PATCH, + method: "PATCH", + route: "/api/auth/me/modules", + body: { mood: false }, + }, + read: get(modules.GET, "/api/auth/me/modules", "modules.mood"), + expected: false, + me: { pick: (me) => at(me, "modules.mood"), expected: false }, + }, + { + columns: ["healthScoreConfigJson"], + write: { + handler: healthScore.PATCH, + method: "PATCH", + route: "/api/auth/me/health-score-config", + body: { + pillars: [ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + "WELLBEING", + ], + }, + }, + read: get( + healthScore.GET, + "/api/auth/me/health-score-config", + "excludedPillars", + ), + expected: ["LIPIDS"], + }, + { + columns: ["sourcePriorityJson"], + write: { + handler: sourcePriority.PUT, + method: "PUT", + route: "/api/auth/me/source-priority", + body: { metricPriority: { steps: ["MANUAL"] } }, + }, + read: get( + sourcePriority.GET, + "/api/auth/me/source-priority", + "metricPriority.steps.0", + ), + expected: "MANUAL", + }, + { + columns: ["notificationPrefs"], + write: { + handler: notificationPrefs.PATCH, + method: "PATCH", + route: "/api/auth/me/notification-prefs", + body: { mood: { reminderHour: 7 } }, + }, + read: get( + notificationPrefs.GET, + "/api/auth/me/notification-prefs", + "mood.reminderHour", + ), + expected: 7, + me: { + pick: (me) => at(me, "notificationPrefs.mood.reminderHour"), + expected: 7, + }, + }, + { + columns: ["lastReportPracticeName"], + write: { + handler: exportHealthRecord, + method: "POST", + route: "/api/export/health-record", + body: { + format: "fhir", + selection: { v: 2, leaves: ["PATIENT_IDENTITY"] }, + practiceName: "Example Practice", + }, + }, + read: get(getMe, "/api/auth/me", "lastReportPracticeName"), + expected: "Example Practice", + }, + { + columns: ["reportSelectionJson"], + write: { + handler: reportSelection.PUT, + method: "PUT", + route: "/api/auth/me/report-selection", + body: { + v: 2, + leaves: ["PATIENT_IDENTITY", "MEDICATION_LIST"], + format: "fhir", + rangeDays: 30, + includeCharts: false, + }, + }, + read: get( + reportSelection.GET, + "/api/auth/me/report-selection", + "profile.leaves", + ), + expected: ["PATIENT_IDENTITY", "MEDICATION_LIST"], + me: { pick: (me) => at(me, "reportSelection.leaves") }, + }, + { + columns: ["coachPrefsJson", "insightsExcludeMetrics"], + write: { + handler: coachPrefs.PUT, + method: "PUT", + route: "/api/auth/me/coach-prefs", + body: { + tone: "concise", + verbosity: "brief", + excludeMetrics: ["weight"], + reasoning: "low", + }, + }, + read: { + call: { + handler: coachPrefs.GET, + method: "GET", + route: "/api/auth/me/coach-prefs", + }, + pick: (res) => ({ + tone: at(res.data, "tone"), + verbosity: at(res.data, "verbosity"), + excludeMetrics: at(res.data, "excludeMetrics"), + }), + }, + expected: { + tone: "concise", + verbosity: "brief", + excludeMetrics: ["weight"], + }, + me: { + pick: (me) => at(me, "coachReasoning.preference"), + expected: "low", + }, + }, + { + columns: ["aiUsageLevel"], + write: { + handler: putAiUsage, + method: "PUT", + route: "/api/auth/me/ai-usage", + body: { level: "frugal" }, + }, + // The route has no GET; the account payload is where a client reads it. + read: get(getMe, "/api/auth/me", "ai.usage.preference"), + expected: "frugal", + }, + { + columns: ["passkeyUpgradeNudgeDismissed"], + write: { + handler: dismissPasskeyNudge, + method: "POST", + route: "/api/auth/me/passkey-nudge/dismiss", + }, + read: get(getMfa, "/api/auth/me/mfa", "passkeyNudgeDismissed"), + expected: true, + }, + { + columns: ["avatarBytes", "avatarContentType", "avatarUpdatedAt"], + write: { + handler: uploadAvatar, + method: "POST", + route: "/api/user/avatar", + body: avatarForm(), + }, + read: { + call: { + handler: getAvatar, + method: "GET", + route: "/api/user/avatar/{id}", + params: { id: user.id }, + }, + pick: (res) => ({ + contentType: res.headers.get("content-type"), + bytes: typeof res.body === "string" ? res.body.length : null, + }), + }, + me: { pick: (me) => at(me, "avatarUrl") }, + }, + { + columns: [ + "aiProvider", + "aiModel", + "aiBaseUrl", + "aiCompatBaseUrl", + "aiCompatModel", + "aiResponseTimeoutSeconds", + ], + write: { + handler: aiProvider.PATCH, + method: "PATCH", + route: "/api/user/ai-provider", + body: { + provider: "LOCAL", + model: "llama3.1:8b", + baseUrl: "https://ollama.example.com", + compatBaseUrl: "https://gateway.example.com/v1", + compatModel: "gpt-4o-mini", + responseTimeoutSeconds: 120, + }, + }, + read: { + call: { + handler: aiProvider.GET, + method: "GET", + route: "/api/user/ai-provider", + }, + pick: (res) => ({ + provider: at(res.data, "provider"), + model: at(res.data, "model"), + baseUrl: at(res.data, "baseUrl"), + compatBaseUrl: at(res.data, "compatBaseUrl"), + compatModel: at(res.data, "compatModel"), + responseTimeoutSeconds: at(res.data, "responseTimeoutSeconds"), + }), + }, + expected: { + provider: "LOCAL", + model: "llama3.1:8b", + baseUrl: "https://ollama.example.com", + compatBaseUrl: "https://gateway.example.com/v1", + compatModel: "gpt-4o-mini", + responseTimeoutSeconds: 120, + }, + me: { + pick: (me) => at(me, "ai.provider.responseTimeoutMs"), + expected: 120_000, + }, + }, + { + columns: ["aiProviderChain"], + write: { + handler: providerChain.PUT, + method: "PUT", + route: "/api/insights/provider-chain", + body: { + chain: [ + { providerType: "anthropic", enabled: true }, + { providerType: "openai", enabled: false }, + ], + }, + }, + read: { + call: { + handler: providerChain.GET, + method: "GET", + route: "/api/insights/provider-chain", + }, + pick: (res) => + ( + at(res.data, "configuredChain") as + Array> | undefined + )?.map((entry) => [entry.providerType, entry.enabled]), + }, + expected: [ + ["anthropic", true], + ["openai", false], + ], + }, + { + columns: ["insightsPrivacyMode"], + write: { + handler: insightsSettings.PUT, + method: "PUT", + route: "/api/insights/settings", + body: { privacyMode: "raw" }, + }, + read: get(insightsSettings.GET, "/api/insights/settings", "privacyMode"), + expected: "raw", + }, + { + columns: ["homeLocationEncrypted", "homeTimezone", "homeSince"], + write: { + handler: putHome, + method: "PUT", + route: "/api/environment/home", + body: { + lat: 48.1, + lon: 11.6, + label: "Home", + timezone: "Europe/Berlin", + }, + }, + read: { + call: { + handler: getEnvironment, + method: "GET", + route: "/api/environment", + }, + pick: (res) => ({ + lat: at(res.data, "home.lat"), + lon: at(res.data, "home.lon"), + label: at(res.data, "home.label"), + timezone: at(res.data, "home.timezone"), + since: typeof at(res.data, "home.since"), + }), + }, + expected: { + lat: 48.1, + lon: 11.6, + label: "Home", + timezone: "Europe/Berlin", + since: "string", + }, + }, + { + columns: ["environmentAirQualityEnabled"], + write: { + handler: patchEnvironmentPrefs, + method: "PATCH", + route: "/api/environment/preferences", + body: { airQualityEnabled: false }, + }, + read: get(getEnvironment, "/api/environment", "airQuality.enabled"), + expected: false, + }, + { + columns: ["thresholdsJson"], + write: { + handler: thresholds.PUT, + method: "PUT", + route: "/api/user/thresholds", + body: { PULSE: { min: 55, max: 95 } }, + }, + read: get(thresholds.GET, "/api/user/thresholds", "overrides.PULSE"), + expected: { min: 55, max: 95 }, + }, + { + columns: ["dashboardWidgetsJson"], + write: { + handler: dashboardWidgets.PUT, + method: "PUT", + route: "/api/dashboard/widgets", + body: { version: 1, todayCardVisible: false }, + }, + read: get( + dashboardWidgets.GET, + "/api/dashboard/widgets", + "todayCardVisible", + ), + expected: false, + }, + { + columns: ["insightsLayoutJson"], + write: { + handler: insightsLayout.PUT, + method: "PUT", + route: "/api/insights/layout", + body: { + version: 2, + sections: [{ id: "vitals", visible: false, order: 0 }], + }, + }, + read: { + call: { + handler: insightsLayout.GET, + method: "GET", + route: "/api/insights/layout", + }, + pick: (res) => + ( + at(res.data, "sections") as + Array> | undefined + )?.find((s) => s.id === "vitals")?.visible, + }, + expected: false, + }, + { + columns: ["medicationListLayoutJson"], + write: { + handler: medicationLayout.PUT, + method: "PUT", + route: "/api/medications/layout", + body: { version: 1, view: "table" }, + }, + read: get(medicationLayout.GET, "/api/medications/layout", "view"), + expected: "table", + }, + { + columns: ["moodTagLayoutJson"], + write: { + handler: moodTagLayout.PUT, + method: "PUT", + route: "/api/mood/tags/layout", + body: { placements: { emotions: ["happy", "calm"] } }, + }, + read: get( + moodTagLayout.GET, + "/api/mood/tags/layout", + "placements.emotions", + ), + expected: ["happy", "calm"], + }, + { + columns: ["documentsLayoutJson"], + write: { + handler: documentsLayout.PUT, + method: "PUT", + route: "/api/documents/inbound/layout", + body: { version: 1, view: "list", arrangement: "stacked" }, + }, + read: { + call: { + handler: documentsLayout.GET, + method: "GET", + route: "/api/documents/inbound/layout", + }, + pick: (res) => [at(res.data, "view"), at(res.data, "arrangement")], + }, + expected: ["list", "stacked"], + }, + { + columns: ["moduleListLayoutJson"], + write: { + handler: moduleListLayout.PUT, + method: "PUT", + route: "/api/settings/module-list-layout", + body: { + version: 1, + list: "illness", + view: "list", + sortDir: "alphaAsc", + }, + }, + read: { + call: { + handler: moduleListLayout.GET, + method: "GET", + route: "/api/settings/module-list-layout", + }, + pick: (res) => [ + at(res.data, "lists.illness.view"), + at(res.data, "lists.illness.sortDir"), + ], + }, + expected: ["list", "alphaAsc"], + }, + { + columns: ["previewFeaturesJson"], + write: { + handler: previewFeatures.PATCH, + method: "PATCH", + route: "/api/settings/preview-features", + body: { topics: true }, + }, + read: get( + previewFeatures.GET, + "/api/settings/preview-features", + "topics", + ), + expected: true, + }, + { + columns: ["activityGoalsJson"], + write: { + handler: activityGoals.PATCH, + method: "PATCH", + route: "/api/activity/goals", + body: { workoutsPerWeek: 4, stepsPerWeek: 70_000 }, + }, + read: { + call: { + handler: activityGoals.GET, + method: "GET", + route: "/api/activity/goals", + }, + pick: (res) => [ + at(res.data, "workoutsPerWeek"), + at(res.data, "stepsPerWeek"), + ], + }, + expected: [4, 70_000], + }, + { + columns: ["healthKitConfigJson"], + write: { + handler: healthKit.PATCH, + method: "PATCH", + route: "/api/integrations/healthkit", + body: { entries: [{ id: "heartRate", direction: "disabled" }] }, + }, + read: { + call: { + handler: healthKit.GET, + method: "GET", + route: "/api/integrations/healthkit", + }, + pick: (res) => + ( + at(res.data, "entries") as + Array> | undefined + )?.find((e) => e.id === "heartRate")?.direction, + }, + expected: "disabled", + }, + ]; +} + +const SETTING_COLUMNS = Object.entries(USER_COLUMN_BACKUP_CLASS) + .filter(([, verdict]) => verdict === "SETTING") + .map(([column]) => column) + .sort(); + +/** The setting columns whose stored form is ciphertext, compared through the API. */ +const SEALED = new Set([ + "insuranceNumberEncrypted", + "homeLocationEncrypted", +]); + +function columnsOf(user: User): Map { + return new Map(Object.entries(user)); +} + +/** + * A stored value in a form two rows can be compared in. In a JSON column a + * key holding `null` and a key left out mean the same unset field (the + * restore writes the second where the settings route wrote the first), and + * key order carries nothing, so both are normalised away. + */ +function comparable(value: unknown): string { + if (value instanceof Uint8Array) + return `bytes:${Buffer.from(value).toString("base64")}`; + if (value instanceof Date) return `date:${value.toISOString()}`; + return JSON.stringify(normalise(value ?? null)); +} + +function normalise(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalise); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => v !== null && v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, normalise(v)]), + ); + } + return value; +} + +async function call( + entryCall: Call, + auth: SessionFixture, +): Promise { + return callRoute(entryCall.handler, { + method: entryCall.method, + route: entryCall.route, + params: entryCall.params, + body: entryCall.body, + contract: entryCall.contract, + auth, + }); +} + +describe("every setting a person can choose", () => { + it("has exactly one entry in the round-trip table", () => { + const listed = entries({ id: "inventory" }).flatMap((e) => e.columns); + expect(SETTING_COLUMNS.length).toBeGreaterThanOrEqual(60); + expect([...listed].sort()).toEqual(SETTING_COLUMNS); + expect(new Set(listed).size).toBe(listed.length); + }); + + describe("set, read back, backed up, restored, read back again", () => { + let owner: User; + let session: SessionFixture; + const before = new Map(); + const meBefore = new Map(); + let stored: Map; + let defaults: Map; + + beforeAll(async () => { + await truncateAllTables(prisma); + owner = await makeUser({ onboardingCompletedAt: null }); + session = await makeSession(owner); + defaults = columnsOf(await makeUser({ onboardingCompletedAt: null })); + }); + + it("stores each setting through its write route and answers it on its read route", async () => { + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + const label = entry.columns.join(", "); + let written: RouteResult; + let read: RouteResult; + try { + written = await call(entry.write, session); + if (written.status >= 300) { + failures.push( + `${label}: write answered ${written.status} ${JSON.stringify(written.body).slice(0, 300)}`, + ); + continue; + } + read = await call(entry.read.call, session); + } catch (error) { + failures.push(`${label}: ${String(error).slice(0, 400)}`); + continue; + } + if (read.status !== 200) { + failures.push(`${label}: read answered ${read.status}`); + continue; + } + const value = entry.read.pick(read); + before.set(index, value); + if ("expected" in entry && !isDeepStrictEqual(value, entry.expected)) { + failures.push( + `${label}: read back ${JSON.stringify(value)}, wrote ${JSON.stringify(entry.expected)}`, + ); + } + } + expect(failures).toEqual([]); + }); + + it("publishes the settings the account payload carries", async () => { + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: session, + }); + expect(me.status).toBe(200); + const failures: string[] = []; + for (const [index, entry] of entries(owner).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + meBefore.set(index, value); + if ( + "expected" in entry.me && + !isDeepStrictEqual(value, entry.me.expected) + ) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me has ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + + it("moves every setting column off its default", async () => { + stored = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }), + ); + const untouched = SETTING_COLUMNS.filter( + (column) => + comparable(stored.get(column)) === comparable(defaults.get(column)), + ); + expect( + untouched, + "these columns still hold what a fresh account holds", + ).toEqual([]); + }); + + it("comes back from a backup restored into a recreated account", async () => { + const exported = await callRoute(exportFullBackup, { + method: "GET", + route: "/api/export/full-backup", + auth: session, + }); + expect(exported.status).toBe(200); + + // The account is recreated under the same id, the way an operator + // brings an account back on a new host, and nothing else of it remains. + await prisma.user.delete({ where: { id: owner.id } }); + const fresh = await makeUser({ + id: owner.id, + username: owner.username, + email: owner.email, + onboardingCompletedAt: null, + }); + const admin = await makeAdmin(); + const operator = await makeSession(admin); + + const uploaded = await callRoute<{ id: string }>(uploadBackup, { + method: "POST", + route: "/api/admin/backups/upload", + body: exported.body, + auth: operator, + contract: { + skip: "the operator's backup routes are not in the published contract", + }, + }); + expect( + uploaded.status, + JSON.stringify(uploaded.body).slice(0, 400), + ).toBeLessThan(300); + const backupId = uploaded.data.id; + + const restored = await callRoute(restoreBackup, { + method: "POST", + route: "/api/admin/backups/{id}/restore", + params: { id: backupId }, + body: { confirm: "RESTORE" }, + auth: operator, + contract: { + skip: "the restore driver answers 200 with the job's report in place of the route's queued 202", + }, + }); + expect(restored.status, JSON.stringify(restored.body).slice(0, 400)).toBe( + 200, + ); + + const after = columnsOf( + await prisma.user.findUniqueOrThrow({ where: { id: fresh.id } }), + ); + const lostInStorage = SETTING_COLUMNS.filter( + (column) => + !SEALED.has(column) && + comparable(after.get(column)) !== comparable(stored.get(column)), + ); + expect( + lostInStorage.map( + (column) => + `${column}: ${comparable(stored.get(column))} -> ${comparable(after.get(column))}`, + ), + "these columns came back with a different stored value", + ).toEqual([]); + + const restoredSession = await makeSession(fresh); + const failures: string[] = []; + for (const [index, entry] of entries(fresh).entries()) { + const read = await call(entry.read.call, restoredSession); + const value = + read.status === 200 ? entry.read.pick(read) : `status ${read.status}`; + if (!isDeepStrictEqual(value, before.get(index))) { + failures.push( + `${entry.columns.join(", ")}: read ${JSON.stringify(before.get(index))} before, ${JSON.stringify(value)} after`, + ); + } + } + const me = await callRoute(getMe, { + method: "GET", + route: "/api/auth/me", + auth: restoredSession, + }); + for (const [index, entry] of entries(fresh).entries()) { + if (!entry.me) continue; + const value = entry.me.pick(me.data); + if (!isDeepStrictEqual(value, meBefore.get(index))) { + failures.push( + `${entry.columns.join(", ")}: /api/auth/me had ${JSON.stringify(meBefore.get(index))}, now ${JSON.stringify(value)}`, + ); + } + } + expect(failures).toEqual([]); + }); + }); +}); From f353f55212d21d5b12c453f796972be5c5f34581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:31:09 +0200 Subject: [PATCH 04/20] test(integration): sessions, devices, security activity and FHIR against Postgres The mocked route tests for the session list and revoke, the device list and revoke, the security-activity feed and the FHIR surface move onto the kit. Each claim is now shown on the stored rows and on the next request: a revoked session, device login or token is refused when it is presented again, another account's session or device answers 404 and stays signed in, the current-session and current-device flags follow the credential that anchors them, and no row id, full IP or push token reaches a response. The FHIR read set, covered against a database only by the scope check until now, is driven with seeded readings and medications: every resource carries the seeded values, another account's rows never appear, a fhir:read token reaches every data route and an unrelated narrow token is refused, the module gate closes all eleven data routes, and $everything serves the same entries as the document builder with references that resolve inside the response. The module-gate inventory now names the new file as its behavioural proof. --- .../module-route-gate-inventory.test.ts | 4 +- .../me/devices/[id]/__tests__/route.test.ts | 147 --- .../auth/me/devices/__tests__/route.test.ts | 221 ----- .../security-activity/__tests__/route.test.ts | 68 -- .../auth/me/sessions/__tests__/route.test.ts | 296 ------ .../capability-and-everything.test.ts | 389 -------- .../api/fhir/__tests__/module-gate.test.ts | 264 ------ .../routes/auth-me-devices.test.ts | 323 +++++++ .../routes/auth-me-security-activity.test.ts | 151 +++ .../routes/auth-me-sessions.test.ts | 524 ++++++++++ .../routes/fhir-module-gate.test.ts | 200 ++++ tests/integration/routes/fhir.test.ts | 896 ++++++++++++++++++ 12 files changed, 2096 insertions(+), 1387 deletions(-) delete mode 100644 src/app/api/auth/me/devices/[id]/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/devices/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/security-activity/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/sessions/__tests__/route.test.ts delete mode 100644 src/app/api/fhir/__tests__/capability-and-everything.test.ts delete mode 100644 src/app/api/fhir/__tests__/module-gate.test.ts create mode 100644 tests/integration/routes/auth-me-devices.test.ts create mode 100644 tests/integration/routes/auth-me-security-activity.test.ts create mode 100644 tests/integration/routes/auth-me-sessions.test.ts create mode 100644 tests/integration/routes/fhir-module-gate.test.ts create mode 100644 tests/integration/routes/fhir.test.ts diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index fc4287334..9758c75e4 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -507,9 +507,9 @@ const REQUIRED_TREE_MODULE: Readonly> = { const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> = [ { - path: "src/app/api/fhir/__tests__/module-gate.test.ts", + path: "tests/integration/routes/fhir-module-gate.test.ts", proves: - "every /api/fhir data route 403s with module.disabled when doctorReport is off", + "every /api/fhir data route, search and read, 403s with module.disabled when doctorReport is off (real database, real handler)", }, { path: "src/lib/fhir/__tests__/rest-module-backstop.test.ts", diff --git a/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts b/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts deleted file mode 100644 index 27ded92be..000000000 --- a/src/app/api/auth/me/devices/[id]/__tests__/route.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - device: { - findUnique: vi.fn(), - delete: vi.fn(), - }, - refreshToken: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - apiToken: { - updateMany: vi.fn(), - }, - // v1.4.23 — the cascade now runs through `prisma.$transaction` - // (HIGH 6 fix). The mock returns the resolved batch payload so the - // route handler walks past the transaction step. - $transaction: vi.fn().mockResolvedValue([] as never), - }, -})); - -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("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { DELETE } 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 req(): NextRequest { - return new NextRequest("http://localhost/api/auth/me/devices/dev-x", { - method: "DELETE", - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]); - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ - count: 0, - } as never); - vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ - count: 0, - } as never); - vi.mocked(prisma.$transaction).mockResolvedValue([] as never); -}); - -describe("DELETE /api/auth/me/devices/[id]", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-1" }), - }); - expect(res.status).toBe(401); - }); - - it("returns 404 when the device doesn't exist", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue(null); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-x" }), - }); - expect(res.status).toBe(404); - }); - - it("returns 404 when the device belongs to another user (no enumeration)", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue({ - id: "dev-x", - userId: "user-2", - model: "iPhone", - bundleId: "io.healthlog.app", - } as never); - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-x" }), - }); - expect(res.status).toBe(404); - expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled(); - }); - - it("revokes refresh tokens + access tokens + deletes the device row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findUnique).mockResolvedValue({ - id: "dev-1", - userId: "user-1", - model: "iPhone", - bundleId: "io.healthlog.app", - } as never); - vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([ - { accessTokenHash: "hash:a1" }, - { accessTokenHash: "hash:a2" }, - { accessTokenHash: null }, - ] as never); - vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ - count: 3, - } as never); - vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ - count: 2, - } as never); - vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never); - - const res = await DELETE(req(), { - params: Promise.resolve({ id: "dev-1" }), - }); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.revoked).toBe(true); - expect(body.data.refreshTokensRevoked).toBe(3); - expect(body.data.accessTokensRevoked).toBe(2); - expect(vi.mocked(prisma.device.delete)).toHaveBeenCalledWith({ - where: { id: "dev-1" }, - }); - // The refresh-token revoke filter MUST scope to the deviceId, not - // every refresh row the user has. - const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock - .calls[0]?.[0]; - expect(updateCall?.where).toMatchObject({ - userId: "user-1", - deviceId: "dev-1", - revokedAt: null, - }); - }); -}); diff --git a/src/app/api/auth/me/devices/__tests__/route.test.ts b/src/app/api/auth/me/devices/__tests__/route.test.ts deleted file mode 100644 index a8ff6aa4e..000000000 --- a/src/app/api/auth/me/devices/__tests__/route.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - device: { - findMany: vi.fn(), - }, - refreshToken: { - findFirst: 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 req(deviceIdHeader?: string): NextRequest { - const headers: Record = {}; - if (deviceIdHeader) headers["x-device-id"] = deviceIdHeader; - return new NextRequest("http://localhost/api/auth/me/devices", { - method: "GET", - headers, - }); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(prisma.device.findMany).mockResolvedValue([]); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); -}); - -describe("GET /api/auth/me/devices", () => { - it("returns 401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await GET(req()); - expect(res.status).toBe(401); - }); - - it("lists devices owned by the current user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: "en-US", - appVersion: "1.0", - model: "iPhone 14 Pro", - apnsToken: "aaaa", - apnsEnvironment: "production", - lastSeen: new Date("2026-05-10T12:00:00Z"), - createdAt: new Date("2026-05-01T12:00:00Z"), - updatedAt: new Date("2026-05-10T12:00:00Z"), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad Pro", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date("2026-05-09T12:00:00Z"), - createdAt: new Date("2026-04-01T12:00:00Z"), - updatedAt: new Date("2026-05-09T12:00:00Z"), - }, - ] as never); - - const res = await GET(req()); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.devices).toHaveLength(2); - expect(body.data.devices[0].id).toBe("dev-1"); - expect(body.data.devices[0].channels).toEqual(["web_push", "apns"]); - expect(body.data.devices[1].channels).toEqual(["web_push"]); - }); - - it("marks the matching device as isCurrent when X-Device-Id is anchored by an unrevoked refresh token", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue({ - id: "rt-1", - } as never); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPhone", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - ] as never); - - const res = await GET(req("dev-2")); - const body = await res.json(); - const current = body.data.devices.find( - (d: { isCurrent: boolean; id: string }) => d.isCurrent, - ); - expect(current?.id).toBe("dev-2"); - }); - - it("only queries devices for the authenticated user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - await GET(req()); - const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where; - expect(where).toEqual({ userId: "user-1" }); - }); - - // v1.4.23 — a forged X-Device-Id header - // pointing at a device the caller doesn't actually hold a credential - // for must NOT flip `isCurrent: true`. The route now cross-checks the - // header against an unrevoked RefreshToken row owned by the same - // user; when no such row exists, every device returns - // `isCurrent: false`. - it("ignores a forged X-Device-Id header when no refresh token anchors it", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null); - vi.mocked(prisma.device.findMany).mockResolvedValue([ - { - id: "dev-1", - userId: "user-1", - platform: "ios", - token: "tok-1", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPhone", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: "dev-2", - userId: "user-1", - platform: "ios", - token: "tok-2", - bundleId: "io.healthlog.app", - locale: null, - appVersion: null, - model: "iPad", - apnsToken: null, - apnsEnvironment: null, - lastSeen: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - ] as never); - - // Caller forges a header pointing at dev-2 but has no live - // refresh-token bound to that device. Expectation: every device is - // `isCurrent: false`. - const res = await GET(req("dev-2")); - const body = await res.json(); - expect( - body.data.devices.every((d: { isCurrent: boolean }) => !d.isCurrent), - ).toBe(true); - - // The refresh-token lookup must scope by userId AND deviceId AND - // revokedAt: null — never trust the header without all three. - const lookupWhere = vi.mocked(prisma.refreshToken.findFirst).mock - .calls[0]?.[0]?.where; - expect(lookupWhere).toEqual({ - userId: "user-1", - deviceId: "dev-2", - revokedAt: null, - }); - }); -}); diff --git a/src/app/api/auth/me/security-activity/__tests__/route.test.ts b/src/app/api/auth/me/security-activity/__tests__/route.test.ts deleted file mode 100644 index fa66d8dc2..000000000 --- a/src/app/api/auth/me/security-activity/__tests__/route.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/db", () => ({ - prisma: { - auditLog: { findMany: vi.fn(), create: 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 { getSession } from "@/lib/auth/session"; -import { prisma } from "@/lib/db"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, -}; - -const req = (qs = "") => - new NextRequest(`http://localhost/api/auth/me/security-activity${qs}`); - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); -}); - -describe("GET /api/auth/me/security-activity", () => { - it("reads one row past the requested page, in a total order", async () => { - const res = await GET(req("?limit=10")); - expect(res.status).toBe(200); - const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0] as { - take: number; - orderBy: unknown; - }; - expect(call.take).toBe(11); - expect(call.orderBy).toEqual([{ createdAt: "desc" }, { id: "desc" }]); - const body = (await res.json()) as { - meta: { truncated: boolean; limit: number }; - }; - expect(body.meta).toMatchObject({ truncated: false, limit: 10 }); - }); - - it("answers a limit past the published ceiling with a 422 naming it", async () => { - const res = await GET(req("?limit=500")); - expect(res.status).toBe(422); - const body = (await res.json()) as { - meta: { errorCode: string }; - details: { issues: Array<{ path: string }> }; - }; - expect(body.meta.errorCode).toBe("security_activity.invalid"); - expect(body.details.issues.map((i) => i.path)).toEqual(["limit"]); - expect(prisma.auditLog.findMany).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/sessions/__tests__/route.test.ts b/src/app/api/auth/me/sessions/__tests__/route.test.ts deleted file mode 100644 index 8f90a9de1..000000000 --- a/src/app/api/auth/me/sessions/__tests__/route.test.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// The handle is an HMAC, so the route needs the key the real helper reads. -process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64); - -vi.mock("@/lib/db", () => ({ - prisma: { - session: { findMany: vi.fn() }, - apiToken: { - findUnique: vi.fn(), - update: vi.fn(() => ({ catch: () => undefined })), - }, - user: { findUnique: vi.fn() }, - accountGrant: { findMany: vi.fn(async () => []) }, - }, -})); - -// `sessionHandle` stays REAL: the point of the handle test is that the route -// emits the actual derivation, and a stubbed one would let the route return -// row ids while the assertion still passed. -vi.mock("@/lib/auth/session", async (importOriginal) => { - const actual = await importOriginal(); - return { - getSession: vi.fn(), - destroyOtherSessions: vi.fn(), - destroySessionById: vi.fn(), - sessionHandle: actual.sessionHandle, - }; -}); - -vi.mock("@/lib/geo", () => ({ - lookupIpLocation: vi.fn(async () => "Berlin, DE"), -})); - -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("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, DELETE } from "../route"; -import { headers } from "next/headers"; -import { hashToken } from "@/lib/auth/hmac"; -import { DELETE as DELETE_ONE } from "../[id]/route"; -import { prisma } from "@/lib/db"; -import { - getSession, - destroyOtherSessions, - destroySessionById, - sessionHandle, -} from "@/lib/auth/session"; - -const SESSION_OK = { - session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) }, - user: { id: "user-1", username: "u", role: "USER" as const }, -}; - -function del(): NextRequest { - return new NextRequest("http://localhost/api/auth/me/sessions", { - method: "DELETE", - }); -} - -beforeEach(() => { - vi.resetAllMocks(); -}); - -describe("GET /api/auth/me/sessions", () => { - it("401 when unauthenticated", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await GET(); - expect(res.status).toBe(401); - }); - - it("lists sessions with masked IP, location, and the current marker", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.session.findMany).mockResolvedValue([ - { - id: "sess-current", - ipAddress: "203.0.113.7", - userAgent: - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0", - lastActiveAt: new Date(), - createdAt: new Date(), - }, - { - id: "sess-other", - ipAddress: "198.51.100.4", - userAgent: - "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36", - lastActiveAt: null, - createdAt: new Date(), - }, - ] as never); - - const res = await GET(); - expect(res.status).toBe(200); - const body = await res.json(); - const list = body.data.sessions; - expect(list).toHaveLength(2); - expect(list[0]).toMatchObject({ - device: "Firefox on macOS", - ipMasked: "203.0.x.x", - location: "Berlin, DE", - isCurrent: true, - }); - expect(list[1].isCurrent).toBe(false); - // The full IP never appears in the response body. - expect(JSON.stringify(body)).not.toContain("203.0.113.7"); - }); - - it("never puts a session row id in the response body", async () => { - // A row id created before the secret cookie landed IS that session's - // cookie value, so listing it hands the client working logins for the - // account's other devices. The list carries an opaque handle instead; - // it is what the revoke route accepts. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.session.findMany).mockResolvedValue([ - { - id: "sess-current", - ipAddress: "203.0.113.7", - userAgent: "Mozilla/5.0 (Macintosh) Firefox/120.0", - lastActiveAt: new Date(), - createdAt: new Date(), - }, - { - id: "sess-other", - ipAddress: "198.51.100.4", - userAgent: "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0", - lastActiveAt: null, - createdAt: new Date(), - }, - ] as never); - - const res = await GET(); - const body = await res.json(); - const serialised = JSON.stringify(body); - - expect(serialised).not.toContain("sess-current"); - expect(serialised).not.toContain("sess-other"); - // Still identified well enough to act on: distinct, stable handles. - const [a, b] = body.data.sessions.map((s: { id: string }) => s.id); - expect(a).toBeTruthy(); - expect(a).not.toBe(b); - expect(a).toBe(sessionHandle("sess-current")); - }); -}); - -describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => { - it("revokes other sessions and reports the count", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(destroyOtherSessions).mockResolvedValue({ - sessionsRevoked: 3, - accessTokensRevoked: 0, - connectorsRevoked: 0, - shareLinksRevoked: 0, - pendingInvitesRevoked: 0, - }); - - const res = await DELETE(del()); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.data.sessionsRevoked).toBe(3); - expect(destroyOtherSessions).toHaveBeenCalledWith( - "user-1", - { kind: "session", sessionId: "sess-current" }, - // Everything, share links included, when the client says nothing: the - // shipped app sends no parameter and gets the safe default. - { reach: "everything", keepShareLinks: false }, - ); - }); - - it("names a Bearer caller by its access token so its own device login is spared", async () => { - // No cookie session: the caller is a phone presenting its login token. - vi.mocked(getSession).mockResolvedValue(null); - const raw = `hlk_${"d".repeat(64)}`; - vi.mocked(headers).mockResolvedValue({ - get: (name: string) => - name.toLowerCase() === "authorization" ? `Bearer ${raw}` : null, - } as never); - vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({ - id: "tok-phone", - userId: "user-1", - permissions: ["*"], - revoked: false, - expiresAt: null, - } as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - id: "user-1", - username: "u", - role: "USER", - } as never); - vi.mocked(destroyOtherSessions).mockResolvedValue({ - sessionsRevoked: 1, - accessTokensRevoked: 1, - connectorsRevoked: 0, - shareLinksRevoked: 0, - pendingInvitesRevoked: 0, - }); - - const res = await DELETE(del()); - - expect(res.status).toBe(200); - expect(destroyOtherSessions).toHaveBeenCalledWith( - "user-1", - { kind: "accessToken", accessTokenHash: hashToken(raw) }, - { reach: "everything", keepShareLinks: false }, - ); - vi.mocked(headers).mockResolvedValue({ get: () => null } as never); - }); -}); - -describe("sign out everywhere and record sharing", () => { - it("reports withdrawn invitations and lists the accepted grants it kept", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(destroyOtherSessions).mockResolvedValue({ - sessionsRevoked: 1, - accessTokensRevoked: 0, - connectorsRevoked: 0, - shareLinksRevoked: 0, - pendingInvitesRevoked: 2, - }); - const party = { id: "user-2", username: "carer", displayName: null }; - const live = { - id: "grant-live", - access: "READ", - acceptedAt: new Date("2026-01-01"), - revokedAt: null, - expiresAt: null, - grantee: party, - }; - const lapsed = { - ...live, - id: "grant-lapsed", - expiresAt: new Date("2026-02-01"), - }; - vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ - live, - lapsed, - ] as never); - - const res = await DELETE(del()); - const body = await res.json(); - expect(body.data.pendingInvitesRevoked).toBe(2); - // The lapsed one confers nothing any more and is not listed. - expect(body.data.grantsKept).toEqual([ - { id: "grant-live", account: party, access: "READ" }, - ]); - expect(prisma.accountGrant.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ - grantorId: "user-1", - revokedAt: null, - }), - }), - ); - }); -}); - -describe("DELETE /api/auth/me/sessions/[id]", () => { - function ctx(id: string) { - return { params: Promise.resolve({ id }) }; - } - function delReq(id: string): NextRequest { - return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, { - method: "DELETE", - }); - } - - it("revokes a single owned session", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(destroySessionById).mockResolvedValue(true); - const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other")); - expect(res.status).toBe(200); - expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other"); - }); - - it("404 for a non-existent or not-owned id", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(destroySessionById).mockResolvedValue(false); - const res = await DELETE_ONE(delReq("nope"), ctx("nope")); - expect(res.status).toBe(404); - }); -}); diff --git a/src/app/api/fhir/__tests__/capability-and-everything.test.ts b/src/app/api/fhir/__tests__/capability-and-everything.test.ts deleted file mode 100644 index d3709b9dd..000000000 --- a/src/app/api/fhir/__tests__/capability-and-everything.test.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * The CapabilityStatement must describe a face that exists, and - * `Patient/$everything` must return the whole record. - * - * A statement is a promise a generic FHIR client acts on: it will issue a - * `read` because the statement said `read` is there. So the advertised set is - * checked against the route tree on disk, not against a second list. And the - * whole-record operation is checked against the document export it mirrors — - * "everything" that quietly omits a resource family is the kind of gap only a - * clinician notices, when the family is the one they needed. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; -import { existsSync } from "node:fs"; -import path from "node:path"; - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/db", () => ({ - prisma: { - // The whole-record gate reads the session's own stamps: a sign-in a - // moment ago is the proof, on an account without a second factor. - session: { - findUnique: vi.fn(async () => ({ - createdAt: new Date(), - mfaVerifiedAt: null, - reproofAt: null, - })), - }, - webauthnMfaCredential: { count: vi.fn(async () => 0) }, - passkey: { count: vi.fn(async () => 0) }, - // The password hash is read off the stored row; none here. - user: { findUnique: vi.fn(async () => null) }, - }, -})); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/modules/gate", () => ({ - requireModuleEnabled: vi.fn(), - isModuleEnabled: vi.fn(), -})); -vi.mock("@/lib/fhir/rest", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, loadFhirContext: vi.fn() }; -}); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET as metadataGet } from "../metadata/route"; -import { GET as everythingGet } from "../Patient/$everything/route"; -import { prisma } from "@/lib/db"; -import { GET as observationRead } from "../Observation/[id]/route"; -import { GET as patientRead } from "../Patient/[id]/route"; -import { GET as coverageRead } from "../Coverage/[id]/route"; -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { loadFhirContext, FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest"; -import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate"; -import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; -import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; -import type { DoctorReportData } from "@/lib/doctor-report-data"; - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "tester", role: "USER" as const }, -}; - -const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z"); - -const DATA = { - period: { - days: 90, - since: "2026-02-02T00:00:00.000Z", - start: "2026-02-02T00:00:00.000Z", - end: "2026-05-03T12:00:00.000Z", - }, - patient: { - username: "sample-user", - dateOfBirth: "1985-06-15T00:00:00.000Z", - gender: "MALE", - heightCm: 182, - fullName: "Sample Patient", - insurerName: "Example Insurer", - insurerIkNumber: "101234567", - }, - practiceName: null, - measurements: { - WEIGHT: [{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" }], - }, - stats: {}, - glucoseStats: {}, - glucoseRanges: {}, - glucoseClinical: computeGlucoseClinicalMetrics([], { now: FIXED_NOW }), - glucoseUnit: "mg/dL", - bmi: 24.1, - compliance: {}, - medications: [{ name: "Example Drug", dose: "5mg", schedules: [] }], - medicationAdministrations: [ - { - medicationName: "Example Drug", - effectiveAt: "2026-04-30T08:00:00.000Z", - status: "completed", - doseText: "5mg", - dose: { value: 5, unit: "mg" }, - injectionSite: null, - atcCode: null, - rxNormCode: null, - deliveryForm: "ORAL", - }, - ], - illnessEpisodes: [ - { - label: "Erkältung", - type: "INFECTION", - lifecycle: "ACUTE", - onsetAt: "2026-04-01T00:00:00.000Z", - resolvedAt: "2026-04-10T00:00:00.000Z", - }, - ], - cycle: { - lastPeriodStart: "2026-04-20", - recentCycles: [], - observedCycleCount: 3, - averageCycleLengthDays: 27.5, - cycleLengthVariabilityDays: 0.5, - averagePeriodLengthDays: 5, - currentPhase: "LUTEAL", - }, - mood: { avg: 3.8, count: 20 }, - glp1: null, -} as unknown as DoctorReportData; - -const RECORDS = { - allergies: [ - { - id: "a1", - substance: "Penicillin", - category: "MEDICATION", - type: "ALLERGY", - status: "ACTIVE", - severity: "SEVERE", - reaction: "Rash", - onsetAt: null, - note: null, - }, - ], - familyHistory: [ - { - id: "f1", - relationship: "MOTHER", - condition: "Hypertension", - ageAtOnset: 50, - note: null, - }, - ], -} as never; - -function req(pathname: string): NextRequest { - return new NextRequest(`http://localhost${pathname}`); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - count: 1, - resetAt: Date.now(), - } as never); - vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); - vi.mocked(isModuleEnabled).mockResolvedValue(true); - vi.mocked(loadFhirContext).mockResolvedValue({ - data: DATA, - identity: { insuranceNumber: "A123456780" }, - germanAtc: false, - records: RECORDS, - }); -}); - -interface CapabilityStatement { - resourceType: string; - implementation?: { description: string; url: string }; - software?: { name: string; version: string }; - description?: string; - rest: Array<{ - resource: Array<{ - type: string; - interaction?: Array<{ code: string }>; - operation?: Array<{ name: string }>; - }>; - }>; -} - -describe("GET /api/fhir/metadata — CapabilityStatement", () => { - it("describes the instance it speaks for (cpb-2)", async () => { - const res = await metadataGet(req("/api/fhir/metadata")); - const body = (await res.json()) as CapabilityStatement; - expect(body.resourceType).toBe("CapabilityStatement"); - expect(body.description?.length).toBeGreaterThan(0); - expect(body.software?.name).toBe("HealthLog"); - expect(body.software?.version.length).toBeGreaterThan(0); - expect(body.implementation?.description.length).toBeGreaterThan(0); - expect(body.implementation?.url).toBe("http://localhost/api/fhir"); - }); - - it("describes Patient exactly once, carrying the operation (cpb-9)", async () => { - const res = await metadataGet(req("/api/fhir/metadata")); - const body = (await res.json()) as CapabilityStatement; - const resources = body.rest[0].resource; - const types = resources.map((r) => r.type); - expect(types.filter((t) => t === "Patient")).toHaveLength(1); - expect(new Set(types).size).toBe(types.length); - const patient = resources.find((r) => r.type === "Patient"); - expect(patient?.operation?.[0].name).toBe("everything"); - expect(patient?.interaction?.map((i) => i.code)).toEqual([ - "read", - "search-type", - ]); - }); - - it("advertises no interaction without a route behind it", async () => { - const res = await metadataGet(req("/api/fhir/metadata")); - const body = (await res.json()) as CapabilityStatement; - const root = path.join(process.cwd(), "src/app/api/fhir"); - - for (const resource of body.rest[0].resource) { - for (const { code } of resource.interaction ?? []) { - const file = - code === "read" - ? path.join(root, resource.type, "[id]", "route.ts") - : path.join(root, resource.type, "route.ts"); - expect(existsSync(file), `${resource.type} ${code} → ${file}`).toBe( - true, - ); - } - for (const { name } of resource.operation ?? []) { - const file = path.join(root, resource.type, `$${name}`, "route.ts"); - expect(existsSync(file), `${resource.type} $${name} → ${file}`).toBe( - true, - ); - } - } - // …and the catalogue the statement derives from is the routed set. - expect(body.rest[0].resource.map((r) => r.type)).toEqual([ - ...FHIR_REST_RESOURCE_TYPES, - ]); - }); - - it("advertises no write interaction", async () => { - const res = await metadataGet(req("/api/fhir/metadata")); - const body = (await res.json()) as CapabilityStatement; - const codes = body.rest[0].resource.flatMap((r) => - (r.interaction ?? []).map((i) => i.code), - ); - for (const code of codes) { - expect(["read", "search-type"]).toContain(code); - } - }); -}); - -describe("GET /api/fhir/{type}/{id} — read", () => { - it("serves the resource the searchset fullUrl points at", async () => { - const res = await observationRead(req("/api/fhir/Observation/obs-1"), { - params: Promise.resolve({ id: "obs-1" }), - }); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toContain("application/fhir+json"); - const body = (await res.json()) as { resourceType: string; id: string }; - expect(body.resourceType).toBe("Observation"); - expect(body.id).toBe("obs-1"); - }); - - it("answers an id the record does not hold with a not-found OperationOutcome", async () => { - const res = await observationRead(req("/api/fhir/Observation/obs-999"), { - params: Promise.resolve({ id: "obs-999" }), - }); - expect(res.status).toBe(404); - const body = (await res.json()) as { - resourceType: string; - issue: Array<{ code: string }>; - }; - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].code).toBe("not-found"); - }); - - it("reads the Patient", async () => { - const res = await patientRead(req("/api/fhir/Patient/patient-1"), { - params: Promise.resolve({ id: "patient-1" }), - }); - const body = (await res.json()) as { resourceType: string }; - expect(body.resourceType).toBe("Patient"); - }); - - it("reads the Coverage", async () => { - const res = await coverageRead(req("/api/fhir/Coverage/coverage-1"), { - params: Promise.resolve({ id: "coverage-1" }), - }); - const body = (await res.json()) as { resourceType: string }; - expect(body.resourceType).toBe("Coverage"); - }); -}); - -describe("GET /api/fhir/Patient/$everything", () => { - it("asks a browser session that signed in an hour ago to confirm first", async () => { - vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({ - createdAt: new Date(Date.now() - 60 * 60 * 1000), - mfaVerifiedAt: null, - reproofAt: null, - } as never); - const res = await everythingGet(req("/api/fhir/Patient/$everything")); - expect(res.status).toBe(401); - expect((await res.json()).meta.errorCode).toBe("auth.reproof.required"); - }); - - it("returns every resource family the document bundle carries", async () => { - const res = await everythingGet( - req("/api/fhir/Patient/$everything?_count=200"), - ); - expect(res.status).toBe(200); - const bundle = (await res.json()) as { - type: string; - total: number; - entry: Array<{ fullUrl: string; resource: { resourceType: string } }>; - }; - expect(bundle.type).toBe("searchset"); - - const document = buildFhirDocumentBundle( - DATA, - { insuranceNumber: "A123456780" }, - FIXED_NOW, - { germanAtc: false }, - RECORDS, - ); - const expected = new Set( - document.entry.map((e) => e.resource.resourceType), - ); - const served = new Set(bundle.entry.map((e) => e.resource.resourceType)); - expect(served).toEqual(expected); - expect(bundle.total).toBe(document.entry.length); - - // The families the operation used to drop. - for (const type of [ - "Composition", - "Device", - "Condition", - "Encounter", - "AllergyIntolerance", - "FamilyMemberHistory", - "DiagnosticReport", - "Coverage", - ]) { - expect(served.has(type), `missing ${type}`).toBe(true); - } - // …including the cycle Observations, which share a resourceType with the - // vitals and would hide inside the set check above. - const ids = bundle.entry.map( - (e) => (e.resource as unknown as { id: string }).id, - ); - expect(ids.some((id) => id.startsWith("obs-cycle-"))).toBe(true); - }); - - it("keeps the entry identities the references resolve against", async () => { - const res = await everythingGet( - req("/api/fhir/Patient/$everything?_count=200"), - ); - const bundle = (await res.json()) as { - entry: Array<{ - fullUrl: string; - search: { mode: string }; - resource: { subject?: { reference?: string } }; - }>; - }; - const urls = new Set(bundle.entry.map((e) => e.fullUrl)); - for (const entry of bundle.entry) { - expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true); - expect(entry.search.mode).toBe("match"); - const subject = entry.resource.subject?.reference; - if (subject) expect(urls.has(subject)).toBe(true); - } - }); -}); diff --git a/src/app/api/fhir/__tests__/module-gate.test.ts b/src/app/api/fhir/__tests__/module-gate.test.ts deleted file mode 100644 index eb97a9833..000000000 --- a/src/app/api/fhir/__tests__/module-gate.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * `/api/fhir/*` — doctorReport module gate. - * - * The FHIR REST face serves the same whole-record aggregate as - * `/api/export/health-record`, including the decrypted insurance number on - * the Patient resource. The export gates on the `doctorReport` module; the - * FHIR routes did not, so the module could be off and `$everything` still - * returned the full Bundle to the same token. - * - * REFUSE, not omit — a whole-record export has no truthful partial answer. - * Every data route 403s with the shared `module.disabled` envelope; only the - * static CapabilityStatement at `/api/fhir/metadata` stays open (server - * metadata, no user data). - * - * Behavioural: the assertions read status codes and check that no aggregate - * was ever loaded, so removing a gate turns them red. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -// The module gate itself is NOT mocked — the whole point is to exercise the -// real resolver and the real 403 envelope. Only its data sources are stubbed, -// so the module state is driven the same way production drives it: through -// the persisted `modulePreferencesJson` allowlist. -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findUnique: vi.fn() }, - cycleProfile: { findUnique: vi.fn() }, - // The whole-record gate reads the session's own stamps: a sign-in a - // moment ago is the proof, on an account without a second factor. - session: { - findUnique: vi.fn(async () => ({ - createdAt: new Date(), - mfaVerifiedAt: null, - reproofAt: null, - })), - }, - webauthnMfaCredential: { count: vi.fn(async () => 0) }, - passkey: { count: vi.fn(async () => 0) }, - }, -})); -vi.mock("@/lib/modules/operator-availability", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/modules/operator-availability") - >(); - return { ...actual, getOperatorModuleAvailability: vi.fn() }; -}); -vi.mock("@/lib/fhir/rest", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, loadFhirContext: vi.fn() }; -}); -vi.mock("@/lib/fhir/resources", () => ({ - GERMAN_ATC_DEFAULT_LOCALES: ["de"], - PATIENT_RESOURCE_ID: "patient-1", - patientResource: vi.fn(() => ({ resourceType: "Patient", id: "patient-1" })), - coverageResource: vi.fn(() => null), - observationsFromReportData: vi.fn(() => []), - cycleObservationsFromReportData: vi.fn(() => []), - anamnesisObservationsFromReportData: vi.fn(() => []), - anamnesisNarrativeFromReportData: vi.fn(() => null), - conditionsFromReportData: vi.fn(() => ({ conditions: [], encounters: [] })), - medicationStatementsFromReportData: vi.fn(() => []), - medicationAdministrationsFromReportData: vi.fn(() => []), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET as patientGet } from "../Patient/route"; -import { GET as coverageGet } from "../Coverage/route"; -import { GET as patientRead } from "../Patient/[id]/route"; -import { GET as coverageRead } from "../Coverage/[id]/route"; -import { GET as observationRead } from "../Observation/[id]/route"; -import { GET as medStatementRead } from "../MedicationStatement/[id]/route"; -import { GET as medAdminRead } from "../MedicationAdministration/[id]/route"; -import { GET as observationGet } from "../Observation/route"; -import { GET as medStatementGet } from "../MedicationStatement/route"; -import { GET as medAdminGet } from "../MedicationAdministration/route"; -import { GET as everythingGet } from "../Patient/$everything/route"; -import { GET as metadataGet } from "../metadata/route"; - -import { getSession } from "@/lib/auth/session"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { MODULE_DISABLED_ERROR_CODE, MODULE_KEYS } from "@/lib/modules/gate"; -import { getOperatorModuleAvailability } from "@/lib/modules/operator-availability"; -import { loadFhirContext } from "@/lib/fhir/rest"; -import { prisma } from "@/lib/db"; - -/** - * Drive the REAL gate the way production does — through the persisted - * `modulePreferencesJson` disabled-allowlist, where only a literal `false` - * turns a module off. - */ -function setDoctorReportModule(enabled: boolean): void { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - gender: null, - disableCoach: false, - modulePreferencesJson: enabled ? {} : { doctorReport: false }, - } as never); - vi.mocked(prisma.cycleProfile.findUnique).mockResolvedValue(null as never); -} - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "tester", role: "USER" as const }, -}; - -/** - * Every data route under `/api/fhir`, by the path a caller would hit. The - * `/{type}/{id}` reads are here too: a route that 403s on search and serves on - * read would leak the same record through the other door. - */ -const DATA_ROUTES: ReadonlyArray< - [string, (req: NextRequest) => Promise] -> = [ - ["Patient", patientGet], - ["Coverage", coverageGet], - ["Observation", observationGet], - ["MedicationStatement", medStatementGet], - ["MedicationAdministration", medAdminGet], - ["Patient/$everything", everythingGet], -]; - -/** - * The `/{type}/{id}` reads. Same gate, different success shape (a bare - * resource, not a Bundle) — a route that 403s on search and serves on read - * would leak the same record through the other door. - */ -function idCtx(id: string) { - return { params: Promise.resolve({ id }) }; -} - -const READ_ROUTES: ReadonlyArray< - [string, (req: NextRequest) => Promise] -> = [ - ["Patient/patient-1", (req) => patientRead(req, idCtx("patient-1"))], - ["Coverage/coverage-1", (req) => coverageRead(req, idCtx("coverage-1"))], - ["Observation/obs-1", (req) => observationRead(req, idCtx("obs-1"))], - ["MedicationStatement/med-1", (req) => medStatementRead(req, idCtx("med-1"))], - [ - "MedicationAdministration/medadmin-1", - (req) => medAdminRead(req, idCtx("medadmin-1")), - ], -]; - -function req(path: string): NextRequest { - return new NextRequest(`http://localhost/api/fhir/${path}`); -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - count: 1, - resetAt: Date.now(), - } as never); - // Operator layer available for every module; the per-user layer is what - // each test drives. - vi.mocked(getOperatorModuleAvailability).mockResolvedValue( - Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as never, - ); - vi.mocked(loadFhirContext).mockResolvedValue({ - data: { - period: { - days: 90, - since: "2026-02-02T00:00:00.000Z", - start: "2026-02-02T00:00:00.000Z", - end: "2026-05-03T00:00:00.000Z", - }, - patient: {}, - } as never, - identity: { insuranceNumber: "A123456789" }, - germanAtc: false, - records: {}, - }); -}); - -describe("/api/fhir/* — doctorReport module gate", () => { - describe.each(DATA_ROUTES)("GET /api/fhir/%s", (path, handler) => { - it("403s with module.disabled when the module is off", async () => { - setDoctorReportModule(false); - - const res = await handler(req(path)); - expect(res.status).toBe(403); - - const body = (await res.json()) as { - data: unknown; - error: string; - meta?: { errorCode?: string; module?: string }; - }; - expect(body.data).toBeNull(); - expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE); - expect(body.meta?.module).toBe("doctorReport"); - - // Refused before any record was assembled — the insurance number - // never left the store. - expect(loadFhirContext).not.toHaveBeenCalled(); - expect(JSON.stringify(body)).not.toContain("A123456789"); - }); - - it("serves the Bundle when the module is on", async () => { - setDoctorReportModule(true); - - const res = await handler(req(path)); - expect(res.status).toBe(200); - expect(res.headers.get("content-type")).toContain( - "application/fhir+json", - ); - - const bundle = (await res.json()) as { resourceType: string }; - expect(bundle.resourceType).toBe("Bundle"); - expect(loadFhirContext).toHaveBeenCalledWith("user-1"); - }); - }); - - describe.each(READ_ROUTES)("GET /api/fhir/%s", (path, handler) => { - it("403s with module.disabled when the module is off", async () => { - setDoctorReportModule(false); - - const res = await handler(req(path)); - expect(res.status).toBe(403); - const body = (await res.json()) as { - meta?: { errorCode?: string; module?: string }; - }; - expect(body.meta?.errorCode).toBe(MODULE_DISABLED_ERROR_CODE); - expect(body.meta?.module).toBe("doctorReport"); - expect(loadFhirContext).not.toHaveBeenCalled(); - expect(JSON.stringify(body)).not.toContain("A123456789"); - }); - - it("reaches the record when the module is on", async () => { - setDoctorReportModule(true); - - const res = await handler(req(path)); - // The emitters are stubbed empty here, so the honest answer is a - // `not-found` OperationOutcome — what matters is that the gate did not - // refuse and the aggregate was assembled. - expect(res.status).not.toBe(403); - expect(loadFhirContext).toHaveBeenCalledWith("user-1"); - }); - }); - - it("leaves the static CapabilityStatement reachable while the module is off", async () => { - // Server metadata, no user data — the one FHIR route that stays open. - setDoctorReportModule(false); - const res = await metadataGet(req("metadata")); - expect(res.status).toBe(200); - const body = (await res.json()) as { resourceType: string }; - expect(body.resourceType).toBe("CapabilityStatement"); - }); -}); diff --git a/tests/integration/routes/auth-me-devices.test.ts b/tests/integration/routes/auth-me-devices.test.ts new file mode 100644 index 000000000..c9631f2df --- /dev/null +++ b/tests/integration/routes/auth-me-devices.test.ts @@ -0,0 +1,323 @@ +/** + * `/api/auth/me/devices` and `/api/auth/me/devices/{id}`: the registered + * device list and the device revoke, against the real database and through + * the real API kit. + * + * What a person relies on here: + * - the list shows the account's own devices, most recently seen first, + * with their delivery channels, and nothing that could be replayed to + * send a notification (no push token, no APNs identifier); + * - the "this device" badge follows `X-Device-Id` only when that device + * holds a live refresh token of this account, so a forged header cannot + * move it; + * - the revoke reaches only the caller's own devices, removes the row, + * revokes every live refresh token bound to it and the access tokens they + * pair with, and those access tokens stop authenticating at once. + */ +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/auth/me/devices/route"; +import { DELETE } from "@/app/api/auth/me/devices/[id]/route"; +import { hashToken } from "@/lib/auth/hmac"; +import type { Prisma, User } from "@/generated/prisma/client"; + +import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route"; +import { + makeSession, + makeToken, + makeUser, + uniqueSuffix, + type BearerFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +type DeviceRow = { + id: string; + label: string; + platform: string; + appVersion: string | null; + locale: string | null; + channels: Array<"web_push" | "apns">; + lastSeenAt: string; + createdAt: string; + isCurrent: boolean; +}; + +type RevokeResult = { + id: string; + revoked: boolean; + refreshTokensRevoked: number; + accessTokensRevoked: number; +}; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +async function makeDevice( + user: Pick, + overrides: Partial> = {}, +) { + return prisma.device.create({ + data: { + userId: user.id, + platform: "ios", + token: `device-token-${uniqueSuffix()}`, + bundleId: "io.healthlog.app", + ...overrides, + }, + }); +} + +/** + * A refresh token bound to a device, paired with an access token the way + * the native sign-in pairs them. `access: null` stores a refresh row with no + * access-token pairing. + */ +async function makeRefresh( + user: Pick, + deviceId: string, + options: { access?: BearerFixture | null; revokedAt?: Date } = {}, +) { + return prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: createHash("sha256") + .update(`refresh-${uniqueSuffix()}`) + .digest("hex"), + deviceId, + accessTokenHash: options.access ? hashToken(options.access.token) : null, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + revokedAt: options.revokedAt ?? null, + }, + }); +} + +async function listDevices(auth: RouteAuth, deviceIdHeader?: string) { + return callRoute<{ devices: DeviceRow[] }>(GET, { + method: "GET", + route: "/api/auth/me/devices", + auth, + headers: deviceIdHeader ? { "x-device-id": deviceIdHeader } : undefined, + }); +} + +describe("GET /api/auth/me/devices", () => { + it("answers 401 without a credential", async () => { + expect((await listDevices(anonymous)).status).toBe(401); + }); + + it("lists the caller's devices most recently seen first, with channels and no push identifiers", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const phone = await makeDevice(owner, { + model: "iPhone 14 Pro", + locale: "en-US", + appVersion: "1.0", + apnsToken: "apns-secret-value", + apnsEnvironment: "production", + lastSeen: new Date("2026-05-10T12:00:00Z"), + }); + const tablet = await makeDevice(owner, { + model: null, + lastSeen: new Date("2026-05-09T12:00:00Z"), + }); + await makeDevice(other, { model: "Not yours" }); + + const res = await listDevices(await makeSession(owner)); + + expect(res.status).toBe(200); + expect(res.data.devices).toEqual([ + { + id: phone.id, + label: "iPhone 14 Pro", + platform: "ios", + appVersion: "1.0", + locale: "en-US", + channels: ["web_push", "apns"], + lastSeenAt: "2026-05-10T12:00:00.000Z", + createdAt: phone.createdAt.toISOString(), + isCurrent: false, + }, + expect.objectContaining({ + id: tablet.id, + // No model: the bundle id labels the row rather than a blank. + label: "io.healthlog.app", + channels: ["web_push"], + isCurrent: false, + }), + ]); + const serialised = JSON.stringify(res.body); + expect(serialised).not.toContain(phone.token); + expect(serialised).not.toContain("apns-secret-value"); + }); + + it("marks the device named by X-Device-Id when it holds a live refresh token of this account", async () => { + const owner = await makeUser(); + const phone = await makeDevice(owner); + const tablet = await makeDevice(owner); + const access = await makeToken(owner); + await makeRefresh(owner, tablet.id, { access }); + + const res = await listDevices(access, tablet.id); + + expect(res.status).toBe(200); + expect(res.data.devices.map((d) => [d.id, d.isCurrent]).sort()).toEqual( + [ + [phone.id, false], + [tablet.id, true], + ].sort(), + ); + }); + + it("ignores an X-Device-Id that no live refresh token of this account anchors", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const forged = await makeDevice(owner); + const revoked = await makeDevice(owner); + const foreignAnchor = await makeDevice(owner); + await makeRefresh(owner, revoked.id, { revokedAt: new Date() }); + // Another account's refresh row naming this device id anchors nothing. + await makeRefresh(other, foreignAnchor.id); + const access = await makeToken(owner); + + for (const header of [forged.id, revoked.id, foreignAnchor.id]) { + const res = await listDevices(access, header); + expect(res.status).toBe(200); + expect(res.data.devices.some((d) => d.isCurrent)).toBe(false); + } + }); +}); + +describe("DELETE /api/auth/me/devices/{id}", () => { + it("answers 401 without a credential and leaves the device", async () => { + const owner = await makeUser(); + const device = await makeDevice(owner); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/devices/{id}", + params: { id: device.id }, + auth: anonymous, + }); + + expect(res.status).toBe(401); + expect(await prisma.device.count({ where: { id: device.id } })).toBe(1); + }); + + it("answers 404 for an id that does not exist", async () => { + const owner = await makeUser(); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/devices/{id}", + params: { id: "no-such-device" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + }); + + it("answers 404 for another account's device and leaves it and its credentials alone", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeDevice(other); + const theirAccess = await makeToken(other); + const theirRefresh = await makeRefresh(other, theirs.id, { + access: theirAccess, + }); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/devices/{id}", + params: { id: theirs.id }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + expect(await prisma.device.count({ where: { id: theirs.id } })).toBe(1); + const refresh = await prisma.refreshToken.findUniqueOrThrow({ + where: { id: theirRefresh.id }, + }); + expect(refresh.revokedAt).toBeNull(); + expect((await listDevices(theirAccess)).status).toBe(200); + }); + + it("deletes the device, revokes its refresh tokens and their paired access tokens, and those stop authenticating", async () => { + const owner = await makeUser(); + const session = await makeSession(owner); + const phone = await makeDevice(owner, { model: "iPhone" }); + const tablet = await makeDevice(owner); + const accessA = await makeToken(owner); + const accessB = await makeToken(owner); + const tabletAccess = await makeToken(owner); + const refreshA = await makeRefresh(owner, phone.id, { access: accessA }); + const refreshB = await makeRefresh(owner, phone.id, { access: accessB }); + const unpaired = await makeRefresh(owner, phone.id, { access: null }); + const earlier = new Date("2026-01-01T00:00:00Z"); + const alreadyRevoked = await makeRefresh(owner, phone.id, { + revokedAt: earlier, + }); + const tabletRefresh = await makeRefresh(owner, tablet.id, { + access: tabletAccess, + }); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/devices/{id}", + params: { id: phone.id }, + auth: session, + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ + id: phone.id, + revoked: true, + refreshTokensRevoked: 3, + accessTokensRevoked: 2, + }); + + expect(await prisma.device.count({ where: { id: phone.id } })).toBe(0); + const refreshRows = await prisma.refreshToken.findMany({ + where: { userId: owner.id }, + }); + const byId = new Map(refreshRows.map((r) => [r.id, r])); + for (const id of [refreshA.id, refreshB.id, unpaired.id]) { + expect(byId.get(id)?.revokedAt).not.toBeNull(); + } + // An earlier revocation keeps its own timestamp. + expect(byId.get(alreadyRevoked.id)?.revokedAt).toEqual(earlier); + // The other device is untouched. + expect(byId.get(tabletRefresh.id)?.revokedAt).toBeNull(); + expect(await prisma.device.count({ where: { id: tablet.id } })).toBe(1); + + expect((await listDevices(accessA)).status).toBe(401); + expect((await listDevices(accessB)).status).toBe(401); + expect((await listDevices(tabletAccess)).status).toBe(200); + // Browser sessions live on their own rows and are not touched. + expect((await listDevices(session)).status).toBe(200); + + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { userId: owner.id, action: "devices.revoke" }, + }); + expect(JSON.parse(audit.details ?? "{}")).toEqual({ + deviceId: phone.id, + label: "iPhone", + refreshTokensRevoked: 3, + accessTokensRevoked: 2, + }); + + // A retry after a successful revoke finds nothing. + const retry = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/devices/{id}", + params: { id: phone.id }, + auth: session, + }); + expect(retry.status).toBe(404); + }); +}); diff --git a/tests/integration/routes/auth-me-security-activity.test.ts b/tests/integration/routes/auth-me-security-activity.test.ts new file mode 100644 index 000000000..67331e25c --- /dev/null +++ b/tests/integration/routes/auth-me-security-activity.test.ts @@ -0,0 +1,151 @@ +/** + * `GET /api/auth/me/security-activity`: the account's recent security events, + * against the real database and through the real API kit. + * + * What a person relies on here: the feed shows their own sign-in, export and + * erasure events and nothing else, newest first in a total order, with the + * IP masked and no event detail body; the page is capped at the requested + * limit and says when more rows exist; a limit outside 1-100 is a 422 that + * names it rather than a silent clamp. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET } from "@/app/api/auth/me/security-activity/route"; +import type { User } from "@/generated/prisma/client"; + +import { anonymous, callRoute, type RouteAuth } from "../../kit/call-route"; +import { makeSession, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +type SecurityEvent = { + action: string; + createdAt: string; + location: string | null; + ipMasked: string | null; + carrier: string | null; +}; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +async function audit( + user: Pick, + action: string, + createdAt: Date, + extra: { ipAddress?: string; location?: string; carrier?: string } = {}, +) { + return prisma.auditLog.create({ + data: { + userId: user.id, + action, + createdAt, + details: JSON.stringify({ secretish: "detail body" }), + ...extra, + }, + }); +} + +async function feed(auth: RouteAuth, limit?: number | string) { + return callRoute<{ events: SecurityEvent[] }>(GET, { + method: "GET", + route: "/api/auth/me/security-activity", + query: { limit }, + auth, + }); +} + +describe("GET /api/auth/me/security-activity", () => { + it("answers 401 without a credential", async () => { + expect((await feed(anonymous)).status).toBe(401); + }); + + it("shows the caller's security events newest first, masked, without detail bodies, and nothing else", async () => { + const owner = await makeUser(); + const other = await makeUser(); + await audit( + owner, + "auth.login.password", + new Date("2026-05-01T08:00:00Z"), + { + ipAddress: "203.0.113.7", + location: "Berlin, DE", + carrier: "Example Carrier", + }, + ); + await audit(owner, "export.download", new Date("2026-05-02T08:00:00Z")); + await audit(owner, "user.data.clear", new Date("2026-05-03T08:00:00Z")); + await audit(owner, "measurement.create", new Date("2026-05-04T08:00:00Z")); + await audit(other, "auth.login.password", new Date("2026-05-05T08:00:00Z")); + + const res = await feed(await makeSession(owner)); + + expect(res.status).toBe(200); + expect(res.meta).toEqual({ truncated: false, limit: 50 }); + expect(res.data.events).toEqual([ + { + action: "user.data.clear", + createdAt: "2026-05-03T08:00:00.000Z", + location: null, + ipMasked: null, + carrier: null, + }, + { + action: "export.download", + createdAt: "2026-05-02T08:00:00.000Z", + location: null, + ipMasked: null, + carrier: null, + }, + { + action: "auth.login.password", + createdAt: "2026-05-01T08:00:00.000Z", + location: "Berlin, DE", + ipMasked: "203.0.x.x", + carrier: "Example Carrier", + }, + ]); + const serialised = JSON.stringify(res.body); + expect(serialised).not.toContain("203.0.113.7"); + expect(serialised).not.toContain("detail body"); + }); + + it("caps the page at the limit, says more exist, and breaks a timestamp tie by id", async () => { + const owner = await makeUser(); + const same = new Date("2026-05-01T08:00:00Z"); + const rows = [ + await audit(owner, "auth.login.password", same), + await audit(owner, "auth.login.passkey", same), + await audit(owner, "auth.logout", same), + ]; + const byIdDesc = [...rows].sort((a, b) => (a.id < b.id ? 1 : -1)); + const session = await makeSession(owner); + + const capped = await feed(session, 2); + expect(capped.status).toBe(200); + expect(capped.meta).toEqual({ truncated: true, limit: 2 }); + expect(capped.data.events.map((e) => e.action)).toEqual( + byIdDesc.slice(0, 2).map((r) => r.action), + ); + + const exact = await feed(session, 3); + expect(exact.meta).toEqual({ truncated: false, limit: 3 }); + expect(exact.data.events.map((e) => e.action)).toEqual( + byIdDesc.map((r) => r.action), + ); + }); + + it("answers a limit past the published ceiling with a 422 naming it", async () => { + const owner = await makeUser(); + + const res = await feed(await makeSession(owner), 500); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("security_activity.invalid"); + expect(res.body).toMatchObject({ + details: { issues: [expect.objectContaining({ path: "limit" })] }, + }); + }); +}); diff --git a/tests/integration/routes/auth-me-sessions.test.ts b/tests/integration/routes/auth-me-sessions.test.ts new file mode 100644 index 000000000..2113336b7 --- /dev/null +++ b/tests/integration/routes/auth-me-sessions.test.ts @@ -0,0 +1,524 @@ +/** + * `/api/auth/me/sessions` and `/api/auth/me/sessions/{id}`: the active-session + * list, "sign out everywhere else", and the single-session revoke, against the + * real database and through the real API kit. + * + * What a person relies on here: + * - the list shows the account's own live sessions, most recently active + * first, with a coarse device label, a masked IP and a resolved location, + * marks the session making the request, and never carries a row id (an + * old row id is that session's cookie) or a full IP; + * - "sign out everywhere" ends every other credential and the ended ones + * stop authenticating on their next request, while the caller's own + * credential keeps working, whether it is a cookie or a device login; + * - it withdraws unaccepted invitations, lists the live accepted grants it + * left standing, and revokes share links unless asked to keep them; + * - the single revoke takes the opaque handle from the list, reaches only + * the caller's own rows, and the revoked session is signed out. + * + * The location resolver's online provider is the one process boundary + * replaced: `safeFetch` answers a canned provider body, so no address leaves + * the test. + */ +import { createHash } from "node:crypto"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { DELETE, GET } from "@/app/api/auth/me/sessions/route"; +import { DELETE as DELETE_ONE } from "@/app/api/auth/me/sessions/[id]/route"; +import { hashToken } from "@/lib/auth/hmac"; +import { sessionHandle } from "@/lib/auth/session"; +import type { User } from "@/generated/prisma/client"; + +import { anonymous, callRoute } from "../../kit/call-route"; +import { + makeGrant, + makeSession, + makeToken, + makeUser, + uniqueSuffix, + type BearerFixture, + type SessionFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: vi.fn(async (input: string | URL) => { + if (String(input).startsWith("https://ipwho.is/")) { + return new Response( + JSON.stringify({ success: true, city: "Berlin", country_code: "DE" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + throw new actual.SafeFetchError("offline in this file", "network"); + }), + }; +}); + +const prisma = getPrismaClient(); + +const FIREFOX_MAC = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0"; +const CHROME_WINDOWS = + "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36"; + +type SessionRow = { + id: string; + device: string; + ipMasked: string | null; + location: string | null; + lastActiveAt: string | null; + createdAt: string; + isCurrent: boolean; +}; + +type SignOutResult = { + sessionsRevoked: number; + accessTokensRevoked: number; + connectorsRevoked: number; + shareLinksRevoked: number; + pendingInvitesRevoked: number; + grantsKept: Array<{ + id: string; + account: { id: string; username: string; displayName: string | null }; + access: "READ" | "WRITE" | "MANAGE"; + }>; +}; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +async function listSessions(auth: SessionFixture | BearerFixture) { + return callRoute<{ sessions: SessionRow[] }>(GET, { + method: "GET", + route: "/api/auth/me/sessions", + auth, + }); +} + +/** A session with the browser details a real sign-in would have stored. */ +async function browserSession( + user: Pick, + details: { userAgent: string; ipAddress: string; lastActiveAt: Date | null }, +): Promise { + const session = await makeSession(user); + await prisma.session.update({ + where: { id: session.sessionId }, + data: details, + }); + return session; +} + +/** + * A native device login: the access token the app presents, paired with the + * refresh row that rotates it, the shape the native sign-in mints. + */ +async function deviceLogin( + user: Pick, + deviceId: string, +): Promise<{ access: BearerFixture; refreshId: string }> { + const access = await makeToken(user, { name: `device ${deviceId}` }); + const refresh = await prisma.refreshToken.create({ + data: { + userId: user.id, + tokenHash: createHash("sha256") + .update(`refresh-${uniqueSuffix()}`) + .digest("hex"), + deviceId, + accessTokenHash: hashToken(access.token), + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + return { access, refreshId: refresh.id }; +} + +async function makeShareLink(user: Pick) { + return prisma.clinicianShareLink.create({ + data: { + userId: user.id, + tokenHash: createHash("sha256") + .update(`share-${uniqueSuffix()}`) + .digest("hex"), + label: "Practice visit", + rangeStart: new Date("2026-01-01T00:00:00Z"), + sectionsJson: { v: 2, leaves: ["WEIGHT"] }, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + }); +} + +describe("GET /api/auth/me/sessions", () => { + it("answers 401 without a credential", async () => { + const res = await callRoute(GET, { + method: "GET", + route: "/api/auth/me/sessions", + auth: anonymous, + }); + expect(res.status).toBe(401); + }); + + it("lists the caller's live sessions, most recent first, labelled, masked and located, with the current one marked", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const current = await browserSession(owner, { + userAgent: FIREFOX_MAC, + ipAddress: "203.0.113.7", + lastActiveAt: new Date(), + }); + const laptop = await browserSession(owner, { + userAgent: CHROME_WINDOWS, + ipAddress: "198.51.100.4", + lastActiveAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }); + const neverActive = await browserSession(owner, { + userAgent: CHROME_WINDOWS, + ipAddress: "10.0.0.8", + lastActiveAt: null, + }); + const expired = await makeSession(owner); + await prisma.session.update({ + where: { id: expired.sessionId }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + await makeSession(other); + + const res = await listSessions(current); + + expect(res.status).toBe(200); + expect(res.data.sessions).toEqual([ + expect.objectContaining({ + id: sessionHandle(current.sessionId), + device: "Firefox on macOS", + ipMasked: "203.0.x.x", + location: "Berlin, DE", + isCurrent: true, + }), + expect.objectContaining({ + id: sessionHandle(laptop.sessionId), + device: "Chrome on Windows", + ipMasked: "198.51.x.x", + location: "Berlin, DE", + isCurrent: false, + }), + expect.objectContaining({ + id: sessionHandle(neverActive.sessionId), + ipMasked: "10.0.x.x", + // A private address is never sent to the resolver. + location: null, + lastActiveAt: null, + isCurrent: false, + }), + ]); + }); + + it("never carries a session row id or a full IP address", async () => { + // A row id minted before the secret cookie IS that session's cookie, so + // listing it would hand out working logins for the other devices. + const owner = await makeUser(); + const current = await browserSession(owner, { + userAgent: FIREFOX_MAC, + ipAddress: "203.0.113.7", + lastActiveAt: new Date(), + }); + const other = await browserSession(owner, { + userAgent: CHROME_WINDOWS, + ipAddress: "198.51.100.4", + lastActiveAt: null, + }); + + const res = await listSessions(current); + + const serialised = JSON.stringify(res.body); + for (const leak of [ + current.sessionId, + other.sessionId, + "203.0.113.7", + "198.51.100.4", + ]) { + expect(serialised).not.toContain(leak); + } + const handles = res.data.sessions.map((s) => s.id); + expect(new Set(handles).size).toBe(2); + }); +}); + +describe("DELETE /api/auth/me/sessions (sign out everywhere else)", () => { + it("ends every other credential, keeps the caller's session, and the ended ones stop authenticating", async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const current = await makeSession(owner); + const otherBrowser = await makeSession(owner); + const strangerSession = await makeSession(stranger); + const connector = await makeToken(owner, { + permissions: ["measurements:write"], + }); + const phone = await deviceLogin(owner, "phone"); + const link = await makeShareLink(owner); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/sessions", + auth: current, + }); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + sessionsRevoked: 1, + // The phone's paired access token, then the programmatic token. + accessTokensRevoked: 2, + connectorsRevoked: 0, + shareLinksRevoked: 1, + pendingInvitesRevoked: 0, + grantsKept: [], + }); + + const sessions = await prisma.session.findMany({ + select: { id: true }, + }); + expect(sessions.map((s) => s.id).sort()).toEqual( + [current.sessionId, strangerSession.sessionId].sort(), + ); + const tokens = await prisma.apiToken.findMany({ + where: { userId: owner.id }, + select: { id: true, revoked: true }, + }); + expect(tokens.every((t) => t.revoked)).toBe(true); + expect(tokens).toHaveLength(2); + const refresh = await prisma.refreshToken.findUniqueOrThrow({ + where: { id: phone.refreshId }, + }); + expect(refresh.revokedAt).not.toBeNull(); + const storedLink = await prisma.clinicianShareLink.findUniqueOrThrow({ + where: { id: link.id }, + }); + expect(storedLink.revokedAt).not.toBeNull(); + + expect((await listSessions(otherBrowser)).status).toBe(401); + expect((await listSessions(phone.access)).status).toBe(401); + expect((await listSessions(current)).status).toBe(200); + expect((await listSessions(strangerSession)).status).toBe(200); + // The narrow token is refused for lack of a credential now, not a scope. + const ingest = await callRoute(GET, { + method: "GET", + route: "/api/auth/me/sessions", + auth: connector, + }); + expect(ingest.status).toBe(401); + + const audit = await prisma.auditLog.findFirstOrThrow({ + where: { userId: owner.id, action: "auth.session.revoke_others" }, + }); + expect(JSON.parse(audit.details ?? "{}")).toMatchObject({ + sessionsRevoked: 1, + accessTokensRevoked: 2, + shareLinksRevoked: 1, + pendingInvitesRevoked: 0, + grantsKept: 0, + keepShareLinks: false, + }); + }); + + it("keeps the share links when the caller asks to", async () => { + const owner = await makeUser(); + const current = await makeSession(owner); + const link = await makeShareLink(owner); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/sessions", + query: { keepShareLinks: "1" }, + auth: current, + }); + + expect(res.status).toBe(200); + expect(res.data.shareLinksRevoked).toBe(0); + const stored = await prisma.clinicianShareLink.findUniqueOrThrow({ + where: { id: link.id }, + }); + expect(stored.revokedAt).toBeNull(); + }); + + it("spares a Bearer caller's own device login and ends the other devices", async () => { + const owner = await makeUser(); + const browser = await makeSession(owner); + const phone = await deviceLogin(owner, "phone"); + const tablet = await deviceLogin(owner, "tablet"); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/sessions", + auth: phone.access, + }); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + sessionsRevoked: 1, + accessTokensRevoked: 1, + }); + + const phoneRefresh = await prisma.refreshToken.findUniqueOrThrow({ + where: { id: phone.refreshId }, + }); + expect(phoneRefresh.revokedAt).toBeNull(); + const tabletRefresh = await prisma.refreshToken.findUniqueOrThrow({ + where: { id: tablet.refreshId }, + }); + expect(tabletRefresh.revokedAt).not.toBeNull(); + const phoneToken = await prisma.apiToken.findUniqueOrThrow({ + where: { id: phone.access.tokenId }, + }); + expect(phoneToken.revoked).toBe(false); + + expect((await listSessions(phone.access)).status).toBe(200); + expect((await listSessions(tablet.access)).status).toBe(401); + expect((await listSessions(browser)).status).toBe(401); + }); + + it("withdraws unaccepted invitations and lists only the live accepted grants it left standing", async () => { + const owner = await makeUser(); + const carer = await makeUser({ displayName: "Carer" }); + const invitee = await makeUser(); + const lapsedGrantee = await makeUser(); + const current = await makeSession(owner); + const live = await makeGrant(owner, carer, { + acceptedAt: new Date("2026-01-01T00:00:00Z"), + }); + const pending = await makeGrant(owner, invitee, { acceptedAt: null }); + const lapsed = await makeGrant(owner, lapsedGrantee, { + acceptedAt: new Date("2026-01-01T00:00:00Z"), + expiresAt: new Date("2026-02-01T00:00:00Z"), + }); + + const res = await callRoute(DELETE, { + method: "DELETE", + route: "/api/auth/me/sessions", + auth: current, + }); + + expect(res.status).toBe(200); + expect(res.data.pendingInvitesRevoked).toBe(1); + // The lapsed grant confers nothing any more and is not listed. + expect(res.data.grantsKept).toEqual([ + { + id: live.id, + account: { + id: carer.id, + username: carer.username, + displayName: "Carer", + }, + access: "READ", + }, + ]); + + const stored = await prisma.accountGrant.findMany({ + where: { id: { in: [live.id, pending.id, lapsed.id] } }, + }); + const byId = new Map(stored.map((g) => [g.id, g])); + expect(byId.get(pending.id)?.revokedAt).not.toBeNull(); + expect(byId.get(pending.id)?.revokedBy).toBe("GRANTOR"); + expect(byId.get(live.id)?.revokedAt).toBeNull(); + expect(byId.get(lapsed.id)?.revokedAt).toBeNull(); + }); +}); + +describe("DELETE /api/auth/me/sessions/{id}", () => { + it("revokes one owned session by its listed handle, and that session is signed out", async () => { + const owner = await makeUser(); + const current = await makeSession(owner); + const laptop = await makeSession(owner); + + const listed = await listSessions(current); + const handle = listed.data.sessions.find((s) => !s.isCurrent)?.id; + expect(handle).toBe(sessionHandle(laptop.sessionId)); + + const res = await callRoute<{ revoked: boolean }>(DELETE_ONE, { + method: "DELETE", + route: "/api/auth/me/sessions/{id}", + params: { id: sessionHandle(laptop.sessionId) }, + auth: current, + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ revoked: true }); + expect( + await prisma.session.findUnique({ where: { id: laptop.sessionId } }), + ).toBeNull(); + expect((await listSessions(laptop)).status).toBe(401); + expect((await listSessions(current)).status).toBe(200); + expect( + await prisma.auditLog.count({ + where: { userId: owner.id, action: "auth.session.revoke" }, + }), + ).toBe(1); + }); + + it("revokes the caller's own session when it names its own handle", async () => { + const owner = await makeUser(); + const current = await makeSession(owner); + + const res = await callRoute<{ revoked: boolean }>(DELETE_ONE, { + method: "DELETE", + route: "/api/auth/me/sessions/{id}", + params: { id: sessionHandle(current.sessionId) }, + auth: current, + }); + + expect(res.status).toBe(200); + expect((await listSessions(current)).status).toBe(401); + }); + + it("answers 404 for another account's session and leaves it signed in", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeSession(other); + + const res = await callRoute(DELETE_ONE, { + method: "DELETE", + route: "/api/auth/me/sessions/{id}", + params: { id: sessionHandle(theirs.sessionId) }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + expect((await listSessions(theirs)).status).toBe(200); + }); + + it("does not accept a raw session row id in place of the handle", async () => { + const owner = await makeUser(); + const current = await makeSession(owner); + const laptop = await makeSession(owner); + + const res = await callRoute(DELETE_ONE, { + method: "DELETE", + route: "/api/auth/me/sessions/{id}", + params: { id: laptop.sessionId }, + auth: current, + }); + + expect(res.status).toBe(404); + expect( + await prisma.session.findUnique({ where: { id: laptop.sessionId } }), + ).not.toBeNull(); + }); + + it("answers 401 without a credential", async () => { + const owner = await makeUser(); + const laptop = await makeSession(owner); + + const res = await callRoute(DELETE_ONE, { + method: "DELETE", + route: "/api/auth/me/sessions/{id}", + params: { id: sessionHandle(laptop.sessionId) }, + auth: anonymous, + }); + + expect(res.status).toBe(401); + expect( + await prisma.session.findUnique({ where: { id: laptop.sessionId } }), + ).not.toBeNull(); + }); +}); diff --git a/tests/integration/routes/fhir-module-gate.test.ts b/tests/integration/routes/fhir-module-gate.test.ts new file mode 100644 index 000000000..e018e8f66 --- /dev/null +++ b/tests/integration/routes/fhir-module-gate.test.ts @@ -0,0 +1,200 @@ +/** + * `/api/fhir/*` and the `doctorReport` module, against the real database and + * through the real API kit. + * + * The FHIR face serves the same whole-record aggregate as the document + * export, down to the decrypted insurance number on the Patient resource, so + * it answers to the same module. A whole-record export has no truthful partial + * answer: with the module off every data route, search and read alike, + * refuses with the shared 403 `module.disabled` envelope and none of the + * record leaves; the static CapabilityStatement carries no user data and + * stays reachable. The module is switched the way the settings switch it, + * through the stored per-account preferences. + * + * The registry declares no 403 for these operations; the refusals below skip + * the contract check for that reason. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as METADATA } from "@/app/api/fhir/metadata/route"; +import { GET as PATIENT_SEARCH } from "@/app/api/fhir/Patient/route"; +import { GET as PATIENT_READ } from "@/app/api/fhir/Patient/[id]/route"; +import { GET as EVERYTHING } from "@/app/api/fhir/Patient/$everything/route"; +import { GET as OBSERVATION_SEARCH } from "@/app/api/fhir/Observation/route"; +import { GET as OBSERVATION_READ } from "@/app/api/fhir/Observation/[id]/route"; +import { GET as STATEMENT_SEARCH } from "@/app/api/fhir/MedicationStatement/route"; +import { GET as STATEMENT_READ } from "@/app/api/fhir/MedicationStatement/[id]/route"; +import { GET as ADMINISTRATION_SEARCH } from "@/app/api/fhir/MedicationAdministration/route"; +import { GET as ADMINISTRATION_READ } from "@/app/api/fhir/MedicationAdministration/[id]/route"; +import { GET as COVERAGE_SEARCH } from "@/app/api/fhir/Coverage/route"; +import { GET as COVERAGE_READ } from "@/app/api/fhir/Coverage/[id]/route"; +import { encrypt } from "@/lib/crypto"; +import { FHIR_READ_SCOPE } from "@/lib/fhir/rest"; +import { MODULE_DISABLED_ERROR_CODE } from "@/lib/modules/gate"; + +import { callRoute, type RouteAuth } from "../../kit/call-route"; +import { + makeMeasurement, + makeMedication, + makeSession, + makeToken, + makeUser, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +const INSURANCE_NUMBER = "A123456789"; +const MODULE_OFF_SKIP = + "the FHIR operations declare no 403; the module gate's module.disabled envelope is undocumented on them"; + +type Handler = Parameters[0]; + +/** Every data route, by the path a caller hits; the reads name a held id. */ +const DATA_ROUTES: ReadonlyArray<{ + handler: Handler; + route: string; + params?: { id: string }; +}> = [ + { handler: PATIENT_SEARCH, route: "/api/fhir/Patient" }, + { handler: COVERAGE_SEARCH, route: "/api/fhir/Coverage" }, + { handler: OBSERVATION_SEARCH, route: "/api/fhir/Observation" }, + { handler: STATEMENT_SEARCH, route: "/api/fhir/MedicationStatement" }, + { + handler: ADMINISTRATION_SEARCH, + route: "/api/fhir/MedicationAdministration", + }, + { handler: EVERYTHING, route: "/api/fhir/Patient/$everything" }, + { + handler: PATIENT_READ, + route: "/api/fhir/Patient/{id}", + params: { id: "patient-1" }, + }, + { + handler: COVERAGE_READ, + route: "/api/fhir/Coverage/{id}", + params: { id: "coverage-1" }, + }, + { + handler: OBSERVATION_READ, + route: "/api/fhir/Observation/{id}", + params: { id: "obs-1" }, + }, + { + handler: STATEMENT_READ, + route: "/api/fhir/MedicationStatement/{id}", + params: { id: "med-1" }, + }, + { + handler: ADMINISTRATION_READ, + route: "/api/fhir/MedicationAdministration/{id}", + params: { id: "medadmin-1" }, + }, +]; + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +/** An account whose saved selection reaches every routed resource type. */ +async function seedOwner(doctorReport: boolean) { + const now = Date.now(); + const user = await makeUser({ + fullName: "Sample Patient", + insurerName: "Example Insurer", + insuranceNumberEncrypted: encrypt(INSURANCE_NUMBER), + reportSelectionJson: { + v: 2, + leaves: [ + "PATIENT_IDENTITY", + "INSURANCE", + "WEIGHT", + "MEDICATION_LIST", + "MEDICATION_ADMINISTRATIONS", + ], + }, + modulePreferencesJson: doctorReport ? {} : { doctorReport: false }, + }); + await makeMeasurement(user, { + value: 79.5, + measuredAt: new Date(now - 24 * 60 * 60 * 1000), + }); + const medication = await makeMedication(user, { + name: "Example Drug", + createdAt: new Date(now - 30 * 24 * 60 * 60 * 1000), + }); + const takenAt = new Date(now - 24 * 60 * 60 * 1000); + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: takenAt, + takenAt, + }, + }); + return user; +} + +async function callEach(auth: RouteAuth, skip: boolean) { + const results = []; + for (const { handler, route, params } of DATA_ROUTES) { + const res = await callRoute(handler, { + method: "GET", + route, + params, + auth, + contract: skip ? { skip: MODULE_OFF_SKIP } : undefined, + }); + results.push({ route, res }); + } + return results; +} + +describe("/api/fhir/* and the doctorReport module", () => { + it("refuses every data route with module.disabled while the module is off, and none of the record leaves", async () => { + const owner = await seedOwner(false); + + for (const auth of [ + await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }), + await makeSession(owner), + ]) { + for (const { route, res } of await callEach(auth, true)) { + expect(res.status, route).toBe(403); + expect(res.data, route).toBeNull(); + expect(res.meta, route).toMatchObject({ + errorCode: MODULE_DISABLED_ERROR_CODE, + module: "doctorReport", + }); + const body = JSON.stringify(res.body); + for (const stored of [INSURANCE_NUMBER, "Sample Patient", "79.5"]) { + expect(body, route).not.toContain(stored); + } + } + } + }); + + it("serves every data route once the module is on", async () => { + const owner = await seedOwner(true); + const auth = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }); + + for (const { route, res } of await callEach(auth, false)) { + expect(res.status, route).toBe(200); + expect(res.headers.get("content-type"), route).toContain( + "application/fhir+json", + ); + } + }); + + it("leaves the CapabilityStatement reachable while the module is off", async () => { + const owner = await seedOwner(false); + + const res = await callRoute(METADATA, { + method: "GET", + route: "/api/fhir/metadata", + auth: await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }), + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ resourceType: "CapabilityStatement" }); + }); +}); diff --git a/tests/integration/routes/fhir.test.ts b/tests/integration/routes/fhir.test.ts new file mode 100644 index 000000000..03133023c --- /dev/null +++ b/tests/integration/routes/fhir.test.ts @@ -0,0 +1,896 @@ +/** + * The read-only FHIR R4 face under `/api/fhir`, against the real database and + * through the real API kit. + * + * What a client relies on here: + * - the CapabilityStatement describes the instance answering, and every + * interaction it advertises has a route behind it; + * - each resource route serves the caller's own stored values, as the + * owner's saved report selection scopes them, and never another + * account's: a foreign id answers a `not-found` OperationOutcome; + * - a narrow token passes with the `fhir:read` scope the routes declare and + * is refused with any other, and the `fhir:read` token reaches nothing + * outside this face; + * - `Patient/$everything` carries every family the document export carries, + * with references that resolve inside the response, and asks a browser + * session for a recent sign-in. + * + * The module gate has its own file (`fhir-module-gate.test.ts`). + */ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import { beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { GET as METADATA } from "@/app/api/fhir/metadata/route"; +import { GET as PATIENT_SEARCH } from "@/app/api/fhir/Patient/route"; +import { GET as PATIENT_READ } from "@/app/api/fhir/Patient/[id]/route"; +import { GET as EVERYTHING } from "@/app/api/fhir/Patient/$everything/route"; +import { GET as OBSERVATION_SEARCH } from "@/app/api/fhir/Observation/route"; +import { GET as OBSERVATION_READ } from "@/app/api/fhir/Observation/[id]/route"; +import { GET as STATEMENT_SEARCH } from "@/app/api/fhir/MedicationStatement/route"; +import { GET as STATEMENT_READ } from "@/app/api/fhir/MedicationStatement/[id]/route"; +import { GET as ADMINISTRATION_SEARCH } from "@/app/api/fhir/MedicationAdministration/route"; +import { GET as ADMINISTRATION_READ } from "@/app/api/fhir/MedicationAdministration/[id]/route"; +import { GET as COVERAGE_SEARCH } from "@/app/api/fhir/Coverage/route"; +import { GET as COVERAGE_READ } from "@/app/api/fhir/Coverage/[id]/route"; +import { GET as SESSIONS } from "@/app/api/auth/me/sessions/route"; +import { encrypt } from "@/lib/crypto"; +import { buildFhirDocumentBundle } from "@/lib/fhir/build-bundle"; +import { + FHIR_READ_SCOPE, + FHIR_REST_RESOURCE_TYPES, + loadFhirContext, +} from "@/lib/fhir/rest"; +import type { User } from "@/generated/prisma/client"; + +import { + anonymous, + callRoute, + type CallRouteOptions, + type RouteAuth, +} from "../../kit/call-route"; +import { + makeMeasurement, + makeMedication, + makeSession, + makeToken, + makeUser, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const prisma = getPrismaClient(); + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The leaves the seeded owner saved, which is all this face may serve. */ +const SAVED_LEAVES = [ + "PATIENT_IDENTITY", + "INSURANCE", + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "MEDICATION_LIST", + "MEDICATION_ADMINISTRATIONS", + "ALLERGIES", + "FAMILY_HISTORY", + "ILLNESS_EPISODES", + "CYCLE", +]; + +const resourceSchema = z.looseObject({ + resourceType: z.string(), + id: z.string(), +}); +type Resource = z.infer; + +const bundleSchema = z.object({ + resourceType: z.literal("Bundle"), + type: z.literal("searchset"), + total: z.number(), + link: z.array(z.object({ relation: z.string(), url: z.string() })), + entry: z.array( + z.object({ + fullUrl: z.string(), + search: z.object({ mode: z.string() }), + resource: resourceSchema, + }), + ), +}); + +const outcomeSchema = z.object({ + resourceType: z.literal("OperationOutcome"), + issue: z.array(z.object({ severity: z.string(), code: z.string() })), +}); + +const codedSchema = z.looseObject({ + code: z.object({ + coding: z.array(z.looseObject({ code: z.string() })).optional(), + }), +}); + +const referenceSchema = z.looseObject({ + subject: z.object({ reference: z.string() }).optional(), +}); + +function codesOf(resource: Resource): string[] { + const parsed = codedSchema.safeParse(resource); + return parsed.success + ? (parsed.data.code.coding ?? []).map((c) => c.code) + : []; +} + +interface SeededRecord { + user: User; + weightId: string; +} + +/** + * An account with a saved selection and something in every family the + * selection names, dated inside the default reporting window. + */ +async function seedRecord(values: { + fullName: string; + insurerName: string; + insuranceNumber: string; + weight: number; + systolic: number; + diastolic: number; + medication: string; + allergy: string; +}): Promise { + const now = Date.now(); + const user = await makeUser({ + fullName: values.fullName, + dateOfBirth: new Date("1985-06-15T00:00:00Z"), + gender: "FEMALE", + insurerName: values.insurerName, + insurerIkNumber: "101234567", + insuranceNumberEncrypted: encrypt(values.insuranceNumber), + reportSelectionJson: { v: 2, leaves: SAVED_LEAVES }, + }); + const weight = await makeMeasurement(user, { + type: "WEIGHT", + value: values.weight, + unit: "kg", + measuredAt: new Date(now - 2 * DAY_MS), + }); + const bpAt = new Date(now - DAY_MS); + await makeMeasurement(user, { + type: "BLOOD_PRESSURE_SYS", + value: values.systolic, + unit: "mmHg", + measuredAt: bpAt, + }); + await makeMeasurement(user, { + type: "BLOOD_PRESSURE_DIA", + value: values.diastolic, + unit: "mmHg", + measuredAt: bpAt, + }); + const medication = await makeMedication(user, { + name: values.medication, + dose: "5mg", + createdAt: new Date(now - 30 * DAY_MS), + }); + const takenAt = new Date(now - DAY_MS); + await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: medication.id, + scheduledFor: takenAt, + takenAt, + }, + }); + await prisma.allergy.create({ + data: { + userId: user.id, + substance: values.allergy, + category: "MEDICATION", + severity: "SEVERE", + }, + }); + await prisma.familyHistoryEntry.create({ + data: { + userId: user.id, + relationship: "MOTHER", + condition: "Hypertension", + ageAtOnset: 50, + }, + }); + await prisma.illnessEpisode.create({ + data: { + userId: user.id, + label: "Common cold", + type: "INFECTION", + onsetAt: new Date(now - 20 * DAY_MS), + resolvedAt: new Date(now - 10 * DAY_MS), + }, + }); + for (const [startDate, periodEndDate, lengthDays] of [ + ["2026-07-01", "2026-07-05", 28], + ["2026-07-29", "2026-08-02", 27], + ["2026-08-25", "2026-08-29", 29], + ] as const) { + await prisma.menstrualCycle.create({ + data: { userId: user.id, startDate, periodEndDate, lengthDays }, + }); + } + return { user, weightId: weight.id }; +} + +async function seedOwnerAndStranger() { + const owner = await seedRecord({ + fullName: "Sample Patient", + insurerName: "Example Insurer", + insuranceNumber: "A123456780", + weight: 79.5, + systolic: 128, + diastolic: 82, + medication: "Example Drug", + allergy: "Penicillin", + }); + const stranger = await seedRecord({ + fullName: "Someone Else", + insurerName: "Other Insurer", + insuranceNumber: "B987654320", + weight: 101.1, + systolic: 151, + diastolic: 97, + medication: "Other Drug", + allergy: "Latex", + }); + return { owner, stranger }; +} + +/** The strings that would only appear if the stranger's record leaked. */ +const STRANGER_MARKERS = [ + "Someone Else", + "Other Insurer", + "B987654320", + "101.1", + "Other Drug", + "Latex", +]; + +async function fhirCall( + handler: Parameters[0], + options: Omit, +) { + const res = await callRoute(handler, { method: "GET", ...options }); + if (res.status === 200 || res.status === 404) { + expect(res.headers.get("content-type")).toContain("application/fhir+json"); + } + return res; +} + +async function search( + handler: Parameters[0], + route: string, + auth: RouteAuth, + query?: CallRouteOptions["query"], +) { + const res = await fhirCall(handler, { route, auth, query }); + expect(res.status).toBe(200); + return { res, bundle: bundleSchema.parse(res.body) }; +} + +async function read( + handler: Parameters[0], + route: string, + id: string, + auth: RouteAuth, +) { + return fhirCall(handler, { route, params: { id }, auth }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("GET /api/fhir/metadata", () => { + it("describes the instance answering and advertises only reads and searches that have a route", async () => { + const owner = await makeUser(); + const res = await fhirCall(METADATA, { + route: "/api/fhir/metadata", + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + const statement = z + .looseObject({ + resourceType: z.literal("CapabilityStatement"), + description: z.string().min(1), + software: z.object({ name: z.string(), version: z.string().min(1) }), + implementation: z.object({ + description: z.string().min(1), + url: z.string(), + }), + rest: z.array( + z.object({ + resource: z.array( + z.looseObject({ + type: z.string(), + interaction: z.array(z.object({ code: z.string() })), + operation: z.array(z.object({ name: z.string() })).optional(), + }), + ), + }), + ), + }) + .parse(res.body); + + expect(statement.software.name).toBe("HealthLog"); + expect(statement.implementation.url).toBe("http://localhost:3000/api/fhir"); + + const resources = statement.rest[0].resource; + expect(resources.map((r) => r.type)).toEqual([...FHIR_REST_RESOURCE_TYPES]); + const patient = resources.filter((r) => r.type === "Patient"); + expect(patient).toHaveLength(1); + expect(patient[0].operation?.map((o) => o.name)).toEqual(["everything"]); + + const root = path.join(process.cwd(), "src/app/api/fhir"); + for (const resource of resources) { + for (const { code } of resource.interaction) { + expect(["read", "search-type"]).toContain(code); + const file = + code === "read" + ? path.join(root, resource.type, "[id]", "route.ts") + : path.join(root, resource.type, "route.ts"); + expect(existsSync(file), `${resource.type} ${code}`).toBe(true); + } + for (const { name } of resource.operation ?? []) { + const file = path.join(root, resource.type, `$${name}`, "route.ts"); + expect(existsSync(file), `${resource.type} $${name}`).toBe(true); + } + } + }); +}); + +describe("FHIR read scope", () => { + it("admits a narrow token holding the declared scope", async () => { + const { owner } = await seedOwnerAndStranger(); + const token = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + for (const [handler, route] of [ + [PATIENT_SEARCH, "/api/fhir/Patient"], + [OBSERVATION_SEARCH, "/api/fhir/Observation"], + [STATEMENT_SEARCH, "/api/fhir/MedicationStatement"], + [ADMINISTRATION_SEARCH, "/api/fhir/MedicationAdministration"], + [COVERAGE_SEARCH, "/api/fhir/Coverage"], + [EVERYTHING, "/api/fhir/Patient/$everything"], + [METADATA, "/api/fhir/metadata"], + ] as const) { + const res = await fhirCall(handler, { route, auth: token }); + expect(res.status, route).toBe(200); + } + }); + + it("refuses a narrow token with an unrelated scope, and a caller with none", async () => { + const { owner } = await seedOwnerAndStranger(); + const ingest = await makeToken(owner.user, { + permissions: ["measurements:write"], + }); + + for (const [handler, route] of [ + [PATIENT_SEARCH, "/api/fhir/Patient"], + [OBSERVATION_SEARCH, "/api/fhir/Observation"], + [EVERYTHING, "/api/fhir/Patient/$everything"], + [METADATA, "/api/fhir/metadata"], + ] as const) { + const refused = await callRoute(handler, { + method: "GET", + route, + auth: ingest, + }); + expect(refused.status, route).toBe(403); + expect(refused.meta?.errorCode).toBe("auth.scope.insufficient"); + expect(JSON.stringify(refused.body)).not.toContain("79.5"); + + const unauthenticated = await callRoute(handler, { + method: "GET", + route, + auth: anonymous, + }); + expect(unauthenticated.status, route).toBe(401); + } + }); + + it("does not let the fhir:read token reach a route outside the FHIR face", async () => { + const owner = await makeUser(); + const token = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }); + + const res = await callRoute(SESSIONS, { + method: "GET", + route: "/api/auth/me/sessions", + auth: token, + }); + + expect(res.status).toBe(403); + expect(res.meta?.errorCode).toBe("auth.scope.insufficient"); + }); +}); + +describe("FHIR resources carry the caller's stored values and nobody else's", () => { + it("serves the owner's Patient with name, birth date, gender and insurance number", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const { res, bundle } = await search( + PATIENT_SEARCH, + "/api/fhir/Patient", + auth, + ); + + expect(bundle.total).toBe(1); + expect(bundle.entry[0].resource).toMatchObject({ + resourceType: "Patient", + id: "patient-1", + name: [{ text: "Sample Patient" }], + birthDate: "1985-06-15", + gender: "female", + identifier: [expect.objectContaining({ value: "A123456780" })], + }); + for (const marker of STRANGER_MARKERS) { + expect(JSON.stringify(res.body)).not.toContain(marker); + } + + const single = await read( + PATIENT_READ, + "/api/fhir/Patient/{id}", + "patient-1", + auth, + ); + expect(single.status).toBe(200); + expect(single.body).toEqual(bundle.entry[0].resource); + }); + + it("serves the owner's latest weight and blood-pressure panel, and each search entry reads back by id", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const { res, bundle } = await search( + OBSERVATION_SEARCH, + "/api/fhir/Observation", + auth, + ); + + const resources = bundle.entry.map((e) => e.resource); + const weight = resources.find((r) => codesOf(r).includes("29463-7")); + expect(weight).toMatchObject({ + status: "final", + valueQuantity: expect.objectContaining({ value: 79.5, unit: "kg" }), + }); + const panel = resources.find((r) => codesOf(r).includes("85354-9")); + expect(panel).toMatchObject({ + component: expect.arrayContaining([ + expect.objectContaining({ + code: expect.objectContaining({ + coding: [expect.objectContaining({ code: "8480-6" })], + }), + valueQuantity: expect.objectContaining({ value: 128 }), + }), + expect.objectContaining({ + code: expect.objectContaining({ + coding: [expect.objectContaining({ code: "8462-4" })], + }), + valueQuantity: expect.objectContaining({ value: 82 }), + }), + ]), + }); + for (const marker of STRANGER_MARKERS) { + expect(JSON.stringify(res.body)).not.toContain(marker); + } + + for (const entry of bundle.entry) { + expect(entry.fullUrl).toBe( + `http://localhost:3000/api/fhir/Observation/${entry.resource.id}`, + ); + const single = await read( + OBSERVATION_READ, + "/api/fhir/Observation/{id}", + entry.resource.id, + auth, + ); + expect(single.status).toBe(200); + expect(single.body).toEqual(entry.resource); + } + + const missing = await read( + OBSERVATION_READ, + "/api/fhir/Observation/{id}", + "obs-999", + auth, + ); + expect(missing.status).toBe(404); + expect(outcomeSchema.parse(missing.body).issue[0].code).toBe("not-found"); + }); + + it("reads a stored measurement by its series id, and answers not-found for another account's", async () => { + const { owner, stranger } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const own = await read( + OBSERVATION_READ, + "/api/fhir/Observation/{id}", + `m-${owner.weightId}`, + auth, + ); + expect(own.status).toBe(200); + expect(own.body).toMatchObject({ + resourceType: "Observation", + id: `m-${owner.weightId}`, + valueQuantity: expect.objectContaining({ value: 79.5 }), + }); + + const foreign = await read( + OBSERVATION_READ, + "/api/fhir/Observation/{id}", + `m-${stranger.weightId}`, + auth, + ); + expect(foreign.status).toBe(404); + expect(outcomeSchema.parse(foreign.body).issue[0].code).toBe("not-found"); + expect(JSON.stringify(foreign.body)).not.toContain("101.1"); + }); + + it("lists the stored series in a date range, newest first, and only the owner's rows", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + const since = new Date(Date.now() - 7 * DAY_MS).toISOString().slice(0, 10); + + const { res, bundle } = await search( + OBSERVATION_SEARCH, + "/api/fhir/Observation", + auth, + { date: `ge${since}` }, + ); + + // The weight and the paired blood-pressure reading. + expect(bundle.total).toBe(2); + expect(bundle.entry.map((e) => e.resource.id)[1]).toBe( + `m-${owner.weightId}`, + ); + for (const marker of STRANGER_MARKERS) { + expect(JSON.stringify(res.body)).not.toContain(marker); + } + }); + + it("serves the owner's medication statement and taken dose", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const statements = await search( + STATEMENT_SEARCH, + "/api/fhir/MedicationStatement", + auth, + ); + expect(statements.bundle.total).toBe(1); + expect(statements.bundle.entry[0].resource).toMatchObject({ + resourceType: "MedicationStatement", + id: "med-1", + status: "active", + medicationCodeableConcept: { text: "Example Drug" }, + dosage: [{ text: "5mg" }], + }); + + const administrations = await search( + ADMINISTRATION_SEARCH, + "/api/fhir/MedicationAdministration", + auth, + ); + expect(administrations.bundle.total).toBe(1); + expect(administrations.bundle.entry[0].resource).toMatchObject({ + resourceType: "MedicationAdministration", + id: "medadmin-1", + status: "completed", + medicationCodeableConcept: { text: "Example Drug" }, + }); + + for (const res of [statements.res, administrations.res]) { + for (const marker of STRANGER_MARKERS) { + expect(JSON.stringify(res.body)).not.toContain(marker); + } + } + + const statement = await read( + STATEMENT_READ, + "/api/fhir/MedicationStatement/{id}", + "med-1", + auth, + ); + expect(statement.body).toEqual(statements.bundle.entry[0].resource); + const administration = await read( + ADMINISTRATION_READ, + "/api/fhir/MedicationAdministration/{id}", + "medadmin-1", + auth, + ); + expect(administration.body).toEqual( + administrations.bundle.entry[0].resource, + ); + const missing = await read( + STATEMENT_READ, + "/api/fhir/MedicationStatement/{id}", + "med-2", + auth, + ); + expect(missing.status).toBe(404); + }); + + it("serves the owner's Coverage with the insurer and member number", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const { res, bundle } = await search( + COVERAGE_SEARCH, + "/api/fhir/Coverage", + auth, + ); + expect(bundle.total).toBe(1); + expect(bundle.entry[0].resource).toMatchObject({ + resourceType: "Coverage", + id: "coverage-1", + subscriberId: "A123456780", + contained: [ + expect.objectContaining({ + name: "Example Insurer", + identifier: [expect.objectContaining({ value: "101234567" })], + }), + ], + }); + for (const marker of STRANGER_MARKERS) { + expect(JSON.stringify(res.body)).not.toContain(marker); + } + + const single = await read( + COVERAGE_READ, + "/api/fhir/Coverage/{id}", + "coverage-1", + auth, + ); + expect(single.body).toEqual(bundle.entry[0].resource); + }); + + it("serves nothing from a record whose owner never saved a selection", async () => { + const owner = await makeUser({ + fullName: "Unscoped Owner", + insurerName: "Example Insurer", + insuranceNumberEncrypted: encrypt("A123456780"), + }); + await makeMeasurement(owner, { + value: 66.6, + measuredAt: new Date(Date.now() - DAY_MS), + }); + await makeMedication(owner, { name: "Unscoped Drug" }); + const auth = await makeToken(owner, { permissions: [FHIR_READ_SCOPE] }); + + const observations = await search( + OBSERVATION_SEARCH, + "/api/fhir/Observation", + auth, + ); + expect(observations.bundle.total).toBe(0); + const statements = await search( + STATEMENT_SEARCH, + "/api/fhir/MedicationStatement", + auth, + ); + expect(statements.bundle.total).toBe(0); + const coverage = await search(COVERAGE_SEARCH, "/api/fhir/Coverage", auth); + expect(coverage.bundle.total).toBe(0); + const patient = await search(PATIENT_SEARCH, "/api/fhir/Patient", auth); + expect(patient.bundle.entry[0].resource).toEqual({ + resourceType: "Patient", + id: "patient-1", + }); + for (const res of [ + observations.res, + statements.res, + coverage.res, + patient.res, + ]) { + const body = JSON.stringify(res.body); + for (const marker of ["66.6", "Unscoped", "A123456780"]) { + expect(body).not.toContain(marker); + } + } + }); +}); + +describe("FHIR paging", () => { + it("pages a search with _count and _offset and links the next page", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const all = await search(OBSERVATION_SEARCH, "/api/fhir/Observation", auth); + expect(all.bundle.total).toBeGreaterThan(1); + + const first = await search( + OBSERVATION_SEARCH, + "/api/fhir/Observation", + auth, + { _count: 1 }, + ); + expect(first.bundle.total).toBe(all.bundle.total); + expect(first.bundle.entry.map((e) => e.resource.id)).toEqual([ + all.bundle.entry[0].resource.id, + ]); + const next = first.bundle.link.find((l) => l.relation === "next"); + expect(next?.url).toContain("_offset=1"); + + const second = await search( + OBSERVATION_SEARCH, + "/api/fhir/Observation", + auth, + { _count: 1, _offset: 1 }, + ); + expect(second.bundle.entry.map((e) => e.resource.id)).toEqual([ + all.bundle.entry[1].resource.id, + ]); + }); + + it("answers an offset past the ceiling with an invalid OperationOutcome", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const res = await callRoute(OBSERVATION_SEARCH, { + method: "GET", + route: "/api/fhir/Observation", + query: { _offset: "1e20" }, + auth, + contract: { + skip: "the registry declares no 400 on the FHIR searches, though the description names the 400 OperationOutcome", + }, + }); + + expect(res.status).toBe(400); + expect(outcomeSchema.parse(res.body).issue[0].code).toBe("invalid"); + }); + + it("throttles a caller past the hourly FHIR budget with a throttled OperationOutcome", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + await prisma.rateLimit.create({ + data: { + key: `fhir:${owner.user.id}`, + count: 120, + resetAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + + const res = await callRoute(PATIENT_SEARCH, { + method: "GET", + route: "/api/fhir/Patient", + auth, + contract: { + skip: "the registry declares the shared 429 as an application/json error envelope; the FHIR routes answer an application/fhir+json OperationOutcome", + }, + }); + + expect(res.status).toBe(429); + expect(outcomeSchema.parse(res.body).issue[0].code).toBe("throttled"); + expect(JSON.stringify(res.body)).not.toContain("Sample Patient"); + }); +}); + +describe("GET /api/fhir/Patient/$everything", () => { + it("returns every family the document export carries, with references that resolve inside the response", async () => { + const { owner } = await seedOwnerAndStranger(); + const auth = await makeToken(owner.user, { + permissions: [FHIR_READ_SCOPE], + }); + + const { res, bundle } = await search( + EVERYTHING, + "/api/fhir/Patient/$everything", + auth, + { _count: 200 }, + ); + + const context = await loadFhirContext(owner.user.id); + const document = buildFhirDocumentBundle( + context.data, + context.identity, + undefined, + { germanAtc: context.germanAtc }, + context.records, + ); + expect(bundle.total).toBe(document.entry.length); + expect( + bundle.entry.map((e) => `${e.resource.resourceType}/${e.resource.id}`), + ).toEqual( + document.entry.map((e) => `${e.resource.resourceType}/${e.resource.id}`), + ); + + const served = new Set(bundle.entry.map((e) => e.resource.resourceType)); + for (const type of [ + "Composition", + "Device", + "Patient", + "Coverage", + "Observation", + "MedicationStatement", + "MedicationAdministration", + "Condition", + "Encounter", + "AllergyIntolerance", + "FamilyMemberHistory", + "DiagnosticReport", + ]) { + expect(served.has(type), `missing ${type}`).toBe(true); + } + // The cycle Observations share a resourceType with the vitals and would + // hide inside the set check above. + expect( + bundle.entry.some((e) => e.resource.id.startsWith("obs-cycle-")), + ).toBe(true); + const body = JSON.stringify(res.body); + for (const stored of ["Penicillin", "Hypertension", "Example Drug"]) { + expect(body).toContain(stored); + } + for (const marker of STRANGER_MARKERS) { + expect(body).not.toContain(marker); + } + + const urls = new Set(bundle.entry.map((e) => e.fullUrl)); + for (const entry of bundle.entry) { + expect(entry.fullUrl.startsWith("urn:uuid:")).toBe(true); + expect(entry.search.mode).toBe("match"); + const subject = referenceSchema.parse(entry.resource).subject?.reference; + if (subject) expect(urls.has(subject)).toBe(true); + } + }); + + it("serves a browser session that signed in a moment ago", async () => { + const { owner } = await seedOwnerAndStranger(); + + const res = await fhirCall(EVERYTHING, { + route: "/api/fhir/Patient/$everything", + auth: await makeSession(owner.user), + }); + + expect(res.status).toBe(200); + expect(bundleSchema.parse(res.body).total).toBeGreaterThan(0); + }); + + it("asks a browser session that signed in an hour ago to confirm first", async () => { + const { owner } = await seedOwnerAndStranger(); + const session = await makeSession(owner.user); + await prisma.session.update({ + where: { id: session.sessionId }, + data: { createdAt: new Date(Date.now() - 60 * 60 * 1000) }, + }); + + const res = await callRoute(EVERYTHING, { + method: "GET", + route: "/api/fhir/Patient/$everything", + auth: session, + }); + + expect(res.status).toBe(401); + expect(res.meta?.errorCode).toBe("auth.reproof.required"); + expect(JSON.stringify(res.body)).not.toContain("Sample Patient"); + }); +}); From 09f088e8a76b52ca9224a5bc5c67ba12eedc8971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:31:51 +0200 Subject: [PATCH 05/20] fix(auth): record a self sign-out as one in the session revoke audit DELETE /api/auth/me/sessions/{id} takes the session's public handle, an HMAC of the row id, and compared that handle with the caller's row id to decide whether the caller signed out the session it was using. The two never match, so every revoke was audited and annotated with self: false, including the ones where a person ended their own session. The handle is compared with the handle of the caller's session now. --- src/app/api/auth/me/sessions/[id]/route.ts | 12 +++++++++--- .../routes/auth-me-sessions.test.ts | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/app/api/auth/me/sessions/[id]/route.ts b/src/app/api/auth/me/sessions/[id]/route.ts index 4a98aed9d..5b9dfd040 100644 --- a/src/app/api/auth/me/sessions/[id]/route.ts +++ b/src/app/api/auth/me/sessions/[id]/route.ts @@ -11,7 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { destroySessionById } from "@/lib/auth/session"; +import { destroySessionById, sessionHandle } from "@/lib/auth/session"; export const dynamic = "force-dynamic"; @@ -25,15 +25,21 @@ export const DELETE = apiHandler( return apiError("Session not found", 404); } + // The path carries the public handle, never the row id, so "is this the + // caller's own session" compares handles. A Bearer caller's `session.id` + // is a token id, whose handle matches no session, which is the right + // answer for it too. + const self = id === sessionHandle(session.id); + await auditLog("auth.session.revoke", { userId: user.id, ipAddress: getClientIp(request), - details: { self: id === session.id }, + details: { self }, }); annotate({ action: { name: "auth.session.revoke" }, - meta: { self: id === session.id }, + meta: { self }, }); return apiSuccess({ revoked: true }); diff --git a/tests/integration/routes/auth-me-sessions.test.ts b/tests/integration/routes/auth-me-sessions.test.ts index 2113336b7..c04f0ebff 100644 --- a/tests/integration/routes/auth-me-sessions.test.ts +++ b/tests/integration/routes/auth-me-sessions.test.ts @@ -449,11 +449,12 @@ describe("DELETE /api/auth/me/sessions/{id}", () => { ).toBeNull(); expect((await listSessions(laptop)).status).toBe(401); expect((await listSessions(current)).status).toBe(200); - expect( - await prisma.auditLog.count({ - where: { userId: owner.id, action: "auth.session.revoke" }, - }), - ).toBe(1); + const audits = await prisma.auditLog.findMany({ + where: { userId: owner.id, action: "auth.session.revoke" }, + }); + expect(audits.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([ + { self: false }, + ]); }); it("revokes the caller's own session when it names its own handle", async () => { @@ -469,6 +470,13 @@ describe("DELETE /api/auth/me/sessions/{id}", () => { expect(res.status).toBe(200); expect((await listSessions(current)).status).toBe(401); + // The audit row says the person signed out the session they were using. + const audits = await prisma.auditLog.findMany({ + where: { userId: owner.id, action: "auth.session.revoke" }, + }); + expect(audits.map((a) => JSON.parse(a.details ?? "{}"))).toEqual([ + { self: true }, + ]); }); it("answers 404 for another account's session and leaves it signed in", async () => { From c0c0fdb421120bb9060f3b1d1295eefec302847d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:31:59 +0200 Subject: [PATCH 06/20] test(integration): move the account preference route tests onto Postgres Fourteen route tests for the /api/auth/me preference family replaced Prisma with a mock and asserted the calls it received. Their claims are now shown through the real handler on the stored row and the response: validation refusals with their codes, normalisation and defaults on read, optimistic lock conflicts against a real second writer, rate limits driven to their first refusal in the Postgres limiter, audit rows read back, cache entries evicted, and the jobs a change queues observed on a real pg-boss queue. Assertions that were only about the shape of a Prisma call are replaced by the behaviour they stood for, and two that described a state only a mock can reach (a missing column) by the stored value that state resolves to. --- .../auth/me/ai-usage/__tests__/route.test.ts | 206 ----- .../me/coach-prefs/__tests__/route.test.ts | 221 ----- .../auth/me/diabetes/__tests__/route.test.ts | 207 ----- .../me/disable-coach/__tests__/route.test.ts | 270 ------- .../__tests__/unknown-key-strict.test.ts | 120 --- .../__tests__/route.test.ts | 197 ----- .../me/glucose-unit/__tests__/route.test.ts | 247 ------ .../__tests__/route.test.ts | 572 ------------- .../auth/me/modules/__tests__/route.test.ts | 463 ----------- .../__tests__/route.test.ts | 754 ------------------ .../source-priority/__tests__/route.test.ts | 153 ---- .../auth/me/timezone/__tests__/route.test.ts | 146 ---- .../unit-preference/__tests__/route.test.ts | 234 ------ .../me/workout-routes/__tests__/route.test.ts | 103 --- .../routes/auth-me-ai-usage.test.ts | 236 ++++++ .../routes/auth-me-coach-prefs.test.ts | 244 ++++++ .../routes/auth-me-diabetes.test.ts | 175 ++++ .../routes/auth-me-disable-coach.test.ts | 276 +++++++ .../auth-me-documents-auto-ai-read.test.ts | 354 ++++++++ .../routes/auth-me-glucose-unit.test.ts | 274 +++++++ .../auth-me-health-score-config.test.ts | 483 +++++++++++ .../routes/auth-me-modules.test.ts | 374 +++++++++ .../routes/auth-me-notification-prefs.test.ts | 405 ++++++++++ .../routes/auth-me-source-priority.test.ts | 152 ++++ .../routes/auth-me-timezone.test.ts | 165 ++++ .../routes/auth-me-unit-preference.test.ts | 290 +++++++ .../routes/auth-me-workout-routes.test.ts | 97 +++ 27 files changed, 3525 insertions(+), 3893 deletions(-) delete mode 100644 src/app/api/auth/me/ai-usage/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/coach-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/diabetes/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/disable-coach/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts delete mode 100644 src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/glucose-unit/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/health-score-config/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/modules/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/notification-prefs/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/source-priority/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/timezone/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/unit-preference/__tests__/route.test.ts delete mode 100644 src/app/api/auth/me/workout-routes/__tests__/route.test.ts create mode 100644 tests/integration/routes/auth-me-ai-usage.test.ts create mode 100644 tests/integration/routes/auth-me-coach-prefs.test.ts create mode 100644 tests/integration/routes/auth-me-diabetes.test.ts create mode 100644 tests/integration/routes/auth-me-disable-coach.test.ts create mode 100644 tests/integration/routes/auth-me-documents-auto-ai-read.test.ts create mode 100644 tests/integration/routes/auth-me-glucose-unit.test.ts create mode 100644 tests/integration/routes/auth-me-health-score-config.test.ts create mode 100644 tests/integration/routes/auth-me-modules.test.ts create mode 100644 tests/integration/routes/auth-me-notification-prefs.test.ts create mode 100644 tests/integration/routes/auth-me-source-priority.test.ts create mode 100644 tests/integration/routes/auth-me-timezone.test.ts create mode 100644 tests/integration/routes/auth-me-unit-preference.test.ts create mode 100644 tests/integration/routes/auth-me-workout-routes.test.ts diff --git a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts b/src/app/api/auth/me/ai-usage/__tests__/route.test.ts deleted file mode 100644 index 71eb1b3af..000000000 --- a/src/app/api/auth/me/ai-usage/__tests__/route.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the - * operator's ceiling. A level above it is refused with 422 - * `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null` - * returns to the instance default; the answer is the resolved `ai.usage`. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const db = vi.hoisted(() => ({ - stored: null as string | null, - settings: { - aiUsageMaxLevel: "STANDARD", - aiUsageDefaultLevel: "STANDARD", - aiSyncRegenGapHours: null, - } as Record, - usage: [] as Array<{ totalTokens: number }>, -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(async () => ({ - aiUsageLevel: db.stored, - timezone: "UTC", - })), - update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => { - db.stored = args.data.aiUsageLevel; - return {}; - }), - }, - appSettings: { findUnique: vi.fn(async () => db.settings) }, - coachUsage: { findMany: vi.fn(async () => db.usage) }, - }, -})); -vi.mock("@/lib/tz/resolver", () => ({ - resolveUserTimezone: vi.fn(async () => "UTC"), -})); -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(), - rateLimitHeaders: () => ({}), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { PUT } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -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 put = PUT as unknown as (req: Request) => Promise; - -function req(body: unknown): Request { - return new Request("http://localhost/api/auth/me/ai-usage", { - method: "PUT", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - db.stored = null; - db.settings = { - aiUsageMaxLevel: "STANDARD", - aiUsageDefaultLevel: "STANDARD", - aiSyncRegenGapHours: null, - }; - db.usage = []; - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - remaining: 29, - resetAt: Date.now() + 3_600_000, - } as never); -}); - -describe("PUT /api/auth/me/ai-usage", () => { - it("stores a level within the ceiling and answers the resolved state", async () => { - db.usage = [ - { totalTokens: 90_000 }, - { totalTokens: 110_000 }, - { totalTokens: 100_000 }, - ]; - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(200); - expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({ - where: { id: "user-1" }, - data: { aiUsageLevel: "FRUGAL" }, - }); - const { data } = await res.json(); - expect(data).toMatchObject({ - level: "frugal", - preference: "frugal", - defaultLevel: "standard", - maxLevel: "standard", - source: "user", - measuredTokensPerDay: 100_000, - measuredDays: 3, - }); - expect(data.options).toEqual([ - { level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 }, - { level: "standard", allowed: true, estimatedTokensPerDay: 200_000 }, - { level: "full", allowed: false, estimatedTokensPerDay: 666_667 }, - ]); - expect(auditLog).toHaveBeenCalledWith( - "user.aiUsage.update", - expect.objectContaining({ - userId: "user-1", - details: { previous: null, next: "frugal" }, - }), - ); - }); - - it("refuses a level above the ceiling with the ceiling named", async () => { - const res = await put(req({ level: "full" })); - expect(res.status).toBe(422); - const body = await res.json(); - expect(body.meta).toMatchObject({ - errorCode: "ai.usage.aboveCeiling", - maxLevel: "standard", - }); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("accepts full once the operator allows it", async () => { - db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" }; - const res = await put(req({ level: "full" })); - expect(res.status).toBe(200); - expect((await res.json()).data.level).toBe("full"); - }); - - it("returns to the instance default on null", async () => { - db.stored = "FRUGAL"; - const res = await put(req({ level: null })); - expect(res.status).toBe(200); - expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({ - aiUsageLevel: null, - }); - const { data } = await res.json(); - expect(data).toMatchObject({ - level: "standard", - preference: null, - source: "default", - }); - // Fewer than three days of history: no estimate. - expect( - data.options.every( - (o: { estimatedTokensPerDay: number | null }) => - o.estimatedTokensPerDay === null, - ), - ).toBe(true); - }); - - it.each([ - [{ level: "lavish" }], - [{}], - [{ level: "frugal", extra: true }], - [{ level: 1 }], - ])("refuses %j as invalid", async (body) => { - const res = await put(req(body)); - expect(res.status).toBe(422); - expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid"); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("refuses the 31st change in an hour", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: false, - remaining: 0, - resetAt: Date.now() + 60_000, - } as never); - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(429); - expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited"); - expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([ - 30, - 60 * 60 * 1000, - ]); - }); - - it("refuses a caller without a session", async () => { - vi.mocked(getSession).mockResolvedValue(null); - const res = await put(req({ level: "frugal" })); - expect(res.status).toBe(401); - }); -}); diff --git a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts b/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts deleted file mode 100644 index e39d0ab96..000000000 --- a/src/app/api/auth/me/coach-prefs/__tests__/route.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * v1.32.22 — `PUT /api/auth/me/coach-prefs` optimistic concurrency. - * - * A full-replace endpoint hydrated from a stale GET (two tabs / two devices) - * clobbers silently; the base token 409s that. The guarded write covers BOTH - * `coachPrefsJson` AND the mirrored `insightsExcludeMetrics` column in one - * conditional update, so a 409 leaves both untouched together. A tokenless PUT - * keeps the prior unconditional write. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: vi.fn(), - updateMany: 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("@/lib/logging/context", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, annotate: vi.fn() }; -}); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { GET, PUT } 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 mkPut(body: unknown): Request { - return new Request("http://localhost/api/auth/me/coach-prefs", { - method: "PUT", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); -}); - -describe("GET /api/auth/me/coach-prefs", () => { - it("returns a SAVED user's prefs plus the optimistic-concurrency token", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - coachPrefsJson: { tone: "concise" }, - updatedAt: new Date("2026-07-24T10:00:00.000Z"), - } as never); - - const res = await (GET as () => Promise)(); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { tone: string; updatedAt?: string }; - }; - expect(env.data.tone).toBe("concise"); - expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z"); - }); - - it("returns the PURE default with NO token for a never-saved user", async () => { - // Mirrors the insights-layout never-saved GET: nothing to guard yet, and - // the first save is the tokenless unconditional write (backward-compat). - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - coachPrefsJson: null, - updatedAt: new Date("2026-07-24T10:00:00.000Z"), - } as never); - - const res = await (GET as () => Promise)(); - expect(res.status).toBe(200); - const env = (await res.json()) as { - data: { tone: string; updatedAt?: string }; - }; - expect(env.data.tone).toBe("warm"); - expect(env.data.updatedAt).toBeUndefined(); - }); -}); - -describe("PUT /api/auth/me/coach-prefs — optimistic concurrency", () => { - it("guards both columns in one conditional write and echoes the token", async () => { - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - updatedAt: new Date("2026-07-24T10:05:00.000Z"), - } as never); - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ - excludeMetrics: ["weight"], - baseUpdatedAt: "2026-07-24T10:00:00.000Z", - }), - ); - expect(res.status).toBe(200); - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0] as { - where: { id: string; updatedAt: Date }; - data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] }; - }; - expect((call.where.updatedAt as Date).toISOString()).toBe( - "2026-07-24T10:00:00.000Z", - ); - // Both mirror columns ride the SAME guarded update. - expect(call.data.coachPrefsJson).toBeTruthy(); - expect(call.data.insightsExcludeMetrics).toEqual(["weight"]); - expect(prisma.user.update).not.toHaveBeenCalled(); - const env = (await res.json()) as { data: { updatedAt: string } }; - expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z"); - }); - - it("interleaved writers: a stale token 409s and leaves BOTH columns untouched", async () => { - vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ - excludeMetrics: ["weight"], - baseUpdatedAt: "2026-07-24T08:00:00.000Z", - }), - ); - expect(res.status).toBe(409); - const env = (await res.json()) as { meta?: { errorCode?: string } }; - expect(env.meta?.errorCode).toBe("coach_prefs_conflict"); - // Nothing written — the single guarded update matched no row. - expect(prisma.user.updateMany).toHaveBeenCalledTimes(1); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("keeps the unconditional write when the token is omitted (compat)", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-07-24T11:00:00.000Z"), - } as never); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ excludeMetrics: ["weight"] }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { - data: { coachPrefsJson: unknown; insightsExcludeMetrics: string[] }; - }; - expect(call.data.insightsExcludeMetrics).toEqual(["weight"]); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); - - it("422s a malformed base token without touching the row", async () => { - const res = await (PUT as (r: Request) => Promise)( - mkPut({ excludeMetrics: ["weight"], baseUpdatedAt: "not-a-date" }), - ); - expect(res.status).toBe(422); - const env = (await res.json()) as { meta?: { errorCode?: string } }; - expect(env.meta?.errorCode).toBe("invalid_base_updated_at"); - expect(prisma.user.update).not.toHaveBeenCalled(); - expect(prisma.user.updateMany).not.toHaveBeenCalled(); - }); -}); - -describe("PUT /api/auth/me/coach-prefs — thinking depth (v1.41)", () => { - it("stores the chosen level and names it on the wide event", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-10-06T11:00:00.000Z"), - } as never); - const { annotate } = await import("@/lib/logging/context"); - - const res = await (PUT as (r: Request) => Promise)( - mkPut({ reasoning: "high" }), - ); - expect(res.status).toBe(200); - const call = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as { - data: { coachPrefsJson: { reasoning?: string } }; - }; - expect(call.data.coachPrefsJson.reasoning).toBe("high"); - const env = (await res.json()) as { data: { reasoning?: string } }; - expect(env.data.reasoning).toBe("high"); - const meta = vi - .mocked(annotate) - .mock.calls.map((c) => c[0]) - .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; - expect(meta).toMatchObject({ reasoning: "high" }); - }); - - it("reads an absent level as the default on the wide event", async () => { - vi.mocked(prisma.user.update).mockResolvedValue({ - updatedAt: new Date("2026-10-06T11:00:00.000Z"), - } as never); - const { annotate } = await import("@/lib/logging/context"); - - await (PUT as (r: Request) => Promise)(mkPut({ tone: "warm" })); - const meta = vi - .mocked(annotate) - .mock.calls.map((c) => c[0]) - .find((a) => a.action?.name === "auth.me.coach-prefs.put")?.meta; - expect(meta).toMatchObject({ reasoning: "medium" }); - }); - - it.each(["xhigh", "none", 2, null])("422s %s as a level", async (value) => { - const res = await (PUT as (r: Request) => Promise)( - mkPut({ reasoning: value }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/diabetes/__tests__/route.test.ts b/src/app/api/auth/me/diabetes/__tests__/route.test.ts deleted file mode 100644 index 0cee31013..000000000 --- a/src/app/api/auth/me/diabetes/__tests__/route.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * The declared diabetes flag is the one preference that switches every - * glucose surface from the general normal bands to the ADA glycemic goal - * bands and reaches the Coach snapshot. The web toggle in the thresholds - * section rides this route, so the round trip has to hold: a PATCH writes - * the column and echoes the resolved state, and a GET reads it back. - */ - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: 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, - limit: 60, - 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 { GET, PATCH } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -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/auth/me/diabetes"); -} - -function mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/diabetes", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/diabetes", () => { - 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); - expect(prisma.user.findUnique).not.toHaveBeenCalled(); - }); - - it("returns false for a user who never declared the flag", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: false, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(false); - }); - - it("returns true after the flag was declared", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: true, - } as never); - - const res = await (GET as (r: Request) => Promise)(mkGet()); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(true); - }); -}); - -describe("PATCH /api/auth/me/diabetes", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("writes the flag on, echoes it back, and audits the transition", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: false, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(true); - - // The write must actually land, scoped to the caller, field-by-field. - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { hasDiabetes: true }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.diabetes.update", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ previous: false, next: true }), - }), - ); - }); - - it("writes the flag off and echoes the resolved state", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - hasDiabetes: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: false }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { hasDiabetes: boolean } }; - expect(env.data.hasDiabetes).toBe(false); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { hasDiabetes: false }, - }); - }); - - it("rejects a missing hasDiabetes field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ otherField: true }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("rejects a non-boolean hasDiabetes field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: "true" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("returns 429 when the per-user rate-limit fires", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 30_000, - }); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ hasDiabetes: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts deleted file mode 100644 index 0726fb835..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: vi.fn(), - update: 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, - limit: 60, - 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 { GET, PATCH } from "../route"; -import { prisma } from "@/lib/db"; -import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; -import { checkRateLimit } from "@/lib/rate-limit"; - -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 mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe("GET /api/auth/me/disable-coach", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(401); - expect(prisma.user.findUnique).not.toHaveBeenCalled(); - }); - - it("returns false for a fresh user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: false, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - }); - - it("returns true for an opted-out user", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(true); - }); - - it("returns false when the column is missing (partial-deploy rollback)", async () => { - // Defensive default — the API surface tolerates a `findUnique` - // result that doesn't contain the column. Belt-and-braces with the - // schema-level NOT NULL default; both layers keep the Coach - // visible by default for users who never opted out. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never); - - const res = await (GET as (r: Request) => Promise)( - new Request("http://localhost/api/auth/me/disable-coach"), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - }); -}); - -describe("PATCH /api/auth/me/disable-coach", () => { - it("rejects an unauthenticated request with 401", async () => { - vi.mocked(getSession).mockResolvedValue(null); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(401); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("flips the column on and writes the enable audit row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: false, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(true); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { disableCoach: true }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.disable-coach.enable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - previous: false, - next: true, - }), - }), - ); - }); - - it("flips the column off and writes the disable audit row", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: false }), - ); - expect(res.status).toBe(200); - const env = (await res.json()) as { data: { disableCoach: boolean } }; - expect(env.data.disableCoach).toBe(false); - - expect(prisma.user.update).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { disableCoach: false }, - }); - - expect(auditLog).toHaveBeenCalledWith( - "user.disable-coach.disable", - expect.objectContaining({ - userId: "user-1", - details: expect.objectContaining({ - previous: true, - next: false, - }), - }), - ); - }); - - it("rejects a missing disableCoach field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ otherField: "ignored" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("rejects a non-boolean disableCoach field with 422", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: "true" }), - ); - expect(res.status).toBe(422); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - // 400, not the 422 this used to pin. A body that will not parse is a - // client-side serialisation fault, and `safeJson` plus roughly two hundred - // and twenty other routes have always answered 400 for it; the dotted token - // moved to `meta.errorCode`, which is where a machine code belongs. - it("rejects malformed JSON with 400", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - - const req = new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: "{ not valid json", - headers: { "Content-Type": "application/json" }, - }); - - const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(400); - expect(await res.json()).toMatchObject({ - error: "Invalid JSON body", - meta: { errorCode: "disable-coach.body.invalid_json" }, - }); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("is idempotent when the column already holds the requested value", async () => { - // Toggling a column from true→true (or false→false) still writes - // through so the audit row exists. Matches the doctor-report-prefs - // route's "always write" posture. - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.findUnique).mockResolvedValue({ - disableCoach: true, - } as never); - vi.mocked(prisma.user.update).mockResolvedValue({} as never); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - expect(auditLog).toHaveBeenCalledTimes(1); - }); - - it("returns 429 when the per-user rate-limit fires", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(checkRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 30_000, - }); - - const res = await (PATCH as (r: Request) => Promise)( - mkPatch({ disableCoach: true }), - ); - expect(res.status).toBe(429); - expect(prisma.user.update).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts b/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts deleted file mode 100644 index 037b72c43..000000000 --- a/src/app/api/auth/me/disable-coach/__tests__/unknown-key-strict.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * The strict half, end to end on a real route. - * - * `patchBodySchema` is `.strict()`, so a PATCH carrying a key the route - * does not declare is refused rather than trimmed down to the keys it - * does. Before that, `{ disableCoachh: true }` parsed clean as `{}` — - * except `disableCoach` is required, so this particular body already - * 422'd on the missing field. The one worth pinning is the body that - * used to succeed while doing something other than what it said: - * `{ disableCoach: true, disableCoachh: false }`. - * - * The second assertion is the user-visible half. A 422 that says only - * "Validation failed" leaves the caller staring at a body it believes is - * correct, so the envelope has to name the key it refused — and name it - * without reflecting a hostile one back verbatim. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn(), update: 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: 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 { PATCH } 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 mkPatch(body: unknown): Request { - return new Request("http://localhost/api/auth/me/disable-coach", { - method: "PATCH", - body: JSON.stringify(body), - headers: { "Content-Type": "application/json" }, - }); -} - -const patch = PATCH as (r: Request) => Promise; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(prisma.user.update).mockResolvedValue({ - disableCoach: true, - } as never); -}); - -describe("PATCH /api/auth/me/disable-coach — unknown keys", () => { - it("accepts the declared body", async () => { - const res = await patch(mkPatch({ disableCoach: true })); - expect(res.status).toBe(200); - expect(prisma.user.update).toHaveBeenCalledTimes(1); - }); - - it("refuses a body that carries an undeclared key alongside a valid one", async () => { - const res = await patch( - mkPatch({ disableCoach: true, disableCoachh: false }), - ); - expect(res.status).toBe(422); - // Nothing was written. Before `.strict()` this wrote `disableCoach: - // true` and answered 200, so a caller that meant the second field - // was told its request had been honoured. - expect(prisma.user.update).not.toHaveBeenCalled(); - }); - - it("names the offending key in the 422", async () => { - const res = await patch( - mkPatch({ disableCoach: true, disableCoachh: false }), - ); - const body = (await res.json()) as { - error: string; - details?: { - issues: Array<{ code: string; message: string; keys?: string[] }>; - }; - }; - const issue = body.details?.issues.find( - (i) => i.code === "unrecognized_keys", - ); - expect(issue).toBeDefined(); - expect(issue?.keys).toEqual(["disableCoachh"]); - expect(issue?.message).toContain("disableCoachh"); - }); - - it("does not reflect a hostile key name back verbatim", async () => { - const res = await patch( - mkPatch({ disableCoach: true, "": 1 }), - ); - expect(res.status).toBe(422); - const raw = await res.text(); - expect(raw).not.toContain("": 1 }, + auth: await makeSession(user), + }); + expect(res.status).toBe(422); + const raw = JSON.stringify(res.body); + expect(raw).not.toContain("