feat: protect sensitive catalog samples
This commit is contained in:
@@ -141,6 +141,83 @@ async function waitForTerminalRun(app: ReturnType<typeof buildApp>, runId: strin
|
||||
throw new Error(`Description Generation Run ${runId} did not finish`);
|
||||
}
|
||||
|
||||
test("suggests sensitive flags from structural metadata without persisting them", async () => {
|
||||
const modelCompleter = {
|
||||
complete: vi.fn(async () => JSON.stringify({
|
||||
suggestions: [{ columnId: expect.any(String), sensitive: true }],
|
||||
})),
|
||||
};
|
||||
const { app, repository, database, column } = await setup(modelCompleter);
|
||||
modelCompleter.complete.mockResolvedValueOnce(JSON.stringify({
|
||||
suggestions: [{ columnId: column.id, sensitive: true }],
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/sensitive-data-suggestions`,
|
||||
payload: { modelId: configuredModel.id },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
suggestions: [{ columnId: column.id, sensitive: true }],
|
||||
});
|
||||
expect(await repository.getColumn(database.id, column.tableId, column.id))
|
||||
.toMatchObject({ sensitive: false });
|
||||
|
||||
const request = modelCompleter.complete.mock.calls[0]![0] as ModelCompletionRequest;
|
||||
const prompt = request.messages.map((message) => message.content).join("\n");
|
||||
expect(prompt).toContain("patients");
|
||||
expect(prompt).toContain("birth_date");
|
||||
expect(prompt).toContain("date");
|
||||
expect(prompt).not.toContain("Patient date of birth");
|
||||
expect(prompt).not.toContain("test-provider-secret");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["malformed", "incomplete", "duplicate"] as const)(
|
||||
"fails safely when sensitive-data suggestions are %s",
|
||||
async (kind) => {
|
||||
const modelCompleter: ModelCompleter = {
|
||||
complete: vi.fn(async () => "unused"),
|
||||
};
|
||||
const { app, repository, database, column } = await setup(modelCompleter);
|
||||
const rawResponse = kind === "malformed"
|
||||
? "RAW_PROVIDER_RESPONSE_DO_NOT_EXPOSE_{"
|
||||
: kind === "incomplete"
|
||||
? JSON.stringify({ suggestions: [] })
|
||||
: JSON.stringify({
|
||||
suggestions: [
|
||||
{ columnId: column.id, sensitive: true },
|
||||
{ columnId: column.id, sensitive: true },
|
||||
],
|
||||
});
|
||||
vi.mocked(modelCompleter.complete).mockResolvedValueOnce(rawResponse);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/sensitive-data-suggestions`,
|
||||
payload: { modelId: configuredModel.id },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect(response.json()).toEqual({
|
||||
code: "sensitive_data_suggestion_failed",
|
||||
message: "Sensitive-data suggestions could not be prepared.",
|
||||
});
|
||||
expect(response.body).not.toContain(rawResponse);
|
||||
expect(await repository.getColumn(database.id, column.tableId, column.id))
|
||||
.toMatchObject({ sensitive: false });
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
interface SseFrame {
|
||||
id?: string;
|
||||
event?: string;
|
||||
@@ -398,6 +475,102 @@ test("keeps real source samples transient across the Fastify API and application
|
||||
}
|
||||
});
|
||||
|
||||
test("never exposes a protected source value to the model, persistence, logs, or browser APIs", async () => {
|
||||
let selectedColumnId = "";
|
||||
const protectedValue = "PROTECTED_SOURCE_VALUE_8f4c2a";
|
||||
const modelCompleter: ModelCompleter = {
|
||||
complete: vi.fn(async () => JSON.stringify({
|
||||
results: [{
|
||||
targetId: selectedColumnId,
|
||||
outcome: "generated",
|
||||
description: "Data di nascita del paziente.",
|
||||
}],
|
||||
})),
|
||||
};
|
||||
const descriptionSourceSampler: DescriptionSourceSampler = {
|
||||
sample: vi.fn(async (_database, targets) => [{
|
||||
targetId: targets[0]!.targetId,
|
||||
tableName: targets[0]!.tableName,
|
||||
rows: [{ fields: [{ name: "birth_date", value: protectedValue }] }],
|
||||
representativeValues: [{ column: "birth_date", values: [protectedValue] }],
|
||||
}]),
|
||||
};
|
||||
const { app, repository, database, table, column } = await setup(
|
||||
modelCompleter,
|
||||
{},
|
||||
"it",
|
||||
descriptionSourceSampler,
|
||||
);
|
||||
selectedColumnId = column.id;
|
||||
await repository.updateColumnMetadata(
|
||||
database.id,
|
||||
table.id,
|
||||
column.id,
|
||||
column.version,
|
||||
column.description,
|
||||
column.generatedDescription,
|
||||
true,
|
||||
);
|
||||
const logSpies = [
|
||||
vi.spyOn(app.log, "info"),
|
||||
vi.spyOn(app.log, "warn"),
|
||||
vi.spyOn(app.log, "error"),
|
||||
];
|
||||
|
||||
try {
|
||||
const start = await app.inject({
|
||||
method: "POST",
|
||||
url: `/catalog/databases/${database.id}/description-generation-runs`,
|
||||
payload: {
|
||||
modelId: configuredModel.id,
|
||||
scope: "selected_columns",
|
||||
targetIds: [column.id],
|
||||
},
|
||||
});
|
||||
expect(start.statusCode).toBe(202);
|
||||
await waitForTerminalRun(app, start.json().id);
|
||||
|
||||
expect(descriptionSourceSampler.sample).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
[expect.objectContaining({ targetId: column.id, columnNames: [] })],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
const completionRequest = vi.mocked(modelCompleter.complete).mock.calls[0]![0];
|
||||
const providerPayload = JSON.stringify(completionRequest.messages);
|
||||
expect(providerPayload).not.toContain(protectedValue);
|
||||
expect(providerPayload).toContain("1981-01-01");
|
||||
|
||||
const apiResponses = await Promise.all([
|
||||
app.inject({ method: "GET", url: `/catalog/description-generation-runs/${start.json().id}` }),
|
||||
app.inject({
|
||||
method: "GET",
|
||||
url: `/catalog/description-generation-runs/${start.json().id}/events-list`,
|
||||
}),
|
||||
app.inject({ method: "GET", url: `/catalog/databases/${database.id}` }),
|
||||
app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables` }),
|
||||
app.inject({
|
||||
method: "GET",
|
||||
url: `/catalog/databases/${database.id}/tables/${table.id}/columns`,
|
||||
}),
|
||||
]);
|
||||
expect(apiResponses.every((response) => response.statusCode === 200)).toBe(true);
|
||||
expect(apiResponses.map((response) => response.body).join("\n")).not.toContain(protectedValue);
|
||||
|
||||
const persisted = JSON.stringify({
|
||||
run: await repository.getDescriptionGenerationRun(start.json().id),
|
||||
events: await repository.listDescriptionGenerationEvents(start.json().id),
|
||||
database: await repository.get(database.id),
|
||||
table: await repository.getTable(database.id, table.id),
|
||||
column: await repository.getColumn(database.id, table.id, column.id),
|
||||
});
|
||||
expect(persisted).not.toContain(protectedValue);
|
||||
expect(JSON.stringify(logSpies.flatMap((spy) => spy.mock.calls))).not.toContain(protectedValue);
|
||||
} finally {
|
||||
for (const spy of logSpies) spy.mockRestore();
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("wires the production sampler to the same injected CatalogPostgresAccess instance", async () => {
|
||||
let selectedColumnId = "";
|
||||
const sampleSecret = "PRODUCTION_WIRING_SAMPLE_f2986a";
|
||||
|
||||
Reference in New Issue
Block a user