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] 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"); + }); +});