diff --git a/src/__tests__/sharing-native-client-contract.test.ts b/src/__tests__/sharing-native-client-contract.test.ts new file mode 100644 index 000000000..96b6fdb24 --- /dev/null +++ b/src/__tests__/sharing-native-client-contract.test.ts @@ -0,0 +1,148 @@ +/** + * v1.37.0 — what the native client may do with the third level, frozen. + * + * The release adds MANAGE and managed profiles, and the authority split it + * ships with is deliberately asymmetric: + * + * * a token EXERCISES a MANAGE grant exactly as a browser does, per request, + * through the account selector; + * * a token cannot MINT one, and cannot create a managed profile at all, + * because both acts are gated on a fresh second factor that resolves + * through the session cookie and has no Bearer fall-through. + * + * The asymmetry is a consequence of the step-up rather than a policy invented + * for the native client, and it is the sentence Plan 16's handoff note is + * derived from — so it is pinned here as a set rather than left implicit in + * five files that each prove a quarter of it. + * + * ## Why this file is a map and not a fifth copy of the assertions + * + * Every claim below is proved BEHAVIOURALLY somewhere — against the real + * resolver, or the real route, or a real Postgres. Re-asserting them here would + * make five tests that agree with each other and drift together. What has no + * home is the CLAIM SET: that these four facts are one contract, and that + * removing any one of them is a change to what a native client may do. This + * file names each claim, the file that proves it and the test title that does, + * and fails when a title moves or a file stops carrying it. + * + * The pattern is the repo's own — `record-session-fence-acceptance-map.test.ts` + * does the same for the fence's ten cases, for the same reason. + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); + +interface NativeAuthorityClaim { + /** What the contract promises. */ + claim: string; + /** Where it is proved, relative to the repository root. */ + file: string; + /** A fragment of the test title that proves it. */ + title: string; + /** How it is proved, so a reader knows what the evidence is worth. */ + kind: "resolver unit" | "route unit" | "postgres integration"; +} + +const NATIVE_AUTHORITY: NativeAuthorityClaim[] = [ + { + claim: "A token exercises a MANAGE grant on a shared record.", + file: "src/lib/__tests__/acting-account-resolver.test.ts", + title: "admits a MANAGE grant over the Bearer transport too", + kind: "resolver unit", + }, + { + claim: "A token exercises a Guardian's authority over a managed profile.", + file: "src/lib/__tests__/acting-account-resolver.test.ts", + title: "admits a Guardian over the Bearer transport, per request", + kind: "resolver unit", + }, + { + claim: + "A token cannot offer MANAGE, and is told so in a code it can branch on.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: "refuses a Bearer caller with a code it can act on", + kind: "route unit", + }, + { + claim: + "The refusal lands before the invitee lookup, so it is no account oracle.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: + "refuses the Bearer caller before it discloses whether the account exists", + kind: "route unit", + }, + { + claim: "A token keeps minting the two levels it always could.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: "lets a Bearer caller keep minting the two levels it always could", + kind: "route unit", + }, + { + claim: "A token cannot create a managed profile, at any permission width.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "cannot be reached with a token, however wide its permissions", + kind: "postgres integration", + }, + { + claim: + "The positive control: a cookie session with a fresh factor creates one.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "creates a profile from exactly the body the form composes", + kind: "postgres integration", + }, + { + claim: "A token cannot read the Guardian roster either — cookie-only.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "refuses a Bearer caller, because the family is cookie-only", + kind: "postgres integration", + }, +]; + +describe("the native client's authority over the third level", () => { + it("claims a non-empty set, over more than one kind of evidence", () => { + // A map that named nothing would pass every assertion below it. + expect(NATIVE_AUTHORITY.length).toBeGreaterThan(5); + expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.kind)).size).toBe(3); + expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.file)).size).toBe(3); + }); + + it.each(NATIVE_AUTHORITY)("is proved: $claim", ({ file, title }) => { + const path = join(ROOT, file); + expect(existsSync(path), `${file} is missing`).toBe(true); + const source = readFileSync(path, "utf8"); + // The title as a test title, not as prose in a comment: a claim proved by + // a docblock is the shape this repo has been burned by. + expect(source, `${file} no longer carries "${title}"`).toContain( + `"${title}"`, + ); + }); + + it("keeps the mint refusal and the exercise on opposite sides", () => { + // The one-sentence version of the contract, asserted on the source of the + // gate rather than on prose about it: the transport check is what makes + // the invitation cookie-only, and it sits above the step-up call so that a + // token is never handed the session gate's "not authenticated". + const invite = readFileSync( + join(ROOT, "src/app/api/account/grants/route.ts"), + "utf8", + ); + const transportCheck = invite.indexOf('auth.authMethod !== "cookie"'); + const stepUp = invite.indexOf("await requireFreshMfaIfEnrolled("); + expect(transportCheck).toBeGreaterThan(-1); + expect(stepUp).toBeGreaterThan(-1); + expect(transportCheck).toBeLessThan(stepUp); + + // And the managed-profile creation does not even have the softer gate: + // `requireFreshMfa` refuses an account with no second factor enrolled, + // where `requireFreshMfaIfEnrolled` would wave it through. + const create = readFileSync( + join(ROOT, "src/app/api/managed-profiles/route.ts"), + "utf8", + ); + expect(create).toContain("requireFreshMfa("); + expect(create).not.toContain("requireFreshMfaIfEnrolled"); + }); +}); diff --git a/src/__tests__/sharing-record-fence-contract.test.ts b/src/__tests__/sharing-record-fence-contract.test.ts new file mode 100644 index 000000000..18f95d036 --- /dev/null +++ b/src/__tests__/sharing-record-fence-contract.test.ts @@ -0,0 +1,168 @@ +/** + * v1.37.0 — the record-session fence is a cookie-transport contract, frozen. + * + * The fence refuses a browser request formed under a record context the session + * has since left. Everything about it — the epoch, the two request headers, the + * echo, the 409 — belongs to the cookie transport, and the native contract is + * unchanged in every direction by it. That is the claim this file freezes, + * because it is the one a client implementer has to be able to rely on without + * reading the server: a token sends no fence header, receives none back, and is + * never refused for the absence of one. + * + * ## What lives here rather than beside the fence + * + * `src/lib/sharing/__tests__/record-session-fence.test.ts` proves the verdict + * table and the enforcement, including both Bearer arms. This file proves the + * two things that face a CLIENT and had no test of their own: + * + * 1. `GET /api/auth/me` publishes `recordSession` on a cookie session and + * `null` on a token — the field is how a Bearer client is told the fence + * does not apply to it, rather than being left to infer that from silence; + * 2. the PUBLISHED contract says so, in the two request-header parameters and + * in the schema of the field, under the exact header names the code uses. + * + * The second is not a paraphrase check. A spec that named the headers slightly + * differently from the server would be a contract nobody could implement + * against, and the names live in a client-safe module precisely so both ends + * can be held to one spelling. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + RECORD_EPOCH_HEADER, + RECORD_FENCE_ERROR_CODE, + RECORD_SCOPE_HEADER, + RECORD_SCOPE_SELF, +} from "@/lib/sharing/record-session-fence-contract"; +import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; +import { + recordEpochParameter, + recordScopeParameter, +} from "@/lib/openapi/routes/account-sharing"; +import type { AuthContext } from "@/lib/api-handler"; + +const ROOT = process.cwd(); + +/** + * The published parameter, narrowed. `ZodOpenApiObject["components"].parameters` + * admits a `$ref` as well as an object, so the union has no `name` — these two + * are literal objects and the cast says which arm they are. + */ +function headerParameter(parameter: unknown): { + name: string; + in: string; + required: boolean; + description: string; +} { + return parameter as { + name: string; + in: string; + required: boolean; + description: string; + }; +} + +/** The shape `recordSessionForPayload` reads, and nothing else. */ +function auth( + authMethod: "cookie" | "bearer", + session: { recordEpoch?: number; actingAsUserId?: string | null } = {}, +): AuthContext { + return { + authMethod, + user: { id: "caller" }, + session: { + recordEpoch: session.recordEpoch, + actingAsUserId: session.actingAsUserId ?? null, + }, + } as unknown as AuthContext; +} + +describe("what the account payload tells each transport about the fence", () => { + it("publishes the context a browser adopts", () => { + expect( + recordSessionForPayload( + auth("cookie", { recordEpoch: 3, actingAsUserId: "record-owner" }), + ), + ).toEqual({ epoch: 3, scope: "record-owner" }); + + // Its own record is `scope: null`, not the absence of the field: null is a + // first-class answer here, and the header spelling for it is `self`. + expect(recordSessionForPayload(auth("cookie", { recordEpoch: 0 }))).toEqual( + { epoch: 0, scope: null }, + ); + }); + + it("publishes null on the Bearer transport, however switched the row looks", () => { + // The stamped columns are ignored rather than absent — a token issued to an + // account whose session row carries a selector still gets null, because the + // token has no session and nothing to be stale about. Told explicitly, so a + // native client can assert on the field instead of inferring from omission. + expect( + recordSessionForPayload( + auth("bearer", { recordEpoch: 9, actingAsUserId: "record-owner" }), + ), + ).toBeNull(); + }); + + it("treats a session row without an epoch as the un-switched start", () => { + expect(recordSessionForPayload(auth("cookie"))).toEqual({ + epoch: 0, + scope: null, + }); + }); +}); + +describe("what the published contract says about the fence's headers", () => { + const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); + + it("publishes both request headers under the names the code uses", () => { + // One header, two spellings, and the difference is deliberate rather than + // sloppy: the server matches the lower-case form because that is what an + // HTTP/2 client sends and what `Headers.get` normalises to, while the spec + // publishes the canonical capitalisation an implementer will type. Held + // equal ignoring case, so a genuine rename cannot pass as a casing choice. + expect(headerParameter(recordEpochParameter).name.toLowerCase()).toBe( + RECORD_EPOCH_HEADER, + ); + expect(headerParameter(recordScopeParameter).name.toLowerCase()).toBe( + RECORD_SCOPE_HEADER, + ); + expect(spec.toLowerCase()).toContain(RECORD_EPOCH_HEADER); + expect(spec.toLowerCase()).toContain(RECORD_SCOPE_HEADER); + }); + + it("marks both as cookie-transport only, in the description a client reads", () => { + for (const parameter of [recordEpochParameter, recordScopeParameter]) { + const { description } = headerParameter(parameter); + expect(description.length).toBeGreaterThan(100); + expect(description).toMatch(/[Cc]ookie transport only/); + expect(parameter).toMatchObject({ in: "header", required: false }); + } + + // The epoch's description additionally carries the two refusals a client + // has to tell apart, because they mean opposite things: reconcile and + // retry, versus leave the record and reload. + const epoch = headerParameter(recordEpochParameter).description; + expect(epoch).toContain(RECORD_FENCE_ERROR_CODE); + expect(epoch).toContain("sharing.access.denied"); + }); + + it("publishes the field a client adopts, and that it is null on Bearer", () => { + // Read off the generated document rather than the source object: the spec + // is what an implementer holds, and a schema that never reached the YAML + // would still satisfy an assertion made against the registry. + const state = spec.slice(spec.indexOf(" RecordSessionState:")); + // Up to the next schema key at the same indent — matched as "four spaces + // then a non-space", because a plain four-space search would stop at the + // first nested line instead. + const next = /\n {4}\S/.exec(state.slice(1)); + const block = state.slice(0, next ? next.index + 1 : state.length); + expect(block).toContain("epoch"); + expect(block).toContain("scope"); + expect(block).toMatch(/[Nn]ull on Bearer/); + expect(block).toContain(RECORD_SCOPE_SELF); + }); +}); diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index affbe2c84..46cb1cf0c 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -822,6 +822,26 @@ describe("a route declaring manage refuses everything below it", () => { ); expect(ctx.user.id).toBe(OWNER); }); + + it("admits a MANAGE grant over the Bearer transport too", async () => { + // The native half of the third level, and the asymmetry worth pinning: + // a token cannot MINT a MANAGE grant (the step-up that gates the + // invitation resolves through the session cookie — see + // `src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts`), + // and it EXERCISES one exactly as a browser does. Nothing about the level + // is transport-dependent once the grant exists; a client that could not + // use what its user was given would be a second authorization model. + signedInBearer(); + selector(OWNER); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute( + () => requireRecordAuth("manage", "measurements"), + "DELETE", + ); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + }); }); /** @@ -845,6 +865,20 @@ describe("the guardian resolver gates on the marker, not the grant", () => { expect(ctx.grantId).toBe("grant-1"); }); + it("admits a Guardian over the Bearer transport, per request", async () => { + // A Guardian's phone administers the profile it looks after, with the + // selector on each request rather than a switch it accumulates. The + // marker still decides; the transport does not. + signedInBearer(); + selector(OWNER); + userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z"))); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute(() => requireGuardianAuth()); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + }); + it("refuses a MANAGE grant on an ordinary adult's record", async () => { // The fence. The grant is perfect and the answer is still no, because the // record has an owner who runs it. diff --git a/tests/integration/managed-profile-surface.test.ts b/tests/integration/managed-profile-surface.test.ts index 54df50a30..253db33a0 100644 --- a/tests/integration/managed-profile-surface.test.ts +++ b/tests/integration/managed-profile-surface.test.ts @@ -370,6 +370,31 @@ describe("who looks after a managed profile (real Postgres)", () => { }); describe("every call the managed-profile card can make", () => { + it("cannot be reached with a token, however wide its permissions", async () => { + // The native half of the contract, and the reason the freeze can promise + // it: `requireFreshMfa` resolves the session cookie and never falls + // through to the Bearer branch, so a wildcard token — the widest a client + // can hold — cannot mint a credential-less person and a permanent + // management relationship. The positive control is the very next case: + // the same body, from a cookie session that has proved its factor, + // creates the record. + const creator = await person("creator"); + await signInWithBearer(creator); + + const response = await createProfile({ + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + expect(response.status).toBe(401); + expect( + await getPrismaClient().user.count({ + where: { managedProfileAt: { not: null } }, + }), + ).toBe(0); + }); + it("creates a profile from exactly the body the form composes", async () => { const creator = await person("creator"); signIn(creator);