mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(sharing): wait for the refusal audit row instead of guessing
The scope-fence test asserts that a refused section leaves a sharing.access.denied audit row naming the reason. That row is written without being awaited, on purpose, so the audit never delays the 403 the caller is waiting on, and it lands some time after the response returns. The test bridged that gap with a fixed 150 ms sleep, which a slow runner outran: the read found an empty table and the assertion reported no refusal, which reads exactly like the section having reached the wire, the one thing this file exists to catch. The positive case now polls for the row and fails with a message naming the caller when none arrives within five seconds. The absence case keeps a fixed wait, longer than before, because an absence claim is only worth something if it waited. Proven by dropping the audit write behind an env flag: eleven cases fail with the new message, and pass again with the write restored.
This commit is contained in:
1 parent
71a6dd0e2a
commit
9d77120136
1 file changed
+43
-10
@@ -143,8 +143,7 @@ async function drive(handler: Handler, url: string): Promise<Response> {
|
||||
* empty table and agree with themselves, which is the exact failure this
|
||||
* repository keeps rediscovering: a check that cannot fail.
|
||||
*/
|
||||
async function refusals(callerId: string): Promise<{ reason?: string }[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
async function readRefusals(callerId: string): Promise<{ reason?: string }[]> {
|
||||
const rows = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: callerId, action: "sharing.access.denied" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -159,12 +158,46 @@ async function refusals(callerId: string): Promise<{ reason?: string }[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/** The reason on the most recent one, or null when there is none. */
|
||||
async function lastRefusal(
|
||||
callerId: string,
|
||||
): Promise<{ reason?: string } | null> {
|
||||
const rows = await refusals(callerId);
|
||||
return rows.length === 0 ? null : rows[rows.length - 1];
|
||||
/**
|
||||
* The refusal audit row is written WITHOUT being awaited (`auditRefusal` in
|
||||
* `src/lib/api-handler.ts` drops the promise on purpose, so the audit write
|
||||
* never delays the 403 a caller is waiting on). The row therefore lands some
|
||||
* time after the response returns, and the two assertions here need opposite
|
||||
* treatment of that gap.
|
||||
*
|
||||
* A fixed 150 ms wait served both until a slow CI runner missed it and the
|
||||
* positive case read an empty table, which reads exactly like "the section
|
||||
* reached the wire" — the one thing this file exists to catch. So the
|
||||
* positive case polls until the row appears, and only the absence case pays
|
||||
* a fixed wait, which is what makes an absence claim mean anything.
|
||||
*/
|
||||
const REFUSAL_APPEAR_TIMEOUT_MS = 5_000;
|
||||
const REFUSAL_ABSENCE_WAIT_MS = 750;
|
||||
|
||||
/** The most recent refusal, waited for. Fails the test when none arrives. */
|
||||
async function lastRefusal(callerId: string): Promise<{ reason?: string }> {
|
||||
const deadline = Date.now() + REFUSAL_APPEAR_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
const rows = await readRefusals(callerId);
|
||||
if (rows.length > 0) return rows[rows.length - 1];
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
`no sharing.access.denied audit row for ${callerId} within ${REFUSAL_APPEAR_TIMEOUT_MS} ms`,
|
||||
);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
/** Every refusal on file, after a wait long enough for a late write. */
|
||||
async function refusals(callerId: string): Promise<{ reason?: string }[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, REFUSAL_ABSENCE_WAIT_MS));
|
||||
return readRefusals(callerId);
|
||||
}
|
||||
|
||||
/** No refusal was recorded. Waits before concluding that. */
|
||||
async function noRefusal(callerId: string): Promise<boolean> {
|
||||
return (await refusals(callerId)).length === 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -277,7 +310,7 @@ describe("a scoped grant opens the sections it names", () => {
|
||||
// Reached, not merely "not 403": a route that answered 500 would satisfy
|
||||
// an inequality against 403 and prove nothing about the fence.
|
||||
expect(response.status, PROBES[open].url).toBe(200);
|
||||
expect(await lastRefusal(delegate.id)).toBeNull();
|
||||
expect(await noRefusal(delegate.id)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(SHARE_DOMAINS)(
|
||||
@@ -324,7 +357,7 @@ describe("a NULL scope still opens the whole record", () => {
|
||||
|
||||
const response = await PROBES[domain].call();
|
||||
expect(response.status, PROBES[domain].url).toBe(200);
|
||||
expect(await lastRefusal(delegate.id)).toBeNull();
|
||||
expect(await noRefusal(delegate.id)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user