feat: complete catalog sensitivity enhancements
This commit is contained in:
@@ -11,6 +11,7 @@ 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";
|
||||
import type { WorkspaceDiagnoser } from "../src/routes/workspaces.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
@@ -33,6 +34,7 @@ function setup(
|
||||
catalogDependencies: {
|
||||
catalogOperationCoordinator?: CatalogOperationCoordinator;
|
||||
catalogPostgresAccess?: CatalogPostgresAccess;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
} = {},
|
||||
workspaceDescriptor: WorkspaceDescriptor = workspace,
|
||||
) {
|
||||
@@ -56,7 +58,7 @@ function setup(
|
||||
workspaceRegistry: registry,
|
||||
workspaceSecretStore: secretStore,
|
||||
catalogRepository: repository,
|
||||
workspaceDiagnoser: vi.fn(),
|
||||
workspaceDiagnoser: vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
...catalogDependencies,
|
||||
});
|
||||
return { app, secretStore, repository };
|
||||
@@ -275,6 +277,72 @@ test("rejects a connection test while another catalog operation owns the databas
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace and database tests use the same current catalog database binding", async () => {
|
||||
const connect = vi.fn(async () => ({
|
||||
query: vi.fn(async () => ({
|
||||
rows: [{ database: "warehouse", schema: "datawarehouse" }],
|
||||
})),
|
||||
end: vi.fn(async () => undefined),
|
||||
}));
|
||||
const diagnose: WorkspaceDiagnoser = vi.fn(async () => ({
|
||||
activatable: true,
|
||||
diagnostics: [{
|
||||
level: "info",
|
||||
code: "binding_ok",
|
||||
message: "Installation bindings and diagnostics succeeded.",
|
||||
}],
|
||||
}));
|
||||
const { app } = setup({
|
||||
THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct",
|
||||
THT_WS_PSD_CLINICAL_DWH_HOST: "legacy-db.internal",
|
||||
THT_WS_PSD_CLINICAL_DWH_PORT: "5432",
|
||||
THT_WS_PSD_CLINICAL_DWH_USER: "legacy-reader",
|
||||
}, {
|
||||
catalogPostgresAccess: { connect } as CatalogPostgresAccess,
|
||||
workspaceDiagnoser: diagnose,
|
||||
});
|
||||
const created = (await app.inject({
|
||||
method: "POST",
|
||||
url: "/catalog/databases",
|
||||
payload: {
|
||||
...direct,
|
||||
binding: { ...direct.binding, host: "current-db.internal", username: "current-reader" },
|
||||
},
|
||||
})).json();
|
||||
|
||||
const databaseTest = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${created.id}/test`,
|
||||
payload: { version: created.version },
|
||||
});
|
||||
const workspaceTest = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workspaces/psd-clinical/test",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(databaseTest.statusCode).toBe(200);
|
||||
expect(workspaceTest.statusCode).toBe(200);
|
||||
expect(connect).toHaveBeenCalledTimes(2);
|
||||
expect(connect.mock.calls.map(([database]) => database)).toEqual([
|
||||
expect.objectContaining({
|
||||
databaseName: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
binding: expect.objectContaining({ host: "current-db.internal", username: "current-reader" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
databaseName: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
binding: expect.objectContaining({ host: "current-db.internal", username: "current-reader" }),
|
||||
}),
|
||||
]);
|
||||
expect(diagnose).toHaveBeenCalledWith(
|
||||
workspace,
|
||||
expect.any(Object),
|
||||
{ writeProbe: false, skipDwh: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("returns exact global and per-database fleet metrics", async () => {
|
||||
const { app, repository } = setup();
|
||||
const database = await repository.create(direct);
|
||||
|
||||
@@ -167,7 +167,7 @@ test("assesses sensitive flags locally without persisting them or calling an LLM
|
||||
scope: "all",
|
||||
engine: "local",
|
||||
modelId: null,
|
||||
policyVersion: "sensitivity-v2",
|
||||
policyVersion: "sensitivity-v4",
|
||||
status: "completed",
|
||||
total: 1,
|
||||
suggestedSensitive: 1,
|
||||
@@ -224,12 +224,30 @@ test("assesses sensitive flags locally without persisting them or calling an LLM
|
||||
runId: responseBody.run.id,
|
||||
sequence: 2,
|
||||
level: "info",
|
||||
message: "Assessed 1 of 1 columns locally.",
|
||||
message: "Scanning source data: pass 1 of 3, table batch 1 of 1.",
|
||||
},
|
||||
{
|
||||
runId: responseBody.run.id,
|
||||
sequence: 3,
|
||||
level: "info",
|
||||
message: "Scanning source data: pass 2 of 3, table batch 1 of 1.",
|
||||
},
|
||||
{
|
||||
runId: responseBody.run.id,
|
||||
sequence: 4,
|
||||
level: "info",
|
||||
message: "Scanning source data: pass 3 of 3, table batch 1 of 1.",
|
||||
},
|
||||
{
|
||||
runId: responseBody.run.id,
|
||||
sequence: 5,
|
||||
level: "info",
|
||||
message: "Assessed 1 of 1 columns locally.",
|
||||
},
|
||||
{
|
||||
runId: responseBody.run.id,
|
||||
sequence: 6,
|
||||
level: "info",
|
||||
message: "Local sensitivity analysis completed for 1 column.",
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_s
|
||||
import { up as upAiTokenUsage } from "../src/catalog/migrations/009_ai_token_usage.js";
|
||||
import { up as upCanonicalModelIds } from "../src/catalog/migrations/010_canonical_model_ids.js";
|
||||
import { up as upLocalSensitivityAnalysis } from "../src/catalog/migrations/011_local_sensitivity_analysis.js";
|
||||
import { up as upSensitivityReason } from "../src/catalog/migrations/012_sensitivity_reason.js";
|
||||
import { KyselyCatalogRepository, type CatalogDatabase } from "../src/catalog/repository.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import type { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
@@ -54,6 +55,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a
|
||||
await upAiTokenUsage(db);
|
||||
await upCanonicalModelIds(db);
|
||||
await upLocalSensitivityAnalysis(db);
|
||||
await upSensitivityReason(db);
|
||||
const repository = new KyselyCatalogRepository(db);
|
||||
const database = await repository.create({
|
||||
workspaceId: "psd-clinical",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { up as upLogicalRelationships } from "../src/catalog/migrations/008_cata
|
||||
import { up as upAiTokenUsage } from "../src/catalog/migrations/009_ai_token_usage.js";
|
||||
import { up as upCanonicalModelIds } from "../src/catalog/migrations/010_canonical_model_ids.js";
|
||||
import { up as upLocalSensitivityAnalysis } from "../src/catalog/migrations/011_local_sensitivity_analysis.js";
|
||||
import { up as upSensitivityReason } from "../src/catalog/migrations/012_sensitivity_reason.js";
|
||||
|
||||
const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
|
||||
|
||||
@@ -56,6 +57,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
|
||||
}).execute();
|
||||
await upCanonicalModelIds(db);
|
||||
await upLocalSensitivityAnalysis(db);
|
||||
await upSensitivityReason(db);
|
||||
await expect(db.selectFrom("sensitiveDataSuggestionRuns")
|
||||
.select(["engine", "modelId", "policyVersion", "unknown"])
|
||||
.where("id", "=", historicalSuggestionRunId)
|
||||
@@ -142,7 +144,11 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
|
||||
patientName.description,
|
||||
patientName.generatedDescription,
|
||||
true,
|
||||
)).toMatchObject({ sensitive: true });
|
||||
"Local assessment matched content rule pii.person_name.",
|
||||
)).toMatchObject({
|
||||
sensitive: true,
|
||||
sensitivityReason: "Local assessment matched content rule pii.person_name.",
|
||||
});
|
||||
expect(await repository.getCatalogMetrics(created.id)).toEqual({
|
||||
scope: "database",
|
||||
databaseId: created.id,
|
||||
@@ -187,6 +193,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
|
||||
)).toMatchObject({ updated: 1 });
|
||||
expect(await repository.getColumn(created.id, patients.id, patientName.id)).toMatchObject({
|
||||
sensitive: true,
|
||||
sensitivityReason: "Local assessment matched content rule pii.person_name.",
|
||||
sourceComment: "Sensitive patient name",
|
||||
});
|
||||
|
||||
@@ -281,10 +288,33 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository performs scoped metadata cl
|
||||
};
|
||||
await repository.applySchemaSync(database.id, database.version, "all", [], snapshot);
|
||||
const patients = (await repository.listTables(database.id)).find((table) => table.name === "patients")!;
|
||||
const context = (await repository.getLogicalRelationshipContext(database.id))!;
|
||||
const source = context.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "visits" && endpoint.columnName === "id"
|
||||
))!;
|
||||
const target = context.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "patients" && endpoint.columnName === "id"
|
||||
))!;
|
||||
const generatedCandidate = { sourceColumnId: source.columnId, targetColumnId: target.columnId };
|
||||
|
||||
expect(await repository.deleteTableMetadata(database.id, [patients.id], "relationships"))
|
||||
.toEqual({ tables: 0, columns: 0, relationships: 1 });
|
||||
await expect(repository.insertGeneratedLogicalRelationships(database.id, [generatedCandidate]))
|
||||
.resolves.toBe(1);
|
||||
await expect(repository.getCatalogMetrics(database.id))
|
||||
.resolves.toMatchObject({ relationships: 2 });
|
||||
expect(await repository.deleteDatabaseMetadata([database.id], "relationships"))
|
||||
.toEqual({ tables: 0, columns: 0, relationships: 2 });
|
||||
expect(await repository.listRelationships(database.id)).toEqual([]);
|
||||
expect(await repository.listLogicalRelationships(database.id)).toEqual([]);
|
||||
await expect(repository.getCatalogMetrics(database.id))
|
||||
.resolves.toMatchObject({ relationships: 0 });
|
||||
|
||||
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
|
||||
await expect(repository.insertGeneratedLogicalRelationships(database.id, [generatedCandidate]))
|
||||
.resolves.toBe(1);
|
||||
expect(await repository.deleteTableMetadata(database.id, [patients.id], "relationships"))
|
||||
.toEqual({ tables: 0, columns: 0, relationships: 2 });
|
||||
expect(await repository.listRelationships(database.id)).toEqual([]);
|
||||
expect(await repository.listLogicalRelationships(database.id)).toEqual([]);
|
||||
expect(await repository.listColumns(database.id, patients.id)).toHaveLength(2);
|
||||
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
|
||||
|
||||
@@ -298,13 +328,24 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository performs scoped metadata cl
|
||||
|
||||
await repository.applySchemaSync(database.id, database.version, "columns", [patients.id], snapshot);
|
||||
await repository.applySchemaSync(database.id, database.version, "relationships", [], snapshot);
|
||||
const refreshedContext = (await repository.getLogicalRelationshipContext(database.id))!;
|
||||
const refreshedSource = refreshedContext.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "visits" && endpoint.columnName === "id"
|
||||
))!;
|
||||
const refreshedTarget = refreshedContext.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "patients" && endpoint.columnName === "id"
|
||||
))!;
|
||||
await expect(repository.insertGeneratedLogicalRelationships(database.id, [{
|
||||
sourceColumnId: refreshedSource.columnId,
|
||||
targetColumnId: refreshedTarget.columnId,
|
||||
}])).resolves.toBe(1);
|
||||
expect(await repository.deleteDatabaseMetadata([
|
||||
database.id,
|
||||
"99999999-9999-4999-8999-999999999999",
|
||||
], "tables")).toBeUndefined();
|
||||
expect(await repository.listTables(database.id)).toHaveLength(2);
|
||||
expect(await repository.deleteDatabaseMetadata([database.id], "tables"))
|
||||
.toEqual({ tables: 2, columns: 4, relationships: 1 });
|
||||
.toEqual({ tables: 2, columns: 4, relationships: 2 });
|
||||
expect(await repository.get(database.id)).toBeDefined();
|
||||
expect(await repository.listTables(database.id)).toEqual([]);
|
||||
expect(await repository.listRelationships(database.id)).toEqual([]);
|
||||
@@ -431,6 +472,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and se
|
||||
await upAiTokenUsage(db);
|
||||
await upCanonicalModelIds(db);
|
||||
await upLocalSensitivityAnalysis(db);
|
||||
await upSensitivityReason(db);
|
||||
const repository = new KyselyCatalogRepository(db);
|
||||
const firstDatabase = await repository.create({
|
||||
workspaceId: "generation-one",
|
||||
@@ -703,6 +745,8 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists logical relationsh
|
||||
const target = context.endpoints.find((item) => item.tableName === "users" && item.columnName === "id")!;
|
||||
const created = await repository.insertLogicalRelationship(database.id, source.columnId, target.columnId, false);
|
||||
expect(created).toMatchObject({ origin: "manual", status: "active" });
|
||||
expect(await repository.getCatalogMetrics(database.id))
|
||||
.toMatchObject({ relationships: 1 });
|
||||
await expect(repository.insertLogicalRelationship(database.id, source.columnId, target.columnId, true))
|
||||
.resolves.toBeUndefined();
|
||||
|
||||
|
||||
@@ -7,7 +7,11 @@ import { loadConfig } from "../src/config.js";
|
||||
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
|
||||
import { CatalogOperationCoordinator } from "../src/catalog/operation-coordinator.js";
|
||||
import type { CatalogSchemaIntrospector } from "../src/catalog/schema-introspector.js";
|
||||
import type { CatalogSyncRun, ObservedSchemaSnapshot } from "../src/catalog/types.js";
|
||||
import {
|
||||
CatalogConnectorError,
|
||||
type CatalogSyncRun,
|
||||
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";
|
||||
@@ -164,6 +168,32 @@ test("synchronizes a full physical schema and derives primary and foreign key fl
|
||||
expect((await repository.get(database.id))?.schemaSyncedVersion).toBe(database.version);
|
||||
});
|
||||
|
||||
test("attempts synchronization after a failed connection test and reports the live access failure", async () => {
|
||||
const { app, repository, database, scan } = await setup();
|
||||
await repository.recordTest(database.id, database.version, {
|
||||
connectionStatus: "failed",
|
||||
testedVersion: database.version,
|
||||
lastTestedAt: new Date().toISOString(),
|
||||
lastErrorCode: "connector_unavailable",
|
||||
lastErrorMessage: "The database connector could not be reached or authenticated.",
|
||||
});
|
||||
scan.mockRejectedValueOnce(new CatalogConnectorError("upstream credentials must not escape"));
|
||||
|
||||
const started = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/sync-runs`,
|
||||
payload: { version: database.version, scope: "all", tableIds: [] },
|
||||
});
|
||||
|
||||
expect(started.statusCode).toBe(202);
|
||||
const failed = await waitFor(repository, started.json().id, "failed");
|
||||
expect(scan).toHaveBeenCalledOnce();
|
||||
expect(failed).toMatchObject({
|
||||
errorCode: "schema_introspection_failed",
|
||||
errorMessage: "The database schema could not be read. Check the connection and credentials, then try again.",
|
||||
});
|
||||
});
|
||||
|
||||
test("synchronizes columns for every catalog table when no table selection is supplied", async () => {
|
||||
const { app, repository, database, setObserved } = await setup();
|
||||
const tablesRun = await app.inject({
|
||||
@@ -247,13 +277,18 @@ test("keeps generated descriptions editable and preserves them across synchroniz
|
||||
});
|
||||
const sensitiveOnly = await app.inject({
|
||||
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}/columns/${idColumn.id}`,
|
||||
payload: { version: editedColumn.json().version, sensitive: true },
|
||||
payload: {
|
||||
version: editedColumn.json().version,
|
||||
sensitive: true,
|
||||
sensitivityReason: "Local assessment matched content rule pii.email.",
|
||||
},
|
||||
});
|
||||
expect(sensitiveOnly.statusCode).toBe(200);
|
||||
expect(sensitiveOnly.json()).toMatchObject({
|
||||
description: "Reviewed key",
|
||||
generatedDescription: "Generated key draft",
|
||||
sensitive: true,
|
||||
sensitivityReason: "Local assessment matched content rule pii.email.",
|
||||
});
|
||||
const emptyPatch = await app.inject({
|
||||
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}/columns/${idColumn.id}`,
|
||||
@@ -268,6 +303,7 @@ test("keeps generated descriptions editable and preserves them across synchroniz
|
||||
description: "Reviewed key",
|
||||
generatedDescription: "Generated key draft",
|
||||
sensitive: true,
|
||||
sensitivityReason: "Local assessment matched content rule pii.email.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,6 +394,67 @@ test("consolidates non-empty generated column descriptions and preserves curated
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("consolidates generated descriptions for every column in a database", async () => {
|
||||
const { app, repository, database, scan } = await setup();
|
||||
await seedCatalog(repository, database);
|
||||
const tables = await repository.listTables(database.id);
|
||||
const patients = tables.find((table) => table.name === "patients")!;
|
||||
const visits = tables.find((table) => table.name === "visits")!;
|
||||
const patientId = (await repository.listColumns(database.id, patients.id))[0]!;
|
||||
const visitColumns = await repository.listColumns(database.id, visits.id);
|
||||
const visitId = visitColumns.find((column) => column.name === "id")!;
|
||||
const visitPatientId = visitColumns.find((column) => column.name === "patient_id")!;
|
||||
await repository.updateColumnMetadata(
|
||||
database.id,
|
||||
patients.id,
|
||||
patientId.id,
|
||||
patientId.version,
|
||||
"Curated patient identifier",
|
||||
"Generated patient identifier",
|
||||
);
|
||||
await repository.updateColumnMetadata(
|
||||
database.id,
|
||||
visits.id,
|
||||
visitId.id,
|
||||
visitId.version,
|
||||
"Curated visit identifier",
|
||||
"Generated visit identifier",
|
||||
);
|
||||
await repository.updateColumnMetadata(
|
||||
database.id,
|
||||
visits.id,
|
||||
visitPatientId.id,
|
||||
visitPatientId.version,
|
||||
"Keep curated patient reference",
|
||||
"",
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
|
||||
payload: { target: "database_columns" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ copied: 2, skipped: 1 });
|
||||
expect(await repository.getColumn(database.id, patients.id, patientId.id)).toMatchObject({
|
||||
description: "Generated patient identifier",
|
||||
generatedDescription: "Generated patient identifier",
|
||||
version: patientId.version + 2,
|
||||
});
|
||||
expect(await repository.getColumn(database.id, visits.id, visitId.id)).toMatchObject({
|
||||
description: "Generated visit identifier",
|
||||
generatedDescription: "Generated visit identifier",
|
||||
version: visitId.version + 2,
|
||||
});
|
||||
expect(await repository.getColumn(database.id, visits.id, visitPatientId.id)).toMatchObject({
|
||||
description: "Keep curated patient reference",
|
||||
generatedDescription: "",
|
||||
version: visitPatientId.version + 1,
|
||||
});
|
||||
expect(scan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects description consolidation while the Workspace Database is reserved", async () => {
|
||||
const { app, repository, database, operations } = await setup();
|
||||
await seedCatalog(repository, database);
|
||||
@@ -432,9 +529,14 @@ test("strictly validates description consolidation database and target ids", asy
|
||||
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
|
||||
payload: { target: "tables", targetIds: [table.id], unexpected: true },
|
||||
}),
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
|
||||
payload: { target: "database_columns", targetIds: [table.id] },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(responses.map((response) => response.statusCode)).toEqual([400, 400, 400]);
|
||||
expect(responses.map((response) => response.statusCode)).toEqual([400, 400, 400, 400]);
|
||||
for (const response of responses) {
|
||||
expect(response.json()).toEqual({
|
||||
code: "description_consolidation_invalid",
|
||||
@@ -524,6 +626,18 @@ test("deletes relationships for selected databases without deleting their tables
|
||||
const { app, repository, database } = await setup();
|
||||
await seedCatalog(repository, database);
|
||||
const tables = await repository.listTables(database.id);
|
||||
const context = (await repository.getLogicalRelationshipContext(database.id))!;
|
||||
const source = context.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "visits" && endpoint.columnName === "id"
|
||||
))!;
|
||||
const target = context.endpoints.find((endpoint) => (
|
||||
endpoint.tableName === "patients" && endpoint.columnName === "id"
|
||||
))!;
|
||||
await expect(repository.insertGeneratedLogicalRelationships(database.id, [{
|
||||
sourceColumnId: source.columnId,
|
||||
targetColumnId: target.columnId,
|
||||
}])).resolves.toBe(1);
|
||||
await expect(repository.getCatalogMetrics(database.id)).resolves.toMatchObject({ relationships: 2 });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -532,10 +646,12 @@ test("deletes relationships for selected databases without deleting their tables
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 1 });
|
||||
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 2 });
|
||||
expect(await repository.listTables(database.id)).toHaveLength(2);
|
||||
expect(await repository.listColumns(database.id, tables[0]!.id)).not.toEqual([]);
|
||||
expect(await repository.listRelationships(database.id)).toEqual([]);
|
||||
expect(await repository.listLogicalRelationships(database.id)).toEqual([]);
|
||||
await expect(repository.getCatalogMetrics(database.id)).resolves.toMatchObject({ relationships: 0 });
|
||||
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -671,6 +787,11 @@ test("rebuilds generated relationships and returns the exact summary", async ()
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.json()).toEqual({ added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0 });
|
||||
const metrics = await app.inject({
|
||||
method: "GET", url: `/catalog/metrics?databaseId=${database.id}`,
|
||||
});
|
||||
expect(metrics.statusCode).toBe(200);
|
||||
expect(metrics.json()).toMatchObject({ relationships: 1 });
|
||||
const generated = (await repository.listLogicalRelationships(database.id))[0]!;
|
||||
|
||||
await app.inject({
|
||||
|
||||
@@ -73,7 +73,7 @@ const running: SensitivityAnalysisRun = {
|
||||
scope: "all",
|
||||
engine: "local",
|
||||
modelId: null,
|
||||
policyVersion: "sensitivity-v2",
|
||||
policyVersion: "sensitivity-v4",
|
||||
status: "running",
|
||||
total: 0,
|
||||
suggestedSensitive: 0,
|
||||
@@ -132,27 +132,36 @@ test("classifies all selected tables in one breadth-first run and reports covera
|
||||
tableId === firstTable.id ? [firstColumn] : [secondColumn]
|
||||
)),
|
||||
} as unknown as CatalogRepository;
|
||||
const assess = vi.fn(async () => [
|
||||
{
|
||||
columnId: firstColumn.id,
|
||||
assessment: "non_sensitive" as const,
|
||||
proposedSensitive: false,
|
||||
evidence: [{ kind: "coverage" as const, ruleId: "coverage.sampled_1000" }],
|
||||
observedValues: 1_000,
|
||||
coverage: "sampled" as const,
|
||||
},
|
||||
{
|
||||
columnId: secondColumn.id,
|
||||
assessment: "sensitive" as const,
|
||||
proposedSensitive: true,
|
||||
evidence: [{ kind: "content" as const, ruleId: "pii.email" }],
|
||||
observedValues: 12,
|
||||
coverage: "sampled" as const,
|
||||
},
|
||||
]);
|
||||
const assess = vi.fn(async (
|
||||
_targets,
|
||||
_signal,
|
||||
_nerBudget,
|
||||
onActivity?: (message: string) => void | Promise<void>,
|
||||
) => {
|
||||
await onActivity?.("Scanning source data: pass 1 of 3, table batch 1 of 1.");
|
||||
return [
|
||||
{
|
||||
columnId: firstColumn.id,
|
||||
assessment: "non_sensitive" as const,
|
||||
proposedSensitive: false,
|
||||
evidence: [{ kind: "coverage" as const, ruleId: "coverage.sampled_1000" }],
|
||||
observedValues: 1_000,
|
||||
coverage: "sampled" as const,
|
||||
},
|
||||
{
|
||||
columnId: secondColumn.id,
|
||||
assessment: "sensitive" as const,
|
||||
proposedSensitive: true,
|
||||
evidence: [{ kind: "content" as const, ruleId: "pii.email" }],
|
||||
observedValues: 12,
|
||||
coverage: "sampled" as const,
|
||||
},
|
||||
];
|
||||
});
|
||||
const classifier = { assess } as unknown as SensitivityClassifier;
|
||||
const onPrepared = vi.fn();
|
||||
const onProgress = vi.fn();
|
||||
const onActivity = vi.fn();
|
||||
|
||||
const suggestions = await new SensitivityAnalysisService(repository, classifier).analyze(
|
||||
database.id,
|
||||
@@ -161,6 +170,7 @@ test("classifies all selected tables in one breadth-first run and reports covera
|
||||
new AbortController().signal,
|
||||
onPrepared,
|
||||
onProgress,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
expect(assess).toHaveBeenCalledOnce();
|
||||
@@ -169,6 +179,9 @@ test("classifies all selected tables in one breadth-first run and reports covera
|
||||
{ database, table: secondTable, columns: [secondColumn] },
|
||||
]);
|
||||
expect(onPrepared).toHaveBeenCalledWith(2);
|
||||
expect(onActivity).toHaveBeenCalledWith(
|
||||
"Scanning source data: pass 1 of 3, table batch 1 of 1.",
|
||||
);
|
||||
expect(onProgress.mock.calls.map(([processed]) => processed)).toEqual([1, 2]);
|
||||
expect(suggestions).toEqual([
|
||||
expect.objectContaining({ columnId: firstColumn.id, sensitive: false, coverage: "sampled" }),
|
||||
@@ -176,6 +189,52 @@ test("classifies all selected tables in one breadth-first run and reports covera
|
||||
]);
|
||||
});
|
||||
|
||||
test("persists classifier activity in the running analysis event log", async () => {
|
||||
let persisted = running;
|
||||
const appendEvent = vi.fn(async () => undefined);
|
||||
const repository = {
|
||||
get: vi.fn(async () => database),
|
||||
createSensitivityAnalysisRun: vi.fn(async () => running),
|
||||
getSensitivityAnalysisRun: vi.fn(async () => persisted),
|
||||
updateSensitivityAnalysisRun: vi.fn(async (
|
||||
_runId: string,
|
||||
changes: Partial<SensitivityAnalysisRun>,
|
||||
) => {
|
||||
persisted = { ...persisted, ...changes };
|
||||
return persisted;
|
||||
}),
|
||||
appendSensitivityAnalysisEvent: appendEvent,
|
||||
} as unknown as CatalogRepository;
|
||||
const analysis = {
|
||||
analyze: vi.fn(async (
|
||||
_databaseId,
|
||||
_scope,
|
||||
_targetIds,
|
||||
_signal,
|
||||
onPrepared,
|
||||
_onProgress,
|
||||
onActivity,
|
||||
) => {
|
||||
await onPrepared?.(0);
|
||||
await onActivity?.("Scanning source data: pass 1 of 3, table batch 1 of 1.");
|
||||
return [];
|
||||
}),
|
||||
} as unknown as SensitivityAnalysisService;
|
||||
|
||||
await new SensitivityAnalysisRunner(repository, analysis).run(
|
||||
database.id,
|
||||
"all",
|
||||
[],
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(appendEvent).toHaveBeenCalledWith(
|
||||
running.id,
|
||||
"info",
|
||||
"Scanning source data: pass 1 of 3, table batch 1 of 1.",
|
||||
);
|
||||
});
|
||||
|
||||
test("marks a created run interrupted if the request deadline expires during persistence", async () => {
|
||||
const controller = new AbortController();
|
||||
const update = vi.fn(async (_runId: string, changes: Partial<SensitivityAnalysisRun>) => ({
|
||||
|
||||
@@ -69,6 +69,32 @@ function source(scan: SensitivityTableScan): SensitivityValueSource {
|
||||
}) };
|
||||
}
|
||||
|
||||
test("reports source-scan activity before a long table scan completes", async () => {
|
||||
let releaseScan!: () => void;
|
||||
const scanGate = new Promise<void>((resolve) => {
|
||||
releaseScan = resolve;
|
||||
});
|
||||
const scanTable = vi.fn(async () => {
|
||||
await scanGate;
|
||||
return { kind: "complete" as const, observedValues: 0 };
|
||||
});
|
||||
const activity = vi.fn();
|
||||
const analysis = new SensitivityClassifier({ scanTable }).assess(
|
||||
[{ database, table, columns: [column()] }],
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
activity,
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(scanTable).toHaveBeenCalledOnce());
|
||||
releaseScan();
|
||||
await analysis;
|
||||
|
||||
expect(activity).toHaveBeenCalledWith(
|
||||
"Scanning source data: pass 1 of 3, table batch 1 of 1.",
|
||||
);
|
||||
});
|
||||
|
||||
test("one email hidden in a generically named column makes the whole column sensitive", async () => {
|
||||
const target = column();
|
||||
const values = source({
|
||||
@@ -351,6 +377,98 @@ test("strong Italian PII metadata is sensitive even when the source column is em
|
||||
expect(values.scanTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("excludes bigint primary keys from content analysis as non-informative identifiers", async () => {
|
||||
const target = column({
|
||||
name: "id",
|
||||
dataType: "bigint",
|
||||
primaryKeyPosition: 1,
|
||||
isPrimaryKey: true,
|
||||
});
|
||||
const values = source({
|
||||
batches: [[{
|
||||
columnId: target.id,
|
||||
value: "3471234567",
|
||||
characterLength: 10,
|
||||
}]],
|
||||
coverage: { kind: "complete", observedValues: 1 },
|
||||
});
|
||||
|
||||
const [assessment] = await new SensitivityClassifier(values).assessTable(
|
||||
{ database, table, columns: [target] },
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(assessment).toMatchObject({
|
||||
assessment: "non_sensitive",
|
||||
proposedSensitive: false,
|
||||
evidence: [{
|
||||
kind: "type",
|
||||
ruleId: "type.bigint_primary_key_non_informative",
|
||||
label: "non-informative bigint primary key",
|
||||
}],
|
||||
coverage: "metadata",
|
||||
});
|
||||
expect(values.scanTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("infers an undeclared bigint column named pk as a non-informative primary-key identifier", async () => {
|
||||
const target = column({
|
||||
name: "pk",
|
||||
dataType: "bigint",
|
||||
primaryKeyPosition: null,
|
||||
isPrimaryKey: false,
|
||||
});
|
||||
const values = source({
|
||||
batches: [[{
|
||||
columnId: target.id,
|
||||
value: "3471234567",
|
||||
characterLength: 10,
|
||||
}]],
|
||||
coverage: { kind: "complete", observedValues: 1 },
|
||||
});
|
||||
|
||||
const [assessment] = await new SensitivityClassifier(values).assessTable(
|
||||
{ database, table, columns: [target] },
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(assessment).toMatchObject({
|
||||
assessment: "non_sensitive",
|
||||
proposedSensitive: false,
|
||||
evidence: [{
|
||||
kind: "metadata",
|
||||
ruleId: "metadata.bigint_pk_identifier_non_informative",
|
||||
label: "non-informative conventional bigint primary-key identifier",
|
||||
}],
|
||||
coverage: "metadata",
|
||||
});
|
||||
expect(values.scanTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("still inspects phone-like values in bigint columns that are not primary keys", async () => {
|
||||
const target = column({ name: "id", dataType: "bigint" });
|
||||
const values = source({
|
||||
batches: [[{
|
||||
columnId: target.id,
|
||||
value: "3471234567",
|
||||
characterLength: 10,
|
||||
}]],
|
||||
coverage: { kind: "complete", observedValues: 1 },
|
||||
});
|
||||
|
||||
const [assessment] = await new SensitivityClassifier(values).assessTable(
|
||||
{ database, table, columns: [target] },
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(assessment).toMatchObject({
|
||||
assessment: "sensitive",
|
||||
proposedSensitive: true,
|
||||
evidence: [{ kind: "content", ruleId: "pii.phone_number" }],
|
||||
});
|
||||
expect(values.scanTable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["RSSMRA85T10A562S", "pii.italian_fiscal_code"],
|
||||
["IT60 X054 2811 1010 0000 0123 456", "financial.iban"],
|
||||
|
||||
@@ -103,7 +103,7 @@ test("requires an exact deletion confirmation before applying the atomic diff",
|
||||
]);
|
||||
});
|
||||
|
||||
test("refuses synchronization until the current binding has passed its connection test", async () => {
|
||||
test("starts synchronization without requiring a prior connection test", async () => {
|
||||
const { app, repository, database } = await setup();
|
||||
await repository.update(database.id, database.version, {
|
||||
workspaceId: database.workspaceId,
|
||||
@@ -117,6 +117,10 @@ test("refuses synchronization until the current binding has passed its connectio
|
||||
url: `/catalog/databases/${database.id}/sync-runs`,
|
||||
payload: { version: database.version + 1, scope: "tables", tableIds: [] },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ code: "schema_sync_conflict" });
|
||||
expect(response.statusCode).toBe(202);
|
||||
expect(response.json()).toMatchObject({
|
||||
databaseId: database.id,
|
||||
scope: "tables",
|
||||
state: "queued",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/reg
|
||||
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
|
||||
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
|
||||
import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js";
|
||||
import type { WorkspaceDatabase } from "../src/catalog/types.js";
|
||||
import type { WorkspaceDatabaseTester } from "../src/routes/workspaces.js";
|
||||
|
||||
const workspace: CanonicalWorkspace = {
|
||||
workspace: {
|
||||
@@ -63,12 +65,35 @@ const readyAuthentication: AuthDiagnostics = {
|
||||
checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }],
|
||||
};
|
||||
|
||||
const reachableWorkspaceDatabase: WorkspaceDatabase = {
|
||||
id: "db-psd-clinical",
|
||||
workspaceId: "psd-clinical",
|
||||
engine: "postgres",
|
||||
databaseName: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
version: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
binding: {
|
||||
transport: "postgres_direct",
|
||||
host: "current-db.internal",
|
||||
port: 5432,
|
||||
username: "current-reader",
|
||||
},
|
||||
connectionStatus: "reachable",
|
||||
testedVersion: 1,
|
||||
lastTestedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function appFor(
|
||||
registry: RegistryFake,
|
||||
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
|
||||
secretStore = testSecretStore(),
|
||||
env: Record<string, string> = {},
|
||||
authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => readyAuthentication) },
|
||||
workspaceDatabaseTester: WorkspaceDatabaseTester = vi.fn(
|
||||
async () => reachableWorkspaceDatabase,
|
||||
),
|
||||
) {
|
||||
return buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "/missing-harness",
|
||||
@@ -80,6 +105,7 @@ function appFor(
|
||||
workspaceDiagnoser: diagnose,
|
||||
workspaceSecretStore: secretStore,
|
||||
authDiagnoser,
|
||||
workspaceDatabaseTester,
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -283,7 +309,48 @@ test("runs diagnostics for a schema v4 workspace", async () => {
|
||||
expect(diagnose).toHaveBeenCalledWith(workspace, {
|
||||
dwh: expect.objectContaining({ transport: "postgres_direct" }),
|
||||
evidence: { missing: [], values: {} },
|
||||
}, { writeProbe: false });
|
||||
}, { writeProbe: false, skipDwh: true });
|
||||
});
|
||||
|
||||
test("reports a missing Database Management configuration without using the legacy DWH test", async () => {
|
||||
const diagnose = vi.fn(async () => ({
|
||||
activatable: true,
|
||||
diagnostics: [{
|
||||
level: "info" as const,
|
||||
code: "binding_ok" as const,
|
||||
message: "Installation bindings and diagnostics succeeded.",
|
||||
}],
|
||||
}));
|
||||
const workspaceDatabaseTester = vi.fn(async () => undefined);
|
||||
const app = appFor(
|
||||
registryFake(),
|
||||
diagnose,
|
||||
testSecretStore(),
|
||||
{},
|
||||
{ inspect: vi.fn(async () => readyAuthentication) },
|
||||
workspaceDatabaseTester,
|
||||
);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/workspaces/psd-clinical/test", payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
activatable: false,
|
||||
diagnostics: [{
|
||||
level: "error",
|
||||
code: "binding_missing",
|
||||
field: "dwh",
|
||||
message: "Configure this workspace in Database Management before testing connections.",
|
||||
}],
|
||||
});
|
||||
expect(diagnose).toHaveBeenCalledWith(
|
||||
workspace,
|
||||
expect.anything(),
|
||||
{ writeProbe: false, skipDwh: true },
|
||||
);
|
||||
expect(workspaceDatabaseTester).toHaveBeenCalledWith("psd-clinical");
|
||||
});
|
||||
|
||||
test("reports runtime secret requirements without returning stored values", async () => {
|
||||
|
||||
@@ -89,6 +89,28 @@ test("diagnoses workspace-v4 DWH plus installation-derived Qdrant and Ollama", a
|
||||
}));
|
||||
});
|
||||
|
||||
test("can delegate the DWH probe to Database Management", async () => {
|
||||
const adapters = successfulAdapters();
|
||||
const result = await diagnose(adapters)(workspace, {
|
||||
dwh: {
|
||||
...bindings.dwh,
|
||||
missing: ["THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE"],
|
||||
},
|
||||
evidence: bindings.evidence,
|
||||
}, { writeProbe: false, skipDwh: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
activatable: true,
|
||||
diagnostics: [{
|
||||
level: "info", code: "binding_ok",
|
||||
message: "Installation bindings and diagnostics succeeded.",
|
||||
}],
|
||||
});
|
||||
expect(adapters.probeConnector).not.toHaveBeenCalled();
|
||||
expect(adapters.inspectQdrant).toHaveBeenCalledTimes(1);
|
||||
expect(adapters.probeEmbedding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("reports incompatible internal Qdrant or Ollama metadata", async () => {
|
||||
const vector = await diagnose(successfulAdapters({
|
||||
inspectQdrant: vi.fn(async () => ({
|
||||
|
||||
Reference in New Issue
Block a user