test: enforce sharing client step-up

Freezes the authority split the third level ships with. A token exercises a
MANAGE grant, and a Guardian's authority over a managed profile, exactly as a
browser does — proved at the resolver, where neither had a Bearer case before.
A token cannot offer MANAGE and cannot create a managed profile at all, because
both gates resolve through the session cookie; the second had no test until now,
and it runs against a real database with a wildcard token to make the width of
the permission irrelevant.

The claim set is held together by a map naming, per claim, the file and the test
title that proves it, so removing any one of them fails rather than quietly
narrowing what a native client may do.

The fence's client-facing half is frozen beside it: the account payload publishes
the record context on a cookie session and null on a token, and the published
contract says so under the same header names the server matches on.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 05:11:02 +02:00
1 parent cc316a7b85
commit 0da9040f2c
4 files changed
+375

No files matched your search

@@ -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");
});
});
@@ -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);
});
});
@@ -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.
@@ -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);