test(contracts): per-client wire-contract suites + §3 production-guard tests
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b7038d7675
commit
da58176735
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* CONTRACT test for src/clients/cl0ud-client.ts against CL0UD's REAL wire
|
||||
* contract (sax3l/cl0ud main — policy evaluate + capability discovery,
|
||||
* verified 2026-08-31):
|
||||
*
|
||||
* POST /api/policy/evaluate — camelCase body, `{ data, meta }` envelope,
|
||||
* decision ∈ {allow, deny} + requiresApproval flag.
|
||||
* GET /api/capabilities — capability-registry discovery.
|
||||
*
|
||||
* The pre-rewrite client POSTed snake_case to /v1/cl0ud/policies/evaluate —
|
||||
* a path that never existed on CL0UD. Policy stays FAIL-CLOSED: missing or
|
||||
* malformed decision reads as deny.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createCl0udClient, type EvaluatePolicyInput } from "@/src/clients/cl0ud-client";
|
||||
import { SiaxError } from "@/src/platform/errors";
|
||||
import { startContractServer, type ContractServer } from "./contract-test-server";
|
||||
|
||||
let server: ContractServer | undefined;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
function input(overrides: Partial<EvaluatePolicyInput> = {}): EvaluatePolicyInput {
|
||||
return {
|
||||
tenant_id: "t_test",
|
||||
project_id: "p_test",
|
||||
actor_id: "svc_0pt",
|
||||
capability_id: "0pt.write_external_record",
|
||||
resource_type: "external_record",
|
||||
resource_id: "rec_1",
|
||||
risk_level: "L4",
|
||||
correlation_id: "corr-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Cl0udClient wire contract — policy evaluate", () => {
|
||||
it("POSTs the REAL camelCase body to /api/policy/evaluate and maps the {data} envelope", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/policy/evaluate",
|
||||
status: 200,
|
||||
respond: () => ({
|
||||
data: {
|
||||
decision: "allow",
|
||||
requiresApproval: false,
|
||||
auditRequired: true,
|
||||
riskLevel: "L4",
|
||||
decisionId: "pdec_r3al",
|
||||
reason: "rbac_ok",
|
||||
},
|
||||
meta: {},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
const decision = await client.evaluatePolicy(input());
|
||||
|
||||
const req = server.requests[0];
|
||||
expect(req.path).toBe("/api/policy/evaluate");
|
||||
const body = req.body as Record<string, unknown>;
|
||||
expect(body.capability).toBe("0pt.write_external_record");
|
||||
expect(body.tenantId).toBe("t_test");
|
||||
expect(body.projectId).toBe("p_test");
|
||||
expect(body.actor).toEqual({ kind: "operator", role: "member" });
|
||||
expect(body.surface).toBe("api");
|
||||
expect(body.resource).toEqual({ type: "external_record", id: "rec_1" });
|
||||
expect(body.correlationId).toBe("corr-1");
|
||||
// Invented pre-rewrite fields must NOT be on the wire.
|
||||
expect(body.capability_id).toBeUndefined();
|
||||
expect(body.tenant_id).toBeUndefined();
|
||||
expect(body.risk_level).toBeUndefined();
|
||||
|
||||
expect(decision).toEqual({
|
||||
decision: "allow",
|
||||
decision_id: "pdec_r3al",
|
||||
reason: "rbac_ok",
|
||||
deny_code: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps allow + requiresApproval=true onto 0pt's approval_required (gates the caller)", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/policy/evaluate",
|
||||
status: 200,
|
||||
respond: () => ({ data: { decision: "allow", requiresApproval: true, decisionId: "pdec_a" }, meta: {} }),
|
||||
},
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
const decision = await client.evaluatePolicy(input());
|
||||
expect(decision.decision).toBe("approval_required");
|
||||
});
|
||||
|
||||
it("maps deny with denyCode", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/policy/evaluate",
|
||||
status: 200,
|
||||
respond: () => ({
|
||||
data: { decision: "deny", requiresApproval: false, decisionId: "pdec_d", denyCode: "rbac_forbidden" },
|
||||
meta: {},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
const decision = await client.evaluatePolicy(input());
|
||||
expect(decision.decision).toBe("deny");
|
||||
expect(decision.deny_code).toBe("rbac_forbidden");
|
||||
});
|
||||
|
||||
it("FAILS CLOSED: 200 with a body missing `decision` is a deny, never an allow", async () => {
|
||||
server = await startContractServer([
|
||||
{ method: "POST", path: "/api/policy/evaluate", status: 200, respond: () => ({ data: {}, meta: {} }) },
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
const decision = await client.evaluatePolicy(input());
|
||||
expect(decision.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("FAILS CLOSED: CL0UD unreachable → SiaxError, never an implicit allow (§52)", async () => {
|
||||
server = await startContractServer([
|
||||
{ method: "POST", path: "/api/policy/evaluate", status: 503, respond: () => ({ error: { code: "DOWN" } }) },
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
try {
|
||||
await client.evaluatePolicy(input());
|
||||
expect.unreachable("must throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SiaxError);
|
||||
expect((err as SiaxError).code).toBe("DEPENDENCY_UNAVAILABLE");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cl0udClient wire contract — capability discovery", () => {
|
||||
it("GETs /api/capabilities with filters and returns the data array", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/capabilities",
|
||||
status: 200,
|
||||
respond: () => ({
|
||||
data: [
|
||||
{
|
||||
name: "0pt.search.query",
|
||||
version: "1.0.0",
|
||||
owner: "0pt",
|
||||
status: "active",
|
||||
requiredEntitlement: "0pt.search",
|
||||
surfaces: ["api"],
|
||||
},
|
||||
],
|
||||
meta: { total: 1 },
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const client = createCl0udClient(server.baseUrl);
|
||||
const caps = await client.listCapabilities({ owner: "0pt", status: "active" });
|
||||
expect(server.requests[0].query).toEqual({ owner: "0pt", status: "active" });
|
||||
expect(caps).toHaveLength(1);
|
||||
expect(caps[0].name).toBe("0pt.search.query");
|
||||
expect(caps[0].requiredEntitlement).toBe("0pt.search");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* tests/contracts/contract-test-server.ts
|
||||
* ========================================
|
||||
* In-process HTTP provider double for the per-client CONTRACT tests
|
||||
* (Master-DoD §10 "Contract tests finns", §50 Contract level).
|
||||
*
|
||||
* NOT a mock of 0pt's clients — a recording provider that answers with
|
||||
* response bodies shaped EXACTLY like the sibling services' real handlers
|
||||
* (verified against sax3l/st0re, sax3l/aud0, sax3l/n0d, sax3l/cl0ud main
|
||||
* this session). Each test asserts the REQUEST the client actually put on
|
||||
* the wire: path, method, headers (tenant/actor/context propagation,
|
||||
* idempotency), and body field names. That is the drift alarm: if a client
|
||||
* regresses to its pre-2026-08-31 invented wire shapes, these tests fail.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface RecordedRequest {
|
||||
method: string;
|
||||
/** Path WITHOUT query string. */
|
||||
path: string;
|
||||
/** Raw query params. */
|
||||
query: Record<string, string>;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface ContractRoute {
|
||||
method: string;
|
||||
/** Exact path to match (no query). */
|
||||
path: string;
|
||||
status: number;
|
||||
/** Response body factory — receives the recorded request. */
|
||||
respond: (req: RecordedRequest) => unknown;
|
||||
}
|
||||
|
||||
export interface ContractServer {
|
||||
baseUrl: string;
|
||||
requests: RecordedRequest[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function startContractServer(routes: ContractRoute[]): Promise<ContractServer> {
|
||||
const requests: RecordedRequest[] = [];
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c: Buffer) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
let body: unknown;
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
body = raw;
|
||||
}
|
||||
const recorded: RecordedRequest = {
|
||||
method: req.method ?? "GET",
|
||||
path: url.pathname,
|
||||
query: Object.fromEntries(url.searchParams.entries()),
|
||||
headers: req.headers,
|
||||
body,
|
||||
};
|
||||
requests.push(recorded);
|
||||
|
||||
const route = routes.find((r) => r.method === recorded.method && r.path === recorded.path);
|
||||
if (!route) {
|
||||
res.writeHead(404, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { code: "NOT_FOUND", message: `no contract route ${recorded.method} ${recorded.path}` } }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(route.status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(route.respond(recorded)));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
requests,
|
||||
close: () => new Promise<void>((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Master-DoD §3/§10: "Production kan ALDRIG tyst falla tillbaka till
|
||||
* mock/in-memory — fail hard med DEPENDENCY_UNAVAILABLE."
|
||||
*
|
||||
* These tests drive every client factory (and the store-mode resolver) in a
|
||||
* production runtime with the dependency unconfigured and assert the HARD
|
||||
* throw at construction time — plus the non-production behaviour that keeps
|
||||
* unit tests hermetic.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolveDependencyBaseUrl, resolveStoreMode } from "@/src/platform/dependency-mode";
|
||||
import { SiaxError } from "@/src/platform/errors";
|
||||
import { createCl0udClient } from "@/src/clients/cl0ud-client";
|
||||
import { createSt0reClient } from "@/src/clients/st0re-client";
|
||||
import { createN0dClient } from "@/src/clients/n0d-client";
|
||||
import { createAud0Client } from "@/src/clients/aud0-client";
|
||||
|
||||
const PROD = { NODE_ENV: "production" } as NodeJS.ProcessEnv;
|
||||
const DEV = { NODE_ENV: "test" } as NodeJS.ProcessEnv;
|
||||
|
||||
describe("resolveDependencyBaseUrl (the §3 kill switch)", () => {
|
||||
it("production + unconfigured => throws SiaxError DEPENDENCY_UNAVAILABLE", () => {
|
||||
try {
|
||||
resolveDependencyBaseUrl("st0re", undefined, PROD);
|
||||
expect.unreachable("must throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SiaxError);
|
||||
expect((err as SiaxError).code).toBe("DEPENDENCY_UNAVAILABLE");
|
||||
expect((err as SiaxError).source).toBe("st0re");
|
||||
}
|
||||
});
|
||||
|
||||
it("production + empty string => throws (whitespace is not a configuration)", () => {
|
||||
expect(() => resolveDependencyBaseUrl("aud0", " ", PROD)).toThrowError(SiaxError);
|
||||
});
|
||||
|
||||
it("non-production + unconfigured => undefined (mock allowed for tests/dev only)", () => {
|
||||
expect(resolveDependencyBaseUrl("n0d", undefined, DEV)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("configured => normalized URL in any runtime (trailing slashes stripped)", () => {
|
||||
expect(resolveDependencyBaseUrl("cl0ud", "https://x.example.com///", PROD)).toBe("https://x.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStoreMode (§3 for stateful registries)", () => {
|
||||
it("production + no database => throws DEPENDENCY_UNAVAILABLE", () => {
|
||||
expect(() => resolveStoreMode("0pt.source_registry", undefined, PROD)).toThrowError(SiaxError);
|
||||
});
|
||||
it("non-production + no database => explicit memory mode", () => {
|
||||
expect(resolveStoreMode("0pt.source_registry", undefined, DEV)).toEqual({ mode: "memory" });
|
||||
});
|
||||
it("database configured => postgres mode", () => {
|
||||
expect(resolveStoreMode("x", "postgres://u@h/db", PROD)).toEqual({ mode: "postgres", url: "postgres://u@h/db" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("client factories under NODE_ENV=production without a base URL", () => {
|
||||
// The factories read process.env.NODE_ENV via isProductionRuntime; drive it
|
||||
// for real, restoring afterwards.
|
||||
function inProduction<T>(fn: () => T): T {
|
||||
const prev = process.env.NODE_ENV;
|
||||
const prevUrls = {
|
||||
CL0UD_URL: process.env.CL0UD_URL,
|
||||
ST0RE_URL: process.env.ST0RE_URL,
|
||||
N0D_URL: process.env.N0D_URL,
|
||||
AUD0_URL: process.env.AUD0_URL,
|
||||
};
|
||||
delete process.env.CL0UD_URL;
|
||||
delete process.env.ST0RE_URL;
|
||||
delete process.env.N0D_URL;
|
||||
delete process.env.AUD0_URL;
|
||||
(process.env as Record<string, string>).NODE_ENV = "production";
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev === undefined) delete (process.env as Record<string, string | undefined>).NODE_ENV;
|
||||
else (process.env as Record<string, string>).NODE_ENV = prev;
|
||||
for (const [k, v] of Object.entries(prevUrls)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("createCl0udClient() fails hard (policy may NEVER silently allow via local pass-through)", () => {
|
||||
inProduction(() => expect(() => createCl0udClient()).toThrowError(SiaxError));
|
||||
});
|
||||
it("createSt0reClient() fails hard", () => {
|
||||
inProduction(() => expect(() => createSt0reClient()).toThrowError(SiaxError));
|
||||
});
|
||||
it("createN0dClient() fails hard", () => {
|
||||
inProduction(() => expect(() => createN0dClient()).toThrowError(SiaxError));
|
||||
});
|
||||
it("createAud0Client() fails hard", () => {
|
||||
inProduction(() => expect(() => createAud0Client()).toThrowError(SiaxError));
|
||||
});
|
||||
|
||||
it("all four factories still return local implementations in a test runtime", () => {
|
||||
expect(createCl0udClient()).toBeTruthy();
|
||||
expect(createSt0reClient()).toBeTruthy();
|
||||
expect(createN0dClient()).toBeTruthy();
|
||||
expect(createAud0Client()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* CONTRACT test for src/clients/n0d-client.ts against N0D's REAL wire
|
||||
* contract (sax3l/n0d main `src/api/schemas.ts` createJobSchema +
|
||||
* `src/api/mappers.ts` + job-lifecycle routes, verified 2026-08-31):
|
||||
*
|
||||
* POST /v1/n0d/jobs (202) — required: job_type, capability_id, tenant_id,
|
||||
* required_role (enum), resource_requirements {cpu, memory_gb, disk_gb};
|
||||
* idempotency is BODY-ONLY (idempotency_key); callback_url = CloudEvents.
|
||||
* GET /v1/n0d/jobs/:id, GET /v1/n0d/jobs/:id/result
|
||||
*
|
||||
* The pre-2026-08-31 client sent required_node_roles[]/resource_profile/
|
||||
* output_artifact_type/risk_level — fields N0D's createJobSchema does not
|
||||
* accept. This test pins the mapped wire shape.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
createN0dClient,
|
||||
resolveRequiredRole,
|
||||
resolveResourceRequirements,
|
||||
type N0dCreateJobInput,
|
||||
} from "@/src/clients/n0d-client";
|
||||
import { SiaxError } from "@/src/platform/errors";
|
||||
import { startContractServer, type ContractServer } from "./contract-test-server";
|
||||
|
||||
let server: ContractServer | undefined;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
function input(overrides: Partial<N0dCreateJobInput> = {}): N0dCreateJobInput {
|
||||
return {
|
||||
tenant_id: "t_test",
|
||||
project_id: "p_test",
|
||||
job_type: "0pt.quality.run",
|
||||
capability_id: "0pt.quality.run.v1",
|
||||
requested_by_app_id: "0pt",
|
||||
requested_by_actor_id: "svc_0pt",
|
||||
input_artifact_ids: ["art_in_1"],
|
||||
output_artifact_type: "quality_result",
|
||||
requires_backbone: false,
|
||||
requires_gpu: false,
|
||||
required_node_roles: ["compute_general"],
|
||||
resource_profile: "standard",
|
||||
risk_level: "L2",
|
||||
correlation_id: "corr-7",
|
||||
idempotency_key: "idem-job-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const WIRE_JOB = {
|
||||
job_id: "job_r3al",
|
||||
job_type: "0pt.quality.run",
|
||||
tenant_id: "t_test",
|
||||
project_id: "p_test",
|
||||
status: "queued",
|
||||
capability_id: "0pt.quality.run.v1",
|
||||
created_at: "2026-08-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("N0dClient wire contract", () => {
|
||||
it("POSTs the REAL createJob body to /v1/n0d/jobs and accepts the 202", async () => {
|
||||
server = await startContractServer([
|
||||
{ method: "POST", path: "/v1/n0d/jobs", status: 202, respond: () => WIRE_JOB },
|
||||
]);
|
||||
const client = createN0dClient(server.baseUrl);
|
||||
const job = await client.createJob(input());
|
||||
|
||||
const req = server.requests[0];
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/v1/n0d/jobs");
|
||||
expect(req.headers["x-tenant-id"]).toBe("t_test");
|
||||
|
||||
const body = req.body as Record<string, unknown>;
|
||||
// Required fields per createJobSchema.
|
||||
expect(body.job_type).toBe("0pt.quality.run");
|
||||
expect(body.capability_id).toBe("0pt.quality.run.v1");
|
||||
expect(body.tenant_id).toBe("t_test");
|
||||
expect(body.required_role).toBe("compute_general");
|
||||
expect(body.resource_requirements).toEqual({ cpu: 2, memory_gb: 4, gpu: 0, disk_gb: 10 });
|
||||
// Mapped optionals.
|
||||
expect(body.app_id).toBe("0pt");
|
||||
expect(body.requested_by).toBe("svc_0pt");
|
||||
expect(body.priority).toBe("normal");
|
||||
expect(body.requires_shared_backbone).toBe(false);
|
||||
expect(body.input).toEqual({ artifact_ids: ["art_in_1"] });
|
||||
expect(body.output_policy).toEqual({ write_to_st0re: true, artifact_type: "quality_result" });
|
||||
expect(body.idempotency_key).toBe("idem-job-1");
|
||||
expect(body.correlation_id).toBe("corr-7");
|
||||
// Legacy invented fields must NOT be on the wire.
|
||||
expect(body.required_node_roles).toBeUndefined();
|
||||
expect(body.resource_profile).toBeUndefined();
|
||||
expect(body.output_artifact_type).toBeUndefined();
|
||||
expect(body.risk_level).toBeUndefined();
|
||||
expect(body.requested_by_app_id).toBeUndefined();
|
||||
|
||||
expect(job.job_id).toBe("job_r3al");
|
||||
expect(job.status).toBe("queued");
|
||||
});
|
||||
|
||||
it("GETs job + result with tenant header (poll path)", async () => {
|
||||
server = await startContractServer([
|
||||
{ method: "GET", path: "/v1/n0d/jobs/job_r3al", status: 200, respond: () => ({ ...WIRE_JOB, status: "completed" }) },
|
||||
{
|
||||
method: "GET",
|
||||
path: "/v1/n0d/jobs/job_r3al/result",
|
||||
status: 200,
|
||||
respond: () => ({
|
||||
job_id: "job_r3al",
|
||||
outcome: "succeeded",
|
||||
output_artifacts: [{ st0re_artifact_id: "art_out_1", artifact_type: "quality_result" }],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
const client = createN0dClient(server.baseUrl);
|
||||
|
||||
const job = await client.getJob("job_r3al", "t_test");
|
||||
expect(job.status).toBe("completed");
|
||||
const result = await client.getJobResult("job_r3al", "t_test");
|
||||
expect(result.outcome).toBe("succeeded");
|
||||
expect(result.output_artifacts?.[0]?.st0re_artifact_id).toBe("art_out_1");
|
||||
|
||||
for (const req of server.requests) {
|
||||
expect(req.headers["x-tenant-id"]).toBe("t_test");
|
||||
}
|
||||
});
|
||||
|
||||
it("role/resource inference: requires_gpu forces ai_gpu + a GPU profile", () => {
|
||||
const gpuInput = input({ requires_gpu: true, required_node_roles: [], resource_profile: "standard" });
|
||||
expect(resolveRequiredRole(gpuInput)).toBe("ai_gpu");
|
||||
expect(resolveResourceRequirements(gpuInput).gpu).toBeGreaterThan(0);
|
||||
// Explicit values always win.
|
||||
const explicit = input({
|
||||
required_role: "storage",
|
||||
resource_requirements: { cpu: 1, memory_gb: 1, disk_gb: 1 },
|
||||
});
|
||||
expect(resolveRequiredRole(explicit)).toBe("storage");
|
||||
expect(resolveResourceRequirements(explicit)).toEqual({ cpu: 1, memory_gb: 1, disk_gb: 1 });
|
||||
});
|
||||
|
||||
it("maps a 404 (foreign-tenant probe / unknown job) to SiaxError NOT_FOUND", async () => {
|
||||
server = await startContractServer([]);
|
||||
const client = createN0dClient(server.baseUrl);
|
||||
try {
|
||||
await client.getJob("job_foreign", "t_other");
|
||||
expect.unreachable("must throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SiaxError);
|
||||
expect((err as SiaxError).code).toBe("NOT_FOUND");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Pins 0pt's MIRROR of cl0ud's canonical request-context contract
|
||||
* (src/platform/siax-context.ts + src/platform/errors.ts) to the canonical
|
||||
* values from sax3l/cl0ud `packages/contract/src/request-context.ts`.
|
||||
* If cl0ud evolves the contract, this test is the tripwire that forces the
|
||||
* mirror (and this pin) to be updated deliberately, never by drift.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
SIAX_CONTEXT_HEADERS,
|
||||
toPropagationHeaders,
|
||||
validateSiaxRequestContext,
|
||||
} from "@/src/platform/siax-context";
|
||||
import {
|
||||
SIAX_ERROR_CODES,
|
||||
SiaxError,
|
||||
httpStatusForSiaxErrorCode,
|
||||
siaxErrorCodeForUpstreamStatus,
|
||||
} from "@/src/platform/errors";
|
||||
|
||||
describe("SIAX context header names (pinned to cl0ud @siax/contract)", () => {
|
||||
it("uses the canonical wire header names, verbatim", () => {
|
||||
expect(SIAX_CONTEXT_HEADERS).toEqual({
|
||||
tenant: "x-tenant-id",
|
||||
actor: "x-actor-id",
|
||||
capability: "x-siax-capability",
|
||||
purpose: "x-siax-purpose",
|
||||
project: "x-siax-project-id",
|
||||
correlation: "x-siax-correlation-id",
|
||||
causation: "x-siax-causation-id",
|
||||
trace: "x-siax-trace-id",
|
||||
idempotency: "idempotency-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes required fields always and optional fields only when present", () => {
|
||||
const minimal = toPropagationHeaders({
|
||||
tenantId: "t_x",
|
||||
actorId: "svc_0pt",
|
||||
capability: "0pt.search.query.v1",
|
||||
correlationId: "corr-1",
|
||||
});
|
||||
expect(minimal).toEqual({
|
||||
"x-tenant-id": "t_x",
|
||||
"x-actor-id": "svc_0pt",
|
||||
"x-siax-capability": "0pt.search.query.v1",
|
||||
"x-siax-correlation-id": "corr-1",
|
||||
});
|
||||
|
||||
const full = toPropagationHeaders({
|
||||
tenantId: "t_x",
|
||||
projectId: "p_1",
|
||||
actorId: "svc_0pt",
|
||||
capability: "0pt.search.query.v1",
|
||||
purpose: "valuation",
|
||||
correlationId: "corr-1",
|
||||
causationId: "cause-1",
|
||||
traceId: "trace-1",
|
||||
idempotencyKey: "idem-1",
|
||||
});
|
||||
expect(full["x-siax-project-id"]).toBe("p_1");
|
||||
expect(full["x-siax-purpose"]).toBe("valuation");
|
||||
expect(full["x-siax-causation-id"]).toBe("cause-1");
|
||||
expect(full["x-siax-trace-id"]).toBe("trace-1");
|
||||
expect(full["idempotency-key"]).toBe("idem-1");
|
||||
});
|
||||
|
||||
it("validates required fields (tenantId/actorId/capability/correlationId)", () => {
|
||||
expect(
|
||||
validateSiaxRequestContext({
|
||||
tenantId: "t_x",
|
||||
actorId: "a",
|
||||
capability: "c",
|
||||
correlationId: "x",
|
||||
})
|
||||
).toEqual([]);
|
||||
const problems = validateSiaxRequestContext({ tenantId: " " });
|
||||
expect(problems.join(" ")).toMatch(/tenantId/);
|
||||
expect(problems.join(" ")).toMatch(/actorId/);
|
||||
expect(problems.join(" ")).toMatch(/capability/);
|
||||
expect(problems.join(" ")).toMatch(/correlationId/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Canonical SIAX error vocabulary (pinned to Master-DoD §6 / cl0ud)", () => {
|
||||
it("carries exactly the 12 canonical codes, in canonical order", () => {
|
||||
expect([...SIAX_ERROR_CODES]).toEqual([
|
||||
"UNAUTHENTICATED",
|
||||
"AUTHORIZATION_DENIED",
|
||||
"ENTITLEMENT_REQUIRED",
|
||||
"CAPABILITY_UNAVAILABLE",
|
||||
"QUOTA_EXCEEDED",
|
||||
"RATE_LIMITED",
|
||||
"INVALID_REQUEST",
|
||||
"NOT_FOUND",
|
||||
"CONFLICT",
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"RETRYABLE_FAILURE",
|
||||
"PERMANENT_FAILURE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps codes to the canonical HTTP statuses (pinned to cl0ud's mapping)", () => {
|
||||
expect(httpStatusForSiaxErrorCode("UNAUTHENTICATED")).toBe(401);
|
||||
expect(httpStatusForSiaxErrorCode("AUTHORIZATION_DENIED")).toBe(403);
|
||||
expect(httpStatusForSiaxErrorCode("ENTITLEMENT_REQUIRED")).toBe(402);
|
||||
expect(httpStatusForSiaxErrorCode("QUOTA_EXCEEDED")).toBe(402);
|
||||
expect(httpStatusForSiaxErrorCode("CAPABILITY_UNAVAILABLE")).toBe(404);
|
||||
expect(httpStatusForSiaxErrorCode("RATE_LIMITED")).toBe(429);
|
||||
expect(httpStatusForSiaxErrorCode("INVALID_REQUEST")).toBe(400);
|
||||
expect(httpStatusForSiaxErrorCode("NOT_FOUND")).toBe(404);
|
||||
expect(httpStatusForSiaxErrorCode("CONFLICT")).toBe(409);
|
||||
expect(httpStatusForSiaxErrorCode("DEPENDENCY_UNAVAILABLE")).toBe(503);
|
||||
expect(httpStatusForSiaxErrorCode("RETRYABLE_FAILURE")).toBe(503);
|
||||
expect(httpStatusForSiaxErrorCode("PERMANENT_FAILURE")).toBe(500);
|
||||
});
|
||||
|
||||
it("classifies upstream statuses with retryable-vs-permanent explicit (§6)", () => {
|
||||
expect(siaxErrorCodeForUpstreamStatus(503)).toBe("DEPENDENCY_UNAVAILABLE");
|
||||
expect(siaxErrorCodeForUpstreamStatus(429)).toBe("RATE_LIMITED");
|
||||
expect(siaxErrorCodeForUpstreamStatus(500)).toBe("RETRYABLE_FAILURE");
|
||||
const retryable = new SiaxError("DEPENDENCY_UNAVAILABLE", "st0re", "down");
|
||||
expect(retryable.retryable).toBe(true);
|
||||
const permanent = new SiaxError("PERMANENT_FAILURE", "st0re", "broken");
|
||||
expect(permanent.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it("carries correlation id in the error and its JSON form (§6)", () => {
|
||||
const err = new SiaxError("NOT_FOUND", "n0d", "job missing", { correlationId: "corr-9" });
|
||||
expect(err.correlationId).toBe("corr-9");
|
||||
expect(err.toJSON()).toMatchObject({ error: "NOT_FOUND", source: "n0d", correlation_id: "corr-9" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* CONTRACT test for src/clients/st0re-client.ts against ST0RE's REAL wire
|
||||
* contract (sax3l/st0re main `src/api/routes/artifacts.ts` +
|
||||
* `src/api/validation.ts` ArtifactInputSchema, verified 2026-08-31):
|
||||
*
|
||||
* POST /v1/st0re/artifacts/register
|
||||
* headers: X-Tenant-Id (required), X-Actor-Id (required),
|
||||
* X-Idempotency-Key (optional)
|
||||
* body: camelCase — tenantId, projectId, appId, artifactType,
|
||||
* displayName, producedBy, primaryObjectId, schemaVersion,
|
||||
* retentionPolicy, createdBy, checksumSha256 (64 hex)
|
||||
* 201 → { artifactId, createdAt, ... } / 200 → idempotent replay
|
||||
*
|
||||
* The pre-2026-08-31 client sent snake_case + object_location{} — that shape
|
||||
* fails ST0RE's Zod validation with a 400. This test is the tripwire.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createSt0reClient, type St0reArtifactInput } from "@/src/clients/st0re-client";
|
||||
import { SiaxError } from "@/src/platform/errors";
|
||||
import { startContractServer, type ContractServer } from "./contract-test-server";
|
||||
|
||||
let server: ContractServer | undefined;
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
|
||||
const CHECKSUM = "a".repeat(64);
|
||||
|
||||
function input(overrides: Partial<St0reArtifactInput> = {}): St0reArtifactInput {
|
||||
return {
|
||||
tenant_id: "t_test",
|
||||
project_id: "p_test",
|
||||
artifact_type: "dataset_export",
|
||||
app_id: "0pt",
|
||||
produced_by: "0pt.dataset.export",
|
||||
object_location: { provider: "minio", bucket: "siax-tenants", key: "t_test/exports/x.parquet" },
|
||||
content_type: "application/vnd.apache.parquet",
|
||||
size_bytes: 1234,
|
||||
checksum_sha256: CHECKSUM,
|
||||
pii_level: "none",
|
||||
retention_policy: "standard",
|
||||
data_residency: "eu",
|
||||
risk_level: "L2",
|
||||
correlation_id: "corr-42",
|
||||
idempotency_key: "idem-artifact-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("St0reClient wire contract", () => {
|
||||
it("POSTs the REAL camelCase ArtifactInput to /v1/st0re/artifacts/register with tenant/actor/idempotency headers", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/st0re/artifacts/register",
|
||||
status: 201,
|
||||
respond: () => ({ artifactId: "art_r3al", tenantId: "t_test", createdAt: 1756640000000 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const client = createSt0reClient(server.baseUrl);
|
||||
const artifact = await client.createArtifact(input());
|
||||
|
||||
expect(server.requests).toHaveLength(1);
|
||||
const req = server.requests[0];
|
||||
expect(req.method).toBe("POST");
|
||||
expect(req.path).toBe("/v1/st0re/artifacts/register");
|
||||
// Required ST0RE headers.
|
||||
expect(req.headers["x-tenant-id"]).toBe("t_test");
|
||||
expect(req.headers["x-actor-id"]).toBe("0pt.dataset.export");
|
||||
expect(req.headers["x-idempotency-key"]).toBe("idem-artifact-1");
|
||||
|
||||
// REAL body contract: camelCase, required fields present, s3:// URI.
|
||||
const body = req.body as Record<string, unknown>;
|
||||
expect(body.tenantId).toBe("t_test");
|
||||
expect(body.projectId).toBe("p_test");
|
||||
expect(body.appId).toBe("0pt");
|
||||
expect(body.artifactType).toBe("dataset_export");
|
||||
expect(body.displayName).toBe("x.parquet");
|
||||
expect(body.producedBy).toBe("0pt.dataset.export");
|
||||
expect(body.primaryObjectId).toBe("s3://siax-tenants/t_test/exports/x.parquet");
|
||||
expect(body.schemaVersion).toBe("v1");
|
||||
expect(body.retentionPolicy).toBe("standard");
|
||||
expect(body.createdBy).toBe("0pt.dataset.export");
|
||||
expect(body.checksumSha256).toBe(CHECKSUM);
|
||||
expect(body.idempotencyKey).toBe("idem-artifact-1");
|
||||
// The invented pre-rewrite fields must NOT be on the wire.
|
||||
expect(body.object_location).toBeUndefined();
|
||||
expect(body.tenant_id).toBeUndefined();
|
||||
|
||||
// Response mapping.
|
||||
expect(artifact.artifact_id).toBe("art_r3al");
|
||||
expect(artifact.created_at).toBe(new Date(1756640000000).toISOString());
|
||||
});
|
||||
|
||||
it("treats HTTP 200 as an idempotent replay success (ST0RE's documented semantics)", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/st0re/artifacts/register",
|
||||
status: 200,
|
||||
respond: () => ({ artifactId: "art_existing", createdAt: 1756000000000 }),
|
||||
},
|
||||
]);
|
||||
const client = createSt0reClient(server.baseUrl);
|
||||
const artifact = await client.createArtifact(input());
|
||||
expect(artifact.artifact_id).toBe("art_existing");
|
||||
});
|
||||
|
||||
it("maps a 400 to SiaxError INVALID_REQUEST without leaking the upstream body", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/st0re/artifacts/register",
|
||||
status: 400,
|
||||
respond: () => ({ error: { code: "ST0RE_VALIDATION_ERROR", message: "secret-internal-detail" } }),
|
||||
},
|
||||
]);
|
||||
const client = createSt0reClient(server.baseUrl);
|
||||
try {
|
||||
await client.createArtifact(input());
|
||||
expect.unreachable("must throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SiaxError);
|
||||
expect((err as SiaxError).code).toBe("INVALID_REQUEST");
|
||||
expect((err as SiaxError).message).toContain("ST0RE_VALIDATION_ERROR");
|
||||
expect((err as SiaxError).message).not.toContain("secret-internal-detail");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a 503 to a RETRYABLE SiaxError DEPENDENCY_UNAVAILABLE", async () => {
|
||||
server = await startContractServer([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/v1/st0re/artifacts/register",
|
||||
status: 503,
|
||||
respond: () => ({ error: { code: "ST0RE_UNAVAILABLE" } }),
|
||||
},
|
||||
]);
|
||||
const client = createSt0reClient(server.baseUrl);
|
||||
try {
|
||||
await client.createArtifact(input());
|
||||
expect.unreachable("must throw");
|
||||
} catch (err) {
|
||||
expect((err as SiaxError).code).toBe("DEPENDENCY_UNAVAILABLE");
|
||||
expect((err as SiaxError).retryable).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user