feat(api): deterministic scan worker + AUD0 wire + tenant binding
CI (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / contracts (pull_request) Successful in 15s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m10s
CI (SIAX Cloud) / security (pull_request) Successful in 14s
CI (SIAX Cloud) / security (push) Successful in 14s
CI (SIAX Cloud) / contracts (pull_request) Successful in 15s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m10s
- scan worker: atomic claim (FOR UPDATE SKIP LOCKED), Playwright-core + system chromium capture → typed EvidenceRecords (runtime-html/dom/computed-style/ stylesheet/asset/network-request/screenshot), confidence=measured - AUD0 emitter (fire-and-forget, advisory): c0py.registry_created/.scan_created/ .scan_completed/.scan_failed, tenant_id = Zitadel resourceowner (org) - tenant binding: resourceowner claim from introspection (deny-by-default 403), migration 002 tenant_id on registries+scans, all queries tenant+owner scoped - evidence gaps stay explicit (screenshot miss → no screenshot record) - 30/30 tests, canonical validator OK
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { buildEvidence, type CapturedPage } from "../src/capture/capture.js";
|
||||
import { createAud0Emitter, noopAud0 } from "../src/audit/aud0.js";
|
||||
import { startScanWorker } from "../src/worker/scan-worker.js";
|
||||
import type { PoolLike } from "../src/services/registries.js";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
|
||||
const PAGE: CapturedPage = {
|
||||
finalUrl: "https://example.com/",
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
title: "Example",
|
||||
description: "desc",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
viewportMeta: "width=device-width",
|
||||
domCounts: { script: 3, img: 2, a: 10 },
|
||||
stylesheets: ["https://example.com/app.css"],
|
||||
images: ["https://example.com/logo.png"],
|
||||
computedStyle: { body: { "font-family": "sans-serif" }, h1: null },
|
||||
networkRequests: [{ url: "https://example.com/", method: "GET", resourceType: "document", status: 200 }],
|
||||
screenshotBase64: "abc",
|
||||
};
|
||||
|
||||
describe("capture evidence builder", () => {
|
||||
it("produces measured EvidenceRecords per kind", () => {
|
||||
const { evidence, summary } = buildEvidence("reg-1", PAGE);
|
||||
const kinds = evidence.map((e) => e.kind);
|
||||
expect(kinds).toEqual(
|
||||
expect.arrayContaining(["runtime-html", "dom", "computed-style", "stylesheet", "asset", "network-request", "screenshot"]),
|
||||
);
|
||||
for (const e of evidence) {
|
||||
expect(e.confidence).toBe("measured");
|
||||
expect(e.targetId).toBe("reg-1");
|
||||
expect(e.source.tool).toContain("playwright-core");
|
||||
expect(e.method).toBe("deterministic-browser-capture");
|
||||
expect(e.capturedAt).toBeTruthy();
|
||||
}
|
||||
expect(summary).toMatchObject({
|
||||
url: "https://example.com/",
|
||||
status: 200,
|
||||
domElementCount: 15,
|
||||
stylesheetCount: 1,
|
||||
screenshotCaptured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits screenshot evidence when capture missed it (evidence gap explicit)", () => {
|
||||
const { evidence, summary } = buildEvidence("reg-1", { ...PAGE, screenshotBase64: null });
|
||||
expect(evidence.some((e) => e.kind === "screenshot")).toBe(false);
|
||||
expect(summary.screenshotCaptured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aud0 emitter", () => {
|
||||
const base = {
|
||||
event_type: "c0py.scan_completed",
|
||||
tenant_id: "org-1",
|
||||
actor_id: "u1",
|
||||
app_id: "c0py",
|
||||
capability_id: "c0py.scan_run",
|
||||
resource_type: "scan",
|
||||
resource_id: "s1",
|
||||
risk_level: "L2" as const,
|
||||
};
|
||||
|
||||
it("no-op when unconfigured", () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const emit = createAud0Emitter({ fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
emit(base);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts event with tenant header (fire-and-forget)", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
const emit = createAud0Emitter({
|
||||
baseUrl: "https://aud0.siax.io",
|
||||
authToken: "tok",
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
emit(base);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://aud0.siax.io/v1/aud0/events",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ "x-tenant-id": "org-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("never throws on network failure (advisory)", async () => {
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new Error("down"));
|
||||
const emit = createAud0Emitter({
|
||||
baseUrl: "https://aud0.siax.io",
|
||||
authToken: "tok",
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
expect(() => emit(base)).not.toThrow();
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
});
|
||||
|
||||
it("noop emitter accepts anything", () => {
|
||||
expect(() => noopAud0(base)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan worker", () => {
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as FastifyBaseLogger;
|
||||
|
||||
function dbWith(queries: { text: string; rows: Record<string, unknown>[] }[]) {
|
||||
const calls: string[] = [];
|
||||
const db: PoolLike = {
|
||||
query: (text) => {
|
||||
calls.push(text);
|
||||
const row = queries.find((q) => text.includes(q.text));
|
||||
return Promise.resolve({ rows: row?.rows ?? [] });
|
||||
},
|
||||
};
|
||||
return { db, calls };
|
||||
}
|
||||
|
||||
it("claims, captures, completes and emits AUD0", async () => {
|
||||
const { db } = dbWith([
|
||||
{ text: "UPDATE scans SET status = 'running'", rows: [{ id: "s1", registry_id: "r1", owner_sub: "u1", tenant_id: "org-1" }] },
|
||||
{ text: "SELECT url FROM registries", rows: [{ url: "https://example.com" }] },
|
||||
{ text: "UPDATE scans SET status = 'completed'", rows: [] },
|
||||
]);
|
||||
const aud0 = vi.fn();
|
||||
const worker = startScanWorker({
|
||||
db,
|
||||
logger,
|
||||
aud0: aud0 as never,
|
||||
enabled: false,
|
||||
capture: async (url) => {
|
||||
expect(url).toBe("https://example.com");
|
||||
return buildEvidence("scan", PAGE);
|
||||
},
|
||||
});
|
||||
const did = await worker.processOne();
|
||||
expect(did).toBe(true);
|
||||
expect(aud0).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event_type: "c0py.scan_completed", tenant_id: "org-1", risk_level: "L2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks scan failed and emits c0py.scan_failed on capture error", async () => {
|
||||
const { db } = dbWith([
|
||||
{ text: "UPDATE scans SET status = 'running'", rows: [{ id: "s1", registry_id: "r1", owner_sub: "u1", tenant_id: "org-1" }] },
|
||||
{ text: "SELECT url FROM registries", rows: [] },
|
||||
{ text: "UPDATE scans SET status = 'failed'", rows: [] },
|
||||
]);
|
||||
const aud0 = vi.fn();
|
||||
const worker = startScanWorker({
|
||||
db,
|
||||
logger,
|
||||
aud0: aud0 as never,
|
||||
enabled: false,
|
||||
capture: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
const did = await worker.processOne();
|
||||
expect(did).toBe(true);
|
||||
expect(aud0).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event_type: "c0py.scan_failed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when no pending scan", async () => {
|
||||
const { db } = dbWith([{ text: "UPDATE scans SET status = 'running'", rows: [] }]);
|
||||
const worker = startScanWorker({ db, logger, aud0: noopAud0, enabled: false, capture: async () => buildEvidence("s", PAGE) });
|
||||
const did = await worker.processOne();
|
||||
expect(did).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -26,26 +26,28 @@ const ROW = {
|
||||
};
|
||||
|
||||
describe("registries service", () => {
|
||||
it("listRegistries filters by owner", async () => {
|
||||
it("listRegistries filters by owner and tenant", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [ROW] }) });
|
||||
const out = await listRegistries(db, "svc-user");
|
||||
const out = await listRegistries(db, "svc-user", "org-1");
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toMatchObject({ id: ROW.id, name: "example", createdAt: "2026-09-16T10:00:00.000Z" });
|
||||
expect(db.queries[0].values).toEqual(["svc-user"]);
|
||||
expect(db.queries[0].values).toEqual(["svc-user", "org-1"]);
|
||||
expect(db.queries[0].text).toContain("owner_sub = $1");
|
||||
expect(db.queries[0].text).toContain("tenant_id = $2");
|
||||
});
|
||||
|
||||
it("createRegistry inserts with owner and config jsonb", async () => {
|
||||
it("createRegistry inserts with owner, tenant and config jsonb", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [ROW] }) });
|
||||
const out = await createRegistry(db, "svc-user", { name: "example", url: "https://example.com" });
|
||||
const out = await createRegistry(db, "svc-user", "org-1", { name: "example", url: "https://example.com" });
|
||||
expect(out.id).toBe(ROW.id);
|
||||
expect(db.queries[0].values?.[0]).toBe("svc-user");
|
||||
expect(db.queries[0].values?.[1]).toBe("org-1");
|
||||
expect(db.queries[0].text).toContain("RETURNING");
|
||||
});
|
||||
|
||||
it("getRegistry returns null when not owner (fail-closed)", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [] }) });
|
||||
const out = await getRegistry(db, "other-user", ROW.id);
|
||||
const out = await getRegistry(db, "other-user", "org-1", ROW.id);
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -54,34 +56,36 @@ describe("scans service", () => {
|
||||
const SCAN_ROW = {
|
||||
...ROW,
|
||||
registry_id: ROW.id,
|
||||
tenant_id: "org-1",
|
||||
status: "pending",
|
||||
result: {},
|
||||
updated_at: ROW.created_at,
|
||||
};
|
||||
|
||||
it("createScan enforces registry ownership in SQL and returns null otherwise", async () => {
|
||||
it("createScan enforces registry ownership+tenant in SQL and returns null otherwise", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [] }) });
|
||||
const out = await createScan(db, "svc-user", ROW.id);
|
||||
const out = await createScan(db, "svc-user", "org-1", ROW.id);
|
||||
expect(out).toBeNull();
|
||||
expect(db.queries[0].text).toContain("owner_sub = $1");
|
||||
expect(db.queries[0].text).toContain("tenant_id = $2");
|
||||
});
|
||||
|
||||
it("createScan returns scan for owned registry", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) });
|
||||
const out = await createScan(db, "svc-user", ROW.id);
|
||||
const out = await createScan(db, "svc-user", "org-1", ROW.id);
|
||||
expect(out).toMatchObject({ id: SCAN_ROW.id, registryId: SCAN_ROW.id, status: "pending" });
|
||||
});
|
||||
|
||||
it("listScans supports optional registryId filter", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [SCAN_ROW] }) });
|
||||
await listScans(db, "svc-user");
|
||||
expect(db.queries[0].text).not.toContain("registry_id = $2");
|
||||
await listScans(db, "svc-user", SCAN_ROW.id);
|
||||
expect(db.queries[1].values).toEqual(["svc-user", SCAN_ROW.id]);
|
||||
await listScans(db, "svc-user", "org-1");
|
||||
expect(db.queries[0].text).not.toContain("registry_id = $3");
|
||||
await listScans(db, "svc-user", "org-1", SCAN_ROW.id);
|
||||
expect(db.queries[1].values).toEqual(["svc-user", "org-1", SCAN_ROW.id]);
|
||||
});
|
||||
|
||||
it("getScan returns null for foreign scan", async () => {
|
||||
const db = fakePool({ query: () => ({ rows: [] }) });
|
||||
expect(await getScan(db, "other-user", SCAN_ROW.id)).toBeNull();
|
||||
expect(await getScan(db, "other-user", "org-1", SCAN_ROW.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user