Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions worker/__tests__/ecosystem.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @canon: chittycanon://core/services/chittyassets
// Integration tests for Phase 2c ecosystem-status fan-out.
// REAL HTTP — no mocks. Hits the live *.chitty.cc/health endpoints in parallel.
//
// Per chittycanon://gov/governance#core-types — ecosystem services are
// Authority (A) bearers. Caller is Person (P). All five P/L/T/E/A enumerated
// in env.ts.

import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { registerEcosystemRoutes } from "../src/routes/ecosystem";
import type { ChittyAuthClaims, Env } from "../src/env";

const CALLER = "01-A-CHT-ASST-P-5T-1-X";

function claims(): ChittyAuthClaims {
const now = Math.floor(Date.now() / 1000);
return {
iss: "https://auth.chitty.cc",
sub: CALLER,
chitty_id: CALLER,
entity_type: "P",
trust_level: 3,
exp: now + 3600,
iat: now,
email: "phase2c@chitty.cc",
};
}

function buildApp(envOverrides: Partial<Env> = {}) {
const stubEnv = {
ENVIRONMENT: "development" as const,
CHITTYAUTH_ISSUER: "https://auth.chitty.cc",
CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json",
CHITTYAUTH_AUDIENCE: "chittyassets-api",
CHITTYASSETS_DB: undefined as unknown as Hyperdrive,
...envOverrides,
} as Env;

const app = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();
app.use("*", async (c, next) => {
Object.defineProperty(c, "env", { get: () => stubEnv, configurable: true });
c.set("claims", claims());
await next();
});
const api = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();
registerEcosystemRoutes(api, async (_c, next) => {
await next();
});
app.route("/api", api);
return app;
}

const EXPECTED_SERVICES = [
"chittyid",
"chittyassets",
"chittytrust",
"chittyresolution",
"chittyfile",
];

interface ServiceRow {
service: string;
url: string;
reachable: boolean;
http_status: number | null;
latency_ms: number | null;
health: Record<string, unknown> | null;
error: string | null;
}
interface EcoBody {
ok: boolean;
checked_at: string;
services: ServiceRow[];
summary: { total: number; reachable: number; unreachable: number };
}

describe("GET /api/ecosystem/status — Phase 2c (live HTTP fan-out)", () => {
it("200 — fans out to 5 services, returns aggregated status", async () => {
const app = buildApp();
const res = await app.request("/api/ecosystem/status");
expect(res.status).toBe(200);
const body = (await res.json()) as EcoBody;

expect(Array.isArray(body.services)).toBe(true);
expect(body.services.length).toBe(5);
expect(body.services.map((s) => s.service).sort()).toEqual(
[...EXPECTED_SERVICES].sort(),
);
// Summary is internally consistent.
expect(body.summary.total).toBe(5);
expect(body.summary.reachable + body.summary.unreachable).toBe(5);
expect(body.summary.reachable).toBe(
body.services.filter((s) => s.reachable).length,
);
// ok flag mirrors all-reachable.
expect(body.ok).toBe(body.summary.reachable === 5);
// checked_at is a valid ISO timestamp.
expect(() => new Date(body.checked_at).toISOString()).not.toThrow();
// Each row has the expected shape.
for (const s of body.services) {
expect(typeof s.url).toBe("string");
expect(s.url).toMatch(/\/health$/);
expect(typeof s.reachable).toBe("boolean");
}
}, 15000);

it("200 — unreachable hosts produce per-service error context (degraded ok=false)", async () => {
const app = buildApp({
CHITTYID_URL: "https://invalid-id-host.chitty-invalid-tld",
CHITTYASSETS_URL: "https://invalid-assets-host.chitty-invalid-tld",
CHITTYTRUST_URL: "https://invalid-trust-host.chitty-invalid-tld",
CHITTYRESOLUTION_URL: "https://invalid-resolution-host.chitty-invalid-tld",
CHITTYFILE_URL: "https://invalid-file-host.chitty-invalid-tld",
});
const res = await app.request("/api/ecosystem/status");
expect(res.status).toBe(200);
const body = (await res.json()) as EcoBody;
expect(body.ok).toBe(false);
expect(body.summary.unreachable).toBe(5);
for (const s of body.services) {
expect(s.reachable).toBe(false);
expect(s.error).not.toBeNull();
}
}, 15000);
});
97 changes: 97 additions & 0 deletions worker/__tests__/evidence-ledger.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// @canon: chittycanon://core/services/chittyassets
// Integration tests for Phase 2c evidence-ledger external read.
// REAL HTTP — no mocks. Tests verify: invalid ID guard (no upstream call),
// upstream-error mapping (real call to a sentinel chitty_id, expect 404 or 502
// passthrough), shape of error envelope.
//
// Per chittycanon://gov/governance#core-types — caller is Person (P); the
// chittyId being looked up may reference any of P/L/T/E/A. All five enumerated
// in env.ts.

import { describe, it, expect } from "vitest";
import { Hono } from "hono";
import { registerEvidenceLedgerRoutes } from "../src/routes/evidence-ledger";
import type { ChittyAuthClaims, Env } from "../src/env";

