226 lines
8.9 KiB
JavaScript
226 lines
8.9 KiB
JavaScript
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);
|
|
});
|
|
});
|