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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 05:31:09 +02:00
1 parent b179e34f58
commit f353f55212
12 files changed
+2096 -1387

No files matched your search

@@ -507,9 +507,9 @@ const REQUIRED_TREE_MODULE: Readonly<Record<string, ModuleKey>> = {
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",
@@ -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,
});
});
});
@@ -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<string, string> = {};
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,
});
});
});
@@ -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();
});
});
@@ -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<typeof import("@/lib/auth/session")>();
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);
});
});
@@ -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<typeof import("@/lib/fhir/rest")>();
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);
}
});
});
@@ -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<typeof import("@/lib/fhir/rest")>();
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<Response>]
> = [
["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<Response>]
> = [
["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");
});
});
@@ -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<User, "id">,
overrides: Partial<Omit<Prisma.DeviceUncheckedCreateInput, "userId">> = {},
) {
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<User, "id">,
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<RevokeResult>(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);
});
});
@@ -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<User, "id">,
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" })] },
});
});
});
@@ -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<typeof import("@/lib/safe-fetch")>();
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<User, "id">,
details: { userAgent: string; ipAddress: string; lastActiveAt: Date | null },
): Promise<SessionFixture> {
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<User, "id">,
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<User, "id">) {
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<SignOutResult>(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<SignOutResult>(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<SignOutResult>(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<SignOutResult>(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();
});
});
@@ -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<typeof callRoute>[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" });
});
});
+896
View File
@@ -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<typeof resourceSchema>;
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<SeededRecord> {
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<typeof callRoute>[0],
options: Omit<CallRouteOptions, "method">,
) {
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<typeof callRoute>[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<typeof callRoute>[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");
});
});