38 tests: recording provider double answers with the sibling services' real response shapes; each suite asserts the exact request wire shape (path, method, tenant/actor/idempotency headers, camelCase vs snake_case body) so a regression to the pre-rewrite invented shapes fails loudly. Includes the fail-closed policy cases (missing decision => deny, CL0UD down => typed error, never implicit allow) and the production factory guard for all four clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
171 lines
6.3 KiB
TypeScript
171 lines
6.3 KiB
TypeScript
/**
|
|
* CONTRACT test for src/clients/aud0-client.ts against AUD0's REAL wire
|
|
* contract (sax3l/aud0 main `src/api/routes.ts` + `src/api/routes/
|
|
* openlineage.ts`, verified 2026-08-31):
|
|
*
|
|
* POST /v1/aud0/events — snake_case evidence event
|
|
* POST /v1/aud0/openlineage?tenant_id=&app_id= — OpenLineage RunEvent;
|
|
* tenant_id is a QUERY PARAM (route contract), app_id defaults
|
|
* "openlineage" server-side when omitted.
|
|
*
|
|
* /v1/aud0/lineage/events (the pre-rewrite path) does not exist on AUD0 and
|
|
* never has — lineage ingest is OpenLineage-only. This test also pins the
|
|
* legacy-shape → OpenLineage conversion.
|
|
*/
|
|
import { describe, it, expect, afterEach } from "vitest";
|
|
import {
|
|
createAud0Client,
|
|
legacyLineageToOpenLineage,
|
|
type Aud0Event,
|
|
type Aud0LineageEvent,
|
|
} from "@/src/clients/aud0-client";
|
|
import type { OpenLineageRunEvent } from "@/src/lineage/openlineage";
|
|
import { startContractServer, type ContractServer } from "./contract-test-server";
|
|
|
|
let server: ContractServer | undefined;
|
|
afterEach(async () => {
|
|
await server?.close();
|
|
server = undefined;
|
|
});
|
|
|
|
function event(overrides: Partial<Aud0Event> = {}): Aud0Event {
|
|
return {
|
|
event_id: "evt_1",
|
|
event_type: "0pt.dataset_registered",
|
|
tenant_id: "t_test",
|
|
project_id: "p_test",
|
|
actor_id: "svc_0pt",
|
|
app_id: "0pt",
|
|
capability_id: "0pt.register_dataset",
|
|
resource_type: "dataset",
|
|
resource_id: "ds_1",
|
|
risk_level: "L2",
|
|
created_at: "2026-08-31T12:00:00.000Z",
|
|
correlation_id: "corr-1",
|
|
purpose: "catalog",
|
|
result: "success",
|
|
output_refs: ["0pt:dataset:ds_1"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("Aud0Client wire contract — events", () => {
|
|
it("POSTs the snake_case evidence event to /v1/aud0/events with tenant/actor headers", async () => {
|
|
server = await startContractServer([
|
|
{ method: "POST", path: "/v1/aud0/events", status: 201, respond: (r) => r.body as object },
|
|
]);
|
|
const client = createAud0Client(server.baseUrl);
|
|
await client.ingestEvent(event());
|
|
|
|
const req = server.requests[0];
|
|
expect(req.path).toBe("/v1/aud0/events");
|
|
expect(req.headers["x-tenant-id"]).toBe("t_test");
|
|
expect(req.headers["x-actor-id"]).toBe("svc_0pt");
|
|
const body = req.body as Record<string, unknown>;
|
|
// §16 evidence-context fields ride on the wire.
|
|
expect(body.event_type).toBe("0pt.dataset_registered");
|
|
expect(body.capability_id).toBe("0pt.register_dataset");
|
|
expect(body.risk_level).toBe("L2");
|
|
expect(body.purpose).toBe("catalog");
|
|
expect(body.result).toBe("success");
|
|
expect(body.output_refs).toEqual(["0pt:dataset:ds_1"]);
|
|
expect(body.correlation_id).toBe("corr-1");
|
|
});
|
|
|
|
it("treats HTTP 200 (deduplicated idempotent replay) as success", async () => {
|
|
server = await startContractServer([
|
|
{ method: "POST", path: "/v1/aud0/events", status: 200, respond: () => ({ deduplicated: true }) },
|
|
]);
|
|
const client = createAud0Client(server.baseUrl);
|
|
await expect(client.ingestEvent(event())).resolves.toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("Aud0Client wire contract — OpenLineage lineage ingest", () => {
|
|
const runEvent: OpenLineageRunEvent = {
|
|
eventType: "COMPLETE",
|
|
eventTime: "2026-08-31T12:00:00.000Z",
|
|
producer: "https://git.siax.io/sax3l/0pt",
|
|
run: { runId: "run-1" },
|
|
job: { namespace: "0pt", name: "0pt.dataset.version.v1" },
|
|
inputs: [{ namespace: "0pt", name: "source/src_1" }],
|
|
outputs: [{ namespace: "0pt", name: "dataset/ds_1" }],
|
|
};
|
|
|
|
it("POSTs the RunEvent to /v1/aud0/openlineage with tenant_id + app_id as QUERY PARAMS", async () => {
|
|
server = await startContractServer([
|
|
{
|
|
method: "POST",
|
|
path: "/v1/aud0/openlineage",
|
|
status: 201,
|
|
respond: () => ({ tenant_id: "t_test", run_id: "run-1", job: "0pt/0pt.dataset.version.v1", event_id: "evt_ol_1" }),
|
|
},
|
|
]);
|
|
const client = createAud0Client(server.baseUrl);
|
|
const ack = await client.ingestOpenLineageEvent("t_test", "0pt", runEvent);
|
|
|
|
const req = server.requests[0];
|
|
expect(req.path).toBe("/v1/aud0/openlineage");
|
|
expect(req.query).toEqual({ tenant_id: "t_test", app_id: "0pt" });
|
|
const body = req.body as Record<string, unknown>;
|
|
expect(body.eventType).toBe("COMPLETE");
|
|
expect((body.run as { runId: string }).runId).toBe("run-1");
|
|
expect((body.job as { namespace: string; name: string })).toEqual({
|
|
namespace: "0pt",
|
|
name: "0pt.dataset.version.v1",
|
|
});
|
|
expect(ack.event_id).toBe("evt_ol_1");
|
|
});
|
|
|
|
it("legacy shim: ingestLineageEvent converts to OpenLineage and hits /v1/aud0/openlineage (never /v1/aud0/lineage/events)", async () => {
|
|
server = await startContractServer([
|
|
{
|
|
method: "POST",
|
|
path: "/v1/aud0/openlineage",
|
|
status: 201,
|
|
respond: () => ({ tenant_id: "t_test", run_id: "lin_1", job: "0pt/0pt.write", event_id: "evt_ol_2" }),
|
|
},
|
|
]);
|
|
const client = createAud0Client(server.baseUrl);
|
|
const legacy: Aud0LineageEvent = {
|
|
lineage_event_id: "lin_1",
|
|
tenant_id: "t_test",
|
|
project_id: "p_test",
|
|
app_id: "0pt",
|
|
capability_id: "0pt.write",
|
|
source_type: "external_record",
|
|
source_id: "idem-1",
|
|
target_type: "external_id",
|
|
target_id: "ext_1",
|
|
lineage_type: "produced",
|
|
created_at: "2026-08-31T12:00:00.000Z",
|
|
correlation_id: "corr-1",
|
|
};
|
|
await client.ingestLineageEvent(legacy);
|
|
|
|
expect(server.requests).toHaveLength(1);
|
|
expect(server.requests[0].path).toBe("/v1/aud0/openlineage");
|
|
const body = server.requests[0].body as OpenLineageRunEvent;
|
|
expect(body.run.runId).toBe("lin_1"); // deterministic → AUD0 dedup key stable
|
|
expect(body.inputs?.[0]?.name).toBe("external_record/idem-1");
|
|
expect(body.outputs?.[0]?.name).toBe("external_id/ext_1");
|
|
});
|
|
|
|
it("conversion is deterministic (same legacy event → identical RunEvent)", () => {
|
|
const legacy: Aud0LineageEvent = {
|
|
lineage_event_id: "lin_x",
|
|
tenant_id: "t",
|
|
project_id: "p",
|
|
app_id: "0pt",
|
|
capability_id: "cap",
|
|
source_type: "a",
|
|
source_id: "1",
|
|
target_type: "b",
|
|
target_id: "2",
|
|
lineage_type: "derived_from",
|
|
created_at: "2026-08-31T00:00:00.000Z",
|
|
};
|
|
expect(legacyLineageToOpenLineage(legacy)).toEqual(legacyLineageToOpenLineage({ ...legacy }));
|
|
});
|
|
});
|