diff --git a/packages/api/src/__tests__/assistant-tools-settings-ai-configured.test.ts b/packages/api/src/__tests__/assistant-tools-settings-ai-configured.test.ts new file mode 100644 index 0000000..1c9fa2a --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-settings-ai-configured.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-settings-test-helpers.js"; + +describe("assistant settings tool get_ai_configured", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("reports AI configuration only to admin users through the real settings path", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + + const ctx = createToolContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: "gpt-4o-mini", + azureOpenAiApiKey: null, + }), + }, + }); + + const result = await executeTool("get_ai_configured", JSON.stringify({}), ctx); + + expect(JSON.parse(result.content)).toEqual({ configured: true }); + }); + + it("rejects get_ai_configured for non-admin assistant users", async () => { + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn(), + }, + }, + SystemRole.MANAGER, + ); + + const result = await executeTool("get_ai_configured", JSON.stringify({}), ctx); + + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to perform this action.", + }); + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-settings-connections.test.ts b/packages/api/src/__tests__/assistant-tools-settings-connections.test.ts new file mode 100644 index 0000000..5f4aeb6 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-settings-connections.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_OPENAI_MODEL, SystemRole } from "@capakraken/shared"; + +import { + createToolContext, + executeTool, + generateGeminiImage, + testSmtpConnection, +} from "./assistant-tools-settings-test-helpers.js"; + +describe("assistant settings tools connection checks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("tests the AI connection through the real settings router path", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + const fetch = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue("ok"), + }); + vi.stubGlobal("fetch", fetch); + + const ctx = createToolContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiDeployment: DEFAULT_OPENAI_MODEL, + azureOpenAiApiKey: null, + }), + }, + }); + + const result = await executeTool("test_ai_connection", JSON.stringify({}), ctx); + + expect(JSON.parse(result.content)).toEqual({ ok: true }); + expect(fetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/chat/completions", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer env-openai-key", + }), + }), + ); + }); + + it("tests the SMTP connection through the real settings router path", async () => { + vi.mocked(testSmtpConnection).mockResolvedValue({ ok: true }); + + const ctx = createToolContext({}); + const result = await executeTool("test_smtp_connection", JSON.stringify({}), ctx); + + expect(JSON.parse(result.content)).toEqual({ ok: true }); + expect(testSmtpConnection).toHaveBeenCalledTimes(1); + }); + + it("tests the Gemini connection through the real settings router path", async () => { + vi.mocked(generateGeminiImage).mockResolvedValue("data:image/png;base64,abc123"); + + const ctx = createToolContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + geminiApiKey: "gemini-key", + geminiModel: "gemini-2.5-flash-image", + }), + }, + }); + + const result = await executeTool("test_gemini_connection", JSON.stringify({}), ctx); + const parsed = JSON.parse(result.content) as { + ok: boolean; + model: string; + preview: string; + }; + + expect(parsed.ok).toBe(true); + expect(parsed.model).toBe("gemini-2.5-flash-image"); + expect(parsed.preview).toContain("data:image/png;base64,abc123"); + expect(generateGeminiImage).toHaveBeenCalledWith( + "gemini-key", + "A simple blue circle on white background, minimal, 256x256", + "gemini-2.5-flash-image", + ); + }); + + it("rejects admin-only connection tools for non-admin assistant users", async () => { + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn(), + }, + }, + SystemRole.MANAGER, + ); + + for (const toolName of [ + "test_ai_connection", + "test_smtp_connection", + "test_gemini_connection", + ] as const) { + const result = await executeTool(toolName, JSON.stringify({}), ctx); + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to perform this action.", + }); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-settings-runtime.test.ts b/packages/api/src/__tests__/assistant-tools-settings-runtime.test.ts new file mode 100644 index 0000000..d4dc072 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-settings-runtime.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_OPENAI_MODEL, SystemRole } from "@capakraken/shared"; + +import { createToolContext, executeTool } from "./assistant-tools-settings-test-helpers.js"; + +describe("assistant settings tools runtime settings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it("returns system settings with runtime secret flags through the real settings router path", async () => { + vi.stubEnv("OPENAI_API_KEY", "env-openai-key"); + vi.stubEnv("SMTP_PASSWORD", "env-smtp-password"); + vi.stubEnv("GEMINI_API_KEY", "env-gemini-key"); + + const ctx = createToolContext({ + systemSettings: { + findUnique: vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiApiKey: null, + smtpPassword: null, + geminiApiKey: null, + }), + }, + }); + + const result = await executeTool("get_system_settings", JSON.stringify({}), ctx); + const parsed = JSON.parse(result.content) as { + hasApiKey: boolean; + hasSmtpPassword: boolean; + hasGeminiApiKey: boolean; + runtimeSecrets: { + azureOpenAiApiKey: { activeSource: string }; + smtpPassword: { activeSource: string }; + }; + legacyStoredSecretFields: string[]; + }; + + expect(parsed.hasApiKey).toBe(true); + expect(parsed.hasSmtpPassword).toBe(true); + expect(parsed.hasGeminiApiKey).toBe(true); + expect(parsed.runtimeSecrets.azureOpenAiApiKey.activeSource).toBe("environment"); + expect(parsed.runtimeSecrets.smtpPassword.activeSource).toBe("environment"); + expect(parsed.legacyStoredSecretFields).toEqual([]); + }); + + it("updates system settings without persisting incoming secret fields", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const upsert = vi.fn().mockResolvedValue({}); + const ctx = createToolContext({ + systemSettings: { + findUnique, + upsert, + }, + }); + + const result = await executeTool( + "update_system_settings", + JSON.stringify({ + 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: DEFAULT_OPENAI_MODEL, + }), + ctx, + ); + + expect(JSON.parse(result.content)).toEqual({ + ok: true, + ignoredSecretFields: [ + "azureOpenAiApiKey", + "smtpPassword", + "anonymizationSeed", + "azureDalleApiKey", + "geminiApiKey", + ], + secretStorageMode: "environment-only", + }); + expect(findUnique).toHaveBeenCalledWith({ where: { id: "singleton" } }); + expect(upsert).toHaveBeenCalledWith({ + where: { id: "singleton" }, + create: { + id: "singleton", + aiProvider: "openai", + azureOpenAiDeployment: DEFAULT_OPENAI_MODEL, + }, + update: { + aiProvider: "openai", + azureOpenAiDeployment: DEFAULT_OPENAI_MODEL, + }, + }); + }); + + 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, + }, + }); + + 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("rejects admin-only runtime settings tools for non-admin assistant users", async () => { + const ctx = createToolContext( + { + systemSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + update: vi.fn(), + }, + }, + SystemRole.MANAGER, + ); + + for (const [toolName, payload] of [ + ["get_system_settings", {}], + ["update_system_settings", { aiProvider: "openai" }], + ["clear_stored_runtime_secrets", {}], + ] as const) { + const result = await executeTool(toolName, JSON.stringify(payload), ctx); + expect(JSON.parse(result.content)).toEqual({ + error: "You do not have permission to perform this action.", + }); + } + }); +}); diff --git a/packages/api/src/__tests__/assistant-tools-settings-test-helpers.ts b/packages/api/src/__tests__/assistant-tools-settings-test-helpers.ts new file mode 100644 index 0000000..8278113 --- /dev/null +++ b/packages/api/src/__tests__/assistant-tools-settings-test-helpers.ts @@ -0,0 +1,60 @@ +import { SystemRole } from "@capakraken/shared"; +import { vi } from "vitest"; + +vi.mock("@capakraken/application", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDashboardBudgetForecast: vi.fn().mockResolvedValue([]), + getDashboardPeakTimes: vi.fn().mockResolvedValue([]), + listAssignmentBookings: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("../lib/logger.js", () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("../lib/email.js", () => ({ + testSmtpConnection: vi.fn(), +})); + +vi.mock("../gemini-client.js", () => ({ + generateGeminiImage: vi.fn(), + parseGeminiError: vi.fn((err: unknown) => (err instanceof Error ? err.message : String(err))), +})); + +import { testSmtpConnection as testSmtpConnectionMock } from "../lib/email.js"; +import { generateGeminiImage as generateGeminiImageMock } from "../gemini-client.js"; +import { executeTool as executeAssistantTool, type ToolContext } from "../router/assistant-tools.js"; + +export const executeTool = executeAssistantTool; +export const testSmtpConnection = testSmtpConnectionMock; +export const generateGeminiImage = generateGeminiImageMock; + +export function createToolContext( + db: Record, + userRole: SystemRole = SystemRole.ADMIN, +): ToolContext { + return { + db: db as ToolContext["db"], + userId: "admin_1", + userRole, + permissions: new Set(), + session: { + user: { email: "admin@example.com", name: "Admin Assistant", image: null }, + expires: "2026-03-30T00:00:00.000Z", + }, + dbUser: { + id: "admin_1", + systemRole: userRole, + permissionOverrides: null, + }, + roleDefaults: null, + }; +}