mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A bridge that pushes workouts from a watch vendor had no credential that reached POST /api/workouts/batch: the route called requireAuth() with no scope, so under the fail-closed default only a cookie session or a wildcard token got through, and a measurements:write token was refused 403. The route's header still described the pre-fail-closed behaviour. Add a separate workouts:write scope rather than widening measurements:write, so a token already handed to a scale does not start accepting workouts. - POST /api/tokens/workouts mints it, mirroring the measurement and document mints: cookie session plus a fresh proof, a literal permission array, 10 mints a minute, at most 10 live tokens (409 tokens.workouts.ceiling_reached), a one-year default lifetime, audited. - POST /api/workouts/batch names the scope. Sessions and wildcard tokens behave as before. A scoped caller's workouts are stored as EXTERNAL, resolved from the credential, and a batch in which any entry names a source is refused 422 (workout.batch.source_not_permitted), the same rule the measurement batch applies. EXTERNAL is on no source ladder, so when the phone recorded the same session the phone's copy is shown. - Settings > API offers a workout token card next to the measurement one, and the endpoint list shows the batch route. - Guards: the mint site, the declared scope vocabulary, the recent-proof routes, and a new check that only the batch declares the scope. - OpenAPI documents the mint, the batch's narrow-token behaviour and both error codes; docs/api/README.md lists the workout and document mints. Refs #1054
1124 lines
41 KiB
TypeScript
1124 lines
41 KiB
TypeScript
/**
|
|
* Behavioural suite for the fail-closed Bearer-scope default, against a real
|
|
* Postgres and the real resolver — no mocked Prisma, no mocked `requireAuth`.
|
|
*
|
|
* The gap this pins: `requireAuth()` enforced a token's scope only when the
|
|
* route passed one, and 324 of 330 route files pass none. A token minted for
|
|
* medication intake therefore reached the full-backup export, the labs surface,
|
|
* the coach, and the bulk deletes. B1 is the direct regression test — it fails
|
|
* on the pre-fix tree.
|
|
*
|
|
* Seven cases against 300-odd routes prove nothing about the other routes by
|
|
* enumeration. The guarantee for those is structural (one resolution path, one
|
|
* authorisation arm) and is held by
|
|
* `src/__tests__/bearer-scope-enforcement-guard.test.ts`. What these cases do
|
|
* is prove the arm itself behaves, end to end, for every credential shape the
|
|
* app actually mints.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
process.env.API_TOKEN_HMAC_KEY ??=
|
|
"test-hmac-key-bearer-scope-enforcement-32-bytes-min-0987654321";
|
|
|
|
const { hashToken } = await import("@/lib/auth/hmac");
|
|
// The canonical name rather than a literal, so a rename cannot leave these
|
|
// cases setting a header nothing reads — which is the exact failure the
|
|
// delegation cases below already shipped once.
|
|
const { ACCOUNT_SELECTOR_HEADER } = await import("@/lib/auth/acting-carrier");
|
|
|
|
const USER_ID = "user-bearer-scope-test";
|
|
const MCP_RESOURCE = "https://health.example/mcp";
|
|
process.env.APP_URL = "https://health.example";
|
|
|
|
vi.mock("next/headers", async () => {
|
|
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
|
return {
|
|
headers: vi.fn(async () => ({
|
|
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
|
})),
|
|
cookies: vi.fn(async () => ({
|
|
get: (name: string) => {
|
|
const value = cookieJar.get(name);
|
|
return value ? { name, value } : undefined;
|
|
},
|
|
set: (name: string, value: string) => {
|
|
cookieJar.set(name, value);
|
|
},
|
|
delete: (name: string) => {
|
|
cookieJar.delete(name);
|
|
},
|
|
})),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "bearer-scope",
|
|
email: "bearer-scope@example.test",
|
|
timezone: "UTC",
|
|
modulePreferencesJson: { mcp: true },
|
|
},
|
|
});
|
|
});
|
|
|
|
/** Mint a real `ApiToken` row with the given scopes and arm the Bearer header. */
|
|
async function armToken(permissions: string[], label = "t"): Promise<string> {
|
|
const raw = `hlk_${label}_${"0".repeat(48)}`;
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
name: label,
|
|
tokenHash: hashToken(raw),
|
|
permissions,
|
|
},
|
|
});
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
return raw;
|
|
}
|
|
|
|
async function armExpiringToken(
|
|
permissions: string[],
|
|
label: string,
|
|
state: { expiresAt?: Date; revoked?: boolean } = {},
|
|
): Promise<string> {
|
|
const raw = await armToken(permissions, label);
|
|
await getPrismaClient().apiToken.update({
|
|
where: { tokenHash: hashToken(raw) },
|
|
data: state,
|
|
});
|
|
return raw;
|
|
}
|
|
|
|
function mcpReq(
|
|
rawToken: string,
|
|
method: "initialize" | "tools/list" | "tools/call",
|
|
params?: Record<string, unknown>,
|
|
): Request {
|
|
return new Request(MCP_RESOURCE, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${rawToken}`,
|
|
"content-type": "application/json",
|
|
accept: "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method,
|
|
...(params ? { params } : {}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
/** Arm a real cookie session instead of a Bearer token. */
|
|
async function useCookieSession(): Promise<void> {
|
|
const session = await getPrismaClient().session.create({
|
|
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
}
|
|
|
|
function req(path: string, method = "GET"): NextRequest {
|
|
const init: RequestInit = { method };
|
|
const raw = headerJar.get("authorization");
|
|
if (raw) init.headers = { authorization: raw };
|
|
return new NextRequest(`https://health.example${path}`, init as never);
|
|
}
|
|
|
|
/** The most recent `auth.bearer.failure` audit row's machine reason. */
|
|
async function lastBearerFailureReason(): Promise<string | undefined> {
|
|
const row = await getPrismaClient().auditLog.findFirst({
|
|
where: { action: "auth.bearer.failure" },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
if (!row?.details) return undefined;
|
|
return (JSON.parse(row.details) as { reason?: string }).reason;
|
|
}
|
|
|
|
describe("B1 — a narrow token cannot read the full-account export", () => {
|
|
it("refuses ['medication:ingest'] on GET /api/export/full-backup with 403", async () => {
|
|
// The vulnerability in one request: this is the token the settings card
|
|
// told users to hand to third-party automations, reading every row the
|
|
// account holds.
|
|
await armToken(["medication:ingest"], "narrow");
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
|
|
const res = await GET(req("/api/export/full-backup"));
|
|
|
|
expect(res.status).toBe(403);
|
|
const json = (await res.json()) as { data: null; error: string };
|
|
expect(json.data).toBeNull();
|
|
expect(json.error).toBe("Insufficient permissions");
|
|
|
|
// The break is visible AND attributable: an operator can name the token.
|
|
// The audit write is fire-and-forget — poll for the value instead of
|
|
// sleeping a fixed span and hoping the runner was fast enough; on
|
|
// timeout the poll asserts against what WAS written.
|
|
await expect
|
|
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
|
|
.toBe("undeclared_scope");
|
|
});
|
|
|
|
it("refuses the same token on GET /api/labs with 403", async () => {
|
|
await armToken(["medication:ingest"], "narrow2");
|
|
const { GET } = await import("@/app/api/labs/route");
|
|
const res = await GET(req("/api/labs"));
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("B2/B3 — the medication-ingest surface is unchanged", () => {
|
|
async function seedMedication(): Promise<string> {
|
|
const med = await getPrismaClient().medication.create({
|
|
data: { userId: USER_ID, name: "Test Med", dose: "1 mg" },
|
|
});
|
|
return med.id;
|
|
}
|
|
|
|
it("B2 — the per-medication token still ingests", async () => {
|
|
// The credential that actually performs medication intake. It never
|
|
// touches `requireAuth` — `/api/ingest/medication` hand-rolls the whole
|
|
// resolution — so the fail-closed default cannot reach it. This is the
|
|
// one flow a fail-closed default could plausibly have broken.
|
|
const medId = await seedMedication();
|
|
await armToken(["medication:ingest", `medication:${medId}:ingest`], "pair");
|
|
const { POST } = await import("@/app/api/ingest/medication/route");
|
|
|
|
const request = new NextRequest(
|
|
"https://health.example/api/ingest/medication",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
medicationName: "Test Med",
|
|
idempotencyKey: "bearer-scope-b2",
|
|
}),
|
|
} as never,
|
|
);
|
|
const res = await POST(request);
|
|
|
|
expect([200, 201]).toContain(res.status);
|
|
const events = await getPrismaClient().medicationIntakeEvent.count({
|
|
where: { userId: USER_ID },
|
|
});
|
|
expect(events).toBe(1);
|
|
});
|
|
|
|
it("B3 — a token without the per-medication grant is still refused", async () => {
|
|
// The pre-existing second gate. This is why the retired `/api/tokens`
|
|
// mint never worked for its advertised purpose.
|
|
await seedMedication();
|
|
await armToken(["medication:ingest"], "familyonly");
|
|
const { POST } = await import("@/app/api/ingest/medication/route");
|
|
|
|
const request = new NextRequest(
|
|
"https://health.example/api/ingest/medication",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
medicationName: "Test Med",
|
|
idempotencyKey: "bearer-scope-b3",
|
|
}),
|
|
} as never,
|
|
);
|
|
const res = await POST(request);
|
|
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("B4 — the native client is not broken", () => {
|
|
it("admits a ['*'] token on POST /api/export/encrypted", async () => {
|
|
// Login, passkey login-verify and refresh rotation all mint `["*"]`, so
|
|
// this is exactly the credential the iOS app holds, on the whole-record
|
|
// export the app calls. If this case ever goes red, the app's backup is
|
|
// down.
|
|
await armToken(["*"], "wildcard");
|
|
const { POST } = await import("@/app/api/export/encrypted/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/export/encrypted", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ passphrase: "a long enough passphrase" }),
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
await res.arrayBuffer();
|
|
});
|
|
|
|
it("passes a ['*'] token through the scope check on GET /api/export/full-backup, to the elevation it needs", async () => {
|
|
// The plaintext full backup asks a Bearer caller for a step-up
|
|
// elevation. No shipped client calls it on Bearer. What this pins is that
|
|
// the refusal is the proof gate's 401, not the scope layer's 403.
|
|
await armToken(["*"], "wildcard");
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
const res = await GET(req("/api/export/full-backup"));
|
|
expect(res.status).toBe(401);
|
|
expect((await res.json()).meta.errorCode).toBe("auth.stepup.required");
|
|
});
|
|
|
|
it("admits a ['*'] token on a batch ingest route", async () => {
|
|
await armToken(["*"], "wildcardbatch");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const request = new NextRequest(
|
|
"https://health.example/api/measurements/batch",
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 70,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-bearer-scope-1",
|
|
},
|
|
],
|
|
}),
|
|
} as never,
|
|
);
|
|
const res = await POST(request);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("B5 — an MCP token is audience-bound to /mcp", () => {
|
|
it("refuses ['health:read'] on a REST read", async () => {
|
|
// Narrowing, not breakage: the token's REST read leg was never a feature
|
|
// an MCP client used. Its audience is now /mcp alone.
|
|
await armToken(["health:read"], "mcpread");
|
|
const { GET } = await import("@/app/api/export/full-backup/route");
|
|
const res = await GET(req("/api/export/full-backup"));
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("still resolves the same token on the /mcp wire", async () => {
|
|
const raw = await armToken(["health:read"], "mcpread2");
|
|
const { resolveMcpAuthContext } = await import("@/lib/mcp/auth");
|
|
|
|
const ctx = await resolveMcpAuthContext(raw);
|
|
|
|
expect(ctx.userId).toBe(USER_ID);
|
|
expect(ctx.canRead).toBe(true);
|
|
// Read-only token: the write tools stay shut.
|
|
expect(ctx.canWrite).toBe(false);
|
|
});
|
|
|
|
it("grants write on /mcp only for a consented health:write token", async () => {
|
|
const raw = await armToken(["health:read", "health:write"], "mcprw");
|
|
const { resolveMcpAuthContext } = await import("@/lib/mcp/auth");
|
|
const ctx = await resolveMcpAuthContext(raw);
|
|
expect(ctx.canWrite).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("B5b — MCP health reads require an explicit read grant", () => {
|
|
async function postMcp(
|
|
rawToken: string,
|
|
method: "initialize" | "tools/list" | "tools/call",
|
|
params?: Record<string, unknown>,
|
|
): Promise<Response> {
|
|
const { POST } = await import("@/app/mcp/route");
|
|
return POST(mcpReq(rawToken, method, params));
|
|
}
|
|
|
|
it.each([
|
|
["health:read", ["health:read"]],
|
|
["wildcard", ["*"]],
|
|
])("admits %s through capability registration", async (_label, scopes) => {
|
|
const raw = await armToken(scopes, `mcp-positive-${_label}`);
|
|
|
|
const res = await postMcp(raw, "tools/list");
|
|
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
result: { tools: Array<{ name: string }> };
|
|
};
|
|
expect(body.result.tools.map((tool) => tool.name)).toContain("get_labs");
|
|
});
|
|
|
|
it.each([
|
|
["medication ingest", ["medication:ingest"]],
|
|
["notifications", ["notifications:send"]],
|
|
["FHIR", ["fhir:read"]],
|
|
["write-only", ["health:write"]],
|
|
["unrelated", ["profile:read"]],
|
|
])(
|
|
"denies a same-user %s token before a real lab read",
|
|
async (_label, scopes) => {
|
|
const raw = await armToken(scopes, `mcp-negative-${_label}`);
|
|
await getPrismaClient().labResult.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
analyte: "LDL",
|
|
value: 177,
|
|
unit: "mg/dL",
|
|
takenAt: new Date("2026-07-20T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const res = await postMcp(raw, "tools/call", {
|
|
name: "get_labs",
|
|
arguments: {},
|
|
});
|
|
|
|
expect([401, 403]).toContain(res.status);
|
|
expect(await res.text()).not.toContain("177");
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["expired", { expiresAt: new Date("2020-01-01T00:00:00.000Z") }],
|
|
["revoked", { revoked: true }],
|
|
])(
|
|
"denies an explicit but %s health:read credential",
|
|
async (_label, state) => {
|
|
const raw = await armExpiringToken(
|
|
["health:read"],
|
|
`mcp-${_label}`,
|
|
state,
|
|
);
|
|
|
|
const res = await postMcp(raw, "tools/list");
|
|
|
|
expect([401, 403]).toContain(res.status);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("B6 — a declared scope grants only what it names", () => {
|
|
it("admits ['fhir:read'] on the FHIR face", async () => {
|
|
await armToken(["fhir:read"], "fhir");
|
|
const { GET } = await import("@/app/api/fhir/Observation/route");
|
|
const res = await GET(req("/api/fhir/Observation"));
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("refuses the same token on /api/labs, which declares no scope", async () => {
|
|
// The whole point of the inversion: holding a scope buys the routes that
|
|
// name it, and nothing else.
|
|
await armToken(["fhir:read"], "fhir2");
|
|
const { GET } = await import("@/app/api/labs/route");
|
|
const res = await GET(req("/api/labs"));
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("B7 — cookie sessions are untouched", () => {
|
|
it("reaches every route the narrow token was refused on", async () => {
|
|
await useCookieSession();
|
|
|
|
const backup = await import("@/app/api/export/full-backup/route");
|
|
expect((await backup.GET(req("/api/export/full-backup"))).status).toBe(200);
|
|
|
|
const labs = await import("@/app/api/labs/route");
|
|
expect((await labs.GET(req("/api/labs"))).status).toBe(200);
|
|
|
|
const fhir = await import("@/app/api/fhir/Observation/route");
|
|
expect((await fhir.GET(req("/api/fhir/Observation"))).status).toBe(200);
|
|
});
|
|
|
|
it("requireAdmin still refuses a ['*'] Bearer token", async () => {
|
|
// The cookie-only property of `requireAdmin()` is orthogonal to this
|
|
// change and must stay orthogonal. A wildcard token is the strongest
|
|
// Bearer credential the app mints and it still cannot elevate.
|
|
await getPrismaClient().user.update({
|
|
where: { id: USER_ID },
|
|
data: { role: "ADMIN" },
|
|
});
|
|
await armToken(["*"], "adminwild");
|
|
|
|
const { requireAdmin, HttpError } = await import("@/lib/api-handler");
|
|
await expect(requireAdmin()).rejects.toBeInstanceOf(HttpError);
|
|
});
|
|
});
|
|
|
|
describe("B8 — the measurement-ingest scope reaches its two routes and no others", () => {
|
|
/**
|
|
* A second account, plus a live, accepted, record-wide grant letting the
|
|
* token's owner write to it.
|
|
*
|
|
* The grant is the whole point of the delegation cases below. Asserting a
|
|
* 403 against a record nobody shared proves nothing — every credential is
|
|
* refused that. What has to hold is that the scoped token is refused a
|
|
* record its own holder genuinely may write to, which is the case a reader
|
|
* would otherwise assume works.
|
|
*/
|
|
async function seedSharedOwner(): Promise<string> {
|
|
const ownerId = "user-bearer-scope-owner";
|
|
await getPrismaClient().user.create({
|
|
data: {
|
|
id: ownerId,
|
|
username: "scope-owner",
|
|
email: "scope-owner@example.test",
|
|
timezone: "UTC",
|
|
},
|
|
});
|
|
await getPrismaClient().accountGrant.create({
|
|
data: {
|
|
grantorId: ownerId,
|
|
granteeId: USER_ID,
|
|
access: "WRITE",
|
|
acceptedAt: new Date(),
|
|
},
|
|
});
|
|
return ownerId;
|
|
}
|
|
|
|
function measurementBody(): string {
|
|
return JSON.stringify({
|
|
type: "WEIGHT",
|
|
value: 74.2,
|
|
measuredAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Claim a record for the next request.
|
|
*
|
|
* It goes in the `headerJar`, NOT on the `NextRequest`, and the difference
|
|
* is the whole reason the first version of these cases passed for the wrong
|
|
* reason. `readSelectorHeader()` resolves through `next/headers`, which this
|
|
* suite mocks against the jar — a header set on the request object is never
|
|
* read by the resolver, so the carrier came back `none` and the delegation
|
|
* cases were silently exercising the ordinary own-record path.
|
|
*/
|
|
function claimRecord(accountId: string): void {
|
|
headerJar.set(ACCOUNT_SELECTOR_HEADER, accountId);
|
|
}
|
|
|
|
function postMeasurement(): NextRequest {
|
|
return new NextRequest("https://health.example/api/measurements", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: measurementBody(),
|
|
} as never);
|
|
}
|
|
|
|
it("admits the scope on POST /api/measurements, attributed EXTERNAL", async () => {
|
|
await armToken(["measurements:write"], "mwrite1");
|
|
const { POST } = await import("@/app/api/measurements/route");
|
|
const res = await POST(postMeasurement());
|
|
|
|
expect(res.status).toBe(201);
|
|
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
|
where: { userId: USER_ID, type: "WEIGHT" },
|
|
});
|
|
// v1.38.x — the body names no source (see `measurementBody`), and the
|
|
// route resolves one from the credential rather than falling back on the
|
|
// schema default. This assertion is the whole feature: a reading pushed
|
|
// by a bridge is distinguishable afterwards from one typed by hand.
|
|
expect(row.source).toBe("EXTERNAL");
|
|
});
|
|
|
|
it("refuses a body naming MANUAL, rather than overriding it", async () => {
|
|
// The source is server-resolved now, so an explicit assertion has only
|
|
// two possible fates: honoured (and the row is indistinguishable from a
|
|
// typed one, defeating the point) or overridden in silence (which hands
|
|
// the caller a row it did not ask for and no way to notice). The route
|
|
// takes neither and refuses. MANUAL specifically, because it used to be
|
|
// the permitted value — the widening from "names APPLE_HEALTH" to "names
|
|
// anything" is exactly this case.
|
|
await armToken(["measurements:write"], "mwrite1b");
|
|
const { POST } = await import("@/app/api/measurements/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/measurements", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
type: "WEIGHT",
|
|
value: 74.2,
|
|
measuredAt: new Date().toISOString(),
|
|
source: "MANUAL",
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await getPrismaClient().measurement.count()).toBe(0);
|
|
});
|
|
|
|
it("admits the scope on the batch route, attributed MANUAL", async () => {
|
|
await armToken(["measurements:write"], "mwrite2");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 70,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-measurements-write-1",
|
|
},
|
|
],
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
|
where: { userId: USER_ID, externalId: "uuid-measurements-write-1" },
|
|
});
|
|
// Absent `source` defaults to APPLE_HEALTH for the phone and resolves to
|
|
// EXTERNAL here. That also moves the dedup key to
|
|
// `(userId, type, EXTERNAL, externalId)`, so a bridge's externalIds can
|
|
// never collide with the phone's.
|
|
expect(row.source).toBe("EXTERNAL");
|
|
});
|
|
|
|
it("does not move the native sync checkpoint", async () => {
|
|
await armToken(["measurements:write"], "mwrite3");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
await POST(
|
|
new NextRequest("https://health.example/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 71,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-measurements-write-2",
|
|
},
|
|
],
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
const user = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: USER_ID },
|
|
});
|
|
expect(user.lastSyncedAt).toBeNull();
|
|
expect(user.healthKitLastSyncedAt).toBeNull();
|
|
});
|
|
|
|
it("refuses an entry forging the HealthKit source", async () => {
|
|
await armToken(["measurements:write"], "mwrite4");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 72,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-measurements-write-3",
|
|
source: "APPLE_HEALTH",
|
|
},
|
|
],
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await getPrismaClient().measurement.count()).toBe(0);
|
|
});
|
|
|
|
it("refuses a batch entry naming MANUAL too, not only APPLE_HEALTH", async () => {
|
|
// MANUAL was permitted here while it was also the value the route forced,
|
|
// so nothing was ever overridden. Now it would be, and the sibling case
|
|
// above says why that is refused instead.
|
|
await armToken(["measurements:write"], "mwrite4b");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 73,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-measurements-write-3b",
|
|
source: "MANUAL",
|
|
},
|
|
],
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect(await getPrismaClient().measurement.count()).toBe(0);
|
|
});
|
|
|
|
it("leaves the row it wrote editable by its owner", async () => {
|
|
// The counterpart to the source split. `EXTERNAL` is outside
|
|
// `WRITABLE_MEASUREMENT_SOURCES` — no client may name it — but it is
|
|
// inside `USER_CORRECTABLE_MEASUREMENT_SOURCES`, because the hardware
|
|
// behind an ingest token is the user's own scale, not a provider. A later
|
|
// tidy-up that folds the two constants back together turns every bridge
|
|
// row value-locked with a 409, and this case is what notices.
|
|
await armToken(["measurements:write"], "mwrite1c");
|
|
const { POST } = await import("@/app/api/measurements/route");
|
|
expect((await POST(postMeasurement())).status).toBe(201);
|
|
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
|
where: { userId: USER_ID, type: "WEIGHT" },
|
|
});
|
|
expect(row.source).toBe("EXTERNAL");
|
|
|
|
// Edit as the owner: a cookie session, no Bearer. The scoped credential
|
|
// could not reach `PUT` at all — that is the point of a write-only scope.
|
|
headerJar.delete("authorization");
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
|
|
const { PUT } = await import("@/app/api/measurements/[id]/route");
|
|
const res = await PUT(
|
|
new NextRequest(`https://health.example/api/measurements/${row.id}`, {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ value: 75.1 }),
|
|
} as never),
|
|
{ params: Promise.resolve({ id: row.id }) },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const reread = await getPrismaClient().measurement.findUniqueOrThrow({
|
|
where: { id: row.id },
|
|
});
|
|
expect(reread.value).toBe(75.1);
|
|
expect(reread.source).toBe("EXTERNAL");
|
|
});
|
|
|
|
it("is refused on the read leg of the very same path", async () => {
|
|
// What makes the scope write-only rather than write-named.
|
|
await armToken(["measurements:write"], "mwrite5");
|
|
const { GET } = await import("@/app/api/measurements/route");
|
|
const res = await GET(req("/api/measurements"));
|
|
|
|
expect(res.status).toBe(403);
|
|
// Polled, not read once: the audit write is fire-and-forget, so a bare
|
|
// read races the request and reports `undefined` for a row that is about
|
|
// to exist. Same posture as B1 above — on timeout the poll asserts against
|
|
// whatever WAS written rather than passing by default.
|
|
await expect
|
|
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
|
|
.toBe("undeclared_scope");
|
|
});
|
|
|
|
it("is refused on the manage legs and on an unrelated surface", async () => {
|
|
await armToken(["measurements:write"], "mwrite6");
|
|
|
|
const bulk = await import("@/app/api/measurements/bulk-delete/route");
|
|
expect(
|
|
(await bulk.POST(req("/api/measurements/bulk-delete", "POST"))).status,
|
|
).toBe(403);
|
|
|
|
const restore = await import("@/app/api/measurements/restore/route");
|
|
expect(
|
|
(await restore.POST(req("/api/measurements/restore", "POST"))).status,
|
|
).toBe(403);
|
|
|
|
const backup = await import("@/app/api/export/full-backup/route");
|
|
expect((await backup.GET(req("/api/export/full-backup"))).status).toBe(403);
|
|
|
|
const labs = await import("@/app/api/labs/route");
|
|
expect((await labs.GET(req("/api/labs"))).status).toBe(403);
|
|
});
|
|
|
|
it("cannot mint another token", async () => {
|
|
// 401, not 403, and the difference is the point: the mint is cookie-only,
|
|
// so a Bearer is turned away as unauthenticated before any scope is read.
|
|
// The refusal is therefore not "this token's scope is too narrow" — no
|
|
// token is wide enough, a wildcard included.
|
|
await armToken(["measurements:write"], "mwrite7");
|
|
const { POST } = await import("@/app/api/tokens/measurements/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/tokens/measurements", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "second" }),
|
|
} as never),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("and neither can a wildcard token", async () => {
|
|
// The case the cookie-only resolver exists for. A native access token
|
|
// lives a day; what this endpoint mints lives a year, so admitting one
|
|
// would let a short-lived compromise leave behind a credential that
|
|
// survives revoking it.
|
|
await armToken(["*"], "wildmint");
|
|
const { POST } = await import("@/app/api/tokens/measurements/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/tokens/measurements", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "minted by a wildcard" }),
|
|
} as never),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
expect(
|
|
await getPrismaClient().apiToken.count({
|
|
where: { permissions: { has: "measurements:write" } },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
/**
|
|
* The live-token ceiling, on a cookie session because that is the only
|
|
* credential the mint admits.
|
|
*
|
|
* Here rather than in a file of its own for one reason: the assertion that
|
|
* needs a real database is `permissions: { has: … }` against a Postgres
|
|
* `text[]`, and the harness for that already exists in this file. A mock
|
|
* can confirm the query was built; only Postgres can confirm it selects the
|
|
* rows it means to.
|
|
*/
|
|
it("refuses a mint once the account holds the ceiling, and revoking frees a slot", async () => {
|
|
const CEILING = 10;
|
|
const now = Date.now();
|
|
await getPrismaClient().apiToken.createMany({
|
|
data: Array.from({ length: CEILING }, (_, i) => ({
|
|
userId: USER_ID,
|
|
name: `bridge ${i}`,
|
|
tokenHash: hashToken(`hlk_ceiling_${i}_${"0".repeat(40)}`),
|
|
permissions: ["measurements:write"],
|
|
expiresAt: new Date(now + 86_400_000),
|
|
})),
|
|
});
|
|
// Neither of these may occupy a slot: one is a different scope, the other
|
|
// has already expired. Seeded so a query that counted either would push
|
|
// the live figure past the ceiling and pass this test for the wrong
|
|
// reason on the revoke leg below.
|
|
await getPrismaClient().apiToken.createMany({
|
|
data: [
|
|
{
|
|
userId: USER_ID,
|
|
name: "a browser session",
|
|
tokenHash: hashToken(`hlk_wild_${"0".repeat(44)}`),
|
|
permissions: ["*"],
|
|
expiresAt: new Date(now + 86_400_000),
|
|
},
|
|
{
|
|
userId: USER_ID,
|
|
name: "long expired",
|
|
tokenHash: hashToken(`hlk_expired_${"0".repeat(40)}`),
|
|
permissions: ["measurements:write"],
|
|
expiresAt: new Date(now - 86_400_000),
|
|
},
|
|
],
|
|
});
|
|
|
|
await useCookieSession();
|
|
const { POST } = await import("@/app/api/tokens/measurements/route");
|
|
const mint = () =>
|
|
POST(
|
|
new NextRequest("https://health.example/api/tokens/measurements", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name: "one too many" }),
|
|
} as never),
|
|
);
|
|
|
|
const refused = await mint();
|
|
expect(refused.status).toBe(409);
|
|
expect((await refused.json()).meta?.errorCode).toBe(
|
|
"tokens.measurements.ceiling_reached",
|
|
);
|
|
|
|
// Revoking one live row — not the expired or wildcard decoys — must open
|
|
// exactly one slot. This is the leg that fails if the count is of mints
|
|
// ever made rather than of tokens currently alive.
|
|
const victim = await getPrismaClient().apiToken.findFirstOrThrow({
|
|
where: { name: "bridge 0" },
|
|
});
|
|
await getPrismaClient().apiToken.update({
|
|
where: { id: victim.id },
|
|
data: { revoked: true },
|
|
});
|
|
|
|
expect((await mint()).status).toBe(201);
|
|
});
|
|
|
|
it("cannot reach the medication ingest surface", async () => {
|
|
await armToken(["measurements:write"], "mwrite8");
|
|
const { POST } = await import("@/app/api/ingest/medication/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/ingest/medication", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ medicationName: "x" }),
|
|
} as never),
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("is refused a shared record its own holder may genuinely write to", async () => {
|
|
const ownerId = await seedSharedOwner();
|
|
await armToken(["measurements:write"], "mwrite9");
|
|
claimRecord(ownerId);
|
|
|
|
const { POST } = await import("@/app/api/measurements/route");
|
|
const res = await POST(postMeasurement());
|
|
|
|
expect(res.status).toBe(403);
|
|
// Nothing landed on the owner, and nothing quietly landed on the holder
|
|
// instead — the reverse data-mixing failure the resolver exists to avoid.
|
|
expect(await getPrismaClient().measurement.count()).toBe(0);
|
|
});
|
|
|
|
it("but a wildcard token writes to that same shared record", async () => {
|
|
// The control for the case above. Without it, a delegation path broken
|
|
// outright would make that 403 look like the scope working.
|
|
const ownerId = await seedSharedOwner();
|
|
await armToken(["*"], "wildshared");
|
|
claimRecord(ownerId);
|
|
|
|
const { POST } = await import("@/app/api/measurements/route");
|
|
const res = await POST(postMeasurement());
|
|
|
|
expect(res.status).toBe(201);
|
|
const row = await getPrismaClient().measurement.findFirstOrThrow({
|
|
where: { type: "WEIGHT" },
|
|
});
|
|
expect(row.userId).toBe(ownerId);
|
|
});
|
|
});
|
|
|
|
describe("B9 — the workout-ingest scope reaches the workout batch and nothing else", () => {
|
|
function postWorkouts(entries: Array<Record<string, unknown>>): NextRequest {
|
|
return new NextRequest("https://health.example/api/workouts/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ workouts: entries }),
|
|
} as never);
|
|
}
|
|
|
|
function bridgedRun(
|
|
externalId: string,
|
|
extra: Record<string, unknown> = {},
|
|
): Record<string, unknown> {
|
|
return {
|
|
sportType: "running",
|
|
startedAt: "2026-09-20T06:30:00.000Z",
|
|
endedAt: "2026-09-20T07:15:00.000Z",
|
|
externalId,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
it("admits the scope on POST /api/workouts/batch, attributed EXTERNAL", async () => {
|
|
await armToken(["workouts:write"], "wwrite1");
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(postWorkouts([bridgedRun("bridge-run-1")]));
|
|
|
|
expect(res.status).toBe(200);
|
|
const row = await getPrismaClient().workout.findFirstOrThrow({
|
|
where: { userId: USER_ID, externalId: "bridge-run-1" },
|
|
});
|
|
// The body names no source; the schema would default it to MANUAL, and
|
|
// the route resolves EXTERNAL from the credential instead.
|
|
expect(row.source).toBe("EXTERNAL");
|
|
});
|
|
|
|
it("re-posting the same bridged workout is a duplicate, not a second row", async () => {
|
|
// The dedup probe has to look in the EXTERNAL namespace the insert wrote
|
|
// to. Probing under the body's defaulted MANUAL would miss the stored row
|
|
// and the unique index would be the only thing standing in the way.
|
|
await armToken(["workouts:write"], "wwrite1b");
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
await POST(postWorkouts([bridgedRun("bridge-run-2")]));
|
|
const again = await POST(postWorkouts([bridgedRun("bridge-run-2")]));
|
|
|
|
expect(again.status).toBe(200);
|
|
expect((await again.json()).data.entries[0].status).toBe("duplicate");
|
|
expect(await getPrismaClient().workout.count()).toBe(1);
|
|
});
|
|
|
|
it.each(["APPLE_HEALTH", "MANUAL"])(
|
|
"refuses a batch in which an entry names %s",
|
|
async (source) => {
|
|
await armToken(["workouts:write"], `wwrite2-${source}`);
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(
|
|
postWorkouts([
|
|
bridgedRun("bridge-run-3"),
|
|
bridgedRun("bridge-run-4", { source }),
|
|
]),
|
|
);
|
|
|
|
expect(res.status).toBe(422);
|
|
expect((await res.json()).meta?.errorCode).toBe(
|
|
"workout.batch.source_not_permitted",
|
|
);
|
|
expect(await getPrismaClient().workout.count()).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("a measurements:write token is refused on the workout batch", async () => {
|
|
// Separate scopes on purpose: a token handed to a scale does not start
|
|
// accepting workouts.
|
|
await armToken(["measurements:write"], "wwrite3");
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(postWorkouts([bridgedRun("bridge-run-5")]));
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(await getPrismaClient().workout.count()).toBe(0);
|
|
});
|
|
|
|
it("and a workouts:write token is refused on the measurement batch", async () => {
|
|
await armToken(["workouts:write"], "wwrite4");
|
|
const { POST } = await import("@/app/api/measurements/batch/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/measurements/batch", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
entries: [
|
|
{
|
|
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
|
value: 70,
|
|
unit: "kg",
|
|
startDate: new Date().toISOString(),
|
|
endDate: new Date().toISOString(),
|
|
externalId: "uuid-workouts-write-1",
|
|
},
|
|
],
|
|
}),
|
|
} as never),
|
|
);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(await getPrismaClient().measurement.count()).toBe(0);
|
|
});
|
|
|
|
it("is refused on the workout read legs", async () => {
|
|
await armToken(["workouts:write"], "wwrite5");
|
|
const { GET } = await import("@/app/api/workouts/route");
|
|
expect((await GET(req("/api/workouts"))).status).toBe(403);
|
|
await expect
|
|
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
|
|
.toBe("undeclared_scope");
|
|
});
|
|
|
|
it("cannot mint another token", async () => {
|
|
await armToken(["workouts:write"], "wwrite6");
|
|
const { POST } = await import("@/app/api/tokens/workouts/route");
|
|
const res = await POST(
|
|
new NextRequest("https://health.example/api/tokens/workouts", {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: headerJar.get("authorization")!,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: "second" }),
|
|
} as never),
|
|
);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("the mint issues exactly this scope, and the token it issues writes", async () => {
|
|
// Mint on a cookie session with a fresh proof, then use the raw token the
|
|
// response carries: the round trip a bridge operator makes.
|
|
await useCookieSession();
|
|
const { POST: mint } = await import("@/app/api/tokens/workouts/route");
|
|
const minted = await mint(
|
|
new NextRequest("https://health.example/api/tokens/workouts", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ name: "watch bridge" }),
|
|
} as never),
|
|
);
|
|
expect(minted.status).toBe(201);
|
|
const raw = (await minted.json()).data.token as string;
|
|
|
|
const row = await getPrismaClient().apiToken.findFirstOrThrow({
|
|
where: { name: "watch bridge" },
|
|
});
|
|
expect(row.permissions).toEqual(["workouts:write"]);
|
|
|
|
cookieJar.clear();
|
|
headerJar.set("authorization", `Bearer ${raw}`);
|
|
const { POST } = await import("@/app/api/workouts/batch/route");
|
|
const res = await POST(postWorkouts([bridgedRun("bridge-run-6")]));
|
|
expect(res.status).toBe(200);
|
|
const written = await getPrismaClient().workout.findFirstOrThrow({
|
|
where: { externalId: "bridge-run-6" },
|
|
});
|
|
expect(written.source).toBe("EXTERNAL");
|
|
});
|
|
});
|