From 95baf58be7c7006b6b1bfa9695ccdca78d8e5117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 02:33:07 +0200 Subject: [PATCH] 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. --- .../bearer-scope-enforcement-guard.test.ts | 17 +++-- src/__tests__/i18n-english-leak-guard.test.ts | 12 ++++ .../suggested-actions/__tests__/route.test.ts | 7 +- .../[id]/inventory/__tests__/route.test.ts | 5 +- .../inventory/__tests__/consumption.test.ts | 4 +- .../inventory/__tests__/service.test.ts | 3 +- .../bearer-scope-enforcement.test.ts | 9 ++- tests/integration/sharing-audit-actor.test.ts | 68 +++++++++++++------ 8 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 669ed5020..83a2d173c 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -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()); }); diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 03abb0fbb..ea51a04c2 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -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; 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]) => { diff --git a/src/app/api/coach/suggested-actions/__tests__/route.test.ts b/src/app/api/coach/suggested-actions/__tests__/route.test.ts index f38a76c2c..2bf579519 100644 --- a/src/app/api/coach/suggested-actions/__tests__/route.test.ts +++ b/src/app/api/coach/suggested-actions/__tests__/route.test.ts @@ -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 })), diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 33c877cd6..cd9d4251a 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -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, diff --git a/src/lib/medications/inventory/__tests__/consumption.test.ts b/src/lib/medications/inventory/__tests__/consumption.test.ts index d83348f1f..0e899662d 100644 --- a/src/lib/medications/inventory/__tests__/consumption.test.ts +++ b/src/lib/medications/inventory/__tests__/consumption.test.ts @@ -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", diff --git a/src/lib/medications/inventory/__tests__/service.test.ts b/src/lib/medications/inventory/__tests__/service.test.ts index 0c5751b42..bb841c2ae 100644 --- a/src/lib/medications/inventory/__tests__/service.test.ts +++ b/src/lib/medications/inventory/__tests__/service.test.ts @@ -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", diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index cbf01602b..47887ad95 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -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 () => { diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 387217e35..426b86d9c 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -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);