refactor(api): extract settings procedures

This commit is contained in:
2026-03-31 19:46:50 +02:00
parent 84094b363d
commit 4586e94c95
4 changed files with 576 additions and 174 deletions
@@ -0,0 +1,193 @@
import { SystemRole } from "@capakraken/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
createAuditEntry,
testSmtpConnection,
generateGeminiImage,
parseGeminiError,
} = vi.hoisted(() => ({
createAuditEntry: vi.fn(),
testSmtpConnection: vi.fn(),
generateGeminiImage: vi.fn(),
parseGeminiError: vi.fn((error: unknown) => `parsed:${String(error)}`),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry,
}));
vi.mock("../lib/email.js", () => ({
testSmtpConnection,
}));
vi.mock("../gemini-client.js", () => ({
generateGeminiImage,
parseGeminiError,
}));
import {
clearStoredRuntimeSecrets,
getAiConfiguredStatus,
getSystemSettingsView,
testSettingsGeminiConnection,
testSettingsSmtpConnection,
updateSystemSettings,
} from "../router/settings-procedure-support.js";
function createAdminContext(db: Record<string, unknown>) {
return {
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
};
}
describe("settings procedure support", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("builds the system settings view model from db and runtime state", async () => {
vi.stubEnv("OPENAI_API_KEY", "env-openai-key");
vi.stubEnv("SMTP_PASSWORD", "env-smtp-password");
const result = await getSystemSettingsView(createAdminContext({
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
aiProvider: "openai",
azureOpenAiApiKey: null,
smtpPassword: null,
}),
},
}));
expect(result.hasApiKey).toBe(true);
expect(result.hasSmtpPassword).toBe(true);
expect(result.aiProvider).toBe("openai");
expect(result.defaultSummaryPrompt).toBeTypeOf("string");
});
it("updates settings without persisting incoming secret fields and writes an audit entry", async () => {
const findUnique = vi.fn().mockResolvedValue({
id: "singleton",
aiProvider: "azure",
azureOpenAiApiKey: "secret",
});
const upsert = vi.fn().mockResolvedValue({});
const result = await updateSystemSettings(createAdminContext({
systemSettings: {
findUnique,
upsert,
},
}), {
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
azureOpenAiApiKey: "should-be-ignored",
smtpPassword: "also-ignored",
});
expect(result).toEqual({
ok: true,
ignoredSecretFields: ["azureOpenAiApiKey", "smtpPassword"],
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",
},
});
expect(createAuditEntry).toHaveBeenCalledWith(expect.objectContaining({
entityType: "SystemSettings",
action: "UPDATE",
after: expect.objectContaining({
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
}),
}));
});
it("clears only legacy runtime secrets that are still stored", async () => {
const update = vi.fn().mockResolvedValue({});
const result = await clearStoredRuntimeSecrets(createAdminContext({
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
azureOpenAiApiKey: "db-key",
azureDalleApiKey: null,
geminiApiKey: "db-gemini",
smtpPassword: null,
anonymizationSeed: "seed",
}),
update,
},
}));
expect(result).toEqual({
ok: true,
clearedFields: ["azureOpenAiApiKey", "geminiApiKey", "anonymizationSeed"],
});
expect(update).toHaveBeenCalledWith({
where: { id: "singleton" },
data: {
azureOpenAiApiKey: null,
geminiApiKey: null,
anonymizationSeed: null,
},
});
});
it("tests smtp and gemini connections with audited outcomes", async () => {
testSmtpConnection.mockResolvedValue({ ok: true });
generateGeminiImage.mockResolvedValue("data:image/png;base64,abc123");
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
geminiApiKey: "gem-key",
geminiModel: "gem-model",
}),
},
};
const ctx = createAdminContext(db);
expect(await testSettingsSmtpConnection(ctx)).toEqual({ ok: true });
expect(await testSettingsGeminiConnection(ctx)).toEqual({
ok: true,
model: "gem-model",
preview: "data:image/png;base64,abc123...",
});
expect(createAuditEntry).toHaveBeenCalledTimes(2);
});
it("reads the ai configured status from runtime-resolved settings", async () => {
vi.stubEnv("OPENAI_API_KEY", "env-openai-key");
const result = await getAiConfiguredStatus(createAdminContext({
systemSettings: {
findUnique: vi.fn().mockResolvedValue({
aiProvider: "openai",
azureOpenAiDeployment: "gpt-4o-mini",
azureOpenAiApiKey: null,
}),
},
}));
expect(result).toEqual({ configured: true });
});
});
@@ -0,0 +1,206 @@
import { isAiConfigured } from "../ai-client.js";
import { createAuditEntry } from "../lib/audit.js";
import { testSmtpConnection } from "../lib/email.js";
import {
getRuntimeSecretStatuses,
RUNTIME_SECRET_FIELDS,
resolveSystemSettingsRuntime,
} from "../lib/system-settings-runtime.js";
import type { TRPCContext } from "../trpc.js";
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
import {
buildSettingsUpdatePayload,
buildSystemSettingsViewModel,
sanitizeSettingsAuditSnapshot,
type SettingsUpdateInput,
testRuntimeAiConnection,
} from "./settings-support.js";
type SettingsProcedureContext = Pick<TRPCContext, "db" | "dbUser">;
function withAuditUser(userId: string | undefined) {
return userId ? { userId } : {};
}
export async function getSystemSettingsView(
ctx: SettingsProcedureContext,
) {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const runtimeSettings = resolveSystemSettingsRuntime(settings);
const runtimeSecrets = getRuntimeSecretStatuses(settings);
return buildSystemSettingsViewModel({
settings,
runtimeSettings,
runtimeSecrets,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
});
}
export async function updateSystemSettings(
ctx: SettingsProcedureContext,
input: SettingsUpdateInput,
) {
const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input);
if (Object.keys(data).length === 0) {
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...data },
update: data,
});
const sanitizedBefore = before
? sanitizeSettingsAuditSnapshot(before as unknown as Record<string, unknown>)
: undefined;
const sanitizedAfter = sanitizeSettingsAuditSnapshot(data);
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "System Settings",
action: before ? "UPDATE" : "CREATE",
...withAuditUser(ctx.dbUser?.id),
...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}),
after: sanitizedAfter,
source: "ui",
});
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}
export async function clearStoredRuntimeSecrets(
ctx: SettingsProcedureContext,
) {
const existing = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: Object.fromEntries(
RUNTIME_SECRET_FIELDS.map((field) => [field, true]),
) as Record<(typeof RUNTIME_SECRET_FIELDS)[number], true>,
});
const clearedFields = RUNTIME_SECRET_FIELDS.filter((field) => !!existing?.[field]);
if (clearedFields.length === 0) {
return { ok: true, clearedFields: [] as string[] };
}
await ctx.db.systemSettings.update({
where: { id: "singleton" },
data: Object.fromEntries(clearedFields.map((field) => [field, null])),
});
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Runtime Secrets",
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
after: { clearedFields },
source: "ui",
summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`,
});
return { ok: true, clearedFields };
}
export async function testSettingsAiConnection(
ctx: SettingsProcedureContext,
) {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
}));
return testRuntimeAiConnection(settings);
}
export async function testSettingsSmtpConnection(
ctx: SettingsProcedureContext,
) {
const result = await testSmtpConnection();
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "SMTP Connection Test",
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
after: { testResult: result.ok ? "success" : "failed" },
source: "ui",
summary: result.ok ? "SMTP connection test succeeded" : "SMTP connection test failed",
});
return result;
}
export async function testSettingsGeminiConnection(
ctx: SettingsProcedureContext,
) {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: { geminiApiKey: true, geminiModel: true },
}));
if (!settings?.geminiApiKey) {
return { ok: false, error: "Gemini API key is not configured." };
}
try {
const { generateGeminiImage } = await import("../gemini-client.js");
const model = settings.geminiModel ?? "gemini-2.5-flash-image";
const dataUrl = await generateGeminiImage(
settings.geminiApiKey,
"A simple blue circle on white background, minimal, 256x256",
model,
);
const hasImage = dataUrl.startsWith("data:image/");
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Gemini Connection Test",
action: "UPDATE",
...withAuditUser(ctx.dbUser?.id),
after: { testResult: hasImage ? "success" : "failed" },
source: "ui",
summary: hasImage ? "Gemini image generation test succeeded" : "Gemini test returned no image",
});
return { ok: hasImage, model, preview: hasImage ? `${dataUrl.slice(0, 100)}...` : undefined };
} catch (err) {
const { parseGeminiError } = await import("../gemini-client.js");
return { ok: false, error: parseGeminiError(err) };
}
}
export async function getAiConfiguredStatus(
ctx: SettingsProcedureContext,
) {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
}));
return { configured: isAiConfigured(settings) };
}
+16 -174
View File
@@ -1,187 +1,29 @@
import { adminProcedure, createTRPCRouter } from "../trpc.js";
import { isAiConfigured } from "../ai-client.js";
import { DEFAULT_SUMMARY_PROMPT } from "./resource.js";
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,
sanitizeSettingsAuditSnapshot,
settingsUpdateInputSchema,
testRuntimeAiConnection,
} from "./settings-support.js";
clearStoredRuntimeSecrets,
getAiConfiguredStatus,
getSystemSettingsView,
testSettingsAiConnection,
testSettingsGeminiConnection,
testSettingsSmtpConnection,
updateSystemSettings,
} from "./settings-procedure-support.js";
import { settingsUpdateInputSchema } from "./settings-support.js";
export const settingsRouter = createTRPCRouter({
getSystemSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
});
const runtimeSettings = resolveSystemSettingsRuntime(settings);
const runtimeSecrets = getRuntimeSecretStatuses(settings);
return buildSystemSettingsViewModel({
settings,
runtimeSettings,
runtimeSecrets,
defaultSummaryPrompt: DEFAULT_SUMMARY_PROMPT,
});
}),
getSystemSettings: adminProcedure.query(({ ctx }) => getSystemSettingsView(ctx)),
updateSystemSettings: adminProcedure
.input(settingsUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const { data, ignoredSecretFields } = buildSettingsUpdatePayload(input);
.mutation(({ ctx, input }) => updateSystemSettings(ctx, input)),
if (Object.keys(data).length === 0) {
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}
clearStoredRuntimeSecrets: adminProcedure.mutation(({ ctx }) => clearStoredRuntimeSecrets(ctx)),
// Fetch current settings for before-snapshot
const before = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
testAiConnection: adminProcedure.mutation(({ ctx }) => testSettingsAiConnection(ctx)),
await ctx.db.systemSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ...data },
update: data,
});
testSmtpConnection: adminProcedure.mutation(({ ctx }) => testSettingsSmtpConnection(ctx)),
const sanitizedBefore = before ? sanitizeSettingsAuditSnapshot(before as unknown as Record<string, unknown>) : undefined;
const sanitizedAfter = sanitizeSettingsAuditSnapshot(data);
testGeminiConnection: adminProcedure.mutation(({ ctx }) => testSettingsGeminiConnection(ctx)),
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "System Settings",
action: before ? "UPDATE" : "CREATE",
userId: ctx.dbUser?.id,
...(sanitizedBefore !== undefined ? { before: sanitizedBefore } : {}),
after: sanitizedAfter,
source: "ui",
});
return {
ok: true,
ignoredSecretFields,
secretStorageMode: "environment-only" as const,
};
}),
clearStoredRuntimeSecrets: adminProcedure.mutation(async ({ ctx }) => {
const existing = await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: Object.fromEntries(
RUNTIME_SECRET_FIELDS.map((field) => [field, true]),
) as Record<(typeof RUNTIME_SECRET_FIELDS)[number], true>,
});
const clearedFields = RUNTIME_SECRET_FIELDS.filter((field) => !!existing?.[field]);
if (clearedFields.length === 0) {
return { ok: true, clearedFields: [] as string[] };
}
await ctx.db.systemSettings.update({
where: { id: "singleton" },
data: Object.fromEntries(clearedFields.map((field) => [field, null])),
});
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Runtime Secrets",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { clearedFields },
source: "ui",
summary: `Cleared ${clearedFields.length} legacy runtime secret field(s) from database storage`,
});
return { ok: true, clearedFields };
}),
testAiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
}));
return testRuntimeAiConnection(settings);
}),
testSmtpConnection: adminProcedure.mutation(async ({ ctx }) => {
const result = await testSmtpConnection();
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "SMTP Connection Test",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { testResult: result.ok ? "success" : "failed" },
source: "ui",
summary: result.ok ? "SMTP connection test succeeded" : "SMTP connection test failed",
});
return result;
}),
testGeminiConnection: adminProcedure.mutation(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: { geminiApiKey: true, geminiModel: true },
}));
if (!settings?.geminiApiKey) {
return { ok: false, error: "Gemini API key is not configured." };
}
try {
const { generateGeminiImage } = await import("../gemini-client.js");
const model = settings.geminiModel ?? "gemini-2.5-flash-image";
// Generate a tiny test image with a simple prompt
const dataUrl = await generateGeminiImage(
settings.geminiApiKey,
"A simple blue circle on white background, minimal, 256x256",
model,
);
const hasImage = dataUrl.startsWith("data:image/");
void createAuditEntry({
db: ctx.db,
entityType: "SystemSettings",
entityId: "singleton",
entityName: "Gemini Connection Test",
action: "UPDATE",
userId: ctx.dbUser?.id,
after: { testResult: hasImage ? "success" : "failed" },
source: "ui",
summary: hasImage ? "Gemini image generation test succeeded" : "Gemini test returned no image",
});
return { ok: hasImage, model, preview: hasImage ? dataUrl.slice(0, 100) + "..." : undefined };
} catch (err) {
const { parseGeminiError } = await import("../gemini-client.js");
return { ok: false, error: parseGeminiError(err) };
}
}),
getAiConfigured: adminProcedure.query(async ({ ctx }) => {
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
where: { id: "singleton" },
select: {
aiProvider: true,
azureOpenAiEndpoint: true,
azureOpenAiDeployment: true,
azureOpenAiApiKey: true,
},
}));
return { configured: isAiConfigured(settings) };
}),
getAiConfigured: adminProcedure.query(({ ctx }) => getAiConfiguredStatus(ctx)),
});