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>
186 lines
6.7 KiB
TypeScript
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" });
|
|
});
|
|
});
|