const CALLER = "01-A-CHT-ASST-P-5T-1-X";
// Canonical-shape chittyId that should not exist on the live ledger — used to
// exercise the real 404 / upstream-error path without mocking.
const NONEXISTENT = "ZZ-Z-ZZZ-ZZZZ-T-ZZ-Z-Z";

function claims(): ChittyAuthClaims {
const now = Math.floor(Date.now() / 1000);
return {
iss: "https://auth.chitty.cc",
sub: CALLER,
chitty_id: CALLER,
entity_type: "P",
trust_level: 3,
exp: now + 3600,
iat: now,
email: "phase2c@chitty.cc",
};
}

function buildApp(envOverrides: Partial<Env> = {}) {
const stubEnv = {
ENVIRONMENT: "development" as const,
CHITTYAUTH_ISSUER: "https://auth.chitty.cc",
CHITTYAUTH_JWKS_URL: "https://auth.chitty.cc/.well-known/jwks.json",
CHITTYAUTH_AUDIENCE: "chittyassets-api",
CHITTYLEDGER_URL: "https://ledger.chitty.cc",
CHITTYASSETS_DB: undefined as unknown as Hyperdrive,
...envOverrides,
} as Env;

const app = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();
app.use("*", async (c, next) => {
Object.defineProperty(c, "env", { get: () => stubEnv, configurable: true });
c.set("claims", claims());
await next();
});
const api = new Hono<{
Bindings: Env;
Variables: { claims: ChittyAuthClaims };
}>();
registerEvidenceLedgerRoutes(api, async (_c, next) => {
await next();
});
app.route("/api", api);
return app;
}

describe("GET /api/evidence-ledger/:chittyId — Phase 2c", () => {
it("400 — rejects malformed chittyId without calling upstream", async () => {
const app = buildApp();
const res = await app.request("/api/evidence-ledger/not-a-valid-id");
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid_chitty_id");
});

it("404 or 502 — real call against live ledger for nonexistent canonical ID", async () => {
// Real network call to https://ledger.chitty.cc — no mocks.
// We accept either: 404 (ledger responded with not-found, which we map to 404),
// 502 (ledger unreachable / 5xx / network — also valid real behavior).
const app = buildApp();
const res = await app.request(`/api/evidence-ledger/${NONEXISTENT}`);
expect([404, 502]).toContain(res.status);
const body = (await res.json()) as { error: string };
expect(typeof body.error).toBe("string");
expect(["not_found", "ledger_unavailable"]).toContain(body.error);
}, 15000);

it("502 — points-at unreachable host returns ledger_unavailable", async () => {
// Real call to a non-routable URL — exercises timeout / fetch-error path.
const app = buildApp({
CHITTYLEDGER_URL: "https://ledger-does-not-exist.chitty-invalid-tld",
});
const res = await app.request(`/api/evidence-ledger/${NONEXISTENT}`);
expect(res.status).toBe(502);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("ledger_unavailable");
}, 10000);
});
76 changes: 76 additions & 0 deletions worker/src/clients/chittyledger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @canon: chittycanon://core/services/chittyassets
// ChittyLedger HTTP client — Workers-compatible (uses global fetch).
//
// Per chittycanon://gov/governance#core-types the chittyId being looked up can
// reference any of the five entity types P/L/T/E/A — the ledger does not
// restrict by type; this client is type-agnostic and forwards opaquely.
//
// Phase 2c: backs GET /api/evidence-ledger/:chittyId. Real HTTP only — no mocks.

import type { Env } from "../env";

const DEFAULT_LEDGER_URL = "https://ledger.chitty.cc";

export interface LedgerEvidenceResponse {
// Opaque pass-through — ledger owns the schema. We surface JSON as-is so the
// schema-overlord can audit ledger schema separately.
[key: string]: unknown;
}

export class LedgerClientError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly upstream: string,
) {
super(message);
this.name = "LedgerClientError";
}
}

function baseUrl(env: Env): string {
return env.CHITTYLEDGER_URL ?? DEFAULT_LEDGER_URL;
}

/**
* Fetch evidence by ChittyID from ChittyLedger.
* Real network call. 3s per-request timeout via AbortController.
* Throws LedgerClientError on non-2xx / timeout / network failure.
*/
export async function getEvidence(
env: Env,
chittyId: string,
opts: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<LedgerEvidenceResponse> {
const url = `${baseUrl(env)}/api/v1/evidence/${encodeURIComponent(chittyId)}`;
const timeoutMs = opts.timeoutMs ?? 3000;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeoutMs);
// Chain caller-supplied signal if present.
if (opts.signal) {
if (opts.signal.aborted) ctrl.abort(opts.signal.reason);
else opts.signal.addEventListener("abort", () => ctrl.abort(opts.signal!.reason));
}
try {
const res = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
signal: ctrl.signal,
});
if (!res.ok) {
throw new LedgerClientError(
`ChittyLedger returned ${res.status}`,
res.status,
url,
);
}
const body = (await res.json()) as LedgerEvidenceResponse;
return body;
} catch (err) {
if (err instanceof LedgerClientError) throw err;
const msg = err instanceof Error ? err.message : String(err);
throw new LedgerClientError(`ledger fetch failed: ${msg}`, 502, url);
} finally {
clearTimeout(timer);
}
}
Loading