diff --git a/services/api/src/doctor.ts b/services/api/src/doctor.ts index bc96c590..ec1051b5 100644 --- a/services/api/src/doctor.ts +++ b/services/api/src/doctor.ts @@ -740,7 +740,7 @@ async function checkReceiptIntegrity(db: Db, orgId: string): Promise { if (runIds?.length === 0) { - return { ok: true, checked: 0, invalidRunIds: [], unauditedRunIds: [] }; + return { + ok: true, + checked: 0, + invalidRunIds: [], + unauditedRunIds: [], + missingReceiptRunIds: [], + }; } const stored = await db .select({ id: runs.id, receipt: runs.receipt }) @@ -41,6 +53,11 @@ export async function verifyStoredReceipts( } const invalidRunIds: string[] = []; const unauditedRunIds: string[] = []; + const storedIds = new Set(stored.map((row) => row.id)); + const scope = runIds ? new Set(runIds) : null; + const missingReceiptRunIds = [...auditedDigests.keys()].filter( + (id) => !storedIds.has(id) && (!scope || scope.has(id)), + ); for (const row of stored) { const parsed = FacilityReceiptSchema.safeParse(row.receipt); if (!parsed.success || !verifyFacilityReceipt(parsed.data)) { @@ -52,10 +69,14 @@ export async function verifyStoredReceipts( } } return { - ok: invalidRunIds.length === 0 && unauditedRunIds.length === 0, + ok: + invalidRunIds.length === 0 && + unauditedRunIds.length === 0 && + missingReceiptRunIds.length === 0, checked: stored.length, invalidRunIds, unauditedRunIds, + missingReceiptRunIds, }; } diff --git a/services/api/test/sandbox.test.ts b/services/api/test/sandbox.test.ts index 139595d0..6ad9b784 100644 --- a/services/api/test/sandbox.test.ts +++ b/services/api/test/sandbox.test.ts @@ -1741,6 +1741,40 @@ describe("sandbox api", async () => { await expect(verifyStoredReceipts(db, orgId, [run.id])).resolves.toMatchObject({ ok: true }); }); + it("receipt integrity notices a receipt destroyed after run.finished was audited", async () => { + // #226: verifyStoredReceipts only walked runs that still HAVE a receipt, + // so nulling one (or deleting the run) silently shrank `checked` while ok + // stayed true. The reverse question — audited digest, no receipt behind + // it — must go red. + const token = "frt_receipt_destroyed"; + const run = await insertRunnerRun(token, "running"); + const response = await app.inject({ + method: "POST", + url: `/internal/runs/${run.id}/result`, + headers: { authorization: `Bearer ${token}` }, + payload: { status: "succeeded" }, + }); + expect(response.statusCode).toBe(200); + await expect(verifyStoredReceipts(db, orgId, [run.id])).resolves.toMatchObject({ ok: true }); + + await db.update(runs).set({ receipt: null }).where(eq(runs.id, run.id)); + const nulled = await verifyStoredReceipts(db, orgId, [run.id]); + expect(nulled.ok).toBe(false); + expect(nulled.missingReceiptRunIds).toEqual([run.id]); + expect(nulled.invalidRunIds).toEqual([]); + + // Scope is respected: asking about other runs stays clean. + const outOfScope = await verifyStoredReceipts(db, orgId, ["run_someone_else"]); + expect(outOfScope.missingReceiptRunIds).toEqual([]); + + // A deleted run row is the same anomaly — the audit trail outlives it. + await db.delete(runEvents).where(eq(runEvents.runId, run.id)); + await db.delete(runs).where(eq(runs.id, run.id)); + const deleted = await verifyStoredReceipts(db, orgId); + expect(deleted.ok).toBe(false); + expect(deleted.missingReceiptRunIds).toContain(run.id); + }); + it("delivers run events over the NOTIFY-backed SSE path without safety polling", async () => { const token = "frt_stream"; const run = await insertRunnerRun(token, "running");