diff --git a/tests/contracts/aud0-client.contract.test.ts b/tests/contracts/aud0-client.contract.test.ts new file mode 100644 index 0000000..f4a84b0 --- /dev/null +++ b/tests/contracts/aud0-client.contract.test.ts @@ -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 { + 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; + // §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; + 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 })); + }); +}); diff --git a/tests/contracts/cl0ud-client.contract.test.ts b/tests/contracts/cl0ud-client.contract.test.ts new file mode 100644 index 0000000..ea19e3f --- /dev/null +++ b/tests/contracts/cl0ud-client.contract.test.ts @@ -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 { + 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; + 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"); + }); +}); diff --git a/tests/contracts/contract-test-server.ts b/tests/contracts/contract-test-server.ts new file mode 100644 index 0000000..74bfd79 --- /dev/null +++ b/tests/contracts/contract-test-server.ts @@ -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; + 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; +} + +export async function startContractServer(routes: ContractRoute[]): Promise { + 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((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((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))), + }; +} diff --git a/tests/contracts/dependency-mode.contract.test.ts b/tests/contracts/dependency-mode.contract.test.ts new file mode 100644 index 0000000..590416f --- /dev/null +++ b/tests/contracts/dependency-mode.contract.test.ts @@ -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(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).NODE_ENV = "production"; + try { + return fn(); + } finally { + if (prev === undefined) delete (process.env as Record).NODE_ENV; + else (process.env as Record).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(); + }); +}); diff --git a/tests/contracts/n0d-client.contract.test.ts b/tests/contracts/n0d-client.contract.test.ts new file mode 100644 index 0000000..6683074 --- /dev/null +++ b/tests/contracts/n0d-client.contract.test.ts @@ -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 { + 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; + // 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"); + } + }); +}); diff --git a/tests/contracts/siax-context.contract.test.ts b/tests/contracts/siax-context.contract.test.ts new file mode 100644 index 0000000..da7db10 --- /dev/null +++ b/tests/contracts/siax-context.contract.test.ts @@ -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" }); + }); +}); diff --git a/tests/contracts/st0re-client.contract.test.ts b/tests/contracts/st0re-client.contract.test.ts new file mode 100644 index 0000000..4c48e5b --- /dev/null +++ b/tests/contracts/st0re-client.contract.test.ts @@ -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 { + 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; + 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); + } + }); +});