refactor(settings): adopt environment-only runtime secret flow

This commit is contained in:
2026-03-30 19:55:06 +02:00
parent fed7aa5b61
commit a19d2cbae0
19 changed files with 757 additions and 172 deletions
@@ -618,10 +618,12 @@ describe("assistant router tool gating", () => {
const adminNames = getToolNames([], SystemRole.ADMIN);
const userNames = getToolNames([], SystemRole.USER);
const managerNames = getToolNames([], SystemRole.MANAGER);
expect(adminNames).toContain("get_system_settings");
expect(adminNames).toContain("update_system_settings");
expect(adminNames).toContain("test_ai_connection");
expect(adminNames).toContain("test_smtp_connection");
expect(adminNames).toContain("clear_stored_runtime_secrets");
expect(adminNames).toContain("test_gemini_connection");
expect(adminNames).toContain("list_system_role_configs");
expect(adminNames).toContain("update_system_role_config");
@@ -632,12 +634,22 @@ describe("assistant router tool gating", () => {
expect(adminNames).toContain("delete_webhook");
expect(adminNames).toContain("test_webhook");
expect(adminNames).toContain("get_ai_configured");
expect(adminNames).toContain("list_system_role_configs");
expect(managerNames).not.toContain("get_system_settings");
expect(managerNames).not.toContain("update_system_settings");
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
expect(managerNames).not.toContain("test_ai_connection");
expect(managerNames).not.toContain("get_ai_configured");
expect(managerNames).not.toContain("list_system_role_configs");
expect(managerNames).not.toContain("update_system_role_config");
expect(managerNames).not.toContain("list_webhooks");
expect(managerNames).not.toContain("create_webhook");
expect(userNames).not.toContain("get_system_settings");
expect(userNames).not.toContain("update_system_settings");
expect(userNames).not.toContain("test_ai_connection");
expect(userNames).not.toContain("get_ai_configured");
expect(userNames).not.toContain("clear_stored_runtime_secrets");
expect(userNames).not.toContain("list_system_role_configs");
expect(userNames).not.toContain("update_system_role_config");
expect(userNames).not.toContain("list_webhooks");
@@ -996,6 +1008,8 @@ describe("assistant router tool gating", () => {
expect(toolDescriptions.get("update_system_settings")).toContain("Always confirm first");
expect(toolDescriptions.get("get_ai_configured")).toContain("Admin role");
expect(toolDescriptions.get("list_system_role_configs")).toContain("Admin role");
expect(toolDescriptions.get("update_system_settings")).toContain("Runtime secrets must be provisioned");
expect(toolDescriptions.get("clear_stored_runtime_secrets")).toContain("deployment secret management");
expect(toolDescriptions.get("update_system_role_config")).toContain("Admin role");
expect(toolDescriptions.get("list_webhooks")).toContain("Secrets are masked");
expect(toolDescriptions.get("create_webhook")).toContain("Always confirm first");
@@ -150,6 +150,7 @@ function createToolContext(
describe("assistant import/export and dispo tools", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
apiRateLimiter.reset();
totpValidateMock.mockReset();
vi.mocked(approveEstimateVersion).mockReset();
@@ -288,6 +289,67 @@ describe("assistant import/export and dispo tools", () => {
expect(JSON.parse(result.content)).toEqual({ configured: true });
});
it("treats environment-backed AI configuration as configured for assistant checks", async () => {
vi.stubEnv("OPENAI_API_KEY", "env-secret");
const ctx = createToolContext(
{
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
azureOpenAiApiKey: null,
}),
},
},
{ userRole: SystemRole.USER },
);
const result = await executeTool("get_ai_configured", "{}", ctx);
expect(JSON.parse(result.content)).toEqual({ configured: true });
});
it("clears legacy runtime secrets through the real settings router path", async () => {
const findUnique = vi.fn().mockResolvedValue({
azureOpenAiApiKey: "db-openai",
azureDalleApiKey: null,
geminiApiKey: "db-gemini",
smtpPassword: null,
anonymizationSeed: "db-seed",
});
const update = vi.fn().mockResolvedValue({ id: "singleton" });
const auditCreate = vi.fn().mockResolvedValue(undefined);
const ctx = createToolContext(
{
systemSettings: {
findUnique,
update,
},
auditLog: {
create: auditCreate,
},
},
{ userRole: SystemRole.ADMIN },
);
const result = await executeTool("clear_stored_runtime_secrets", "{}", ctx);
expect(JSON.parse(result.content)).toEqual({
ok: true,
clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "anonymizationSeed"],
});
expect(update).toHaveBeenCalledWith({
where: { id: "singleton" },
data: {
azureOpenAiApiKey: null,
geminiApiKey: null,
anonymizationSeed: null,
},
});
expect(auditCreate).toHaveBeenCalled();
});
it("masks webhook secrets in assistant responses", async () => {
const ctx = createToolContext(
{
@@ -118,6 +118,9 @@ describe("runtime config hardening", () => {
expect(result.hasApiKey).toBe(true);
expect(result.hasSmtpPassword).toBe(true);
expect(result.hasGeminiApiKey).toBe(true);
expect(result.runtimeSecrets.azureOpenAiApiKey.activeSource).toBe("environment");
expect(result.runtimeSecrets.smtpPassword.activeSource).toBe("environment");
expect(result.legacyStoredSecretFields).toEqual([]);
});
it("prefers environment API keys during AI connection tests", async () => {
@@ -150,4 +153,81 @@ describe("runtime config hardening", () => {
}),
);
});
it("does not persist incoming secret fields through updateSystemSettings", async () => {
const findUnique = vi.fn().mockResolvedValue(null);
const upsert = vi.fn().mockResolvedValue({});
const caller = createAdminCaller({
systemSettings: {
findUnique,
upsert,
},
});
const result = await caller.updateSystemSettings({
azureOpenAiApiKey: "sk-should-not-store",
smtpPassword: "smtp-should-not-store",
geminiApiKey: "gemini-should-not-store",
azureDalleApiKey: "dalle-should-not-store",
anonymizationSeed: "seed-should-not-store",
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
});
expect(result).toEqual({
ok: true,
ignoredSecretFields: [
"azureOpenAiApiKey",
"smtpPassword",
"anonymizationSeed",
"azureDalleApiKey",
"geminiApiKey",
],
secretStorageMode: "environment-only",
});
expect(upsert).toHaveBeenCalledWith({
where: { id: "singleton" },
create: {
id: "singleton",
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
},
update: {
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
},
});
});
it("can clear legacy runtime secrets from database storage", async () => {
const findUnique = vi.fn().mockResolvedValue({
azureOpenAiApiKey: "db-key",
azureDalleApiKey: null,
geminiApiKey: "db-gemini",
smtpPassword: "db-smtp",
anonymizationSeed: null,
});
const update = vi.fn().mockResolvedValue({});
const caller = createAdminCaller({
systemSettings: {
findUnique,
update,
},
});
const result = await caller.clearStoredRuntimeSecrets();
expect(result).toEqual({
ok: true,
clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "smtpPassword"],
});
expect(update).toHaveBeenCalledWith({
where: { id: "singleton" },
data: {
azureOpenAiApiKey: null,
geminiApiKey: null,
smtpPassword: null,
},
});
});
});
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
import { getRuntimeSecretStatuses, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js";
describe("system settings runtime resolution", () => {
afterEach(() => {
@@ -39,4 +39,27 @@ describe("system settings runtime resolution", () => {
expect(settings.smtpPassword).toBe("db-password");
});
it("reports active source and legacy DB presence separately", () => {
vi.stubEnv("OPENAI_API_KEY", "env-openai-key");
const statuses = getRuntimeSecretStatuses({
aiProvider: "openai",
azureOpenAiApiKey: "db-key",
smtpPassword: "db-password",
});
expect(statuses.azureOpenAiApiKey).toEqual({
configured: true,
activeSource: "environment",
hasStoredValue: true,
envVarNames: ["OPENAI_API_KEY", "AZURE_OPENAI_API_KEY"],
});
expect(statuses.smtpPassword).toEqual({
configured: true,
activeSource: "database",
hasStoredValue: true,
envVarNames: ["SMTP_PASSWORD"],
});
});
});