mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user