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