Harden the guards that could go green for the wrong reason

The bearer MINT matcher demanded a literal apiToken.create( in the very
file that documents how a whitespace-wrapped call walked past its T1
sibling; it is whitespace-tolerant now, asserts a non-zero match count,
and the always-true filter beside it is gone. The T1 resolver matcher no
longer demands tokenHash first in the where object. The English-leak
guard pins a non-zero key floor per bundle so a broken walker cannot
pass as no-leaks-found. The two audit-row integration suites poll for
the fire-and-forget rows instead of sleeping a fixed span, with one
deliberately widened grace kept for the absence assertion. And the
fixture fuses with 2027 horizons (suggested-actions next-due, the
printed-expiry trio) are now-anchored so they can never age into
inverted assertions.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 02:33:07 +02:00
1 parent b49013e6c6
commit 95baf58be7
8 files changed
+95 -30

No files matched your search

@@ -87,8 +87,11 @@ describe("T1 — the Bearer resolution set is frozen", () => {
// three releases without this guard ever seeing it. A matcher that misses
// a real resolver is green because it matched nothing, not because
// nothing was there.
// `tokenHash` may sit anywhere inside the `where` object — demanding it
// first would let a formatting change (another key ahead of it) walk a
// resolver out of the guard's sight.
const resolvers = filesMatching(
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{\s*tokenHash/,
/resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{[^}]*?tokenHash/,
);
// Non-zero proof: an empty match set must fail rather than agree with an
@@ -149,10 +152,16 @@ describe("T3 — the mint sites are frozen", () => {
};
it("only the known files create ApiToken rows", () => {
const minters = filesMatching(/apiToken\.create\(|issueApiToken\(/).filter(
// `issue-token.ts` defines the helper; the others call it.
(rel) => rel !== "lib/auth/issue-token.ts" || true,
// Whitespace-tolerant like the T1 resolver matcher — the lesson this
// very file documents: a prettier-wrapped `apiToken\n .create({` was
// invisible to the literal matcher, and a guard that matches nothing
// is green for the wrong reason.
const minters = filesMatching(
/apiToken\s*\.\s*create\s*\(|issueApiToken\s*\(/,
);
// Non-zero proof: an empty match set must fail rather than agree with
// an emptied site table.
expect(minters.length).toBeGreaterThan(0);
expect(minters).toEqual(Object.keys(MINT_SITES).sort());
});
@@ -204,6 +204,15 @@ describe("i18n English-leak guard", () => {
flattenValues(en, "", enFlat);
const enMap = new Map(enFlat);
it("actually reads the bundles (non-zero floor)", () => {
// The walker is the guard's eyes. If `flattenValues` rots — a shape
// change, a broken recursion — every leak test above it goes green over
// locales it never read. The bundle carries ~7800 keys; a floor well
// below that but far above zero fails on rot without failing on
// ordinary key churn.
expect(enFlat.length).toBeGreaterThan(3000);
});
it.each(LEAK_LOCALES.map((locale) => ({ locale })))(
"$locale does not echo English for multi-word strings",
({ locale }) => {
@@ -212,6 +221,9 @@ describe("i18n English-leak guard", () => {
) as Record<string, unknown>;
const flat: [string, string][] = [];
flattenValues(data, "", flat);
// Same non-zero proof per locale — an unreadable or empty bundle must
// fail loudly, never pass as "no leaks found".
expect(flat.length).toBeGreaterThan(3000);
const leaks = flat
.filter(([key, value]) => {
@@ -37,7 +37,12 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
encryptToBytes: (s: string) => new Uint8Array(Buffer.from(`enc:${s}`)),
}));
vi.mock("@/lib/measurement-reminders/scheduling", () => ({
computeReminderNextDueAt: vi.fn(() => new Date("2027-06-27T09:00:00Z")),
// Now-anchored: the fixed 2027 date was a fuse — on that day the mocked
// next-due flipped from future to past and every due/overdue assertion
// downstream inverted.
computeReminderNextDueAt: vi.fn(
() => new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
),
}));
vi.mock("@/lib/measurement-reminders/dto", () => ({
toMeasurementReminderDto: vi.fn((r: { id: string }) => ({ id: r.id })),
@@ -214,7 +214,10 @@ describe("POST /api/medications/[id]/inventory", () => {
state: "ACTIVE",
} as never);
const printed = "2027-06-01T00:00:00Z";
// Now-anchored so the fixture can never age into a past expiry.
const printed = new Date(
Date.now() + 300 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
@@ -395,7 +395,9 @@ describe("consumeForIntake", () => {
}),
item({
id: "later-expiry",
printedExpiry: new Date("2027-06-01T00:00:00.000Z"),
// Now-anchored so the fixture can never age into an expired
// container and flip the state assertions.
printedExpiry: new Date(Date.now() + 300 * 24 * 60 * 60 * 1000),
}),
item({
id: "sooner-expiry",
@@ -72,7 +72,8 @@ describe("expireStaleInUseItems", () => {
describe("buildCreateInventoryInput", () => {
it("computes expiresAt = printedExpiry when firstUseAt is null", () => {
const printed = new Date("2027-06-01T00:00:00Z");
// Now-anchored so the fixture can never age into a past expiry.
const printed = new Date(Date.now() + 300 * 24 * 60 * 60 * 1000);
const input = buildCreateInventoryInput({
userId: "u",
medicationId: "m",
@@ -160,9 +160,12 @@ describe("B1 — a narrow token cannot read the full-account export", () => {
expect(json.error).toBe("Insufficient permissions");
// The break is visible AND attributable: an operator can name the token.
// Audit writes are fire-and-forget; give the microtask a beat to land.
await new Promise((r) => setTimeout(r, 100));
expect(await lastBearerFailureReason()).toBe("undeclared_scope");
// The audit write is fire-and-forget — poll for the value instead of
// sleeping a fixed span and hoping the runner was fast enough; on
// timeout the poll asserts against what WAS written.
await expect
.poll(() => lastBearerFailureReason(), { timeout: 5_000, interval: 100 })
.toBe("undeclared_scope");
});
it("refuses the same token on GET /api/labs with 403", async () => {
+49 -19
View File
@@ -117,9 +117,33 @@ async function rowsFor(action: string) {
});
}
/** The resolver's access row is fire-and-forget; give it a tick to land. */
async function settle() {
await new Promise((resolve) => setTimeout(resolve, 80));
/**
* Poll the trail until `action` carries exactly `count` rows, then return
* them. The access rows are fire-and-forget, and the old fixed 80 ms grace
* was a race the assertion lost whenever the runner was busy — which is
* exactly when CI is slowest and least willing to explain itself. On
* timeout the poll asserts against the real count, so a genuine regression
* reports what WAS written rather than a bare timeout.
*/
async function rowsSettled(action: string, count: number) {
await expect
.poll(async () => (await rowsFor(action)).length, {
timeout: 5_000,
interval: 100,
})
.toBe(count);
return rowsFor(action);
}
/**
* The ONE remaining fixed grace, for the absence assertion below —
* "writes nothing when nobody delegates" cannot be polled into existence
* (there is no value to wait for), so it waits a bounded span and then
* proves the silence. Widened from the old 80 ms so a busy runner cannot
* turn a late write into a false pass.
*/
async function settleForAbsence() {
await new Promise((resolve) => setTimeout(resolve, 250));
}
beforeEach(async () => {
@@ -188,10 +212,8 @@ describe("who the trail says did it", () => {
await switchSessionTo(session.id, stranger.id);
expect((await act()).status).toBe(403);
await settle();
const rows = await rowsFor("sharing.access.denied");
expect(rows).toHaveLength(1);
const rows = await rowsSettled("sharing.access.denied", 1);
expect(rows[0].userId).toBe(delegate.id);
expect(rows[0].actorUserId).toBeNull();
});
@@ -204,13 +226,27 @@ describe("delegated reads coalesce to one row a day", () => {
await act();
await act();
await act();
await settle();
// Poll on the COUNTED value: the coalesced row exists after the first
// read, so a length poll would pass while increments are still in
// flight.
await expect
.poll(
async () => {
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
return rows.length === 1
? (JSON.parse(rows[0].details ?? "{}") as { accesses?: number })
.accesses
: null;
},
{ timeout: 5_000, interval: 100 },
)
.toBe(3);
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
expect(rows).toHaveLength(1);
expect(rows[0].userId).toBe(owner.id);
expect(rows[0].actorUserId).toBe(delegate.id);
expect(JSON.parse(rows[0].details!)).toEqual({ accesses: 3 });
});
it("keeps a second delegate's reads on their own row", async () => {
@@ -231,10 +267,8 @@ describe("delegated reads coalesce to one row a day", () => {
const secondSession = await signIn(second.id);
await switchSessionTo(secondSession.id, owner.id);
await act();
await settle();
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
expect(rows).toHaveLength(2);
const rows = await rowsSettled(DELEGATED_ACCESS_ACTION, 2);
expect(rows.map((r) => r.actorUserId).sort()).toEqual(
[delegate.id, second.id].sort(),
);
@@ -245,7 +279,7 @@ describe("delegated reads coalesce to one row a day", () => {
const { owner, delegate } = await household();
await act();
await settle();
await rowsSettled(DELEGATED_ACCESS_ACTION, 1);
// Age today's row by a day. The coalescing key is the row's own date, so
// this is exactly what tomorrow looks like to the next read — and if the
@@ -258,10 +292,8 @@ describe("delegated reads coalesce to one row a day", () => {
`;
await act();
await settle();
const rows = await rowsFor(DELEGATED_ACCESS_ACTION);
expect(rows).toHaveLength(2);
const rows = await rowsSettled(DELEGATED_ACCESS_ACTION, 2);
expect(rows.every((r) => r.userId === owner.id)).toBe(true);
expect(rows.every((r) => r.actorUserId === delegate.id)).toBe(true);
});
@@ -271,7 +303,7 @@ describe("delegated reads coalesce to one row a day", () => {
await signIn(solo.id);
await act();
await settle();
await settleForAbsence();
expect(await rowsFor(DELEGATED_ACCESS_ACTION)).toHaveLength(0);
});
@@ -365,9 +397,7 @@ describe("a refused read is reported as a refused read", () => {
// The breadcrumb is fire-and-forget, which is the property that made it
// invisible to a guard reading only awaited calls.
await settle();
const rows = await rowsFor("mood-entries.list.validation-failed");
expect(rows, "no breadcrumb row was written").toHaveLength(1);
const rows = await rowsSettled("mood-entries.list.validation-failed", 1);
expect(rows[0]?.userId).toBe(owner.id);
expect(rows[0]?.actorUserId).toBe(delegate.id);