import { jest } from "@jest/globals"; import { summarizeVerification, verifyMany } from "./verify.js"; // Build the record shape fetchAuditRecords produces. const record = (auditId, sigCount = 1) => ({ auditId, audit: { version: 1, hashType: "sha256", digest: `d${auditId}`, created: 100, eventId: "e1" }, signatures: Array.from({ length: sigCount }, (_, i) => ({ id: `alice@a`, jws: `x${i}` })), }); // A verifier verdict as the canonical verifier returns it: the record we passed // in, echoed back on `.audit`, plus its per-check results. const verdict = (rec, results) => ({ audit: rec, results }); describe("summarizeVerification", () => { it("reports no audit record when nothing was found", () => { expect(summarizeVerification({ eventUuid: "e1", auditId: null }, [], null)).toEqual({ eventUuid: "e1", auditId: null, status: "no-audit", verified: false, signatureCount: 0, auditCount: 0, checks: null, reason: "no audit record", }); }); it("marks verified when the record's verdict is in the valid bucket", () => { const r = record(9, 2); const result = { data: { valid: [verdict(r, { idMatch: true, auditSig: true })], invalid: [] } }; expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toEqual({ eventUuid: null, auditId: 9, status: "verified", verified: true, signatureCount: 2, auditCount: 1, checks: { idMatch: true, auditSig: true }, }); }); it("marks invalid and surfaces the failed checks", () => { const r = record(9, 2); const result = { data: { valid: [], invalid: [verdict(r, { auditSig: false })] } }; expect(summarizeVerification({ eventUuid: null, auditId: 9 }, [r], result)).toMatchObject({ status: "invalid", verified: false, checks: { auditSig: false }, }); }); // The regression this whole change is about: one event, two audit records. describe("an event with several audit records", () => { const produce = record(9); const process = record(10); const target = { eventUuid: "e1", auditId: null }; it("matches each verdict to its own record by audit id, not by position", () => { // Deliberately out of order: `process` is valid, `produce` is not. const result = { data: { valid: [verdict(process, { auditSig: true })], invalid: [verdict(produce, { auditSig: false })], }, }; const out = summarizeVerification(target, [produce, process], result); expect(out).toMatchObject({ status: "invalid", verified: false, auditCount: 2 }); // The failing record's checks are what surface, not the first bucket's. expect(out.checks).toEqual({ auditSig: false }); }); it("only reports verified when EVERY audit record passed", () => { const allGood = { data: { valid: [verdict(produce, { auditSig: true }), verdict(process, { auditSig: true })], invalid: [] }, }; expect(summarizeVerification(target, [produce, process], allGood)).toMatchObject({ status: "verified", verified: true, auditCount: 2, signatureCount: 2, }); }); }); }); // A fake pooled client that routes by SQL so we can exercise verifyMany's // orchestration without a DB or the crypto engine. `entity` is matched first so // the audit query's audit_signature JOIN can't shadow it. function routingClient(routes) { const query = jest.fn(async (sql, params) => { if (/FROM entity/.test(sql)) return { rows: routes.entity ?? [] }; if (/FROM audit/.test(sql)) return { rows: routes.audit?.(sql, params) ?? [] }; return { rows: [] }; }); return { query, release: jest.fn(async () => {}) }; } // One audit row as it comes back from Postgres (snake_case + aggregated JSON). const auditRow = (auditId, eventId = "e1") => ({ audit_id: auditId, version: 1, event_id: eventId, hash_type: "sha256", digest: `d${auditId}`, created: "100", sender_id: "did:alice", recipient_id: "did:bob", signatures: [{ version: 1, id: "alice@a", signedon: "101", type: "audit", jws: "x" }], }); describe("verifyMany", () => { it("returns [] when there are no targets", async () => { expect(await verifyMany(1, {})).toEqual([]); expect(await verifyMany(1, { eventUuids: [], auditIds: [] })).toEqual([]); }); it("verifies an event by assembling its audit records and calling the verifier", async () => { const client = routingClient({ entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], audit: () => [auditRow(9)], }); const verify = jest.fn(async (input) => ({ data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, })); const out = await verifyMany(1, { eventUuids: ["e1"] }, { acquire: async () => client, verify }); expect(out).toEqual([{ eventUuid: "e1", auditId: null, status: "verified", verified: true, signatureCount: 1, auditCount: 1, checks: { ok: true }, }]); expect(verify).toHaveBeenCalledWith( expect.objectContaining({ didDocs: [{ id: "did:alice" }], audits: [expect.objectContaining({ auditId: 9, audit: expect.objectContaining({ eventId: "e1" }) })], }), 1, ); }); it("resolves only the DIDs the batch references, once, and never all entities", async () => { const entityCalls = []; const client = routingClient({ entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], audit: (_sql, params) => [auditRow(params[1])], }); const inner = client.query; client.query = jest.fn(async (sql, params) => { if (/FROM entity/.test(sql)) entityCalls.push(params); return inner(sql, params); }); const verify = jest.fn(async (input) => ({ data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, })); await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify }); // One lookup for the batch, scoped to the two DIDs on those events. expect(entityCalls).toHaveLength(1); expect(entityCalls[0]).toEqual([1, ["did:alice", "did:bob"]]); // Second target reused the cache rather than re-querying. expect(verify.mock.calls[1][0].didDocs).toEqual([{ id: "did:alice" }]); }); it("looks an audit-tab target up by audit_id, scoped to the user", async () => { let seen; const client = routingClient({ entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], audit: (sql, params) => { seen = { sql, params }; return [auditRow(42)]; }, }); const verify = jest.fn(async (input) => ({ data: { valid: [{ audit: input.audits[0], results: { ok: true } }], invalid: [] }, })); const out = await verifyMany(1, { auditIds: [42] }, { acquire: async () => client, verify }); expect(out[0]).toMatchObject({ auditId: 42, eventUuid: null, status: "verified" }); expect(seen.sql).toMatch(/a\.audit_id = \$2/); expect(seen.sql).not.toMatch(/a\.event_id = \$2/); // user_id is always $1 — an audit id alone never reaches another tenant's row. expect(seen.params.slice(0, 2)).toEqual([1, 42]); }); it("gives each audit record of one event its own independent result", async () => { // Both rows belong to event e1; only audit 9 verifies. const client = routingClient({ entity: [{ did_id: "did:alice", did_doc: { id: "did:alice" } }], audit: (_sql, params) => [auditRow(params[1])], }); const verify = jest.fn(async (input) => { const rec = input.audits[0]; return rec.auditId === 9 ? { data: { valid: [{ audit: rec, results: { ok: true } }], invalid: [] } } : { data: { valid: [], invalid: [{ audit: rec, results: { ok: false } }] } }; }); const out = await verifyMany(1, { auditIds: [9, 10] }, { acquire: async () => client, verify }); expect(out).toHaveLength(2); expect(out[0]).toMatchObject({ auditId: 9, verified: true }); expect(out[1]).toMatchObject({ auditId: 10, verified: false, status: "invalid" }); }); it("reports no audit record when the target has none", async () => { const client = routingClient({ entity: [{ short_name: "alice@a" }], audit: () => [] }); const verify = jest.fn(); const out = await verifyMany(1, { eventUuids: ["missing"] }, { acquire: async () => client, verify }); expect(out[0]).toMatchObject({ status: "no-audit", verified: false, signatureCount: 0, reason: "no audit record" }); expect(verify).not.toHaveBeenCalled(); }); it("caps the batch at 200 targets regardless of input length", async () => { const client = routingClient({ entity: [{ short_name: "a" }], audit: () => [] }); const eventUuids = Array.from({ length: 150 }, (_, i) => `id-${i}`); const auditIds = Array.from({ length: 150 }, (_, i) => i + 1); const out = await verifyMany(1, { eventUuids, auditIds }, { acquire: async () => client, verify: jest.fn() }); expect(out).toHaveLength(200); }); });