PR W: DBIS Complete Credential (cc-*) adapters with provider-switch #27
183
orchestrator/src/services/completeCredential/controlsMatrix.ts
Normal file
183
orchestrator/src/services/completeCredential/controlsMatrix.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Loader for the `DBIS/cc-compliance-controls` controls matrix.
|
||||
*
|
||||
* `cc-compliance-controls` ships a v0 matrix at
|
||||
* `controls/matrix/v0.yaml`. When `CC_CONTROLS_MATRIX_URL` is set the
|
||||
* loader fetches that remote YAML; otherwise it returns an embedded
|
||||
* snapshot so the orchestrator always has a usable matrix to assert
|
||||
* against in validation/obligation flows without a network hop.
|
||||
*
|
||||
* The embedded snapshot is a faithful copy of the upstream v0 matrix
|
||||
* at recon time — if upstream evolves, re-sync by fetching and
|
||||
* replacing the `EMBEDDED_V0_MATRIX` literal.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logging/logger";
|
||||
import type { CcControlsMatrix } from "./types";
|
||||
|
||||
export interface CcControlsConfig {
|
||||
url?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded v0 matrix — kept small and hand-typed rather than parsed
|
||||
* from YAML so the orchestrator doesn't drag in a YAML runtime.
|
||||
*/
|
||||
const EMBEDDED_V0_MATRIX: CcControlsMatrix = {
|
||||
version: 0,
|
||||
source: "embedded",
|
||||
domains: [
|
||||
{
|
||||
id: "identity_proofing",
|
||||
controls: [
|
||||
{
|
||||
id: "IDP-001",
|
||||
title: "Identity enrollment recorded in audit ledger",
|
||||
evidenceType: "audit_event",
|
||||
ownerTeam: "TBD",
|
||||
frequency: "continuous",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "payment_issuance",
|
||||
controls: [
|
||||
{
|
||||
id: "PAY-001",
|
||||
title: "No production PAN in non-production",
|
||||
evidenceType: "config_scan",
|
||||
ownerTeam: "TBD",
|
||||
frequency: "per_release",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "audit_non_repudiation",
|
||||
controls: [
|
||||
{
|
||||
id: "AUD-001",
|
||||
title: "Credential state change only via workflow + immutable event",
|
||||
evidenceType: "architecture_review",
|
||||
ownerTeam: "TBD",
|
||||
frequency: "quarterly",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "registry_verticals",
|
||||
controls: [
|
||||
{
|
||||
id: "REG-001",
|
||||
title: "Judicial registry data classified high sensitivity; tenant-scoped APIs only",
|
||||
evidenceType: "policy_review",
|
||||
ownerTeam: "TBD",
|
||||
frequency: "quarterly",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function loadConfigFromEnv(): CcControlsConfig {
|
||||
return {
|
||||
url: process.env.CC_CONTROLS_MATRIX_URL,
|
||||
timeoutMs: process.env.CC_CONTROLS_MATRIX_TIMEOUT_MS
|
||||
? parseInt(process.env.CC_CONTROLS_MATRIX_TIMEOUT_MS, 10)
|
||||
: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal JSON-or-YAML-ish adapter: upstream ships YAML today but
|
||||
* could add a JSON endpoint. This loader only accepts `application/
|
||||
* json` responses — if the endpoint is pure YAML, serve it via a thin
|
||||
* JSON-convert proxy or extend this loader.
|
||||
*/
|
||||
export async function loadControlsMatrix(
|
||||
cfg: CcControlsConfig = loadConfigFromEnv(),
|
||||
): Promise<CcControlsMatrix> {
|
||||
if (!cfg.url) {
|
||||
logger.info(
|
||||
{ source: "embedded" },
|
||||
"[CcControls] controls matrix (no CC_CONTROLS_MATRIX_URL — embedded v0)",
|
||||
);
|
||||
return EMBEDDED_V0_MATRIX;
|
||||
}
|
||||
const fetchImpl = cfg.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 10_000);
|
||||
try {
|
||||
const resp = await fetchImpl(cfg.url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(
|
||||
`cc-controls matrix GET failed: HTTP ${resp.status}`,
|
||||
);
|
||||
}
|
||||
const body = (await resp.json()) as unknown;
|
||||
const parsed = normaliseMatrix(body);
|
||||
logger.info(
|
||||
{ source: "remote", url: cfg.url, version: parsed.version },
|
||||
"[CcControls] controls matrix (remote)",
|
||||
);
|
||||
return parsed;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseMatrix(raw: unknown): CcControlsMatrix {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("cc-controls matrix: response is not an object");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
const version = typeof r.version === "number" ? r.version : 0;
|
||||
const domains = Array.isArray(r.domains) ? r.domains : [];
|
||||
return {
|
||||
version,
|
||||
source: "remote",
|
||||
domains: domains.map((d) => normaliseDomain(d)),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseDomain(raw: unknown): CcControlsMatrix["domains"][number] {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const controls = Array.isArray(r.controls) ? r.controls : [];
|
||||
return {
|
||||
id: String(r.id ?? ""),
|
||||
controls: controls.map((c) => normaliseControl(c)),
|
||||
};
|
||||
}
|
||||
|
||||
function normaliseControl(raw: unknown): CcControlsMatrix["domains"][number]["controls"][number] {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(r.id ?? ""),
|
||||
title: String(r.title ?? ""),
|
||||
evidenceType: String(r.evidence_type ?? r.evidenceType ?? ""),
|
||||
ownerTeam: String(r.owner_team ?? r.ownerTeam ?? "TBD"),
|
||||
frequency: String(r.frequency ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper — resolve a control by id across all domains.
|
||||
* Used by evaluator flows that need to attach control evidence to a
|
||||
* transition.
|
||||
*/
|
||||
export function findControl(
|
||||
matrix: CcControlsMatrix,
|
||||
controlId: string,
|
||||
): CcControlsMatrix["domains"][number]["controls"][number] | undefined {
|
||||
for (const d of matrix.domains) {
|
||||
for (const c of d.controls) {
|
||||
if (c.id === controlId) return c;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
155
orchestrator/src/services/completeCredential/identityClient.ts
Normal file
155
orchestrator/src/services/completeCredential/identityClient.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* HTTP client adapter for `DBIS/cc-identity-core`.
|
||||
*
|
||||
* Provider-switched: when `CC_IDENTITY_URL` is set the client makes
|
||||
* real HTTP calls to the upstream Complete Credential identity
|
||||
* service; otherwise every method returns a deterministic mock so
|
||||
* unit tests, local dev, and CI still work.
|
||||
*
|
||||
* Upstream surface (openapi.yaml + src/server.mjs at recon time):
|
||||
* GET /health
|
||||
* GET /ready
|
||||
* POST /v1/subjects
|
||||
*
|
||||
* Extend as additional endpoints ship upstream.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { logger } from "../../logging/logger";
|
||||
import type {
|
||||
CcHealthStatus,
|
||||
CcSubject,
|
||||
CcSubjectCreate,
|
||||
} from "./types";
|
||||
|
||||
export interface CcIdentityConfig {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface CcIdentityClient {
|
||||
mode: "live" | "mock";
|
||||
health(): Promise<CcHealthStatus>;
|
||||
ready(): Promise<CcHealthStatus>;
|
||||
createSubject(req: CcSubjectCreate, correlationId?: string): Promise<CcSubject>;
|
||||
}
|
||||
|
||||
function loadConfigFromEnv(): CcIdentityConfig {
|
||||
return {
|
||||
baseUrl: process.env.CC_IDENTITY_URL,
|
||||
apiKey: process.env.CC_IDENTITY_API_KEY,
|
||||
timeoutMs: process.env.CC_IDENTITY_TIMEOUT_MS
|
||||
? parseInt(process.env.CC_IDENTITY_TIMEOUT_MS, 10)
|
||||
: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
class HttpCcIdentityClient implements CcIdentityClient {
|
||||
readonly mode = "live" as const;
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey?: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(
|
||||
cfg: Required<Pick<CcIdentityConfig, "baseUrl">> & CcIdentityConfig,
|
||||
) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
|
||||
this.apiKey = cfg.apiKey;
|
||||
this.timeoutMs = cfg.timeoutMs ?? 10_000;
|
||||
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
correlationId?: string,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
||||
if (correlationId) headers["X-Correlation-Id"] = correlationId;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const resp = await this.fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(
|
||||
`cc-identity ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return (await resp.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
health(): Promise<CcHealthStatus> {
|
||||
return this.request<CcHealthStatus>("GET", "/health");
|
||||
}
|
||||
|
||||
ready(): Promise<CcHealthStatus> {
|
||||
return this.request<CcHealthStatus>("GET", "/ready");
|
||||
}
|
||||
|
||||
createSubject(
|
||||
req: CcSubjectCreate,
|
||||
correlationId?: string,
|
||||
): Promise<CcSubject> {
|
||||
return this.request<CcSubject>(
|
||||
"POST",
|
||||
"/v1/subjects",
|
||||
req,
|
||||
correlationId ?? randomUUID(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MockCcIdentityClient implements CcIdentityClient {
|
||||
readonly mode = "mock" as const;
|
||||
|
||||
async health(): Promise<CcHealthStatus> {
|
||||
return { status: "ok", service: "cc-identity-core" };
|
||||
}
|
||||
|
||||
async ready(): Promise<CcHealthStatus> {
|
||||
return { status: "ok", service: "cc-identity-core", persistence: false };
|
||||
}
|
||||
|
||||
async createSubject(req: CcSubjectCreate): Promise<CcSubject> {
|
||||
return {
|
||||
subjectId: randomUUID(),
|
||||
tenantId: req.tenantId ?? "tenant-demo",
|
||||
entityId: req.entityId ?? "entity-demo",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createCcIdentityClient(
|
||||
cfg: CcIdentityConfig = loadConfigFromEnv(),
|
||||
): CcIdentityClient {
|
||||
if (cfg.baseUrl) {
|
||||
logger.info(
|
||||
{ baseUrl: cfg.baseUrl, mode: "live" },
|
||||
"[CcIdentity] HTTP client",
|
||||
);
|
||||
return new HttpCcIdentityClient({ ...cfg, baseUrl: cfg.baseUrl });
|
||||
}
|
||||
logger.info(
|
||||
{ mode: "mock" },
|
||||
"[CcIdentity] HTTP client (no CC_IDENTITY_URL — mock mode)",
|
||||
);
|
||||
return new MockCcIdentityClient();
|
||||
}
|
||||
28
orchestrator/src/services/completeCredential/index.ts
Normal file
28
orchestrator/src/services/completeCredential/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Public surface for the DBIS Complete Credential (cc-*) adapters.
|
||||
*
|
||||
* Covers the upstream bounded-context repos the orchestrator needs:
|
||||
* - cc-identity-core → identityClient (HTTP, provider-switched)
|
||||
* - cc-compliance-controls → controlsMatrix (embedded v0 with
|
||||
* optional remote JSON override)
|
||||
*
|
||||
* cc-payment-adapters / cc-audit-ledger / cc-shared-events are still
|
||||
* template scaffolds upstream at recon time; when those services
|
||||
* ship, add sibling clients here following the same pattern.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export {
|
||||
createCcIdentityClient,
|
||||
} from "./identityClient";
|
||||
export type {
|
||||
CcIdentityClient,
|
||||
CcIdentityConfig,
|
||||
} from "./identityClient";
|
||||
export {
|
||||
loadControlsMatrix,
|
||||
findControl,
|
||||
} from "./controlsMatrix";
|
||||
export type {
|
||||
CcControlsConfig,
|
||||
} from "./controlsMatrix";
|
||||
49
orchestrator/src/services/completeCredential/types.ts
Normal file
49
orchestrator/src/services/completeCredential/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Types shared across the Complete Credential (DBIS cc-*) adapters.
|
||||
*
|
||||
* Shapes mirror the relevant upstream repos:
|
||||
* - cc-identity-core (openapi/openapi.yaml + src/server.mjs)
|
||||
* - cc-compliance-controls (controls/matrix/v0.yaml)
|
||||
*
|
||||
* Only the fields the orchestrator actually consumes are typed —
|
||||
* extend as needed when more of the CC surface is wired.
|
||||
*/
|
||||
|
||||
export interface CcHealthStatus {
|
||||
status: "ok" | "unready";
|
||||
service: string;
|
||||
persistence?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CcSubjectCreate {
|
||||
tenantId?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface CcSubject {
|
||||
subjectId: string;
|
||||
tenantId: string;
|
||||
entityId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CcControl {
|
||||
id: string;
|
||||
title: string;
|
||||
evidenceType: string;
|
||||
ownerTeam: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface CcControlDomain {
|
||||
id: string;
|
||||
controls: CcControl[];
|
||||
}
|
||||
|
||||
export interface CcControlsMatrix {
|
||||
version: number;
|
||||
source: "embedded" | "remote";
|
||||
domains: CcControlDomain[];
|
||||
}
|
||||
232
orchestrator/tests/services/completeCredential.test.ts
Normal file
232
orchestrator/tests/services/completeCredential.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Unit tests for the Complete Credential (DBIS cc-*) adapters.
|
||||
*
|
||||
* All tests are headless — they either exercise the embedded mock /
|
||||
* matrix or stub `fetch` directly. No network, no UI.
|
||||
*/
|
||||
|
||||
import {
|
||||
createCcIdentityClient,
|
||||
loadControlsMatrix,
|
||||
findControl,
|
||||
type CcIdentityClient,
|
||||
} from "../../src/services/completeCredential";
|
||||
|
||||
describe("completeCredential.createCcIdentityClient() — mock mode", () => {
|
||||
let client: CcIdentityClient;
|
||||
|
||||
beforeAll(() => {
|
||||
delete process.env.CC_IDENTITY_URL;
|
||||
delete process.env.CC_IDENTITY_API_KEY;
|
||||
client = createCcIdentityClient();
|
||||
});
|
||||
|
||||
it("reports mock mode", () => {
|
||||
expect(client.mode).toBe("mock");
|
||||
});
|
||||
|
||||
it("returns ok for health()", async () => {
|
||||
const h = await client.health();
|
||||
expect(h.status).toBe("ok");
|
||||
expect(h.service).toBe("cc-identity-core");
|
||||
});
|
||||
|
||||
it("returns a ready response with persistence=false in mock", async () => {
|
||||
const r = await client.ready();
|
||||
expect(r.status).toBe("ok");
|
||||
expect(r.persistence).toBe(false);
|
||||
});
|
||||
|
||||
it("creates a subject with a uuid and defaulted tenant/entity", async () => {
|
||||
const s = await client.createSubject({});
|
||||
expect(s.subjectId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(s.tenantId).toBe("tenant-demo");
|
||||
expect(s.entityId).toBe("entity-demo");
|
||||
});
|
||||
|
||||
it("passes tenant/entity through when provided", async () => {
|
||||
const s = await client.createSubject({
|
||||
tenantId: "t-acme",
|
||||
entityId: "e-bank-1",
|
||||
});
|
||||
expect(s.tenantId).toBe("t-acme");
|
||||
expect(s.entityId).toBe("e-bank-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeCredential.createCcIdentityClient() — live mode (stubbed fetch)", () => {
|
||||
function makeFetch(
|
||||
record: (url: string, init: RequestInit) => void,
|
||||
responseBody: unknown,
|
||||
status = 200,
|
||||
): typeof fetch {
|
||||
return (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
record(String(input), init ?? {});
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
it("reports live mode when baseUrl is set", () => {
|
||||
const client = createCcIdentityClient({
|
||||
baseUrl: "http://cc.example.test",
|
||||
fetchImpl: makeFetch(() => undefined, { status: "ok", service: "x" }),
|
||||
});
|
||||
expect(client.mode).toBe("live");
|
||||
});
|
||||
|
||||
it("hits GET /health", async () => {
|
||||
const calls: string[] = [];
|
||||
const client = createCcIdentityClient({
|
||||
baseUrl: "http://cc.example.test",
|
||||
fetchImpl: makeFetch(
|
||||
(url) => {
|
||||
calls.push(url);
|
||||
},
|
||||
{ status: "ok", service: "cc-identity-core" },
|
||||
),
|
||||
});
|
||||
const h = await client.health();
|
||||
expect(h.status).toBe("ok");
|
||||
expect(calls[0]).toBe("http://cc.example.test/health");
|
||||
});
|
||||
|
||||
it("posts to /v1/subjects with X-Correlation-Id + api key header", async () => {
|
||||
const calls: { url: string; headers: Record<string, string>; body?: string }[] = [];
|
||||
const client = createCcIdentityClient({
|
||||
baseUrl: "http://cc.example.test",
|
||||
apiKey: "k-1",
|
||||
fetchImpl: makeFetch(
|
||||
(url, init) => {
|
||||
calls.push({
|
||||
url,
|
||||
headers: (init.headers ?? {}) as Record<string, string>,
|
||||
body: init.body as string,
|
||||
});
|
||||
},
|
||||
{
|
||||
subjectId: "11111111-2222-3333-4444-555555555555",
|
||||
tenantId: "t-1",
|
||||
entityId: "e-1",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const s = await client.createSubject({ tenantId: "t-1", entityId: "e-1" }, "corr-42");
|
||||
expect(s.subjectId).toContain("-");
|
||||
expect(calls[0].url).toBe("http://cc.example.test/v1/subjects");
|
||||
expect(calls[0].headers["X-API-Key"]).toBe("k-1");
|
||||
expect(calls[0].headers["X-Correlation-Id"]).toBe("corr-42");
|
||||
expect(JSON.parse(calls[0].body ?? "{}")).toEqual({
|
||||
tenantId: "t-1",
|
||||
entityId: "e-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-generates a correlation id when not provided", async () => {
|
||||
const calls: Record<string, string>[] = [];
|
||||
const client = createCcIdentityClient({
|
||||
baseUrl: "http://cc.example.test",
|
||||
fetchImpl: makeFetch(
|
||||
(_url, init) => {
|
||||
calls.push((init.headers ?? {}) as Record<string, string>);
|
||||
},
|
||||
{
|
||||
subjectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
tenantId: "t",
|
||||
entityId: "e",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
),
|
||||
});
|
||||
await client.createSubject({});
|
||||
expect(calls[0]["X-Correlation-Id"]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("throws a descriptive error on non-2xx", async () => {
|
||||
const client = createCcIdentityClient({
|
||||
baseUrl: "http://cc.example.test",
|
||||
fetchImpl: makeFetch(() => undefined, { error: "boom" }, 500),
|
||||
});
|
||||
await expect(client.health()).rejects.toThrow(/HTTP 500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeCredential.loadControlsMatrix() — embedded mode", () => {
|
||||
beforeAll(() => {
|
||||
delete process.env.CC_CONTROLS_MATRIX_URL;
|
||||
});
|
||||
|
||||
it("returns the embedded v0 matrix when no URL is set", async () => {
|
||||
const m = await loadControlsMatrix();
|
||||
expect(m.source).toBe("embedded");
|
||||
expect(m.version).toBe(0);
|
||||
expect(m.domains.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("exposes expected control ids", async () => {
|
||||
const m = await loadControlsMatrix();
|
||||
const ids = m.domains.flatMap((d) => d.controls.map((c) => c.id));
|
||||
expect(ids).toEqual(expect.arrayContaining(["IDP-001", "PAY-001", "AUD-001", "REG-001"]));
|
||||
});
|
||||
|
||||
it("findControl() resolves by id", async () => {
|
||||
const m = await loadControlsMatrix();
|
||||
const c = findControl(m, "PAY-001");
|
||||
expect(c?.title).toContain("PAN");
|
||||
});
|
||||
|
||||
it("findControl() returns undefined for unknown ids", async () => {
|
||||
const m = await loadControlsMatrix();
|
||||
expect(findControl(m, "NOPE-999")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completeCredential.loadControlsMatrix() — remote mode", () => {
|
||||
function makeFetch(responseBody: unknown, status = 200): typeof fetch {
|
||||
return (async () =>
|
||||
new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
}
|
||||
|
||||
it("fetches and normalises a JSON matrix", async () => {
|
||||
const matrix = await loadControlsMatrix({
|
||||
url: "http://cc.example.test/controls/matrix/v0.json",
|
||||
fetchImpl: makeFetch({
|
||||
version: 1,
|
||||
domains: [
|
||||
{
|
||||
id: "extra",
|
||||
controls: [
|
||||
{
|
||||
id: "X-001",
|
||||
title: "Extra",
|
||||
evidence_type: "doc_review",
|
||||
owner_team: "ops",
|
||||
frequency: "monthly",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(matrix.source).toBe("remote");
|
||||
expect(matrix.version).toBe(1);
|
||||
expect(matrix.domains[0].controls[0].evidenceType).toBe("doc_review");
|
||||
expect(matrix.domains[0].controls[0].ownerTeam).toBe("ops");
|
||||
});
|
||||
|
||||
it("throws on non-2xx", async () => {
|
||||
await expect(
|
||||
loadControlsMatrix({
|
||||
url: "http://cc.example.test/nope",
|
||||
fetchImpl: makeFetch({}, 404),
|
||||
}),
|
||||
).rejects.toThrow(/HTTP 404/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user