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