413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, expect, test, vi } from "vitest";
|
|
import { buildApp } from "../src/app.js";
|
|
import { loadConfig } from "../src/config.js";
|
|
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
|
|
import { CatalogOperationCoordinator } from "../src/catalog/operation-coordinator.js";
|
|
import type { CatalogPostgresAccess } from "../src/catalog/postgres-access.js";
|
|
import type { ObservedSchemaSnapshot } from "../src/catalog/types.js";
|
|
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
|
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
|
|
import type { WorkspaceDescriptor } from "../src/workspaces/schema.js";
|
|
|
|
const roots: string[] = [];
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
const workspace: WorkspaceDescriptor = {
|
|
workspace: { schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it" },
|
|
dwh: {
|
|
engine: "postgres", database: "warehouse", schema: "datawarehouse", port: 5432,
|
|
supported_transports: ["postgres_direct", "rest_api"],
|
|
},
|
|
diagnostics: { dwh_rest: { method: "GET", path: "/health", auth: "bearer", response: { database: "database", schema: "schema" } } },
|
|
};
|
|
const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" };
|
|
|
|
function setup(
|
|
environment: Record<string, string> = {},
|
|
catalogDependencies: {
|
|
catalogOperationCoordinator?: CatalogOperationCoordinator;
|
|
catalogPostgresAccess?: CatalogPostgresAccess;
|
|
} = {},
|
|
workspaceDescriptor: WorkspaceDescriptor = workspace,
|
|
) {
|
|
const secretRoot = mkdtempSync(join(tmpdir(), "catalog-secret-"));
|
|
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-secret-runtime-"));
|
|
roots.push(secretRoot, runtimeRoot);
|
|
const secretStore = new WorkspaceSecretStore({ root: secretRoot, runtimeRoot, installationId: "test" });
|
|
const repository = new MemoryCatalogRepository();
|
|
const registry = {
|
|
list: vi.fn(async () => [revision]),
|
|
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
|
|
read: vi.fn(async () => ({ workspace: workspaceDescriptor, revision })),
|
|
readPinned: vi.fn(async () => ({ workspace: workspaceDescriptor, workspaceConfigPath: revision.snapshotPath })),
|
|
} as unknown as WorkspaceRegistry;
|
|
const app = buildApp(loadConfig({
|
|
THT_HARNESS_DIR: "/missing",
|
|
NODE_ENV: "test",
|
|
...environment,
|
|
}), {
|
|
thtRunner: {} as never,
|
|
workspaceRegistry: registry,
|
|
workspaceSecretStore: secretStore,
|
|
catalogRepository: repository,
|
|
workspaceDiagnoser: vi.fn(),
|
|
...catalogDependencies,
|
|
});
|
|
return { app, secretStore, repository };
|
|
}
|
|
|
|
const direct = {
|
|
workspaceId: "psd-clinical",
|
|
engine: "postgres",
|
|
databaseName: "warehouse",
|
|
schema: "datawarehouse",
|
|
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
|
|
};
|
|
|
|
const fleetSnapshot: ObservedSchemaSnapshot = {
|
|
schemaVersion: 1,
|
|
capabilities: { tables: "available", columns: "available", relationships: "available" },
|
|
tables: [
|
|
{ name: "patients", sourceComment: "Clinical patients" },
|
|
{ name: "visits", sourceComment: null },
|
|
],
|
|
columns: [
|
|
{ tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
|
|
{ tableName: "patients", name: "name", ordinalPosition: 2, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
|
|
{ tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
|
|
{ tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
|
|
],
|
|
relationships: [{
|
|
constraintName: "visits_patient_id_fkey",
|
|
sourceTableName: "visits",
|
|
targetTableName: "patients",
|
|
updateRule: "NO ACTION",
|
|
deleteRule: "CASCADE",
|
|
deferrable: false,
|
|
initiallyDeferred: false,
|
|
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
|
|
}],
|
|
};
|
|
|
|
test("lists every YAML workspace and creates its one database configuration", async () => {
|
|
const { app, secretStore } = setup();
|
|
const initial = await app.inject({ method: "GET", url: "/catalog/databases" });
|
|
expect(initial.statusCode).toBe(200);
|
|
expect(initial.json()).toMatchObject([{
|
|
workspaceId: "psd-clinical",
|
|
configured: false,
|
|
databaseName: "warehouse",
|
|
workspaceRevision: { commit: revision.commit, blob: revision.blob },
|
|
workspaceEvidence: { sourceType: null, state: "not_declared" },
|
|
runtimeBinding: {
|
|
transport: "postgres_direct",
|
|
configurationState: "configuration_required",
|
|
sessionTransportSupported: true,
|
|
},
|
|
}]);
|
|
|
|
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_HOST", "runtime-db.internal");
|
|
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_PORT", "5432");
|
|
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_USER", "runtime-reader");
|
|
vi.stubEnv("THT_WS_PSD_CLINICAL_DWH_TRANSPORT", "postgres_direct");
|
|
secretStore.putMany("psd-clinical", { "dwh.password": "runtime-password" });
|
|
const runtimeReady = await app.inject({ method: "GET", url: "/catalog/databases" });
|
|
expect(runtimeReady.json()).toMatchObject([{
|
|
runtimeBinding: { configurationState: "ready", sessionTransportSupported: true },
|
|
}]);
|
|
|
|
const created = await app.inject({ method: "POST", url: "/catalog/databases", payload: direct });
|
|
expect(created.statusCode).toBe(201);
|
|
expect(created.json()).toMatchObject({ configured: true, workspaceId: "psd-clinical", version: 1 });
|
|
expect((await app.inject({ method: "POST", url: "/catalog/databases", payload: direct })).statusCode).toBe(409);
|
|
|
|
const listed = await app.inject({ method: "GET", url: "/catalog/databases" });
|
|
expect(listed.json()).toMatchObject([{ configured: true, binding: { transport: "postgres_direct", host: "db.internal" } }]);
|
|
});
|
|
|
|
test("projects remote Evidence credential state without conflating catalog secrets", async () => {
|
|
const evidenceWorkspace: WorkspaceDescriptor = {
|
|
...workspace,
|
|
evidence: {
|
|
schema_version: 2,
|
|
source: {
|
|
type: "http",
|
|
uris: ["https://evidence.example.test/guide.md"],
|
|
authentication: "signed_urls_file",
|
|
connect_timeout_ms: 5_000,
|
|
read_timeout_ms: 30_000,
|
|
max_bytes: 10 * 1024 * 1024,
|
|
max_redirects: 5,
|
|
allow_private_hosts: false,
|
|
max_cache_bytes: 64 * 1024 * 1024,
|
|
},
|
|
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
|
},
|
|
};
|
|
const { app, secretStore } = setup({}, {}, evidenceWorkspace);
|
|
|
|
const missing = await app.inject({ method: "GET", url: "/catalog/databases" });
|
|
expect(missing.json()).toMatchObject([{
|
|
workspaceEvidence: { sourceType: "http", state: "configuration_required" },
|
|
}]);
|
|
|
|
secretStore.putMany("psd-clinical", { "evidence.signed_urls": "https://signed.example.test/evidence" });
|
|
const configured = await app.inject({ method: "GET", url: "/catalog/databases" });
|
|
expect(configured.json()).toMatchObject([{
|
|
workspaceEvidence: { sourceType: "http", state: "configured_unverified" },
|
|
}]);
|
|
});
|
|
|
|
test("lists orphaned records and takes the REST diagnostic path from workspace YAML", async () => {
|
|
const { app, repository } = setup();
|
|
await repository.create({
|
|
workspaceId: "removed-workspace",
|
|
engine: "postgres",
|
|
databaseName: "legacy",
|
|
schema: "public",
|
|
binding: { transport: "postgres_direct", host: "legacy.internal", port: 5432, username: "reader" },
|
|
});
|
|
const created = await app.inject({
|
|
method: "POST",
|
|
url: "/catalog/databases",
|
|
payload: {
|
|
...direct,
|
|
binding: {
|
|
transport: "rest_api", baseUrl: "https://psd.example/api", restPath: "/client-controlled", restAuth: "bearer",
|
|
},
|
|
},
|
|
});
|
|
expect(created.statusCode).toBe(201);
|
|
expect(created.json()).toMatchObject({ binding: { restPath: "/health" } });
|
|
|
|
const rows = (await app.inject({ method: "GET", url: "/catalog/databases" })).json();
|
|
expect(rows).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ workspaceId: "removed-workspace", configured: true, workspaceAvailable: false }),
|
|
expect.objectContaining({ workspaceId: "psd-clinical", configured: true, workspaceAvailable: true }),
|
|
]));
|
|
});
|
|
|
|
test.each([
|
|
"https://reader:secret@psd.example/api",
|
|
"https://psd.example/api?token=secret",
|
|
"https://psd.example/api#secret",
|
|
"ftp://psd.example/api",
|
|
])("rejects unsafe REST base URL %s before persistence", async (baseUrl) => {
|
|
const { app, repository } = setup();
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/catalog/databases",
|
|
payload: {
|
|
...direct,
|
|
binding: { transport: "rest_api", baseUrl, restPath: "/health", restAuth: "bearer" },
|
|
},
|
|
});
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json()).toEqual({
|
|
code: "database_invalid",
|
|
message: "Database configuration is invalid.",
|
|
});
|
|
expect(await repository.getByWorkspace("psd-clinical")).toBeUndefined();
|
|
});
|
|
|
|
test("uses optimistic versions, keeps secrets write-only, and hard-deletes only local configuration", async () => {
|
|
const { app, secretStore } = setup();
|
|
const created = (await app.inject({ method: "POST", url: "/catalog/databases", payload: direct })).json();
|
|
const stale = await app.inject({ method: "PATCH", url: `/catalog/databases/${created.id}`, payload: { ...direct, version: 99 } });
|
|
expect(stale.statusCode).toBe(409);
|
|
|
|
const secret = await app.inject({
|
|
method: "PUT", url: `/catalog/databases/${created.id}/secrets`,
|
|
payload: { version: 1, values: { password: "do-not-return-this" } },
|
|
});
|
|
expect(secret.statusCode).toBe(200);
|
|
expect(secret.body).not.toContain("do-not-return-this");
|
|
expect(secret.json()).toMatchObject({ version: 2, secrets: { password: true } });
|
|
expect(secretStore.has("psd-clinical", "catalog.dwh.password")).toBe(true);
|
|
|
|
const removed = await app.inject({ method: "DELETE", url: `/catalog/databases/${created.id}?version=2` });
|
|
expect(removed.statusCode).toBe(204);
|
|
expect(secretStore.has("psd-clinical", "catalog.dwh.password")).toBe(false);
|
|
expect((await app.inject({ method: "GET", url: "/catalog/databases" })).json()).toMatchObject([{ configured: false }]);
|
|
});
|
|
|
|
test("rejects a connection test while another catalog operation owns the database", async () => {
|
|
const coordinator = new CatalogOperationCoordinator();
|
|
const postgres: CatalogPostgresAccess = {
|
|
connect: vi.fn(async () => { throw new Error("connection must not start"); }),
|
|
};
|
|
const { app } = setup({}, {
|
|
catalogOperationCoordinator: coordinator,
|
|
catalogPostgresAccess: postgres,
|
|
});
|
|
const created = (await app.inject({
|
|
method: "POST",
|
|
url: "/catalog/databases",
|
|
payload: direct,
|
|
})).json();
|
|
const release = coordinator.reserve(created.id);
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: `/catalog/databases/${created.id}/test`,
|
|
payload: { version: created.version },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json()).toEqual({
|
|
code: "database_operation_in_progress",
|
|
message: "A database operation is already in progress.",
|
|
});
|
|
expect(postgres.connect).not.toHaveBeenCalled();
|
|
expect((await app.inject({
|
|
method: "GET",
|
|
url: `/catalog/databases/${created.id}`,
|
|
})).json()).toMatchObject({ connectionStatus: "untested" });
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|
|
|
|
test("returns exact global and per-database fleet metrics", async () => {
|
|
const { app, repository } = setup();
|
|
const database = await repository.create(direct);
|
|
await repository.applySchemaSync(database.id, database.version, "all", [], fleetSnapshot);
|
|
|
|
const patients = (await repository.listTables(database.id))
|
|
.find((table) => table.name === "patients")!;
|
|
await repository.updateTableDescription(
|
|
database.id,
|
|
patients.id,
|
|
patients.version,
|
|
"Curated patients",
|
|
);
|
|
const patientName = (await repository.listColumns(database.id, patients.id))
|
|
.find((column) => column.name === "name")!;
|
|
await repository.updateColumnMetadata(
|
|
database.id,
|
|
patients.id,
|
|
patientName.id,
|
|
patientName.version,
|
|
null,
|
|
"Generated patient name",
|
|
true,
|
|
);
|
|
|
|
const archive = await repository.create({
|
|
workspaceId: "removed-workspace",
|
|
engine: "postgres",
|
|
databaseName: "archive",
|
|
schema: "public",
|
|
binding: {
|
|
transport: "postgres_direct",
|
|
host: "archive.internal",
|
|
port: 5432,
|
|
username: "reader",
|
|
},
|
|
});
|
|
await repository.applySchemaSync(archive.id, archive.version, "all", [], {
|
|
schemaVersion: 1,
|
|
capabilities: { tables: "available", columns: "available", relationships: "available" },
|
|
tables: [{ name: "events", sourceComment: null }],
|
|
columns: [{
|
|
tableName: "events",
|
|
name: "id",
|
|
ordinalPosition: 1,
|
|
dataType: "bigint",
|
|
isNullable: false,
|
|
defaultExpression: null,
|
|
primaryKeyPosition: 1,
|
|
sourceComment: null,
|
|
}],
|
|
relationships: [],
|
|
});
|
|
const events = (await repository.listTables(archive.id))[0]!;
|
|
await repository.updateTableMetadata(
|
|
archive.id,
|
|
events.id,
|
|
events.version,
|
|
null,
|
|
"Generated archive events",
|
|
);
|
|
|
|
const scoped = await app.inject({
|
|
method: "GET",
|
|
url: `/catalog/metrics?databaseId=${database.id}`,
|
|
});
|
|
expect(scoped.statusCode).toBe(200);
|
|
expect(scoped.json()).toEqual({
|
|
scope: "database",
|
|
databaseId: database.id,
|
|
tables: 2,
|
|
columns: 4,
|
|
sensitiveColumns: 1,
|
|
relationships: 1,
|
|
descriptionTargets: 6,
|
|
describedTargets: 2,
|
|
descriptionCoverage: 33,
|
|
updatedAt: expect.any(String),
|
|
});
|
|
|
|
const global = await app.inject({ method: "GET", url: "/catalog/metrics" });
|
|
expect(global.statusCode).toBe(200);
|
|
expect(global.json()).toEqual({
|
|
scope: "global",
|
|
databaseId: null,
|
|
tables: 3,
|
|
columns: 5,
|
|
sensitiveColumns: 1,
|
|
relationships: 1,
|
|
descriptionTargets: 8,
|
|
describedTargets: 3,
|
|
descriptionCoverage: 38,
|
|
updatedAt: expect.any(String),
|
|
});
|
|
expect(Number.isNaN(Date.parse(global.json().updatedAt))).toBe(false);
|
|
});
|
|
|
|
test("validates fleet metric scope and requires database.manage", async () => {
|
|
const { app } = setup();
|
|
const unknown = await app.inject({
|
|
method: "GET",
|
|
url: "/catalog/metrics?databaseId=99999999-9999-4999-8999-999999999999",
|
|
});
|
|
expect(unknown.statusCode).toBe(404);
|
|
expect(unknown.json()).toEqual({
|
|
code: "database_not_found",
|
|
message: "Database configuration was not found.",
|
|
});
|
|
|
|
const invalid = await app.inject({
|
|
method: "GET",
|
|
url: "/catalog/metrics?databaseId=not-a-uuid",
|
|
});
|
|
expect(invalid.statusCode).toBe(400);
|
|
expect(invalid.json()).toEqual({
|
|
code: "database_invalid",
|
|
message: "Database configuration is invalid.",
|
|
});
|
|
|
|
const { app: restrictedApp } = setup({ AUTH_MODE: "upstream" });
|
|
const forbidden = await restrictedApp.inject({
|
|
method: "GET",
|
|
url: "/catalog/metrics",
|
|
headers: {
|
|
"x-thoth-principal-issuer": "portal",
|
|
"x-thoth-principal-subject": "catalog-reader",
|
|
"x-thoth-is-admin": "0",
|
|
},
|
|
});
|
|
expect(forbidden.statusCode).toBe(403);
|
|
expect(forbidden.json()).toEqual({
|
|
code: "auth_forbidden",
|
|
error: "This operation is not permitted",
|
|
});
|
|
});
|