diff --git a/packages/api/src/__tests__/calculation-rules-router.test.ts b/packages/api/src/__tests__/calculation-rules-router.test.ts new file mode 100644 index 0000000..80829ba --- /dev/null +++ b/packages/api/src/__tests__/calculation-rules-router.test.ts @@ -0,0 +1,345 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { calculationRuleRouter } from "../router/calculation-rules.js"; +import { createCallerFactory } from "../trpc.js"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Caller factory ─────────────────────────────────────────────────────────── + +const createCaller = createCallerFactory(calculationRuleRouter); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +function createControllerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "controller@example.com", name: "Controller", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_controller", + systemRole: SystemRole.CONTROLLER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +function createViewerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "viewer@example.com", name: "Viewer", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_viewer", + systemRole: SystemRole.VIEWER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const ruleFixture = { + id: "rule_1", + name: "Sick Leave Rule", + description: "Apply zero cost during sick leave", + triggerType: "SICK", + projectId: null, + orderType: null, + costEffect: "ZERO", + costReductionPercent: null, + chargeabilityEffect: "SKIP", + priority: 100, + isActive: true, +}; + +const ruleWithProjectFixture = { + ...ruleFixture, + project: { id: "proj_1", name: "Big Feature", code: "BF-2025" }, +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("calculationRule.list", () => { + it("returns all rules ordered by priority descending then name", async () => { + const findMany = vi.fn().mockResolvedValue([ruleWithProjectFixture]); + const caller = createAdminCaller({ calculationRule: { findMany } }); + + const result = await caller.list(); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ priority: "desc" }, { name: "asc" }], + include: expect.objectContaining({ project: expect.anything() }), + }), + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "rule_1", name: "Sick Leave Rule" }); + }); + + it("returns an empty list when no rules are defined", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createAdminCaller({ calculationRule: { findMany } }); + + const result = await caller.list(); + + expect(result).toHaveLength(0); + }); + + it("allows CONTROLLER role to list rules", async () => { + const findMany = vi.fn().mockResolvedValue([ruleWithProjectFixture]); + const caller = createControllerCaller({ calculationRule: { findMany } }); + + const result = await caller.list(); + + expect(result).toHaveLength(1); + }); + + it("rejects VIEWER role with FORBIDDEN", async () => { + const caller = createViewerCaller({ calculationRule: { findMany: vi.fn() } }); + + await expect(caller.list()).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("calculationRule.getById", () => { + it("returns the rule with project include when it exists", async () => { + const findUnique = vi.fn().mockResolvedValue(ruleWithProjectFixture); + const caller = createAdminCaller({ calculationRule: { findUnique } }); + + const result = await caller.getById({ id: "rule_1" }); + + expect(findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "rule_1" } })); + expect(result).toMatchObject({ id: "rule_1", name: "Sick Leave Rule" }); + }); + + it("throws NOT_FOUND when the rule does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ calculationRule: { findUnique } }); + + await expect(caller.getById({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("calculationRule.getActive", () => { + it("returns only active rules without project include", async () => { + const activeRule = { ...ruleFixture }; + const findMany = vi.fn().mockResolvedValue([activeRule]); + const caller = createAdminCaller({ calculationRule: { findMany } }); + + const result = await caller.getActive(); + + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { isActive: true }, + orderBy: [{ priority: "desc" }], + }), + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "rule_1", isActive: true }); + }); + + it("returns an empty list when no active rules exist", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createAdminCaller({ calculationRule: { findMany } }); + + const result = await caller.getActive(); + + expect(result).toHaveLength(0); + }); +}); + +describe("calculationRule.create", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates a rule with required fields and returns it", async () => { + const created = { ...ruleFixture }; + const dbCreate = vi.fn().mockResolvedValue(created); + const caller = createManagerCaller({ calculationRule: { create: dbCreate } }); + + const result = await caller.create({ + name: "Sick Leave Rule", + triggerType: "SICK", + costEffect: "ZERO", + chargeabilityEffect: "SKIP", + priority: 100, + isActive: true, + }); + + expect(dbCreate).toHaveBeenCalled(); + expect(result).toMatchObject({ + id: "rule_1", + name: "Sick Leave Rule", + triggerType: "SICK", + costEffect: "ZERO", + }); + }); + + it("creates a rule with optional description and projectId", async () => { + const created = { + ...ruleFixture, + description: "Project-scoped rule", + projectId: "proj_1", + costEffect: "REDUCE", + costReductionPercent: 50, + }; + const dbCreate = vi.fn().mockResolvedValue(created); + const caller = createManagerCaller({ calculationRule: { create: dbCreate } }); + + const result = await caller.create({ + name: "Sick Leave Rule", + description: "Project-scoped rule", + triggerType: "SICK", + projectId: "proj_1", + costEffect: "REDUCE", + costReductionPercent: 50, + chargeabilityEffect: "SKIP", + priority: 50, + isActive: true, + }); + + expect(result).toMatchObject({ + description: "Project-scoped rule", + projectId: "proj_1", + costReductionPercent: 50, + }); + }); + + it("rejects CONTROLLER role with FORBIDDEN (requires MANAGER or ADMIN)", async () => { + const caller = createControllerCaller({ calculationRule: { create: vi.fn() } }); + + await expect( + caller.create({ + name: "Test", + triggerType: "VACATION", + costEffect: "CHARGE", + chargeabilityEffect: "COUNT", + priority: 0, + isActive: true, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects invalid triggerType with a validation error", async () => { + const caller = createManagerCaller({ calculationRule: { create: vi.fn() } }); + + await expect( + caller.create({ + name: "Bad Rule", + triggerType: "UNKNOWN_TRIGGER" as never, + costEffect: "ZERO", + chargeabilityEffect: "SKIP", + priority: 0, + isActive: true, + }), + ).rejects.toThrow(); + }); +}); + +describe("calculationRule.update", () => { + it("updates a rule name and returns the updated record", async () => { + const updated = { ...ruleFixture, name: "Updated Sick Rule" }; + const findUnique = vi.fn().mockResolvedValue(ruleFixture); + const update = vi.fn().mockResolvedValue(updated); + const caller = createManagerCaller({ calculationRule: { findUnique, update } }); + + const result = await caller.update({ id: "rule_1", name: "Updated Sick Rule" }); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "rule_1" } })); + expect(result).toMatchObject({ name: "Updated Sick Rule" }); + }); + + it("throws NOT_FOUND when the rule to update does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createManagerCaller({ calculationRule: { findUnique } }); + + await expect(caller.update({ id: "missing", name: "Ghost Rule" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("deactivates a rule via isActive: false", async () => { + const updated = { ...ruleFixture, isActive: false }; + const findUnique = vi.fn().mockResolvedValue(ruleFixture); + const update = vi.fn().mockResolvedValue(updated); + const caller = createManagerCaller({ calculationRule: { findUnique, update } }); + + const result = await caller.update({ id: "rule_1", isActive: false }); + + expect(result).toMatchObject({ isActive: false }); + }); +}); + +describe("calculationRule.delete", () => { + it("deletes the rule and returns success", async () => { + const findUnique = vi.fn().mockResolvedValue(ruleFixture); + const dbDelete = vi.fn().mockResolvedValue(undefined); + const caller = createManagerCaller({ calculationRule: { findUnique, delete: dbDelete } }); + + const result = await caller.delete({ id: "rule_1" }); + + expect(dbDelete).toHaveBeenCalledWith({ where: { id: "rule_1" } }); + expect(result).toMatchObject({ success: true }); + }); + + it("throws NOT_FOUND when the rule does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createManagerCaller({ calculationRule: { findUnique } }); + + await expect(caller.delete({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); + + it("rejects CONTROLLER role with FORBIDDEN", async () => { + const caller = createControllerCaller({ calculationRule: { findUnique: vi.fn() } }); + + await expect(caller.delete({ id: "rule_1" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); diff --git a/packages/api/src/__tests__/settings-router.test.ts b/packages/api/src/__tests__/settings-router.test.ts new file mode 100644 index 0000000..dcbacc0 --- /dev/null +++ b/packages/api/src/__tests__/settings-router.test.ts @@ -0,0 +1,315 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { settingsRouter } from "../router/settings.js"; +import { createCallerFactory } from "../trpc.js"; + +// ─── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { runPruning } = vi.hoisted(() => ({ + runPruning: vi.fn(), +})); + +const { testSmtpConnection } = vi.hoisted(() => ({ + testSmtpConnection: vi.fn(), +})); + +vi.mock("../lib/pruning.js", () => ({ runPruning })); + +vi.mock("../lib/email.js", () => ({ testSmtpConnection })); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +// ai-client mock — isAiConfigured drives getAiConfigured and testAiConnection +vi.mock("../ai-client.js", () => ({ + isAiConfigured: vi.fn().mockReturnValue(false), + parseAiError: vi.fn().mockReturnValue("AI connection error"), + sanitizeDiagnosticError: vi.fn().mockReturnValue("sanitized"), +})); + +// ─── Caller factory ─────────────────────────────────────────────────────────── + +const createCaller = createCallerFactory(settingsRouter); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("settings.getSystemSettings", () => { + it("returns system settings view with defaults when no record exists", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ systemSettings: { findUnique } }); + + const result = await caller.getSystemSettings(); + + expect(findUnique).toHaveBeenCalledWith({ where: { id: "singleton" } }); + expect(result).toMatchObject({ + aiProvider: "openai", + smtpPort: 587, + anonymizationEnabled: false, + vacationDefaultDays: 28, + }); + }); + + it("returns stored settings when a singleton record exists", async () => { + const findUnique = vi.fn().mockResolvedValue({ + aiProvider: "azure", + smtpHost: "mail.example.com", + smtpPort: 465, + smtpUser: "user@example.com", + smtpFrom: "noreply@example.com", + smtpTls: true, + anonymizationEnabled: true, + vacationDefaultDays: 30, + }); + const caller = createAdminCaller({ systemSettings: { findUnique } }); + + const result = await caller.getSystemSettings(); + + expect(result).toMatchObject({ + aiProvider: "azure", + smtpHost: "mail.example.com", + smtpPort: 465, + anonymizationEnabled: true, + vacationDefaultDays: 30, + }); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({ systemSettings: { findUnique: vi.fn() } }); + + await expect(caller.getSystemSettings()).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); + +describe("settings.updateSystemSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("upserts settings and returns ok with ignored secret fields", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const upsert = vi.fn().mockResolvedValue({ id: "singleton" }); + const caller = createAdminCaller({ + systemSettings: { findUnique, upsert }, + }); + + const result = await caller.updateSystemSettings({ + aiProvider: "openai", + azureOpenAiApiKey: "secret-key", // should be ignored + vacationDefaultDays: 25, + }); + + expect(result.ok).toBe(true); + expect(result.ignoredSecretFields).toContain("azureOpenAiApiKey"); + expect(upsert).toHaveBeenCalled(); + }); + + it("returns ok without DB write when only secret fields are supplied", async () => { + const findUnique = vi.fn(); + const upsert = vi.fn(); + const caller = createAdminCaller({ + systemSettings: { findUnique, upsert }, + }); + + const result = await caller.updateSystemSettings({ + azureOpenAiApiKey: "secret", + smtpPassword: "password", + }); + + expect(result.ok).toBe(true); + expect(result.ignoredSecretFields).toContain("azureOpenAiApiKey"); + expect(result.ignoredSecretFields).toContain("smtpPassword"); + // No DB write because only secret fields were provided + expect(upsert).not.toHaveBeenCalled(); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({ systemSettings: { upsert: vi.fn() } }); + + await expect(caller.updateSystemSettings({ vacationDefaultDays: 20 })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("rejects invalid score weights that do not sum to 1.0", async () => { + const caller = createAdminCaller({ systemSettings: {} }); + + await expect( + caller.updateSystemSettings({ + scoreWeights: { + skillDepth: 0.5, + skillBreadth: 0.5, + costEfficiency: 0.5, + chargeability: 0.5, + experience: 0.5, + }, + }), + ).rejects.toThrow(); + }); +}); + +describe("settings.clearStoredRuntimeSecrets", () => { + it("returns empty clearedFields when no secrets are stored", async () => { + const findUnique = vi.fn().mockResolvedValue({ + azureOpenAiApiKey: null, + azureDalleApiKey: null, + geminiApiKey: null, + smtpPassword: null, + anonymizationSeed: null, + }); + const caller = createAdminCaller({ systemSettings: { findUnique } }); + + const result = await caller.clearStoredRuntimeSecrets(); + + expect(result.ok).toBe(true); + expect(result.clearedFields).toHaveLength(0); + }); + + it("clears fields that have stored values and returns their names", async () => { + const findUnique = vi.fn().mockResolvedValue({ + azureOpenAiApiKey: "stored-key", + azureDalleApiKey: null, + geminiApiKey: "gemini-key", + smtpPassword: null, + anonymizationSeed: null, + }); + const update = vi.fn().mockResolvedValue({ id: "singleton" }); + const caller = createAdminCaller({ systemSettings: { findUnique, update } }); + + const result = await caller.clearStoredRuntimeSecrets(); + + expect(result.ok).toBe(true); + expect(result.clearedFields).toContain("azureOpenAiApiKey"); + expect(result.clearedFields).toContain("geminiApiKey"); + expect(result.clearedFields).not.toContain("smtpPassword"); + expect(update).toHaveBeenCalled(); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({ systemSettings: { findUnique: vi.fn() } }); + + await expect(caller.clearStoredRuntimeSecrets()).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); + +describe("settings.getAiConfigured", () => { + it("returns configured: false when AI is not configured", async () => { + const { isAiConfigured } = await import("../ai-client.js"); + vi.mocked(isAiConfigured).mockReturnValue(false); + + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ systemSettings: { findUnique } }); + + const result = await caller.getAiConfigured(); + + expect(result.configured).toBe(false); + }); + + it("returns configured: true when AI settings are present", async () => { + const { isAiConfigured } = await import("../ai-client.js"); + vi.mocked(isAiConfigured).mockReturnValue(true); + + const findUnique = vi.fn().mockResolvedValue({ + aiProvider: "openai", + azureOpenAiApiKey: "sk-test", + }); + const caller = createAdminCaller({ systemSettings: { findUnique } }); + + const result = await caller.getAiConfigured(); + + expect(result.configured).toBe(true); + }); +}); + +describe("settings.runPruning", () => { + beforeEach(() => { + runPruning.mockReset(); + }); + + it("delegates to runPruning and returns its result", async () => { + runPruning.mockResolvedValue({ + inviteTokensDeleted: 3, + passwordResetTokensDeleted: 1, + notificationsDeleted: 20, + }); + + const caller = createAdminCaller({ inviteToken: {}, passwordResetToken: {}, notification: {} }); + + const result = await caller.runPruning(); + + expect(runPruning).toHaveBeenCalled(); + expect(result).toMatchObject({ + inviteTokensDeleted: 3, + passwordResetTokensDeleted: 1, + notificationsDeleted: 20, + }); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({}); + + await expect(caller.runPruning()).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("settings.testSmtpConnection", () => { + beforeEach(() => { + testSmtpConnection.mockReset(); + }); + + it("returns success result when SMTP connection succeeds", async () => { + testSmtpConnection.mockResolvedValue({ ok: true }); + + const caller = createAdminCaller({ systemSettings: {} }); + + const result = await caller.testSmtpConnection(); + + expect(result.ok).toBe(true); + }); + + it("returns failure result when SMTP connection fails", async () => { + testSmtpConnection.mockResolvedValue({ ok: false, error: "Connection refused" }); + + const caller = createAdminCaller({ systemSettings: {} }); + + const result = await caller.testSmtpConnection(); + + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/api/src/__tests__/webhook-router.test.ts b/packages/api/src/__tests__/webhook-router.test.ts new file mode 100644 index 0000000..6fd0e8d --- /dev/null +++ b/packages/api/src/__tests__/webhook-router.test.ts @@ -0,0 +1,287 @@ +import { SystemRole } from "@capakraken/shared"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { webhookRouter } from "../router/webhook.js"; +import { createCallerFactory } from "../trpc.js"; + +// ─── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { assertWebhookUrlAllowed } = vi.hoisted(() => ({ + assertWebhookUrlAllowed: vi.fn().mockResolvedValue(undefined), +})); + +const { sendWebhookTestRequest } = vi.hoisted(() => ({ + sendWebhookTestRequest: vi.fn(), +})); + +vi.mock("../lib/ssrf-guard.js", () => ({ assertWebhookUrlAllowed })); + +vi.mock("../router/webhook-support.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + sendWebhookTestRequest, + }; +}); + +vi.mock("../lib/audit.js", () => ({ + createAuditEntry: vi.fn().mockResolvedValue(undefined), +})); + +// ─── Caller factory ─────────────────────────────────────────────────────────── + +const createCaller = createCallerFactory(webhookRouter); + +function createAdminCaller(db: Record) { + return createCaller({ + session: { + user: { email: "admin@example.com", name: "Admin", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_admin", + systemRole: SystemRole.ADMIN, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +function createManagerCaller(db: Record) { + return createCaller({ + session: { + user: { email: "manager@example.com", name: "Manager", image: null }, + expires: "2099-01-01T00:00:00.000Z", + }, + db: db as never, + dbUser: { + id: "user_manager", + systemRole: SystemRole.MANAGER, + permissionOverrides: null, + }, + roleDefaults: null, + }); +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const webhookFixture = { + id: "wh_1", + name: "Deploy Notifier", + url: "https://hooks.example.com/deploy", + secret: null, + events: ["allocation.created"], + isActive: true, + createdAt: new Date("2025-01-01"), +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("webhook.list", () => { + it("returns all webhooks ordered by creation date descending", async () => { + const findMany = vi.fn().mockResolvedValue([webhookFixture]); + const caller = createAdminCaller({ webhook: { findMany } }); + + const result = await caller.list(); + + expect(findMany).toHaveBeenCalledWith({ orderBy: { createdAt: "desc" } }); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "wh_1", name: "Deploy Notifier" }); + }); + + it("returns an empty array when no webhooks exist", async () => { + const findMany = vi.fn().mockResolvedValue([]); + const caller = createAdminCaller({ webhook: { findMany } }); + + const result = await caller.list(); + + expect(result).toHaveLength(0); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({ webhook: { findMany: vi.fn() } }); + + await expect(caller.list()).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("webhook.getById", () => { + it("returns the webhook when it exists", async () => { + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const caller = createAdminCaller({ webhook: { findUnique } }); + + const result = await caller.getById({ id: "wh_1" }); + + expect(findUnique).toHaveBeenCalledWith({ where: { id: "wh_1" } }); + expect(result).toMatchObject({ id: "wh_1", name: "Deploy Notifier" }); + }); + + it("throws NOT_FOUND when the webhook does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ webhook: { findUnique } }); + + await expect(caller.getById({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("webhook.create", () => { + beforeEach(() => { + assertWebhookUrlAllowed.mockReset().mockResolvedValue(undefined); + }); + + it("creates a webhook and returns it after SSRF validation", async () => { + const created = { ...webhookFixture }; + const dbCreate = vi.fn().mockResolvedValue(created); + const caller = createAdminCaller({ webhook: { create: dbCreate } }); + + const result = await caller.create({ + name: "Deploy Notifier", + url: "https://hooks.example.com/deploy", + events: ["allocation.created"], + isActive: true, + }); + + expect(assertWebhookUrlAllowed).toHaveBeenCalledWith("https://hooks.example.com/deploy"); + expect(dbCreate).toHaveBeenCalled(); + expect(result).toMatchObject({ id: "wh_1", name: "Deploy Notifier" }); + }); + + it("rejects when the SSRF guard blocks the URL", async () => { + const { TRPCError } = await import("@trpc/server"); + assertWebhookUrlAllowed.mockRejectedValue( + new TRPCError({ code: "BAD_REQUEST", message: "Webhook URLs must use HTTPS." }), + ); + + const caller = createAdminCaller({ webhook: { create: vi.fn() } }); + + await expect( + caller.create({ + name: "Internal", + url: "https://192.168.1.1/hook", + events: ["project.created"], + isActive: true, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("rejects non-admin callers with FORBIDDEN", async () => { + const caller = createManagerCaller({ webhook: { create: vi.fn() } }); + + await expect( + caller.create({ + name: "Test", + url: "https://example.com/hook", + events: ["allocation.created"], + isActive: true, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("webhook.update", () => { + beforeEach(() => { + assertWebhookUrlAllowed.mockReset().mockResolvedValue(undefined); + }); + + it("updates a webhook name and returns the updated record", async () => { + const updated = { ...webhookFixture, name: "CI Notifier" }; + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const update = vi.fn().mockResolvedValue(updated); + const caller = createAdminCaller({ webhook: { findUnique, update } }); + + const result = await caller.update({ id: "wh_1", data: { name: "CI Notifier" } }); + + expect(update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: "wh_1" } })); + expect(result).toMatchObject({ name: "CI Notifier" }); + }); + + it("validates the new URL via SSRF guard when updating URL", async () => { + const updated = { ...webhookFixture, url: "https://new.example.com/hook" }; + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const update = vi.fn().mockResolvedValue(updated); + const caller = createAdminCaller({ webhook: { findUnique, update } }); + + await caller.update({ id: "wh_1", data: { url: "https://new.example.com/hook" } }); + + expect(assertWebhookUrlAllowed).toHaveBeenCalledWith("https://new.example.com/hook"); + }); + + it("throws NOT_FOUND when the webhook to update does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ webhook: { findUnique } }); + + await expect(caller.update({ id: "missing", data: { name: "Ghost" } })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("webhook.delete", () => { + it("deletes the webhook and resolves without error", async () => { + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const dbDelete = vi.fn().mockResolvedValue(undefined); + const caller = createAdminCaller({ webhook: { findUnique, delete: dbDelete } }); + + await caller.delete({ id: "wh_1" }); + + expect(dbDelete).toHaveBeenCalledWith({ where: { id: "wh_1" } }); + }); + + it("throws NOT_FOUND when the webhook does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ webhook: { findUnique } }); + + await expect(caller.delete({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("webhook.test", () => { + beforeEach(() => { + assertWebhookUrlAllowed.mockReset().mockResolvedValue(undefined); + sendWebhookTestRequest.mockReset(); + }); + + it("sends a test request and returns the result on success", async () => { + sendWebhookTestRequest.mockResolvedValue({ + success: true, + statusCode: 200, + statusText: "OK", + }); + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const caller = createAdminCaller({ webhook: { findUnique } }); + + const result = await caller.test({ id: "wh_1" }); + + expect(assertWebhookUrlAllowed).toHaveBeenCalledWith(webhookFixture.url); + expect(sendWebhookTestRequest).toHaveBeenCalledWith(webhookFixture); + expect(result).toMatchObject({ success: true, statusCode: 200 }); + }); + + it("returns failure result when the remote endpoint rejects the request", async () => { + sendWebhookTestRequest.mockResolvedValue({ + success: false, + statusCode: 500, + statusText: "Internal Server Error", + }); + const findUnique = vi.fn().mockResolvedValue(webhookFixture); + const caller = createAdminCaller({ webhook: { findUnique } }); + + const result = await caller.test({ id: "wh_1" }); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(500); + }); + + it("throws NOT_FOUND when the webhook to test does not exist", async () => { + const findUnique = vi.fn().mockResolvedValue(null); + const caller = createAdminCaller({ webhook: { findUnique } }); + + await expect(caller.test({ id: "missing" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +});