Files
0pt/tests/p0/source-activation-gate.test.ts
T

191 lines
7.5 KiB
TypeScript

/**
* P060 DoD #2: "an unclassified source can NOT be activated."
*
* Before this pass the opposite was true, and provably so:
* src/repositories/postgres-source-repository.ts wrote
* `${input.status ?? "active"}` and there was no activation operation at all
* — registering a source with no rights, no provenance and no authorization
* made it `active` on the spot. These tests pin the closed gate AND its
* obvious bypasses (asking for status "active" at registration; a service
* identity authorizing itself; an expired authorization).
*/
import { describe, expect, it } from "vitest";
import { SourceRegistryService } from "@/src/services/source-registry-service";
import { SourceService } from "@/src/services/source-service";
import { InMemorySourceRepository } from "@/src/repositories/in-memory-source-repository";
import { InMemorySourceRegistryRepository } from "@/src/repositories/in-memory-source-registry-repository";
import { SiaxError } from "@/src/platform/errors";
function newService(): SourceRegistryService {
return new SourceRegistryService({
repository: new InMemorySourceRegistryRepository(),
sourceService: new SourceService({ repository: new InMemorySourceRepository() }),
});
}
const TENANT = "t_test";
const base = {
source_id: "src_gate_1",
tenant_id: TENANT,
project_id: "p_test",
source_type: "api" as const,
name: "Bolagsverket öppna data",
owner_id: "usr_simon",
};
const RIGHTS = {
license_id: "CC-BY-4.0",
allowed_purposes: ["catalog", "enrichment"],
pii_allowed: false,
redistribution: "customer_deliverable" as const,
verified_at: "2026-09-04",
verified_by: "usr_simon",
};
const PROVENANCE = { method: "official_api" as const, authority: "Bolagsverket" };
const AUTHORIZATION = {
basis: "public_register" as const,
granted_by: "usr_simon",
granted_at: "2026-09-01T08:00:00.000Z",
};
describe("P060 source registry — the activation gate", () => {
it("a newly registered source is PENDING, never active", async () => {
const svc = newService();
const descriptor = await svc.registerSource(base);
expect(descriptor.source.status).toBe("pending");
});
it("asking for status:'active' at registration does NOT bypass the gate", async () => {
const svc = newService();
// The most obvious bypass: just ask for it.
await expect(svc.registerSource({ ...base, status: "active" })).rejects.toBeInstanceOf(SiaxError);
// ...and the source is still on record, still pending — not half-created.
const source = await svc.sources.getSource(base.source_id);
expect(source.status).toBe("pending");
});
it("activating an unclassified source is denied and names every missing fact", async () => {
const svc = newService();
await svc.registerSource({ ...base, owner_id: undefined });
const err = await svc.activateSource(base.source_id, TENANT).catch((e: unknown) => e);
expect(err).toBeInstanceOf(SiaxError);
const siax = err as SiaxError;
expect(siax.code).toBe("AUTHORIZATION_DENIED");
expect(siax.message).toContain("owner_id is not set");
expect(siax.message).toContain("no authorization on record");
expect(siax.message).toContain("no rights record");
expect(siax.message).toContain("no provenance record");
expect((await svc.sources.getSource(base.source_id)).status).toBe("pending");
});
it("a fully classified source activates, and describe() reports the classification", async () => {
const svc = newService();
await svc.registerSource({
...base,
rights: RIGHTS,
provenance: PROVENANCE,
authorization: AUTHORIZATION,
});
const classification = await svc.classifySource(base.source_id, TENANT);
expect(classification).toEqual({ source_id: base.source_id, classified: true, missing: [] });
const activated = await svc.activateSource(base.source_id, TENANT, "usr_simon");
expect(activated.status).toBe("active");
const descriptor = await svc.describeSource(base.source_id, TENANT);
expect(descriptor.classification?.classified).toBe(true);
expect(descriptor.authorization?.basis).toBe("public_register");
});
it("registering fully classified WITH status:'active' activates through the gate, not around it", async () => {
const svc = newService();
const descriptor = await svc.registerSource({
...base,
status: "active",
rights: RIGHTS,
provenance: PROVENANCE,
authorization: AUTHORIZATION,
});
expect(descriptor.source.status).toBe("active");
});
it("a service identity cannot authorize a source (no self-granted rubber stamp)", async () => {
const svc = newService();
await svc.registerSource(base);
await expect(
svc.setAuthorization({
source_id: base.source_id,
tenant_id: TENANT,
basis: "internal",
granted_by: "svc_0pt",
granted_at: "2026-09-01T08:00:00.000Z",
}),
).rejects.toThrow(/accountable actor/);
});
it("an EXPIRED authorization does not classify the source", async () => {
const svc = newService();
await svc.registerSource({
...base,
rights: RIGHTS,
provenance: PROVENANCE,
authorization: { ...AUTHORIZATION, expires_at: "2026-01-01T00:00:00.000Z" },
});
const classification = await svc.classifySource(base.source_id, TENANT);
expect(classification.classified).toBe(false);
expect(classification.missing.join(" ")).toContain("expired");
await expect(svc.activateSource(base.source_id, TENANT)).rejects.toThrow(/not classified/);
});
it("rights with an empty purpose list do not classify (purpose limitation is not optional)", async () => {
const svc = newService();
await svc.registerSource({
...base,
rights: { ...RIGHTS, allowed_purposes: [] },
provenance: PROVENANCE,
authorization: AUTHORIZATION,
});
const classification = await svc.classifySource(base.source_id, TENANT);
expect(classification.classified).toBe(false);
expect(classification.missing.join(" ")).toContain("allowed_purposes is empty");
});
it("deactivating is never gated — a source can always be taken out of service", async () => {
const svc = newService();
await svc.registerSource({ ...base, rights: RIGHTS, provenance: PROVENANCE, authorization: AUTHORIZATION });
await svc.activateSource(base.source_id, TENANT);
const paused = await svc.deactivateSource(base.source_id, TENANT, "paused");
expect(paused.status).toBe("paused");
});
it("the gate is tenant-scoped: another tenant cannot activate this source", async () => {
const svc = newService();
await svc.registerSource({ ...base, rights: RIGHTS, provenance: PROVENANCE, authorization: AUTHORIZATION });
await expect(svc.activateSource(base.source_id, "t_attacker")).rejects.toThrow(/not found/);
expect((await svc.sources.getSource(base.source_id)).status).toBe("pending");
});
it("schedule is recorded as a registry field and validated", async () => {
const svc = newService();
await svc.registerSource({
...base,
schedule: { mode: "batch", interval_seconds: 3600, enabled: true },
});
const schedule = await svc.getSchedule(base.source_id, TENANT);
expect(schedule?.mode).toBe("batch");
expect(schedule?.interval_seconds).toBe(3600);
await expect(
svc.setSchedule({
source_id: base.source_id,
tenant_id: TENANT,
mode: "stream",
interval_seconds: 0,
enabled: true,
updated_at: new Date().toISOString(),
}),
).rejects.toThrow(/interval_seconds must be positive/);
});
});