mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
The eight journeys were enumerated and one of them was wired. The other
seven existed as claims in a fixture file, which is the gap the enumeration
was written to close: a browser suite reports what it ran, never what it did
not, and a journey nobody wired reads exactly like a journey that passed.
All eight are wired now, and the list is checked in both directions — an
enumerated journey missing from the wiring fails, not just a wiring entry
naming a journey nobody declared. Before this, a subset was legal, so a
dropped journey and a not-yet-written one looked the same.
A journey names more than one file where more than one file carries it. J1
claims a grant runs through invite, accept, switch, expiry, revoke and a
direct URL; the browser proves the switch, what each level opens and the
direct-URL refusal, and the transitions either side are database facts
proved against real Postgres. Folding that into one file would mean a
browser spec driving expiry through the clock or an integration file
asserting what a page renders, and both are worse tests than the two that
exist.
Three journeys ride Playwright and five ride Vitest, so a green run of this
guard means all eight are claimed and here is which have executed in the
ordinary gate — never that all eight passed. The runner is derived from the
path rather than trusted, so it cannot become a label set to whichever value
made the split read better.
The extractor now reads `it(` as well as `test(`, and the parameterised
`it.each(table)("title")` form alongside the single-call one. It saw neither
before: five of the journeys are proved by Vitest files, every per-file leg
is over a matched set, and an empty match set satisfied them all. The
synthetic spec that proves the parse grew a Vitest half, and it is what
caught the parameterised form being invisible. What the matcher still cannot
see is written into its own doc comment.
265 lines
8.0 KiB
TypeScript
265 lines
8.0 KiB
TypeScript
/**
|
|
* A delegated replay must still be authorised when the cache has a body.
|
|
*
|
|
* These cases use the shipped idempotency wrapper, bearer selector and grant
|
|
* state machine against Postgres. The small handler keeps the assertion at the
|
|
* cache boundary: the route body counts mutations, while `requireRecordAuth`
|
|
* supplies the same switched-record admission that delegable routes use.
|
|
*/
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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),
|
|
}));
|
|
|
|
import {
|
|
ACCOUNT_SELECTOR_HEADER,
|
|
apiHandler,
|
|
requireRecordAuth,
|
|
} from "@/lib/api-handler";
|
|
import { hashToken } from "@/lib/auth/hmac";
|
|
import { prisma } from "@/lib/db";
|
|
import { withIdempotency } from "@/lib/idempotency";
|
|
import { SECURITY_PRINCIPALS } from "../fixtures/v137/security-principals";
|
|
|
|
const KEY = "delegated-replay-0f3a1c9d";
|
|
let tokenNumber = 0;
|
|
let handlerRuns = 0;
|
|
|
|
async function createUser(id: string) {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
id,
|
|
username: `replay-${id}`,
|
|
email: `replay-${id}@example.test`,
|
|
role: "USER",
|
|
},
|
|
});
|
|
}
|
|
|
|
async function mintToken(userId: string): Promise<string> {
|
|
const raw = `hlk_replay_${tokenNumber++}`.padEnd(24, "0");
|
|
await getPrismaClient().apiToken.create({
|
|
data: {
|
|
userId,
|
|
name: "sharing-replay-test",
|
|
tokenHash: hashToken(raw),
|
|
permissions: ["*"],
|
|
},
|
|
});
|
|
return raw;
|
|
}
|
|
|
|
async function mintSession(userId: string): Promise<void> {
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId,
|
|
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
}
|
|
|
|
async function createWriteGrant(input: {
|
|
id: string;
|
|
grantorId: string;
|
|
granteeId: string;
|
|
}) {
|
|
return getPrismaClient().accountGrant.create({
|
|
data: {
|
|
id: input.id,
|
|
grantorId: input.grantorId,
|
|
granteeId: input.granteeId,
|
|
access: "WRITE",
|
|
acceptedAt: new Date(Date.now() - 60_000),
|
|
},
|
|
});
|
|
}
|
|
|
|
const delegatedWrite: (request: NextRequest) => Promise<Response> = apiHandler(
|
|
withIdempotency<[NextRequest]>(async () => {
|
|
const { user } = await requireRecordAuth("write", "measurements");
|
|
handlerRuns += 1;
|
|
const row = await prisma.measurement.create({
|
|
data: {
|
|
userId: user.id,
|
|
type: "WEIGHT",
|
|
value: 70 + handlerRuns,
|
|
unit: "kg",
|
|
measuredAt: new Date(),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
return NextResponse.json(
|
|
{ data: { id: row.id, userId: row.userId }, error: null },
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
);
|
|
|
|
function post(key = KEY): Promise<Response> {
|
|
return delegatedWrite(
|
|
new NextRequest("http://localhost/api/test/sharing-idempotency", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", "idempotency-key": key },
|
|
body: JSON.stringify({ ok: true }),
|
|
}),
|
|
);
|
|
}
|
|
|
|
function actAs(recordUserId: string | null): void {
|
|
if (recordUserId === null) headerJar.delete(ACCOUNT_SELECTOR_HEADER);
|
|
else headerJar.set(ACCOUNT_SELECTOR_HEADER, recordUserId);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
handlerRuns = 0;
|
|
tokenNumber = 0;
|
|
});
|
|
|
|
describe("delegated idempotency replay", () => {
|
|
it("[J5-revoked-idempotent-replay] replays an active delegated retry without a second mutation", async () => {
|
|
const principal = SECURITY_PRINCIPALS.activeDelegation;
|
|
await createUser(principal.identities.actorUserId);
|
|
await createUser(principal.identities.recordUserId);
|
|
await createWriteGrant({
|
|
id: principal.grant.id!,
|
|
grantorId: principal.identities.recordUserId,
|
|
granteeId: principal.identities.actorUserId,
|
|
});
|
|
headerJar.set(
|
|
"authorization",
|
|
`Bearer ${await mintToken(principal.identities.actorUserId)}`,
|
|
);
|
|
actAs(principal.identities.recordUserId);
|
|
|
|
const first = await post();
|
|
const firstBody = await first.json();
|
|
const replay = await post();
|
|
|
|
expect(first.status).toBe(201);
|
|
expect(replay.status).toBe(201);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
expect(await replay.json()).toEqual(firstBody);
|
|
expect(handlerRuns).toBe(1);
|
|
expect(
|
|
await getPrismaClient().measurement.count({
|
|
where: { userId: principal.identities.recordUserId },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("[J5-revoked-idempotent-replay] refuses a revoked delegated retry without returning the cached DTO", async () => {
|
|
const principal = SECURITY_PRINCIPALS.revokedDelegation;
|
|
await createUser(principal.identities.actorUserId);
|
|
await createUser(principal.identities.recordUserId);
|
|
const grant = await createWriteGrant({
|
|
id: principal.grant.id!,
|
|
grantorId: principal.identities.recordUserId,
|
|
granteeId: principal.identities.actorUserId,
|
|
});
|
|
headerJar.set(
|
|
"authorization",
|
|
`Bearer ${await mintToken(principal.identities.actorUserId)}`,
|
|
);
|
|
actAs(principal.identities.recordUserId);
|
|
|
|
const first = await post();
|
|
const cached = await first.json();
|
|
expect(first.status).toBe(201);
|
|
|
|
await getPrismaClient().accountGrant.update({
|
|
where: { id: grant.id },
|
|
data: { revokedAt: new Date(), revokedBy: "GRANTOR" },
|
|
});
|
|
|
|
const replay = await post();
|
|
const body = await replay.json();
|
|
|
|
expect(replay.status).toBe(403);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).not.toBe("true");
|
|
expect(body.data).toBeNull();
|
|
expect(body).not.toEqual(cached);
|
|
expect(handlerRuns).toBe(1);
|
|
expect(
|
|
await getPrismaClient().measurement.count({
|
|
where: { userId: principal.identities.recordUserId },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("continues to replay an own-record retry", async () => {
|
|
const principal = SECURITY_PRINCIPALS.ownerIdempotencyPositiveControl;
|
|
await createUser(principal.identities.actorUserId);
|
|
headerJar.set(
|
|
"authorization",
|
|
`Bearer ${await mintToken(principal.identities.actorUserId)}`,
|
|
);
|
|
actAs(null);
|
|
|
|
const first = await post();
|
|
const firstBody = await first.json();
|
|
const replay = await post();
|
|
|
|
expect(first.status).toBe(201);
|
|
expect(replay.status).toBe(201);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).toBe("true");
|
|
expect(await replay.json()).toEqual(firstBody);
|
|
expect(handlerRuns).toBe(1);
|
|
expect(
|
|
await getPrismaClient().measurement.count({
|
|
where: { userId: principal.identities.recordUserId },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("does not replay when the cookie transport carries a misplaced selector", async () => {
|
|
const principal = SECURITY_PRINCIPALS.ownerIdempotencyPositiveControl;
|
|
await createUser(principal.identities.actorUserId);
|
|
await mintSession(principal.identities.actorUserId);
|
|
|
|
const first = await post();
|
|
expect(first.status).toBe(201);
|
|
|
|
headerJar.set(ACCOUNT_SELECTOR_HEADER, "untrusted-record-selector");
|
|
const replay = await post();
|
|
|
|
expect(replay.status).toBe(403);
|
|
expect(replay.headers.get("X-Idempotent-Replay")).not.toBe("true");
|
|
expect(handlerRuns).toBe(1);
|
|
expect(
|
|
await getPrismaClient().measurement.count({
|
|
where: { userId: principal.identities.recordUserId },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
});
|