Files
HealthLog/src/__tests__/sharing-native-client-contract.test.ts
T
Marc-André Bombeck 0da9040f2c 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.
2026-08-07 05:11:02 +02:00

149 lines
6.2 KiB
TypeScript

/**
* 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");
});
});