feat: classify sensitive columns locally
This commit is contained in:
@@ -9,7 +9,7 @@ import type {
|
||||
CatalogSyncRun,
|
||||
CatalogTable,
|
||||
DescriptionGenerationRun,
|
||||
SensitiveDataSuggestionRun,
|
||||
SensitivityAnalysisRun,
|
||||
} from "../api/catalog-databases";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
@@ -159,18 +159,21 @@ function makeDescriptionGenerationRun(
|
||||
};
|
||||
}
|
||||
|
||||
function makeSensitiveDataSuggestionRun(
|
||||
overrides: Partial<SensitiveDataSuggestionRun> = {},
|
||||
): SensitiveDataSuggestionRun {
|
||||
function makeSensitivityAnalysisRun(
|
||||
overrides: Partial<SensitivityAnalysisRun> = {},
|
||||
): SensitivityAnalysisRun {
|
||||
return {
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
modelId: "local-qwen",
|
||||
engine: "local",
|
||||
modelId: null,
|
||||
policyVersion: "sensitivity-v1",
|
||||
scope: "selected_columns",
|
||||
status: "completed",
|
||||
total: 2,
|
||||
suggestedSensitive: 1,
|
||||
suggestedNonSensitive: 1,
|
||||
unknown: 0,
|
||||
createdAt: "2026-08-28T11:00:00Z",
|
||||
startedAt: "2026-08-28T11:00:00Z",
|
||||
updatedAt: "2026-08-28T11:00:01Z",
|
||||
@@ -505,7 +508,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
name: "View description generation history",
|
||||
});
|
||||
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
||||
name: "View sensitive suggestion history",
|
||||
name: "View sensitivity analysis history",
|
||||
});
|
||||
|
||||
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
||||
@@ -517,7 +520,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
||||
expect(suggestionHistoryButton).toBeVisible();
|
||||
expect(suggestionHistoryButton).toBeEnabled();
|
||||
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
|
||||
expect(suggestionHistoryButton).toHaveTextContent("View sensitivity analysis history");
|
||||
expect(toolbar.lastElementChild).toBe(actions);
|
||||
expect(actions).toHaveClass("sm:justify-end");
|
||||
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
||||
@@ -533,8 +536,8 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
|
||||
await user.click(suggestionHistoryButton);
|
||||
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
||||
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
|
||||
expect(within(suggestionDrawer).getByText("No sensitive suggestion runs yet.")).toBeVisible();
|
||||
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
|
||||
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes the metadata-description model in page-local state", async () => {
|
||||
@@ -1642,8 +1645,8 @@ test("observes an active run from another browser and reopens a terminal run fro
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the sensitive suggestion history label stable while showing active status separately", async () => {
|
||||
const runningRun = makeSensitiveDataSuggestionRun({
|
||||
test("keeps the sensitivity analysis history label stable while showing active status separately", async () => {
|
||||
const runningRun = makeSensitivityAnalysisRun({
|
||||
status: "running",
|
||||
finishedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -1655,12 +1658,12 @@ test("keeps the sensitive suggestion history label stable while showing active s
|
||||
renderPage();
|
||||
|
||||
const historyButton = await screen.findByRole("button", {
|
||||
name: "View sensitive suggestion history",
|
||||
name: "View sensitivity analysis history",
|
||||
});
|
||||
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
|
||||
expect(historyButton).toHaveTextContent("View sensitivity analysis history");
|
||||
await waitFor(() => expect(historyButton).toHaveAttribute(
|
||||
"title",
|
||||
"Sensitive suggestion generation is active",
|
||||
"Sensitivity analysis is active",
|
||||
));
|
||||
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
|
||||
});
|
||||
@@ -2015,7 +2018,7 @@ test("starts one selected column with the configured default model", async () =>
|
||||
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows database sensitive suggestions only for a selection and rejects multiple databases clearly", async () => {
|
||||
test("shows database sensitivity analysis only for a selection and rejects multiple databases clearly", async () => {
|
||||
const user = userEvent.setup();
|
||||
let suggestionCalls = 0;
|
||||
const radiology = makeDatabase({
|
||||
@@ -2035,24 +2038,24 @@ test("shows database sensitive suggestions only for a selection and rejects mult
|
||||
);
|
||||
renderPage({ rows: [makeDatabase(), radiology] });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
|
||||
const psdRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const radiologyRow = await screen.findByRole("row", { name: /Radiology/ });
|
||||
await user.click(within(psdRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
expect(screen.getByRole("button", { name: "Suggest sensitive fields" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Analyze sensitive fields" })).toBeVisible();
|
||||
await user.click(within(radiologyRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
||||
|
||||
expect(await screen.findByText("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.")).toBeVisible();
|
||||
expect(await screen.findByText("Sensitivity analysis can run for only one database at a time. Select one database and try again.")).toBeVisible();
|
||||
expect(suggestionCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("requests database-level sensitive suggestions for the only selected database", async () => {
|
||||
test("requests database-level sensitivity analysis for the only selected database", async () => {
|
||||
const user = userEvent.setup();
|
||||
let suggestionBody: unknown;
|
||||
let suggestionFinished = false;
|
||||
let historyCalls = 0;
|
||||
const run = makeSensitiveDataSuggestionRun({ scope: "all", total: 1 });
|
||||
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
@@ -2075,6 +2078,9 @@ test("requests database-level sensitive suggestions for the only selected databa
|
||||
version: patientIdColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
assessment: "sensitive",
|
||||
evidence: [{ kind: "content", ruleId: "pii.email" }],
|
||||
observedValues: 1,
|
||||
}],
|
||||
});
|
||||
}),
|
||||
@@ -2083,14 +2089,14 @@ test("requests database-level sensitive suggestions for the only selected databa
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
|
||||
await waitFor(() => expect(suggestionBody).toEqual({ scope: "all" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
||||
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
||||
});
|
||||
|
||||
test("refetches sensitive suggestion history after a failed request", async () => {
|
||||
test("refetches sensitivity analysis history after a failed request", async () => {
|
||||
const user = userEvent.setup();
|
||||
let historyCalls = 0;
|
||||
server.use(
|
||||
@@ -2104,8 +2110,8 @@ test("refetches sensitive suggestion history after a failed request", async () =
|
||||
}),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
|
||||
HttpResponse.json({
|
||||
code: "sensitive_data_suggestion_invalid_response",
|
||||
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
|
||||
code: "sensitivity_source_scan_failed",
|
||||
message: "Sensitivity analysis could not read the source. No assessments were applied.",
|
||||
}, { status: 502 })
|
||||
)),
|
||||
);
|
||||
@@ -2113,13 +2119,13 @@ test("refetches sensitive suggestion history after a failed request", async () =
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
||||
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("requests sensitive suggestions only for selected tables", async () => {
|
||||
test("requests sensitivity analysis only for selected tables", async () => {
|
||||
const user = userEvent.setup();
|
||||
const visitsTable: CatalogTable = {
|
||||
...patientsTable,
|
||||
@@ -2153,6 +2159,9 @@ test("requests sensitive suggestions only for selected tables", async () => {
|
||||
version: visitColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
assessment: "sensitive",
|
||||
evidence: [{ kind: "metadata", ruleId: "metadata.health" }],
|
||||
observedValues: 0,
|
||||
}],
|
||||
});
|
||||
}),
|
||||
@@ -2163,17 +2172,16 @@ test("requests sensitive suggestions only for selected tables", async () => {
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
const visitsRow = await screen.findByRole("row", { name: /visits/ });
|
||||
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_tables",
|
||||
targetIds: [visitsTable.id],
|
||||
}));
|
||||
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
|
||||
test("allows a human downgrade and saves only explicit sensitivity changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const idColumn = { ...patientIdColumn, sensitive: false };
|
||||
const nameColumn = {
|
||||
@@ -2185,7 +2193,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
isPrimaryKey: false,
|
||||
description: "Patient name",
|
||||
generatedDescription: "Name of the patient",
|
||||
sensitive: false,
|
||||
sensitive: true,
|
||||
};
|
||||
const unselectedColumn = {
|
||||
...patientIdColumn,
|
||||
@@ -2225,6 +2233,9 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
version: idColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
assessment: "sensitive",
|
||||
evidence: [{ kind: "content", ruleId: "pii.email" }],
|
||||
observedValues: 1,
|
||||
},
|
||||
{
|
||||
columnId: nameColumn.id,
|
||||
@@ -2232,8 +2243,11 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
tableName: patientsTable.name,
|
||||
columnName: nameColumn.name,
|
||||
version: nameColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
currentSensitive: true,
|
||||
sensitive: false,
|
||||
assessment: "non_sensitive",
|
||||
evidence: [],
|
||||
observedValues: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -2263,8 +2277,8 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
const idSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for id" });
|
||||
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
|
||||
expect(idSensitive).not.toBeChecked();
|
||||
expect(nameSensitive).not.toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
|
||||
expect(nameSensitive).toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
|
||||
|
||||
const selectableRow = async (name: RegExp) => {
|
||||
const rows = await screen.findAllByRole("row", { name });
|
||||
@@ -2274,31 +2288,30 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
.getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(await selectableRow(/Patient name/))
|
||||
.getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [idColumn.id, nameColumn.id],
|
||||
}));
|
||||
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).not.toBeChecked();
|
||||
expect(patches).toHaveLength(0);
|
||||
|
||||
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.name" }));
|
||||
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.id" }));
|
||||
await user.click(within(review).getByRole("button", { name: "Save 1" }));
|
||||
|
||||
await waitFor(() => expect(patches).toEqual([{
|
||||
columnId: idColumn.id,
|
||||
columnId: nameColumn.id,
|
||||
body: {
|
||||
version: idColumn.version,
|
||||
sensitive: true,
|
||||
version: nameColumn.version,
|
||||
sensitive: false,
|
||||
},
|
||||
}]));
|
||||
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).not.toBeChecked());
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user