diff --git a/packages/api/src/__tests__/settings-support.test.ts b/packages/api/src/__tests__/settings-support.test.ts new file mode 100644 index 0000000..5556060 --- /dev/null +++ b/packages/api/src/__tests__/settings-support.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + buildSettingsUpdatePayload, + buildSystemSettingsViewModel, + getDefaultScoreWeights, + sanitizeSettingsAuditSnapshot, +} from "../router/settings-support.js"; + +describe("settings support", () => { + it("builds the admin-facing system settings view model with defaults", () => { + const runtimeSecrets = { + azureOpenAiApiKey: { + configured: true, + activeSource: "environment" as const, + hasStoredValue: true, + envVarNames: ["OPENAI_API_KEY"], + }, + azureDalleApiKey: { + configured: false, + activeSource: "none" as const, + hasStoredValue: false, + envVarNames: ["AZURE_DALLE_API_KEY"], + }, + geminiApiKey: { + configured: true, + activeSource: "database" as const, + hasStoredValue: true, + envVarNames: ["GEMINI_API_KEY"], + }, + smtpPassword: { + configured: false, + activeSource: "none" as const, + hasStoredValue: false, + envVarNames: ["SMTP_PASSWORD"], + }, + anonymizationSeed: { + configured: false, + activeSource: "none" as const, + hasStoredValue: false, + envVarNames: ["ANONYMIZATION_SEED"], + }, + }; + + expect(buildSystemSettingsViewModel({ + settings: { + aiProvider: "azure", + azureOpenAiEndpoint: "https://example.openai.azure.com", + scoreVisibleRoles: ["ADMIN"], + smtpTls: false, + vacationDefaultDays: 30, + }, + runtimeSettings: { + azureOpenAiApiKey: "secret", + smtpPassword: null, + azureDalleApiKey: null, + geminiApiKey: "gemini-secret", + }, + runtimeSecrets, + defaultSummaryPrompt: "Summarize this resource.", + })).toEqual({ + aiProvider: "azure", + azureOpenAiEndpoint: "https://example.openai.azure.com", + azureOpenAiDeployment: null, + azureApiVersion: "2025-01-01-preview", + aiMaxCompletionTokens: 300, + aiTemperature: 1, + aiSummaryPrompt: null, + defaultSummaryPrompt: "Summarize this resource.", + hasApiKey: true, + runtimeSecrets, + legacyStoredSecretFields: ["azureOpenAiApiKey", "geminiApiKey"], + scoreWeights: getDefaultScoreWeights(), + scoreVisibleRoles: ["ADMIN"], + smtpHost: null, + smtpPort: 587, + smtpUser: null, + smtpFrom: null, + smtpTls: false, + hasSmtpPassword: false, + anonymizationEnabled: false, + anonymizationDomain: "superhartmut.de", + anonymizationMode: "global", + azureDalleDeployment: null, + azureDalleEndpoint: null, + hasDalleApiKey: false, + geminiModel: "gemini-2.5-flash-image", + hasGeminiApiKey: true, + imageProvider: "dalle", + vacationDefaultDays: 30, + timelineUndoMaxSteps: 50, + }); + }); + + it("builds sparse updates and strips secret persistence", () => { + expect(buildSettingsUpdatePayload({ + aiProvider: "openai", + azureOpenAiApiKey: "secret", + smtpPassword: "smtp-secret", + aiSummaryPrompt: "", + imageProvider: "gemini", + })).toEqual({ + data: { + aiProvider: "openai", + aiSummaryPrompt: null, + imageProvider: "gemini", + }, + ignoredSecretFields: ["azureOpenAiApiKey", "smtpPassword"], + }); + }); + + it("sanitizes sensitive fields in audit snapshots", () => { + expect(sanitizeSettingsAuditSnapshot({ + azureOpenAiApiKey: "secret", + smtpPassword: null, + aiProvider: "azure", + })).toEqual({ + azureOpenAiApiKey: "***", + smtpPassword: null, + aiProvider: "azure", + }); + }); +}); diff --git a/packages/api/src/router/settings-support.ts b/packages/api/src/router/settings-support.ts index abb9621..bf538e6 100644 --- a/packages/api/src/router/settings-support.ts +++ b/packages/api/src/router/settings-support.ts @@ -5,6 +5,10 @@ import { parseAiError, sanitizeDiagnosticError, } from "../ai-client.js"; +import { + RUNTIME_SECRET_FIELDS, +} from "../lib/system-settings-runtime.js"; +import type { RuntimeSecretField, RuntimeSecretStatus } from "../lib/system-settings-runtime.js"; import { logger } from "../lib/logger.js"; /** Fields that must never appear in audit log values */ @@ -75,6 +79,80 @@ export function getDefaultScoreWeights() { }; } +export function buildSystemSettingsViewModel(input: { + settings: { + aiProvider?: string | null; + azureOpenAiEndpoint?: string | null; + azureOpenAiDeployment?: string | null; + azureApiVersion?: string | null; + aiMaxCompletionTokens?: number | null; + aiTemperature?: number | null; + aiSummaryPrompt?: string | null; + scoreWeights?: unknown; + scoreVisibleRoles?: unknown; + smtpHost?: string | null; + smtpPort?: number | null; + smtpUser?: string | null; + smtpFrom?: string | null; + smtpTls?: boolean | null; + anonymizationEnabled?: boolean | null; + anonymizationDomain?: string | null; + anonymizationMode?: string | null; + azureDalleDeployment?: string | null; + azureDalleEndpoint?: string | null; + geminiModel?: string | null; + imageProvider?: string | null; + vacationDefaultDays?: number | null; + timelineUndoMaxSteps?: number | null; + } | null | undefined; + runtimeSettings: { + azureOpenAiApiKey?: string | null; + smtpPassword?: string | null; + azureDalleApiKey?: string | null; + geminiApiKey?: string | null; + } | null | undefined; + runtimeSecrets: Record; + defaultSummaryPrompt: string; +}) { + const defaultWeights = getDefaultScoreWeights(); + const settings = input.settings; + + return { + aiProvider: settings?.aiProvider ?? "openai", + azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null, + azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null, + azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview", + aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300, + aiTemperature: settings?.aiTemperature ?? 1, + aiSummaryPrompt: settings?.aiSummaryPrompt ?? null, + defaultSummaryPrompt: input.defaultSummaryPrompt, + hasApiKey: !!input.runtimeSettings?.azureOpenAiApiKey, + runtimeSecrets: input.runtimeSecrets, + legacyStoredSecretFields: RUNTIME_SECRET_FIELDS.filter( + (field) => input.runtimeSecrets[field].hasStoredValue, + ), + scoreWeights: (settings?.scoreWeights as typeof defaultWeights | null | undefined) ?? defaultWeights, + scoreVisibleRoles: (settings?.scoreVisibleRoles as string[] | null | undefined) ?? ["ADMIN", "MANAGER"], + smtpHost: settings?.smtpHost ?? null, + smtpPort: settings?.smtpPort ?? 587, + smtpUser: settings?.smtpUser ?? null, + smtpFrom: settings?.smtpFrom ?? null, + smtpTls: settings?.smtpTls ?? true, + hasSmtpPassword: !!input.runtimeSettings?.smtpPassword, + anonymizationEnabled: settings?.anonymizationEnabled ?? false, + anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de", + anonymizationMode: settings?.anonymizationMode ?? "global", + azureDalleDeployment: settings?.azureDalleDeployment ?? null, + azureDalleEndpoint: settings?.azureDalleEndpoint ?? null, + hasDalleApiKey: !!input.runtimeSettings?.azureDalleApiKey, + geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image", + hasGeminiApiKey: !!input.runtimeSettings?.geminiApiKey, + imageProvider: settings?.imageProvider ?? "dalle", + vacationDefaultDays: settings?.vacationDefaultDays ?? 28, + timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50, + }; +} + export function buildSettingsUpdatePayload(input: SettingsUpdateInput): { data: Record; ignoredSecretFields: string[]; diff --git a/packages/api/src/router/settings.ts b/packages/api/src/router/settings.ts index a69b650..0d993f3 100644 --- a/packages/api/src/router/settings.ts +++ b/packages/api/src/router/settings.ts @@ -5,8 +5,8 @@ import { testSmtpConnection } from "../lib/email.js"; import { createAuditEntry } from "../lib/audit.js"; import { getRuntimeSecretStatuses, RUNTIME_SECRET_FIELDS, resolveSystemSettingsRuntime } from "../lib/system-settings-runtime.js"; import { + buildSystemSettingsViewModel, buildSettingsUpdatePayload, - getDefaultScoreWeights, sanitizeSettingsAuditSnapshot, settingsUpdateInputSchema, testRuntimeAiConnection, @@ -19,51 +19,12 @@ export const settingsRouter = createTRPCRouter({ }); const runtimeSettings = resolveSystemSettingsRuntime(settings); const runtimeSecrets = getRuntimeSecretStatuses(settings); - const legacyStoredSecretFields = RUNTIME_SECRET_FIELDS.filter( - (field) => runtimeSecrets[field].hasStoredValue, - ); - - const defaultWeights = getDefaultScoreWeights(); - - return { - aiProvider: settings?.aiProvider ?? "openai", - azureOpenAiEndpoint: settings?.azureOpenAiEndpoint ?? null, - azureOpenAiDeployment: settings?.azureOpenAiDeployment ?? null, - azureApiVersion: settings?.azureApiVersion ?? "2025-01-01-preview", - aiMaxCompletionTokens: settings?.aiMaxCompletionTokens ?? 300, - aiTemperature: settings?.aiTemperature ?? 1, - aiSummaryPrompt: settings?.aiSummaryPrompt ?? null, - defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, - hasApiKey: !!runtimeSettings.azureOpenAiApiKey, + return buildSystemSettingsViewModel({ + settings, + runtimeSettings, runtimeSecrets, - legacyStoredSecretFields, - scoreWeights: (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights, - scoreVisibleRoles: (settings?.scoreVisibleRoles as unknown as string[]) ?? ["ADMIN", "MANAGER"], - // SMTP - smtpHost: settings?.smtpHost ?? null, - smtpPort: settings?.smtpPort ?? 587, - smtpUser: settings?.smtpUser ?? null, - smtpFrom: settings?.smtpFrom ?? null, - smtpTls: settings?.smtpTls ?? true, - hasSmtpPassword: !!runtimeSettings.smtpPassword, - // Global anonymization - anonymizationEnabled: settings?.anonymizationEnabled ?? false, - anonymizationDomain: settings?.anonymizationDomain ?? "superhartmut.de", - anonymizationMode: settings?.anonymizationMode ?? "global", - // DALL-E - azureDalleDeployment: settings?.azureDalleDeployment ?? null, - azureDalleEndpoint: settings?.azureDalleEndpoint ?? null, - hasDalleApiKey: !!runtimeSettings.azureDalleApiKey, - // Gemini - geminiModel: settings?.geminiModel ?? "gemini-2.5-flash-image", - hasGeminiApiKey: !!runtimeSettings.geminiApiKey, - // Image provider - imageProvider: settings?.imageProvider ?? "dalle", - // Vacation defaults - vacationDefaultDays: settings?.vacationDefaultDays ?? 28, - // Timeline - timelineUndoMaxSteps: settings?.timelineUndoMaxSteps ?? 50, - }; + defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT, + }); }), updateSystemSettings: adminProcedure