Files
0pt/tests/p0/source-registry.test.ts
Simon AxelssonandClaude Sonnet 5 07b13bd833 feat(data-os): Source Registry Postgres persistence + full HTTP route surface
Replaces the in-memory (per-instance, not even per-process-shared) Maps in
SourceService/SourceRegistryService with real Postgres persistence, and adds
the register/query/describe/health/rights/provenance/freshness HTTP routes
that never existed (grep for SourceRegistryService in app/ previously
returned nothing).

- sql/002_source_registry.sql: opt_sources, opt_source_rights,
  opt_source_provenance, opt_source_health_checks, opt_source_freshness.
  Numbered 002 (not 006) because 004's opt_source_rate_policies already
  forward-references opt_sources, which no sql/ file defined until now —
  fixes 004's pre-existing broken migration ordering (opt_datasets remains
  a separate, unrelated, still-open gap in 004, flagged not fixed).
- src/repositories/*: SourceRepository / SourceRegistryRepository seams,
  each with an in-memory implementation (business logic extracted verbatim)
  and a Postgres implementation, chosen via resolveStoreMode — production
  still fails hard with no DATABASE_URL (Master-DoD §3), but a configured
  production now actually works instead of always being blocked.
- SourceService / SourceRegistryService: storage moved behind the
  repository seam; getHealth/getFreshness are now async and tenant-scoped
  at the accessor itself (needed once they're reachable directly over HTTP,
  not only after an earlier caller's tenant check).
- src/services/source-registry.ts: shared singleton instances for the
  sources/* route group, fixing the "each route module owned its own
  private registry" defect (source-rate-policies and .../query previously
  couldn't see a source registered via POST /api/0pt/sources).
- New routes: POST/GET /api/0pt/sources, GET /api/0pt/sources/[id],
  GET+PUT .../rights, GET+PUT .../provenance, GET+POST .../health,
  GET .../freshness.
- Business logic unchanged: fail-closed purpose gate, cross-tenant
  NOT_FOUND (no existence oracle, §50), bounded 100-row health history.

Tested against a real Postgres container (not mocked) —
tests/p0/source-registry-postgres.realinfra.test.ts, env-gated on
SOURCE_REGISTRY_TEST_DATABASE_URL, skips honestly when unset. All 249
pre-existing P0 tests + 7 new realinfra tests pass; also smoke-tested the
full HTTP route surface end-to-end against next dev + live Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:42:41 +02:00

186 lines
6.7 KiB
TypeScript

/**
* P0 Source Registry (Master-DoD §10): register/query/describe/health +
* rights/license/purpose metadata, freshness, provenance — including the §50
* cross-tenant attack case and the fail-closed purpose gate.
*/
import { describe, it, expect } from "vitest";
import { SourceRegistryService } from "@/src/services/source-registry-service";
import { SiaxError } from "@/src/platform/errors";
function svc() {
return new SourceRegistryService();
}
async function registerBase(registry: SourceRegistryService, overrides: Record<string, unknown> = {}) {
return registry.registerSource({
source_id: "src_bolagsverket",
tenant_id: "t_a",
project_id: "p_1",
source_type: "api",
name: "Bolagsverket API",
access: "api",
created_by: "actor_1",
rights: {
license_id: "official-public-data",
allowed_purposes: ["company_enrichment", "compliance"],
pii_allowed: false,
redistribution: "customer_deliverable",
verified_at: "2026-08-31",
verified_by: "actor_1",
},
provenance: {
method: "official_api",
authority: "Bolagsverket",
cadence: "daily",
freshness_sla_hours: 48,
},
...overrides,
});
}
describe("SourceRegistryService — register + describe", () => {
it("source.register stores base + rights + provenance and source.describe returns the full descriptor", async () => {
const registry = svc();
const descriptor = await registerBase(registry);
expect(descriptor.source.source_id).toBe("src_bolagsverket");
expect(descriptor.rights?.license_id).toBe("official-public-data");
expect(descriptor.rights?.allowed_purposes).toContain("company_enrichment");
expect(descriptor.provenance?.method).toBe("official_api");
expect(descriptor.provenance?.authority).toBe("Bolagsverket");
expect(descriptor.health.status).toBe("unknown");
expect(descriptor.freshness.fresh).toBe(false); // SLA declared, no success recorded yet
});
it("describe on a foreign tenant's source → NOT_FOUND (no existence oracle, §50)", async () => {
const registry = svc();
await registerBase(registry);
try {
await registry.describeSource("src_bolagsverket", "t_b");
expect.unreachable("must throw");
} catch (err) {
expect(err).toBeInstanceOf(SiaxError);
expect((err as SiaxError).code).toBe("NOT_FOUND");
}
});
});
describe("SourceRegistryService — purpose gate (fail closed)", () => {
it("allows a purpose on the allowlist", async () => {
const registry = svc();
await registerBase(registry);
const rights = await registry.assertPurposeAllowed("src_bolagsverket", "t_a", "company_enrichment");
expect(rights.license_id).toBe("official-public-data");
});
it("denies a purpose outside the allowlist", async () => {
const registry = svc();
await registerBase(registry);
await expect(registry.assertPurposeAllowed("src_bolagsverket", "t_a", "marketing")).rejects.toMatchObject(
{
code: "AUTHORIZATION_DENIED",
},
);
});
it("denies EVERY purpose when no rights record exists (fail closed)", async () => {
const registry = svc();
await registry.registerSource({
source_id: "src_norights",
tenant_id: "t_a",
project_id: "p_1",
source_type: "web",
name: "No rights yet",
});
await expect(registry.assertPurposeAllowed("src_norights", "t_a", "anything")).rejects.toMatchObject({
code: "AUTHORIZATION_DENIED",
});
});
});
describe("SourceRegistryService — health + freshness", () => {
it("records health checks, tracks consecutive failures, and an 'up' check drives freshness", async () => {
const registry = svc();
await registerBase(registry);
let health = await registry.recordHealthCheck({
source_id: "src_bolagsverket",
tenant_id: "t_a",
status: "down",
checked_at: new Date().toISOString(),
});
expect(health.status).toBe("down");
expect(health.consecutive_failures).toBe(1);
health = await registry.recordHealthCheck({
source_id: "src_bolagsverket",
tenant_id: "t_a",
status: "up",
checked_at: new Date().toISOString(),
latency_ms: 120,
});
expect(health.status).toBe("up");
expect(health.consecutive_failures).toBe(0);
const descriptor = await registry.describeSource("src_bolagsverket", "t_a");
expect(descriptor.freshness.fresh).toBe(true);
expect(descriptor.freshness.age_hours).toBeLessThan(1);
});
it("freshness goes stale when the last success exceeds the SLA", async () => {
const registry = svc();
await registerBase(registry);
const old = new Date(Date.now() - 72 * 3_600_000).toISOString(); // 72h > 48h SLA
await registry.recordSuccess("src_bolagsverket", "t_a", old);
const freshness = await registry.getFreshness("src_bolagsverket", "t_a");
expect(freshness.fresh).toBe(false);
expect(freshness.age_hours).toBeGreaterThan(48);
});
});
describe("SourceRegistryService — source.query with purpose/rights filtering", () => {
it("filters by purpose and redistribution; sources without rights never match a purpose filter", async () => {
const registry = svc();
await registerBase(registry);
await registry.registerSource({
source_id: "src_internal_only",
tenant_id: "t_a",
project_id: "p_1",
source_type: "file",
name: "Internal drop",
rights: {
license_id: "internal",
allowed_purposes: ["compliance"],
pii_allowed: false,
redistribution: "internal",
verified_at: "2026-08-31",
verified_by: "actor_1",
},
});
await registry.registerSource({
source_id: "src_unassessed",
tenant_id: "t_a",
project_id: "p_1",
source_type: "web",
name: "Unassessed",
});
const enrichment = await registry.querySources({ tenant_id: "t_a", purpose: "company_enrichment" });
expect(enrichment.map((s) => s.source_id)).toEqual(["src_bolagsverket"]);
const compliance = await registry.querySources({ tenant_id: "t_a", purpose: "compliance" });
expect(compliance.map((s) => s.source_id).sort()).toEqual(["src_bolagsverket", "src_internal_only"]);
const internalOnly = await registry.querySources({ tenant_id: "t_a", redistribution: "internal" });
expect(internalOnly.map((s) => s.source_id)).toEqual(["src_internal_only"]);
// Tenant isolation on query.
const foreign = await registry.querySources({ tenant_id: "t_b", purpose: "compliance" });
expect(foreign).toEqual([]);
});
it("rejects a query without tenant_id", async () => {
const registry = svc();
await expect(registry.querySources({ tenant_id: "" })).rejects.toMatchObject({ code: "INVALID_REQUEST" });
});
});