PR W: DBIS Complete Credential (cc-*) adapters with provider-switch
Some checks failed
CI / Frontend Lint (pull_request) Failing after 6s
CI / Frontend Type Check (pull_request) Failing after 5s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 10s
CI / Orchestrator Build (pull_request) Failing after 5s
CI / Orchestrator Unit Tests (pull_request) Failing after 6s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 25s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Some checks failed
CI / Frontend Lint (pull_request) Failing after 6s
CI / Frontend Type Check (pull_request) Failing after 5s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 10s
CI / Orchestrator Build (pull_request) Failing after 5s
CI / Orchestrator Unit Tests (pull_request) Failing after 6s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 25s
Code Quality / Code Quality Checks (pull_request) Failing after 4s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Headless clients for the DBIS/cc-* bounded-context repos that actually
ship runnable code today:
- cc-identity-core — HTTP service (GET /health, GET /ready,
POST /v1/subjects per upstream openapi.yaml / src/server.mjs).
- cc-compliance-controls — controls/matrix/v0.yaml (loader with
embedded v0 snapshot + optional remote JSON override).
Other cc-* repos (cc-payment-adapters, cc-audit-ledger,
cc-shared-events, cc-shared-schemas) remain template scaffolds
upstream at recon time; when those services ship, add sibling clients
here following the same pattern.
Wired via the standard provider-switch pattern:
- CC_IDENTITY_URL unset → MockCcIdentityClient.
- CC_IDENTITY_URL set → HttpCcIdentityClient (X-API-Key +
X-Correlation-Id auto-minted, 10s AbortController timeout).
- CC_CONTROLS_MATRIX_URL unset → embedded v0 matrix.
- CC_CONTROLS_MATRIX_URL set → remote JSON loader, normalises
snake_case → camelCase per upstream YAML.
16 jest tests cover both legs of both adapters: mock shapes + live
fetch stubs for auth headers, correlation ids, JSON body, path
encoding, error handling, and matrix normalisation. No network, no
UI. Full unit suite: 12 suites / 156 tests still passing. tsc clean.
This commit is contained in:
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