191 lines
7.5 KiB
TypeScript
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/);
|
|
});
|
|
});
|