diff --git a/packages/control-plane/src/routes/automation-create.test.ts b/packages/control-plane/src/routes/automation-create.test.ts new file mode 100644 index 000000000..a52671a1d --- /dev/null +++ b/packages/control-plane/src/routes/automation-create.test.ts @@ -0,0 +1,760 @@ +/** + * Unit tests for the automation create route. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { HttpError, resolveRepoOrError } from "./shared"; +import { PERMISSION_IDS } from "@open-inspect/shared/rbac"; +import { createTestRequestHandler } from "../router.test-support"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + mockBatch, + mockProviderAdapterGet, + mockResolveGitHubCredentialAuthority, + mockResolveGitHubEnrichmentForRequest, + SLACK_BOT_PRINCIPAL, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +vi.mock("../auth/model-provider-account-default-adapters", () => ({ + modelProviderAccountAdapterRegistry: { + get: (...args: unknown[]) => mockProviderAdapterGet(...args), + }, +})); + +vi.mock("../source-control/github-credential-authority", () => ({ + resolveGitHubCredentialAuthority: (...args: unknown[]) => + mockResolveGitHubCredentialAuthority(...args), +})); + +vi.mock("../session/identity", () => ({ + resolveGitHubEnrichmentForRequest: (...args: unknown[]) => + mockResolveGitHubEnrichmentForRequest(...args), +})); + +vi.mock("../auth/crypto", () => ({ + generateId: vi.fn(() => "generated-id"), +})); + +vi.mock("./shared", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + resolveRepoOrError: vi.fn().mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }), + }; +}); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation create route", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + }); + + describe("POST /automations (create)", () => { + const validBody = { + name: "Daily sync", + repositories: [{ repoOwner: "acme", repoName: "web-app" }], + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + }; + + it("creates automation with valid input", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { body: validBody }); + expect(res.status).toBe(201); + + // The selection persists as repository rows; the automation row carries + // no repo columns. Both land in a single atomic batch. + expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( + "generated-id", + [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledTimes(1); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "insert-automation" }, { sql: "insert-repositories" }]) + ); + }); + + it("rejects partial create payloads before persistence", async () => { + const res = await callRoute("POST", "/automations", { + body: { instructions: "Run tests" }, + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid automation request" }); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("persists a complete provider pin map in the create batch", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + const providerSelections = { + openai: { + mode: "provider_account" as const, + accountId: "0123456789abcdef0123456789abcdef", + }, + xai: { mode: "api_key" as const }, + }; + + const res = await callRoute("POST", "/automations", { + body: { ...validBody, providerSelections }, + }); + + expect(res.status).toBe(201); + expect(mockProviderAuthStore.bindInserts).toHaveBeenCalledWith( + "generated-id", + providerSelections, + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "insert-provider-auth" }]) + ); + }); + + it.each([ + ["missing account", null, 404], + ["wrong provider", { provider: "xai", status: "active", archivedAt: null }, 400], + ["inactive account", { provider: "openai", status: "disabled", archivedAt: null }, 409], + ["archived account", { provider: "openai", status: "active", archivedAt: 123 }, 409], + ])("rejects a provider pin for a %s", async (_label, account, status) => { + mockProviderAccountStore.getById.mockResolvedValue({ + id: "0123456789abcdef0123456789abcdef", + ...account, + }); + if (!account) mockProviderAccountStore.getById.mockResolvedValue(null); + + const res = await callRoute("POST", "/automations", { + body: { + ...validBody, + providerSelections: { + openai: { + mode: "provider_account", + accountId: "0123456789abcdef0123456789abcdef", + }, + }, + }, + }); + + expect(res.status).toBe(status); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("rejects a provider-account pin when its adapter is unavailable", async () => { + mockProviderAdapterGet.mockReturnValue(undefined); + + const res = await callRoute("POST", "/automations", { + body: { + ...validBody, + providerSelections: { + openai: { + mode: "provider_account", + accountId: "0123456789abcdef0123456789abcdef", + }, + }, + }, + }); + + expect(res.status).toBe(409); + expect(mockProviderAccountStore.getById).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it.each([{ triggerConfig: {} }, { triggerConfig: { conditions: null } }])( + "rejects malformed trigger config before persistence", + async ({ triggerConfig }) => { + const response = await callRoute("POST", "/automations", { + body: { + name: "Webhook automation", + instructions: "Handle the event", + triggerType: "webhook", + triggerConfig, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining("triggerConfig.conditions"), + }); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + } + ); + + it("creates a multi-repository automation from the repositories list", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { + body: { + name: "Fan-out sync", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + repositories: [ + { repoOwner: "Acme", repoName: "Web-App" }, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + }, + }); + + expect(res.status).toBe(201); + expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( + "generated-id", + [ + { repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }, + { repo_owner: "acme", repo_name: "api", repo_id: 12345, base_branch: "develop" }, + ], + expect.any(Number) + ); + }); + + it("does not write partial data when repository resolution fails", async () => { + vi.mocked(resolveRepoOrError).mockImplementation(async (_env, owner, name) => { + if (name === "api") { + throw new HttpError("Repository is not installed for the GitHub App", 404); + } + return { + repoId: 12345, + repoOwner: owner, + repoName: name, + defaultBranch: "main", + }; + }); + + const res = await callRoute("POST", "/automations", { + body: { + ...validBody, + repositories: [ + { repoOwner: "acme", repoName: "web-app" }, + { repoOwner: "acme", repoName: "api" }, + ], + }, + }); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ + error: "Repository is not installed for the GitHub App", + }); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + expect(mockStore.bindRepositoryInserts).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("reports repository resolution failures in input order", async () => { + vi.mocked(resolveRepoOrError).mockImplementation( + (_env, _owner, name) => + new Promise((_, reject) => { + const delay = name === "first" ? 5 : 0; + setTimeout(() => reject(new HttpError(`failed ${name}`, 404)), delay); + }) + ); + + const res = await callRoute("POST", "/automations", { + body: { + ...validBody, + repositories: [ + { repoOwner: "acme", repoName: "first" }, + { repoOwner: "acme", repoName: "second" }, + ], + }, + }); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ error: "failed first" }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("rejects duplicate repositories in the list", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Dup", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + repositories: [ + { repoOwner: "acme", repoName: "web-app" }, + { repoOwner: "ACME", repoName: "Web-App" }, + ], + }, + }); + + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("repositories"); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("rejects multi-repository selections on non-schedule triggers", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Webhook fan-out", + instructions: "Run tests", + triggerType: "webhook", + repositories: [ + { repoOwner: "acme", repoName: "web-app" }, + { repoOwner: "acme", repoName: "api" }, + ], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Multi-target selections require a schedule trigger", + }); + }); + + it("creates an environment-targeted automation", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { + body: { + name: "Workspace sync", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + environmentIds: ["env_1", "env_2"], + }, + }); + + expect(res.status).toBe(201); + expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_1"); + expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_2"); + expect(mockStore.bindEnvironmentInserts).toHaveBeenCalledWith( + "generated-id", + ["env_1", "env_2"], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "insert-automation" }, { sql: "insert-environments" }]) + ); + }); + + it("creates a mixed repository + environment fan-out", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { + body: { ...validBody, environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(201); + expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( + "generated-id", + [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], + expect.any(Number) + ); + expect(mockStore.bindEnvironmentInserts).toHaveBeenCalledWith( + "generated-id", + ["env_1"], + expect.any(Number) + ); + }); + + it("rejects duplicate environment ids", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Dup envs", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + environmentIds: ["env_1", "env_1"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "environmentIds must not contain duplicates" }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("rejects unknown environments, naming every missing one", async () => { + mockEnvironmentStore.getById.mockResolvedValue(null); + + const res = await callRoute("POST", "/automations", { + body: { + name: "Workspace sync", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + environmentIds: ["env_a", "env_b"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Environment not found: env_a, env_b" }); + }); + + it("checks environment-use permission before disclosing whether an environment exists", async () => { + mockEnvironmentStore.getById.mockResolvedValue(null); + + const res = await callRoute("POST", "/automations", { + body: { + name: "Workspace sync", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + environmentIds: ["env_missing"], + }, + permissions: PERMISSION_IDS.filter((permission) => permission !== "environments.use"), + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + code: "permission_required", + permission: "environments.use", + }); + expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); + }); + + it("rejects malformed environment ids", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Workspace sync", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + environmentIds: ["not-an-environment"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "environmentIds must be an array of environment ids (env_…)", + }); + expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); + }); + + it("rejects environments on repo-scoped event triggers", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "PR review", + instructions: "Review", + triggerType: "github_event", + eventType: "pull_request.opened", + repositories: [{ repoOwner: "acme", repoName: "web-app" }], + environmentIds: ["env_1"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Repository-scoped triggers cannot target environments", + }); + }); + + it("rejects multi-target selections on non-schedule triggers", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Webhook fan-out", + instructions: "Run tests", + triggerType: "webhook", + repositories: [{ repoOwner: "acme", repoName: "web-app" }], + environmentIds: ["env_1"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Multi-target selections require a schedule trigger", + }); + }); + + it("enforces the combined target cap", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "Too many targets", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Run tests", + repositories: Array.from({ length: 8 }, (_, i) => ({ + repoOwner: "acme", + repoName: `repo-${i}`, + })), + environmentIds: ["env_1", "env_2", "env_3"], + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "At most 10 repositories and environments combined", + }); + }); + + it("creates repo-less automation without repo fields", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { + body: { + name: "Incident sweep", + scheduleCron: "0 9 * * *", + scheduleTz: "UTC", + instructions: "Check recent incidents and summarize.", + }, + }); + + expect(res.status).toBe(201); + expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( + "generated-id", + [], + expect.any(Number) + ); + }); + + it("rejects repo-less repo-scoped triggers", async () => { + const res = await callRoute("POST", "/automations", { + body: { + name: "PR review", + instructions: "Review the PR.", + triggerType: "github_event", + eventType: "pull_request.opened", + }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Repository-scoped triggers require exactly one repository", + }); + }); + + it("rejects conditions that do not apply to the GitHub event type", async () => { + const response = await callRoute("POST", "/automations", { + body: { + name: "PR workflow filter", + instructions: "Review the pull request.", + triggerType: "github_event", + eventType: "pull_request.opened", + repositories: [{ repoOwner: "acme", repoName: "web-app" }], + triggerConfig: { + conditions: [{ type: "workflow_name", operator: "eq", value: "CI" }], + }, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Condition "workflow_name" does not apply to GitHub event pull_request.opened', + }); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + }); + + it.each([ + [undefined, "eventType is required for github_event triggers"], + ["workflow_run.typo", "Unsupported eventType for github_event: workflow_run.typo"], + ])("rejects an invalid GitHub event type without conditions", async (eventType, message) => { + const response = await callRoute("POST", "/automations", { + body: { + name: "GitHub watcher", + instructions: "Inspect the event.", + triggerType: "github_event", + eventType, + repositories: [{ repoOwner: "acme", repoName: "web-app" }], + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: message }); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + }); + + it("stores the user principal's canonical id without consulting the user store", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { body: validBody }); + + expect(res.status).toBe(201); + expect(mockUserStore.resolveOrCreateUser).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationInsert).toHaveBeenCalledWith( + expect.objectContaining({ created_by: "user-1", user_id: "user-1" }) + ); + }); + + it("refuses a bot actor at admission before any identity is resolved", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("POST", "/automations", { + body: { + ...validBody, + actorDisplayName: "Alice", + actorEmail: "alice@corp.com", + actorAvatarUrl: "https://avatars.test/alice.png", + }, + principal: SLACK_BOT_PRINCIPAL, + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: "Forbidden", + code: "service_capability_required", + }); + expect(mockUserStore.resolveOrCreateUser).not.toHaveBeenCalled(); + expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); + }); + + it("rejects forbidden body identity fields", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, scmUserId: "12345" }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Field 'scmUserId' is not accepted from verified callers", + }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("stores reasoning effort when valid for the selected model", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "high" }); + + const res = await callRoute("POST", "/automations", { + body: { ...validBody, model: "anthropic/claude-sonnet-4-6", reasoningEffort: "high" }, + }); + + expect(res.status).toBe(201); + expect(mockStore.bindAutomationInsert).toHaveBeenCalledWith( + expect.objectContaining({ model: "anthropic/claude-sonnet-4-6", reasoning_effort: "high" }) + ); + }); + + it("returns 400 for invalid reasoning effort", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, model: "anthropic/claude-sonnet-4-6", reasoningEffort: "xhigh" }, + }); + + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("reasoning"); + }); + + it("returns 400 when name is missing", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, name: "" }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("name"); + }); + + it("returns 400 when name exceeds 200 chars", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, name: "a".repeat(201) }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("200"); + }); + + it("returns 400 when instructions is missing", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, instructions: "" }, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 when instructions exceeds the maximum length", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, instructions: "x".repeat(15_001) }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("15000"); + }); + + it("returns 400 for invalid cron expression", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, scheduleCron: "not-a-cron" }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("cron"); + }); + + it("returns 400 for cron interval under 15 minutes", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, scheduleCron: "*/5 * * * *" }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("15 minutes"); + }); + + it("returns 400 for invalid timezone", async () => { + const res = await callRoute("POST", "/automations", { + body: { ...validBody, scheduleTz: "Not/A/Timezone" }, + }); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("timezone"); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-crud.ts b/packages/control-plane/src/routes/automation-crud.ts new file mode 100644 index 000000000..25db2299f --- /dev/null +++ b/packages/control-plane/src/routes/automation-crud.ts @@ -0,0 +1,682 @@ +/** + * Automation create, read, update, and delete routes. + */ + +import { isValidCron, nextCronOccurrence, cronIntervalMinutes } from "@open-inspect/shared/cron"; +import { triggerConfigSchema } from "@open-inspect/shared/triggers"; +import type { AutomationTriggerType } from "@open-inspect/shared/triggers"; +import { updateAutomationRequestSchema } from "@open-inspect/shared/types/automations"; +import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import { getValidModelOrDefault, isValidModel } from "@open-inspect/shared/models"; +import { + AutomationStore, + type AutomationRow, + type AutomationRepositoryInsert, +} from "../db/automation-store"; +import { SlackChannelStore } from "../db/slack-channel-store"; +import { AutomationModelProviderAuthStore } from "../db/automation-model-provider-auth"; +import { + AutomationProviderSelectionError, + parseAndValidateAutomationProviderSelections, +} from "../model-provider-accounts/automation-provider-selection"; +import { generateId } from "../auth/crypto"; +import { + applyIdentityEnforcement, + requireAdmittedCanonicalUserId, +} from "../routing/identity-enforcement"; +import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key"; +import { hydrateAutomation } from "../automation/hydrate"; +import { Hono } from "hono"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { + type RequestContext, + GITHUB_USER_OR_SERVICE_ROUTE, + json, + error, + parseJsonBody, + requirePermission, +} from "./shared"; +import type { Env } from "../types"; +import type { SqlDatabase, SqlStatement } from "../db/sql-database"; +import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; +import { createLogger } from "../logger"; +import { AUTOMATIONS_READ, AUTOMATION_MANAGE, admittedAutomation } from "./automation-shared"; +import { + type CreateAutomationBody, + FAR_FUTURE_THRESHOLD_MS, + MAX_INSTRUCTIONS_LENGTH, + MAX_NAME_LENGTH, + MIN_CRON_INTERVAL_MINUTES, + TargetSelectionError, + consumeCondition, + createAutomationBodySchema, + extractSlackChannels, + formatAutomationRequestError, + getEnvironmentSelection, + getRepositorySelection, + getTriggerConditionErrors, + getTriggerEventTypeError, + isValidTimezone, + requireTargetPermissions, + resolveEnvironmentSelection, + resolveReasoningEffort, + resolveRepositorySelection, + validateSlackTriggerConfig, + validateTargetCounts, +} from "./automation-validation"; + +const logger = createLogger("router:automations"); + +async function handleCreateAutomation( + request: Request, + env: Env, + ctx: RequestContext +): Promise { + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; + + // Automation attribution comes from the verified principal. The stored + // values are replayed by the scheduler as session identity at fire time, + // so this is where they become trustworthy. + const enforcement = applyIdentityEnforcement(ctx, "automation-create", rawBody); + if (enforcement.rejection) return enforcement.rejection; + const enforced = enforcement.enforced; + + const parsedBody = createAutomationBodySchema.safeParse(rawBody); + if (!parsedBody.success) { + return error(formatAutomationRequestError(parsedBody.error, rawBody), 400); + } + const body: CreateAutomationBody = parsedBody.data; + + // Validate required fields + if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { + return error("name is required", 400); + } + if (body.name.length > MAX_NAME_LENGTH) { + return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); + } + if ( + !body.instructions || + typeof body.instructions !== "string" || + body.instructions.trim().length === 0 + ) { + return error("instructions is required", 400); + } + if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { + return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); + } + + const selection = getRepositorySelection(body); + const requestedRepositories = selection.kind === "replace" ? selection.repositories : []; + + // Validate trigger type + const triggerType: AutomationTriggerType = body.triggerType || "schedule"; + const validTriggerTypes: AutomationTriggerType[] = [ + "schedule", + "sentry", + "webhook", + "github_event", + "linear_event", + "slack_event", + ]; + if (!validTriggerTypes.includes(triggerType)) { + return error(`triggerType must be one of: ${validTriggerTypes.join(", ")}`, 400); + } + let requestedEnvironmentIds: string[]; + try { + const environmentSelection = getEnvironmentSelection(body); + requestedEnvironmentIds = + environmentSelection.kind === "replace" ? environmentSelection.environmentIds : []; + validateTargetCounts(triggerType, requestedRepositories.length, requestedEnvironmentIds.length); + } catch (e) { + if (e instanceof TargetSelectionError) return error(e.message, 400); + throw e; + } + if (ctx.principal?.kind === "user") { + const targetAuthorizationError = requireTargetPermissions(ctx, [ + ...(requestedRepositories.length > 0 ? (["repositories.use"] as const) : []), + ...(requestedEnvironmentIds.length > 0 ? (["environments.use"] as const) : []), + ]); + if (targetAuthorizationError) return targetAuthorizationError; + } + try { + await resolveEnvironmentSelection(ctx.db, requestedEnvironmentIds); + } catch (e) { + if (e instanceof TargetSelectionError) return error(e.message, 400); + throw e; + } + + const isSchedule = triggerType === "schedule"; + + // Schedule-specific validation + if (isSchedule) { + if (!body.scheduleCron || !isValidCron(body.scheduleCron)) { + return error("scheduleCron must be a valid 5-field cron expression", 400); + } + const interval = cronIntervalMinutes(body.scheduleCron); + if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { + return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); + } + if (!body.scheduleTz || !isValidTimezone(body.scheduleTz)) { + return error("scheduleTz must be a valid IANA timezone", 400); + } + } else { + // Reject schedule fields for non-schedule types + if (body.scheduleCron || body.scheduleTz) { + return error("scheduleCron and scheduleTz are only valid for schedule triggers", 400); + } + } + + const eventTypeError = getTriggerEventTypeError(triggerType, body.eventType); + if (eventTypeError) return error(eventTypeError, 400); + + // Validate conditions + if (body.triggerConfig) { + const conditionErrors = getTriggerConditionErrors( + triggerType, + body.triggerConfig, + body.eventType + ); + if (conditionErrors.length > 0) { + return error(conditionErrors.map(({ message }) => message).join("; "), 400); + } + } + + // Slack triggers require explicit scoping (at least one watched channel). + if (triggerType === "slack_event") { + const slackError = validateSlackTriggerConfig(body.triggerConfig); + if (slackError) return error(slackError, 400); + } + + // Validate model + const model = getValidModelOrDefault(body.model); + const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); + if (body.reasoningEffort !== undefined && body.reasoningEffort !== null && !reasoningEffort) { + return error("Invalid reasoning effort for selected model", 400); + } + + const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx); + + let providerSelections: ModelProviderSelections; + try { + providerSelections = await parseAndValidateAutomationProviderSelections( + ctx.db, + body.providerSelections ?? {} + ); + } catch (e) { + if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); + if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); + throw e; + } + + // Compute next run (only for schedule triggers) + const nextRunAt = isSchedule + ? nextCronOccurrence(body.scheduleCron!, body.scheduleTz!).getTime() + : null; + + const id = generateId(); + const now = Date.now(); + + // Generate auth data for trigger types that need it + let webhookApiKey: string | undefined; + let triggerAuthData: string | null = null; + if (triggerType === "webhook") { + webhookApiKey = generateWebhookApiKey(); + triggerAuthData = await hashApiKey(webhookApiKey); + } else if (triggerType === "sentry") { + const sentrySecret = body.sentryClientSecret; + if (!sentrySecret || typeof sentrySecret !== "string" || sentrySecret.trim().length === 0) { + return error("sentryClientSecret is required for sentry triggers", 400); + } + if (!env.REPO_SECRETS_ENCRYPTION_KEY) { + return error("Encryption key not configured", 503); + } + triggerAuthData = await encryptSentrySecret(sentrySecret, env.REPO_SECRETS_ENCRYPTION_KEY); + } + + // The scheduler replays user_id as session identity at fire time, so the + // handler may consume only the canonical subject admitted before RBAC. + const resolution = requireAdmittedCanonicalUserId(ctx, enforced); + if (resolution instanceof Response) return resolution; + const resolvedUserId = resolution; + + const db: SqlDatabase = ctx.db; + const store = new AutomationStore(db); + const providerAuthStore = new AutomationModelProviderAuthStore(db); + const row: AutomationRow = { + id, + name: body.name.trim(), + instructions: body.instructions, + trigger_type: triggerType, + schedule_cron: body.scheduleCron ?? null, + schedule_tz: body.scheduleTz ?? "UTC", + model, + reasoning_effort: reasoningEffort, + enabled: 1, + next_run_at: nextRunAt, + consecutive_failures: 0, + created_by: enforced.participantUserId, + user_id: resolvedUserId, + created_at: now, + updated_at: now, + deleted_at: null, + event_type: body.eventType ?? null, + trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, + trigger_auth_data: triggerAuthData, + }; + + // Persist the automation, its repository selection, and (for slack_event) + // its watched-channel index in a single atomic write, so none of the three + // can drift apart on a partial failure. The batch composes the single-table + // stores' prepared statements. + const createStatements = [ + store.bindAutomationInsert(row), + ...store.bindRepositoryInserts(id, newRepositories, now), + ...store.bindEnvironmentInserts(id, requestedEnvironmentIds, now), + ...providerAuthStore.bindInserts(id, providerSelections, now), + ]; + if (triggerType === "slack_event") { + const slackStore = new SlackChannelStore(db); + createStatements.push( + ...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig)) + ); + } + await ctx.db.batch(createStatements); + + const automation = await hydrateAutomation(db, (await store.getById(id))!); + + logger.info("automation.created", { + event: "automation.created", + automation_id: id, + repo: newRepositories.map((repo) => `${repo.repo_owner}/${repo.repo_name}`).join(",") || null, + environments: requestedEnvironmentIds.join(",") || null, + trigger_type: triggerType, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + const workerUrl = env.WORKER_URL || ""; + const result: { + automation: typeof automation; + warning?: string; + webhookApiKey?: string; + webhookUrl?: string; + sentryWebhookUrl?: string; + } = { automation }; + + if (webhookApiKey) { + result.webhookApiKey = webhookApiKey; + result.webhookUrl = `${workerUrl}/webhooks/automation/${id}`; + } + + if (triggerType === "sentry") { + result.sentryWebhookUrl = `${workerUrl}/webhooks/sentry/${id}`; + } + + if (nextRunAt && nextRunAt - now > FAR_FUTURE_THRESHOLD_MS) { + result.warning = "Next scheduled run is more than 31 days away"; + } + + return json(result, 201); +} + +async function handleGetAutomation( + _request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const store = new AutomationStore(ctx.db); + const row = await store.getById(id); + if (!row) return error("Automation not found", 404); + + return json({ automation: await hydrateAutomation(ctx.db, row) }); +} + +async function handleUpdateAutomation( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const db: SqlDatabase = ctx.db; + const store = new AutomationStore(db); + const providerAuthStore = new AutomationModelProviderAuthStore(db); + const admission = admittedAutomation(ctx); + const { automation: existing } = admission; + + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; + const parsedBody = updateAutomationRequestSchema.safeParse(rawBody); + if (!parsedBody.success) { + return error(formatAutomationRequestError(parsedBody.error, rawBody), 400); + } + const body = parsedBody.data; + + if (body.triggerConfig !== undefined && existing.trigger_type === "schedule") { + return error("Cannot set triggerConfig on schedule automations", 400); + } + + let replacementProviderSelections: ModelProviderSelections | null = null; + if (body.providerSelections !== undefined) { + try { + replacementProviderSelections = await parseAndValidateAutomationProviderSelections( + ctx.db, + body.providerSelections + ); + } catch (e) { + if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); + if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); + throw e; + } + } + + // Validate fields if provided + if (body.name !== undefined) { + if (typeof body.name !== "string" || body.name.trim().length === 0) { + return error("name cannot be empty", 400); + } + if (body.name.length > MAX_NAME_LENGTH) { + return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); + } + } + + if (body.instructions !== undefined) { + if (typeof body.instructions !== "string" || body.instructions.trim().length === 0) { + return error("instructions cannot be empty", 400); + } + if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { + return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); + } + } + + if (body.scheduleCron !== undefined) { + if (!isValidCron(body.scheduleCron)) { + return error("scheduleCron must be a valid 5-field cron expression", 400); + } + const interval = cronIntervalMinutes(body.scheduleCron); + if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { + return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); + } + } + + if (body.scheduleTz !== undefined && !isValidTimezone(body.scheduleTz)) { + return error("scheduleTz must be a valid IANA timezone", 400); + } + + if (body.model !== undefined && !isValidModel(body.model)) { + return error("Invalid model", 400); + } + + const nextModel = body.model !== undefined ? getValidModelOrDefault(body.model) : existing.model; + const requestedReasoningEffort = body.reasoningEffort; + const resolvedReasoningEffort = + requestedReasoningEffort !== undefined + ? resolveReasoningEffort(nextModel, requestedReasoningEffort) + : body.model !== undefined && existing.reasoning_effort !== null + ? resolveReasoningEffort(nextModel, existing.reasoning_effort) + : existing.reasoning_effort; + + if ( + requestedReasoningEffort !== undefined && + requestedReasoningEffort !== null && + resolvedReasoningEffort === null + ) { + return error("Invalid reasoning effort for selected model", 400); + } + + // Build update fields + const updateFields: Record = {}; + if (body.name !== undefined) updateFields.name = body.name.trim(); + if (body.instructions !== undefined) updateFields.instructions = body.instructions; + if (body.scheduleCron !== undefined) updateFields.schedule_cron = body.scheduleCron; + if (body.scheduleTz !== undefined) updateFields.schedule_tz = body.scheduleTz; + if (body.model !== undefined) updateFields.model = nextModel; + if (body.reasoningEffort !== undefined || body.model !== undefined) { + updateFields.reasoning_effort = resolvedReasoningEffort; + } + + // Repository-set edits are UNCONDITIONAL — no cardinality freeze and no + // active-invocation guard. In-flight invocations already materialized their + // children from their firing-time snapshot, so an edit cannot corrupt them; + // it simply applies from the next invocation. + const selection = getRepositorySelection(body); + const environmentSelection = getEnvironmentSelection(body); + const requiredTargetPermissions: PermissionId[] = [ + ...(selection.kind === "replace" && selection.repositories.length > 0 + ? (["repositories.use"] as const) + : []), + ...(environmentSelection.kind === "replace" && environmentSelection.environmentIds.length > 0 + ? (["environments.use"] as const) + : []), + ]; + if (requiredTargetPermissions.length > 0) { + const targetAuthorizationError = requireTargetPermissions(ctx, requiredTargetPermissions); + if (targetAuthorizationError) return targetAuthorizationError; + } + + // The count rules span both selections, so when EITHER is replaced they are + // validated against the automation's FINAL state (the replacement plus the + // other side's existing rows). Edits that touch neither selection skip this + // — count rules stay write-time so a stored selection predating a rule can + // never brick unrelated edits. + let replacementRepositories: AutomationRepositoryInsert[] | null = null; + const replacementEnvironmentIds: string[] | null = + environmentSelection.kind === "replace" ? environmentSelection.environmentIds : null; + if (selection.kind === "replace" || replacementEnvironmentIds !== null) { + try { + const finalRepositoryCount = + selection.kind === "replace" + ? selection.repositories.length + : (await store.getRepositoriesForAutomation(id)).length; + const finalEnvironmentCount = + replacementEnvironmentIds !== null + ? replacementEnvironmentIds.length + : (await store.getEnvironmentsForAutomation(id)).length; + validateTargetCounts( + existing.trigger_type as AutomationTriggerType, + finalRepositoryCount, + finalEnvironmentCount + ); + if (replacementEnvironmentIds !== null) { + await resolveEnvironmentSelection(ctx.db, replacementEnvironmentIds); + } + } catch (e) { + if (e instanceof TargetSelectionError) return error(e.message, 400); + throw e; + } + if (selection.kind === "replace") { + replacementRepositories = await resolveRepositorySelection(env, selection.repositories, ctx); + } + } + + // Update event type — only for non-schedule types + if (body.eventType !== undefined) { + if (existing.trigger_type === "schedule") { + return error("Cannot set eventType on schedule automations", 400); + } + updateFields.event_type = body.eventType; + } + + const effectiveEventType = + body.eventType !== undefined ? body.eventType : (existing.event_type ?? undefined); + const eventTypeError = getTriggerEventTypeError( + existing.trigger_type as AutomationTriggerType, + effectiveEventType + ); + if (eventTypeError) return error(eventTypeError, 400); + + let triggerConfigToValidate = body.triggerConfig; + if ( + body.eventType !== undefined && + triggerConfigToValidate === undefined && + existing.trigger_config + ) { + // This column was written through parseTriggerConfig, so a failure here is a + // corrupt row, not user input — parseTriggerConfig's per-condition messages + // would have no one to help. + try { + triggerConfigToValidate = triggerConfigSchema.parse(JSON.parse(existing.trigger_config)); + } catch { + return error("Stored triggerConfig is invalid", 500); + } + } + + // A slack_event's trigger_config holds its required channel scope. Clearing it + // would leave the automation enabled but untriggerable. + if (body.triggerConfig === null && existing.trigger_type === "slack_event") { + return error( + "Cannot clear triggerConfig on slack_event automations; pause or delete instead", + 400 + ); + } + if (body.triggerConfig && existing.trigger_type === "slack_event") { + const slackError = validateSlackTriggerConfig(body.triggerConfig); + if (slackError) return error(slackError, 400); + } + + if (triggerConfigToValidate) { + let conditionErrors = getTriggerConditionErrors( + existing.trigger_type as AutomationTriggerType, + triggerConfigToValidate, + effectiveEventType + ); + + // Existing source-wide GitHub conditions predate event-scoped validation. + // Preserve an unchanged condition on unrelated edits, but validate strictly + // when its value or the selected event changes. + const eventTypeChanged = body.eventType !== undefined && body.eventType !== existing.event_type; + if (existing.trigger_type === "github_event" && !eventTypeChanged && existing.trigger_config) { + try { + const parsedExisting = triggerConfigSchema.safeParse(JSON.parse(existing.trigger_config)); + if (parsedExisting.success) { + const consumedIndexes = new Set(); + conditionErrors = conditionErrors.filter(({ code, condition }) => { + if (code !== "event_incompatible") return true; + return !consumeCondition(parsedExisting.data, condition, consumedIndexes); + }); + } + } catch { + // A valid replacement should be able to repair malformed stored JSON. + } + } + + if (conditionErrors.length > 0) { + return error(conditionErrors.map(({ message }) => message).join("; "), 400); + } + } + + // trigger_config is a single source-interpreted JSON blob (the conditions), + // so a PUT replaces it wholesale (null clears it). The caller owns the full + // blob; the web form always re-submits the conditions within triggerConfig. + if (body.triggerConfig === null) { + updateFields.trigger_config = null; + } else if (body.triggerConfig !== undefined) { + updateFields.trigger_config = JSON.stringify(body.triggerConfig); + } + + // Recompute next_run_at if schedule changed (only for schedule types) + if ( + existing.trigger_type === "schedule" && + (body.scheduleCron !== undefined || body.scheduleTz !== undefined) + ) { + const cron = body.scheduleCron ?? existing.schedule_cron; + const tz = body.scheduleTz ?? existing.schedule_tz; + if (!cron) { + return error("Cannot compute schedule: no cron expression", 400); + } + updateFields.next_run_at = nextCronOccurrence(cron, tz).getTime(); + } + + // Apply the field update, the repository-selection replacement (which + // carries the transitional scalar-mirror dual-write), and any slack + // watched-channel re-sync in ONE atomic batch so none of them can drift + // apart on a partial failure. Tolerates a null update statement (e.g. a + // repositories-only edit). + const resyncSlackChannels = + existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; + const statements: SqlStatement[] = []; + const updateStatement = store.bindAutomationUpdate(id, updateFields); + if (updateStatement) statements.push(updateStatement); + if (replacementRepositories !== null) { + statements.push(...store.bindReplaceRepositories(id, replacementRepositories, Date.now())); + } + if (replacementEnvironmentIds !== null) { + statements.push(...store.bindReplaceEnvironments(id, replacementEnvironmentIds, Date.now())); + } + if (replacementProviderSelections !== null) { + statements.push( + ...providerAuthStore.bindReplace(id, replacementProviderSelections, Date.now()) + ); + } + if (resyncSlackChannels) { + const slackStore = new SlackChannelStore(db); + statements.push( + ...slackStore.bindChannelStatements(id, extractSlackChannels(body.triggerConfig)) + ); + } + if (statements.length > 0) { + await ctx.db.batch(statements); + } + const updated = await store.getById(id); + if (!updated) return error("Automation not found", 404); + + logger.info("automation.updated", { + event: "automation.updated", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return json({ automation: await hydrateAutomation(db, updated) }); +} + +async function handleDeleteAutomation( + _request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const store = new AutomationStore(ctx.db); + admittedAutomation(ctx); + const result = await ctx.db.batch([store.bindSoftDelete(id)]); + const deleted = result[0]?.meta.changes === 1; + if (!deleted) return error("Automation not found", 404); + + logger.info("automation.deleted", { + event: "automation.deleted", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return json({ status: "deleted", automationId: id }); +} + +export const automationCrudRoutes = new Hono(); + +automationCrudRoutes.post( + "/automations", + admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requirePermission("automations.create"), + }), + (c) => handleCreateAutomation(c.var.admitted.request, c.env, c.var.admitted.ctx) +); +automationCrudRoutes.get("/automations/:id", AUTOMATIONS_READ, (c) => + dispatch(c, handleGetAutomation) +); +automationCrudRoutes.put("/automations/:id", AUTOMATION_MANAGE, (c) => + dispatch(c, handleUpdateAutomation) +); +automationCrudRoutes.delete("/automations/:id", AUTOMATION_MANAGE, (c) => + dispatch(c, handleDeleteAutomation) +); diff --git a/packages/control-plane/src/routes/automation-keys.test.ts b/packages/control-plane/src/routes/automation-keys.test.ts new file mode 100644 index 000000000..01967588c --- /dev/null +++ b/packages/control-plane/src/routes/automation-keys.test.ts @@ -0,0 +1,106 @@ +/** + * Unit tests for the automation key regeneration route. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { createTestRequestHandler } from "../router.test-support"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + mockBatch, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation key regeneration route", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + }); + + describe("POST /automations/:id/regenerate-key", () => { + it.each([123, " "])( + "rejects malformed sentry secret payloads before persistence", + async (sentryClientSecret) => { + mockStore.getById.mockResolvedValue({ ...sampleRow, trigger_type: "sentry" }); + + const res = await callRoute("POST", "/automations/auto-1/regenerate-key", { + body: { sentryClientSecret }, + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "sentryClientSecret is required" }); + expect(mockStore.update).not.toHaveBeenCalled(); + } + ); + + it("returns 404 when the key update affects no current automation", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, trigger_type: "webhook" }); + mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); + + const res = await callRoute("POST", "/automations/auto-1/regenerate-key"); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ error: "Automation not found" }); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-keys.ts b/packages/control-plane/src/routes/automation-keys.ts new file mode 100644 index 000000000..9935b10c8 --- /dev/null +++ b/packages/control-plane/src/routes/automation-keys.ts @@ -0,0 +1,102 @@ +/** + * Automation webhook key and Sentry secret regeneration route. + */ + +import { sentryClientSecretSchema } from "@open-inspect/shared/types/automations"; +import { AutomationStore } from "../db/automation-store"; +import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key"; +import { Hono } from "hono"; +import { dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { type RequestContext, json, error, parseJsonBody } from "./shared"; +import type { Env } from "../types"; +import { z } from "zod"; +import { createLogger } from "../logger"; +import { AUTOMATION_MANAGE, admittedAutomation } from "./automation-shared"; + +const logger = createLogger("router:automations"); + +const regenerateSentrySecretBodySchema = z.object({ + sentryClientSecret: sentryClientSecretSchema, +}); + +async function handleRegenerateKey( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const store = new AutomationStore(ctx.db); + const { automation } = admittedAutomation(ctx); + + const workerUrl = env.WORKER_URL || ""; + + if (automation.trigger_type === "sentry") { + // Sentry: user provides a new client secret + const rawBody = await parseJsonBody(request); + if (rawBody instanceof Response) return rawBody; + const parsedBody = regenerateSentrySecretBodySchema.safeParse(rawBody); + if (!parsedBody.success) { + return error("sentryClientSecret is required", 400); + } + if (!env.REPO_SECRETS_ENCRYPTION_KEY) { + return error("Encryption key not configured", 503); + } + const encrypted = await encryptSentrySecret( + parsedBody.data.sentryClientSecret, + env.REPO_SECRETS_ENCRYPTION_KEY + ); + const statement = store.bindAutomationUpdate(id, { + trigger_auth_data: encrypted, + } as Record); + if (!statement) return error("Automation not found", 404); + const result = await ctx.db.batch([statement]); + if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404); + + logger.info("automation.secret_updated", { + event: "automation.secret_updated", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return json({ + sentryWebhookUrl: `${workerUrl}/webhooks/sentry/${id}`, + }); + } + + if (automation.trigger_type !== "webhook") { + return error("Only webhook and sentry automations support key regeneration", 400); + } + + // Webhook: generate a new API key + const apiKey = generateWebhookApiKey(); + const hash = await hashApiKey(apiKey); + + const statement = store.bindAutomationUpdate(id, { + trigger_auth_data: hash, + } as Record); + if (!statement) return error("Automation not found", 404); + const result = await ctx.db.batch([statement]); + if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404); + + logger.info("automation.key_regenerated", { + event: "automation.key_regenerated", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return json({ + webhookApiKey: apiKey, + webhookUrl: `${workerUrl}/webhooks/automation/${id}`, + }); +} + +export const automationKeyRoutes = new Hono(); + +automationKeyRoutes.post("/automations/:id/regenerate-key", AUTOMATION_MANAGE, (c) => + dispatch(c, handleRegenerateKey) +); diff --git a/packages/control-plane/src/routes/automation-lifecycle.test.ts b/packages/control-plane/src/routes/automation-lifecycle.test.ts new file mode 100644 index 000000000..cf13bcdd3 --- /dev/null +++ b/packages/control-plane/src/routes/automation-lifecycle.test.ts @@ -0,0 +1,220 @@ +/** + * Unit tests for the automation lifecycle routes. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { + AutomationExecutionUnauthorizedError, + AutomationTriggerBlockedError, +} from "../scheduler/scheduler"; +import { createTestRequestHandler } from "../router.test-support"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + mockBatch, + mockSchedulerTrigger, + mockResolveGitHubCredentialAuthority, + mockResolveGitHubEnrichmentForRequest, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +vi.mock("../source-control/github-credential-authority", () => ({ + resolveGitHubCredentialAuthority: (...args: unknown[]) => + mockResolveGitHubCredentialAuthority(...args), +})); + +vi.mock("../session/identity", () => ({ + resolveGitHubEnrichmentForRequest: (...args: unknown[]) => + mockResolveGitHubEnrichmentForRequest(...args), +})); + +vi.mock("../scheduler/scheduler", () => ({ + AutomationExecutionUnauthorizedError: class AutomationExecutionUnauthorizedError extends Error { + constructor() { + super("Automation owner is not authorized to execute"); + this.name = "AutomationExecutionUnauthorizedError"; + } + }, + AutomationTriggerBlockedError: class AutomationTriggerBlockedError extends Error { + constructor() { + super("An active run already exists"); + this.name = "AutomationTriggerBlockedError"; + } + }, + Scheduler: vi.fn().mockImplementation(function () { + return { trigger: (...args: unknown[]) => mockSchedulerTrigger(...args) }; + }), +})); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation lifecycle routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + }); + + describe("POST /automations/:id/pause", () => { + it("pauses automation", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 }); + + const res = await callRoute("POST", "/automations/auto-1/pause"); + expect(res.status).toBe(200); + expect(mockStore.bindPause).toHaveBeenCalledWith("auto-1"); + }); + + it("returns 404 when not found", async () => { + mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); + + const res = await callRoute("POST", "/automations/missing/pause"); + expect(res.status).toBe(404); + }); + }); + + describe("POST /automations/:id/resume", () => { + it("resumes automation and recomputes next_run_at", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 }); + + const res = await callRoute("POST", "/automations/auto-1/resume"); + expect(res.status).toBe(200); + expect(mockStore.bindResume).toHaveBeenCalledWith("auto-1", expect.any(Number)); + }); + + it("returns 404 when not found", async () => { + mockStore.getById.mockResolvedValue(null); + + const res = await callRoute("POST", "/automations/missing/resume"); + expect(res.status).toBe(404); + }); + + it("returns 400 when automation has no cron schedule", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + schedule_cron: null, + }); + + const res = await callRoute("POST", "/automations/auto-1/resume"); + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("no cron schedule"); + }); + }); + + describe("POST /automations/:id/trigger", () => { + it("triggers automation via the scheduler", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockStore.getActiveRunForAutomation.mockResolvedValue(null); + const enrichment = { + scmUserId: "123", + scmLogin: "requester", + accessTokenEncrypted: "encrypted-access", + }; + mockResolveGitHubEnrichmentForRequest.mockResolvedValue(enrichment); + + const res = await callRoute("POST", "/automations/auto-1/trigger"); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ + invocationId: "inv-1", + runs: [{ id: "run-1" }], + }); + expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", enrichment); + }); + + it("returns 404 when automation not found", async () => { + mockStore.getById.mockResolvedValue(null); + + const res = await callRoute("POST", "/automations/missing/trigger"); + expect(res.status).toBe(404); + }); + + it("returns 409 when the scheduler reports an active run", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + mockSchedulerTrigger.mockRejectedValue(new AutomationTriggerBlockedError()); + + const res = await callRoute("POST", "/automations/auto-1/trigger"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "A run is already active for this automation", + }); + }); + + it("returns 403 when the owner is unauthorized to execute", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockSchedulerTrigger.mockRejectedValue(new AutomationExecutionUnauthorizedError()); + + const res = await callRoute("POST", "/automations/auto-1/trigger"); + + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ error: "Execution authorization required" }); + }); + + it("returns 500 when the scheduler cannot launch the automation", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockSchedulerTrigger.mockRejectedValue(new Error("launch failed")); + + const res = await callRoute("POST", "/automations/auto-1/trigger"); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "Failed to trigger automation" }); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-lifecycle.ts b/packages/control-plane/src/routes/automation-lifecycle.ts new file mode 100644 index 000000000..c85303677 --- /dev/null +++ b/packages/control-plane/src/routes/automation-lifecycle.ts @@ -0,0 +1,178 @@ +/** + * Automation pause, resume, and manual trigger routes. + */ + +import { nextCronOccurrence } from "@open-inspect/shared/cron"; +import { AutomationStore } from "../db/automation-store"; +import { UserStore } from "../db/user-store"; +import { + AutomationExecutionUnauthorizedError, + AutomationTriggerBlockedError, + Scheduler, +} from "../scheduler/scheduler"; +import { hydrateAutomation } from "../automation/hydrate"; +import { Hono } from "hono"; +import { admit, dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { + type RequestContext, + GITHUB_USER_OR_SERVICE_ROUTE, + json, + error, + requireAutomation, +} from "./shared"; +import type { Env } from "../types"; +import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; +import { resolveGitHubEnrichmentForRequest } from "../session/identity"; +import { createLogger } from "../logger"; +import { AUTOMATION_MANAGE, admittedAutomation } from "./automation-shared"; + +const logger = createLogger("router:automations"); + +async function handlePauseAutomation( + _request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const store = new AutomationStore(ctx.db); + admittedAutomation(ctx); + const result = await ctx.db.batch([store.bindPause(id)]); + const paused = result[0]?.meta.changes === 1; + if (!paused) return error("Automation not found", 404); + + logger.info("automation.paused", { + event: "automation.paused", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + const row = await store.getById(id); + return json({ + automation: row ? await hydrateAutomation(ctx.db, row) : null, + }); +} + +async function handleResumeAutomation( + _request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + const store = new AutomationStore(ctx.db); + const { automation: existing } = admittedAutomation(ctx); + + // For schedule automations, compute the next run time. + // For event-driven automations, resume with null next_run_at. + let nextRunAt: number | null; + if (existing.trigger_type === "schedule") { + if (!existing.schedule_cron) { + return error("Cannot resume: automation has no cron schedule", 400); + } + nextRunAt = nextCronOccurrence(existing.schedule_cron, existing.schedule_tz).getTime(); + } else { + nextRunAt = null; + } + + const result = await ctx.db.batch([store.bindResume(id, nextRunAt)]); + const resumed = result[0]?.meta.changes === 1; + if (!resumed) return error("Automation not found", 404); + + logger.info("automation.resumed", { + event: "automation.resumed", + automation_id: id, + next_run_at: nextRunAt, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + const row = await store.getById(id); + return json({ + automation: row ? await hydrateAutomation(ctx.db, row) : null, + }); +} + +async function handleTriggerAutomation( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const id = params.id; + + admittedAutomation(ctx); + const requesterUserId = ctx.authorization?.userId; + if (!requesterUserId) return error("Authorization unavailable", 503); + + let requesterEnrichment; + try { + requesterEnrichment = await resolveGitHubEnrichmentForRequest( + env, + ctx.db, + new UserStore(ctx.db), + requesterUserId, + await resolveGitHubCredentialAuthority(ctx, request.headers) + ); + } catch (enrichmentError) { + logger.warn("Failed to enrich manual automation trigger with GitHub identity", { + error: + enrichmentError instanceof Error ? enrichmentError : new Error(String(enrichmentError)), + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + } + + // The scheduler performs the authoritative D1-backed concurrency check. + let triggerResult; + try { + triggerResult = await new Scheduler(ctx.db, env, ctx.executionCtx).trigger( + id, + requesterUserId, + requesterEnrichment ?? undefined + ); + } catch (triggerError) { + logger.error("automation.trigger_failed", { + event: "automation.trigger_failed", + automation_id: id, + error: triggerError instanceof Error ? triggerError : new Error(String(triggerError)), + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + if (triggerError instanceof AutomationTriggerBlockedError) { + return error("A run is already active for this automation", 409); + } + if (triggerError instanceof AutomationExecutionUnauthorizedError) { + return json({ error: "Execution authorization required" }, 403); + } + return error("Failed to trigger automation", 500); + } + + logger.info("automation.triggered", { + event: "automation.triggered", + automation_id: id, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + + return json({ invocationId: triggerResult.invocationId, runs: triggerResult.runs }, 201); +} + +export const automationLifecycleRoutes = new Hono(); + +automationLifecycleRoutes.post("/automations/:id/pause", AUTOMATION_MANAGE, (c) => + dispatch(c, handlePauseAutomation) +); +automationLifecycleRoutes.post("/automations/:id/resume", AUTOMATION_MANAGE, (c) => + dispatch(c, handleResumeAutomation) +); +automationLifecycleRoutes.post( + "/automations/:id/trigger", + admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, authorization: requireAutomation("trigger") }), + (c) => dispatch(c, handleTriggerAutomation) +); diff --git a/packages/control-plane/src/routes/automation-list.test.ts b/packages/control-plane/src/routes/automation-list.test.ts new file mode 100644 index 000000000..60d8e34ba --- /dev/null +++ b/packages/control-plane/src/routes/automation-list.test.ts @@ -0,0 +1,167 @@ +/** + * Unit tests for the automation listing routes. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { createTestRequestHandler } from "../router.test-support"; +import { MAX_NAME_LENGTH } from "./automation-validation"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation listing routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + }); + + describe("GET /automations (list)", () => { + it("returns the first page with default pagination", async () => { + mockStore.list.mockResolvedValue({ + automations: [sampleRow], + hasMore: false, + nextCursor: null, + }); + + const res = await callRoute("GET", "/automations"); + expect(res.status).toBe(200); + + const body = await res.json<{ + automations: unknown[]; + hasMore: boolean; + nextCursor: string | null; + }>(); + expect(body.automations).toHaveLength(1); + expect(body.hasMore).toBe(false); + expect(body.nextCursor).toBeNull(); + expect(mockStore.list).toHaveBeenCalledWith({ limit: 25, cursor: null }); + expect(mockStore.listRecentExecutionsForAutomationIds).toHaveBeenCalledWith(["auto-1"], 10); + expect(body.automations[0]).toMatchObject({ recentExecutions: [] }); + }); + + it.each<{ query: Record; error: string }>([ + { query: { limit: "0" }, error: "Invalid limit" }, + { query: { limit: "abc" }, error: "Invalid limit" }, + { query: { limit: "101" }, error: "Invalid limit" }, + { query: { limit: ["5", "6"] }, error: "Invalid limit" }, + { query: { cursor: "not-a-cursor" }, error: "Invalid cursor" }, + { query: { search: "x".repeat(MAX_NAME_LENGTH + 1) }, error: "Search is too long" }, + ])("rejects list query $query without listing", async ({ query, error }) => { + const res = await callRoute("GET", "/automations", { query }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error }); + expect(mockStore.list).not.toHaveBeenCalled(); + }); + + it("passes name search and pagination params to the store", async () => { + mockStore.list.mockResolvedValue({ automations: [], hasMore: false, nextCursor: null }); + + await callRoute("GET", "/automations", { + query: { search: " Daily sync ", limit: "10", cursor: "123:auto-9" }, + }); + + expect(mockStore.list).toHaveBeenCalledWith({ + nameSearch: "Daily sync", + limit: 10, + cursor: { createdAt: 123, id: "auto-9" }, + }); + }); + + it("preserves explicit repository filters", async () => { + mockStore.list.mockResolvedValue({ automations: [], hasMore: false, nextCursor: null }); + + await callRoute("GET", "/automations", { + query: { repoOwner: "acme", repoName: "web-app" }, + }); + + expect(mockStore.list).toHaveBeenCalledWith({ + limit: 25, + cursor: null, + repoOwner: "acme", + repoName: "web-app", + }); + }); + + it.each([ + [{ limit: "0" }, "limit"], + [{ limit: "101" }, "limit"], + [{ limit: "ten" }, "limit"], + [{ limit: "1e1" }, "limit"], + [{ limit: " 10 " }, "limit"], + [{ limit: ["10", "20"] }, "limit"], + [{ cursor: "not-a-cursor" }, "cursor"], + [{ search: "a".repeat(201) }, "Search"], + ])("rejects invalid pagination params", async (query, expectedField) => { + const response = await callRoute("GET", "/automations", { query }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining(expectedField), + }); + expect(mockStore.list).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-list.ts b/packages/control-plane/src/routes/automation-list.ts new file mode 100644 index 000000000..3174fa91c --- /dev/null +++ b/packages/control-plane/src/routes/automation-list.ts @@ -0,0 +1,104 @@ +/** + * Automation listing route. + */ + +import { AutomationStore, toAutomation } from "../db/automation-store"; +import { + encodeAutomationListCursor, + parseAutomationListCursor, +} from "../db/automation-list-cursor"; +import { AutomationModelProviderAuthStore } from "../db/automation-model-provider-auth"; +import { Hono } from "hono"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { type RequestContext, json } from "./shared"; +import type { Env } from "../types"; +import { z } from "zod"; +import { AUTOMATIONS_READ } from "./automation-shared"; +import { parseQuery } from "./query"; +import { MAX_NAME_LENGTH } from "./automation-validation"; + +const RECENT_EXECUTION_COUNT = 10; + +const DEFAULT_AUTOMATION_LIST_PAGE_SIZE = 25; + +const MAX_AUTOMATION_LIST_PAGE_SIZE = 100; + +const automationListLimitSchema = z + .string() + .regex(/^\d+$/, { message: "Invalid limit" }) + .transform(Number) + .refine((limit) => limit >= 1 && limit <= MAX_AUTOMATION_LIST_PAGE_SIZE, { + message: "Invalid limit", + }); + +const automationListQuerySchema = z.object({ + limit: automationListLimitSchema + .optional() + .transform((limit) => limit ?? DEFAULT_AUTOMATION_LIST_PAGE_SIZE), + cursor: z + .string() + .optional() + .transform((raw, context) => { + const parsed = parseAutomationListCursor(raw ?? null); + if (!parsed.ok) { + context.addIssue({ code: "custom", message: parsed.error }); + return z.NEVER; + } + return parsed.cursor; + }), + search: z.string().trim().max(MAX_NAME_LENGTH, { message: "Search is too long" }).optional(), + repoOwner: z.string().optional(), + repoName: z.string().optional(), +}); + +async function handleListAutomations( + request: Request, + env: Env, + ctx: RequestContext +): Promise { + const query = parseQuery(request, automationListQuerySchema); + if (query instanceof Response) return query; + + const store = new AutomationStore(ctx.db); + const providerAuthStore = new AutomationModelProviderAuthStore(ctx.db); + const result = await store.list({ + limit: query.limit, + cursor: query.cursor, + ...(query.search ? { nameSearch: query.search } : {}), + ...(query.repoOwner ? { repoOwner: query.repoOwner } : {}), + ...(query.repoName ? { repoName: query.repoName } : {}), + }); + const automationIds = result.automations.map((row) => row.id); + const [ + repositoriesByAutomation, + environmentsByAutomation, + providerAuthByAutomation, + recentExecutionsByAutomation, + ] = await Promise.all([ + store.getRepositoriesForAutomationIds(automationIds), + store.getEnvironmentsForAutomationIds(automationIds), + providerAuthStore.listForAutomationIds(automationIds), + store.listRecentExecutionsForAutomationIds(automationIds, RECENT_EXECUTION_COUNT), + ]); + + const automations = result.automations.map((row) => ({ + ...toAutomation( + row, + repositoriesByAutomation.get(row.id) ?? [], + environmentsByAutomation.get(row.id) ?? [], + providerAuthByAutomation.get(row.id) ?? [] + ), + recentExecutions: recentExecutionsByAutomation.get(row.id) ?? [], + })); + return json({ + automations, + hasMore: result.hasMore, + nextCursor: result.nextCursor ? encodeAutomationListCursor(result.nextCursor) : null, + }); +} + +export const automationListRoutes = new Hono(); + +automationListRoutes.get("/automations", AUTOMATIONS_READ, (c) => + handleListAutomations(c.var.admitted.request, c.env, c.var.admitted.ctx) +); diff --git a/packages/control-plane/src/routes/automation-runs.test.ts b/packages/control-plane/src/routes/automation-runs.test.ts new file mode 100644 index 000000000..fee6a2801 --- /dev/null +++ b/packages/control-plane/src/routes/automation-runs.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for the automation run routes. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { createTestRequestHandler } from "../router.test-support"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation run routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + }); + + describe("GET /automations/:id/invocations (list invocations)", () => { + it("returns invocations for automation", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockStore.listInvocations.mockResolvedValue({ + invocations: [{ id: "inv-1", status: "completed", runs: [{ id: "run-1" }] }], + total: 1, + }); + + const res = await callRoute("GET", "/automations/auto-1/invocations"); + expect(res.status).toBe(200); + + const body = await res.json<{ invocations: unknown[]; total: number }>(); + expect(body.invocations).toHaveLength(1); + expect(body.total).toBe(1); + }); + + it("returns 404 when automation not found", async () => { + mockStore.getById.mockResolvedValue(null); + + const res = await callRoute("GET", "/automations/missing/invocations"); + expect(res.status).toBe(404); + }); + + it("respects limit and offset params", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockStore.listInvocations.mockResolvedValue({ invocations: [], total: 0 }); + + await callRoute("GET", "/automations/auto-1/invocations", { + query: { limit: "5", offset: "10" }, + }); + + expect(mockStore.listInvocations).toHaveBeenCalledWith("auto-1", { + limit: 5, + offset: 10, + }); + }); + }); + + describe("GET /automations/:id/runs/:runId (get run)", () => { + it("returns a specific run", async () => { + mockStore.getRunById.mockResolvedValue({ id: "run-1", status: "completed" }); + + const res = await callRoute("GET", "/automations/auto-1/runs/run-1"); + expect(res.status).toBe(200); + + const body = await res.json<{ run: { id: string } }>(); + expect(body.run.id).toBe("run-1"); + }); + + it("returns 404 when run not found", async () => { + mockStore.getRunById.mockResolvedValue(null); + + const res = await callRoute("GET", "/automations/auto-1/runs/missing"); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-runs.ts b/packages/control-plane/src/routes/automation-runs.ts new file mode 100644 index 000000000..018c654c4 --- /dev/null +++ b/packages/control-plane/src/routes/automation-runs.ts @@ -0,0 +1,64 @@ +/** + * Automation invocation and run read routes. + */ + +import { AutomationStore, toAutomationRun } from "../db/automation-store"; +import { Hono } from "hono"; +import { dispatch } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { type RequestContext, json, error } from "./shared"; +import type { Env } from "../types"; +import { AUTOMATIONS_READ } from "./automation-shared"; + +function parseRunListParams(request: Request): { limit: number; offset: number } { + const url = new URL(request.url); + const limit = Math.max(1, Math.min(parseInt(url.searchParams.get("limit") || "20") || 20, 100)); + const offset = Math.max(0, parseInt(url.searchParams.get("offset") || "0") || 0); + return { limit, offset }; +} + +/** GET /automations/:id/invocations — one row per firing; `total` counts invocations. */ +async function handleListInvocations( + request: Request, + env: Env, + params: { id: string }, + ctx: RequestContext +): Promise { + const automationId = params.id; + + const store = new AutomationStore(ctx.db); + const automation = await store.getById(automationId); + if (!automation) return error("Automation not found", 404); + + const { limit, offset } = parseRunListParams(request); + const result = await store.listInvocations(automationId, { limit, offset }); + + return json({ + invocations: result.invocations, + total: result.total, + }); +} + +async function handleGetRun( + _request: Request, + env: Env, + params: { id: string; runId: string }, + ctx: RequestContext +): Promise { + const { id: automationId, runId } = params; + + const store = new AutomationStore(ctx.db); + const run = await store.getRunById(automationId, runId); + if (!run) return error("Run not found", 404); + + return json({ run: toAutomationRun(run) }); +} + +export const automationRunRoutes = new Hono(); + +automationRunRoutes.get("/automations/:id/invocations", AUTOMATIONS_READ, (c) => + dispatch(c, handleListInvocations) +); +automationRunRoutes.get("/automations/:id/runs/:runId", AUTOMATIONS_READ, (c) => + dispatch(c, handleGetRun) +); diff --git a/packages/control-plane/src/routes/automation-shared.ts b/packages/control-plane/src/routes/automation-shared.ts new file mode 100644 index 000000000..cb5e07f2a --- /dev/null +++ b/packages/control-plane/src/routes/automation-shared.ts @@ -0,0 +1,27 @@ +/** + * Admission shared by the automation route modules. + */ + +import { admit } from "../routing/admit"; +import { + type RequestContext, + GITHUB_USER_OR_SERVICE_ROUTE, + requireAutomation, + requirePermission, + type AutomationRouteAdmission, +} from "./shared"; + +export function admittedAutomation(ctx: RequestContext): AutomationRouteAdmission { + if (!ctx.automationAdmission) throw new Error("Missing automation route admission"); + return ctx.automationAdmission; +} + +export const AUTOMATIONS_READ = admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requirePermission("automations.read"), +}); + +export const AUTOMATION_MANAGE = admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requireAutomation("manage"), +}); diff --git a/packages/control-plane/src/routes/automation-slack-settings.ts b/packages/control-plane/src/routes/automation-slack-settings.ts new file mode 100644 index 000000000..d06c720ae --- /dev/null +++ b/packages/control-plane/src/routes/automation-slack-settings.ts @@ -0,0 +1,85 @@ +/** + * Slack integration settings read by the slack-bot and the automation form. + */ + +import { listChannels } from "@open-inspect/shared/slack"; +import { SlackChannelStore } from "../db/slack-channel-store"; +import { Hono } from "hono"; +import { admit } from "../routing/admit"; +import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { + type RequestContext, + GITHUB_USER_OR_SERVICE_ROUTE, + json, + requirePermission, +} from "./shared"; +import type { Env } from "../types"; +import { createLogger } from "../logger"; +import { AUTOMATIONS_READ } from "./automation-shared"; + +const logger = createLogger("router:automations"); + +/** + * GET /integration-settings/slack/watched-channels + * + * Returns the distinct set of Slack channel IDs referenced by enabled + * `slack_event` automations. The slack-bot polls this (cached) to pre-filter + * channel messages before normalizing and forwarding them — only messages in a + * watched channel are worth forwarding to the scheduler. + * + * Grouped under the `/integration-settings/slack` prefix the bot already uses + * for its runtime config (routing rules), even though the data is sourced from + * the automations store. Internal-auth gated by the router (non-public route). + */ +async function handleGetWatchedSlackChannels( + _request: Request, + env: Env, + ctx: RequestContext +): Promise { + const channels = await new SlackChannelStore(ctx.db).getWatchedSlackChannels(); + return json({ channels }); +} + +/** + * GET /integration-settings/slack/channels + * + * Lists the workspace's channels (public + private the bot can see) so the + * automation form can offer a channel picker instead of a raw channel ID. Sourced + * live from Slack via `conversations.list` using the bot token. + * + * Returns `{ channels }` on success, or `{ channels: [], error }` when the token + * is unset or Slack rejects the call (e.g. missing `channels:read`/`groups:read` + * scope) — the form then degrades to manual channel-ID entry. Internal-auth gated + * by the router (non-public route). + */ +async function handleGetSlackChannels( + request: Request, + env: Env, + _ctx: RequestContext +): Promise { + if (!env.SLACK_BOT_TOKEN) { + return json({ channels: [], error: "not_configured" }); + } + const result = await listChannels(env.SLACK_BOT_TOKEN, { signal: request.signal }); + if (!result.ok) { + logger.warn("slack.channels.list_failed", { slack_error: result.error }); + return json({ channels: [], error: result.error }); + } + return json({ channels: result.channels }); +} + +export const automationSlackSettingsRoutes = new Hono(); + +automationSlackSettingsRoutes.get( + "/integration-settings/slack/watched-channels", + admit({ + ...GITHUB_USER_OR_SERVICE_ROUTE, + authorization: requirePermission("automations.read", { + actorlessGrants: [{ service: "slack-bot" }], + }), + }), + (c) => handleGetWatchedSlackChannels(c.var.admitted.request, c.env, c.var.admitted.ctx) +); +automationSlackSettingsRoutes.get("/integration-settings/slack/channels", AUTOMATIONS_READ, (c) => + handleGetSlackChannels(c.var.admitted.request, c.env, c.var.admitted.ctx) +); diff --git a/packages/control-plane/src/routes/automation-update.test.ts b/packages/control-plane/src/routes/automation-update.test.ts new file mode 100644 index 000000000..940db8922 --- /dev/null +++ b/packages/control-plane/src/routes/automation-update.test.ts @@ -0,0 +1,759 @@ +/** + * Unit tests for the automation read, update, and delete routes. + * + * Tests run in Node (not workerd) with mocked stores and source control. + * Requests dispatch through the production module, so admission (including + * the automation ownership requirement) runs; authentication is mocked to + * supply the principal. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { resolveRepoOrError } from "./shared"; +import { PERMISSION_IDS } from "@open-inspect/shared/rbac"; +import { createTestRequestHandler } from "../router.test-support"; +import { automationRoutes } from "./automations"; +import { + mocks, + mockStore, + mockProviderAuthStore, + mockProviderAccountStore, + mockUserStore, + mockEnvironmentStore, + mockBatch, + mockProviderAdapterGet, + mockResolveGitHubCredentialAuthority, + mockResolveGitHubEnrichmentForRequest, + sampleRow, + applyMockDefaults, + automationRequest, +} from "./automations.test-support"; + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: Parameters) => mocks.authenticate(...args), +})); + +vi.mock("../db/automation-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + toAutomation: vi.fn((row: unknown) => row), + toAutomationRun: vi.fn((row: unknown) => row), + }; +}); + +vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { + return mockProviderAuthStore; + }), + }; +}); + +vi.mock("../db/model-provider-accounts", () => ({ + ModelProviderAccountStore: vi.fn().mockImplementation(function () { + return mockProviderAccountStore; + }), +})); + +vi.mock("../db/user-store", () => ({ + UserStore: vi.fn().mockImplementation(function () { + return mockUserStore; + }), +})); + +vi.mock("../db/environments", () => ({ + EnvironmentStore: vi.fn().mockImplementation(function () { + return mockEnvironmentStore; + }), +})); + +vi.mock("../auth/model-provider-account-default-adapters", () => ({ + modelProviderAccountAdapterRegistry: { + get: (...args: unknown[]) => mockProviderAdapterGet(...args), + }, +})); + +vi.mock("../source-control/github-credential-authority", () => ({ + resolveGitHubCredentialAuthority: (...args: unknown[]) => + mockResolveGitHubCredentialAuthority(...args), +})); + +vi.mock("../session/identity", () => ({ + resolveGitHubEnrichmentForRequest: (...args: unknown[]) => + mockResolveGitHubEnrichmentForRequest(...args), +})); + +vi.mock("../auth/crypto", () => ({ + generateId: vi.fn(() => "generated-id"), +})); + +vi.mock("./shared", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + resolveRepoOrError: vi.fn().mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }), + }; +}); + +const callRoute = automationRequest(createTestRequestHandler([automationRoutes])); + +describe("automation read, update, and delete routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + applyMockDefaults(); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 12345, + repoOwner: "acme", + repoName: "web-app", + defaultBranch: "main", + }); + }); + + describe("GET /automations/:id (get)", () => { + it("returns automation by id", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("GET", "/automations/auto-1"); + expect(res.status).toBe(200); + + const body = await res.json<{ automation: typeof sampleRow }>(); + expect(body.automation.id).toBe("auto-1"); + }); + + it("returns 404 when not found", async () => { + mockStore.getById.mockResolvedValue(null); + + const res = await callRoute("GET", "/automations/nonexistent"); + expect(res.status).toBe(404); + }); + }); + + describe("PUT /automations/:id (update)", () => { + it.each([ + ["repository", { repositories: [] }, "repositories.use"], + ["environment", { environmentIds: [] }, "environments.use"], + ] as const)( + "allows clearing a %s replacement without target-use permission", + async (_target, body, permission) => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body, + permissions: PERMISSION_IDS.filter((candidate) => candidate !== permission), + }); + + expect(res.status).toBe(200); + expect(mockBatch).toHaveBeenCalled(); + } + ); + + it.each([ + [ + "repository", + { repositories: [{ repoOwner: "acme", repoName: "api" }] }, + "repositories.use", + ], + ["environment", { environmentIds: ["env_1"] }, "environments.use"], + ] as const)( + "requires target-use permission for a non-empty %s replacement", + async (_target, body, permission) => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body, + permissions: PERMISSION_IDS.filter((candidate) => candidate !== permission), + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ + error: "Forbidden", + code: "permission_required", + permission, + }); + expect(mockBatch).not.toHaveBeenCalled(); + } + ); + + it("updates automation fields", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { name: "Updated" }, + }); + expect(res.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ name: "Updated" }) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "update-automation" }]) + ); + }); + + it("leaves provider pins unchanged when providerSelections is omitted", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { body: { name: "Updated" } }); + + expect(res.status).toBe(200); + expect(mockProviderAuthStore.bindReplace).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "replaces", + { + openai: { + mode: "provider_account" as const, + accountId: "0123456789abcdef0123456789abcdef", + }, + }, + ], + ["clears", {}], + ])( + "%s provider pins when providerSelections is present", + async (_label, providerSelections) => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { providerSelections }, + }); + + expect(res.status).toBe(200); + expect(mockProviderAuthStore.bindReplace).toHaveBeenCalledWith( + "auto-1", + providerSelections, + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "replace-provider-auth" }]) + ); + } + ); + + it.each([{ triggerConfig: {} }, { triggerConfig: { conditions: null } }])( + "rejects malformed trigger config before updating", + async ({ triggerConfig }) => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "webhook", + schedule_cron: null, + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { triggerConfig }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining("triggerConfig.conditions"), + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + } + ); + + it("validates trigger config shape before schedule automation semantics", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { triggerConfig: {} }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining("triggerConfig.conditions"), + }); + }); + + it("rejects an event type change that would leave incompatible conditions", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "workflow_run.completed", + trigger_config: JSON.stringify({ + conditions: [{ type: "workflow_name", operator: "eq", value: "CI" }], + }), + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { eventType: "pull_request.opened" }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Condition "workflow_name" does not apply to GitHub event pull_request.opened', + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + }); + + it.each([null, "", " "])("rejects an invalid explicit event type: %j", async (eventType) => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "workflow_run.completed", + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { eventType }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "eventType must be a non-empty string", + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + }); + + it("rejects an unsupported explicit event type", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "workflow_run.completed", + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { eventType: "workflow_run.typo" }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "Unsupported eventType for github_event: workflow_run.typo", + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + }); + + it("allows an unchanged legacy condition on an unrelated edit", async () => { + const legacyTriggerConfig = { + conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], + } as const; + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "pull_request.opened", + trigger_config: JSON.stringify(legacyTriggerConfig), + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { name: "Updated", triggerConfig: legacyTriggerConfig }, + }); + + expect(response.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ + name: "Updated", + trigger_config: JSON.stringify(legacyTriggerConfig), + }) + ); + }); + + it("allows resubmitting the same event type without a legacy trigger config", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "pull_request.opened", + trigger_config: JSON.stringify({ + conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], + }), + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { eventType: "pull_request.opened" }, + }); + + expect(response.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith("auto-1", { + event_type: "pull_request.opened", + }); + }); + + it("rejects modifying a grandfathered incompatible condition", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "pull_request.opened", + trigger_config: JSON.stringify({ + conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], + }), + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { + triggerConfig: { + conditions: [{ type: "path_glob", operator: "any_match", value: ["packages/**"] }], + }, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Condition "path_glob" does not apply to github triggers', + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + }); + + it("rejects appending a duplicate grandfathered condition", async () => { + const legacyCondition = { + type: "path_glob", + operator: "any_match", + value: ["src/**"], + } as const; + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + schedule_cron: null, + schedule_tz: null, + event_type: "pull_request.opened", + trigger_config: JSON.stringify({ conditions: [legacyCondition] }), + }); + + const response = await callRoute("PUT", "/automations/auto-1", { + body: { + triggerConfig: { conditions: [legacyCondition, legacyCondition] }, + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: 'Condition "path_glob" does not apply to github triggers', + }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + }); + + it("updates reasoning effort when valid for the selected model", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { reasoningEffort: "high" }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ reasoning_effort: "high" }) + ); + }); + + it("accepts nullable reasoning effort in update payloads", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "high" }); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { reasoningEffort: null }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ reasoning_effort: null }) + ); + }); + + it("rejects malformed update payloads before persistence", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { reasoningEffort: 123 }, + }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "Invalid automation request" }); + expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("clears incompatible reasoning effort when model changes", async () => { + mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "max" }); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { model: "openai/gpt-5.4" }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ model: "openai/gpt-5.4", reasoning_effort: null }) + ); + }); + + it("replaces the environment selection", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(200); + expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_1"); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + ["env_1"], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "replace-environments" }]) + ); + }); + + it("clears the environment selection with an empty list", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: [] }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( + "auto-1", + [], + expect.any(Number) + ); + }); + + it("validates the combined count against the other side's existing rows", async () => { + // A webhook automation with one existing repository row: adding an + // environment makes it multi-target, which requires a schedule trigger. + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "webhook", + schedule_cron: null, + }); + mockStore.getRepositoriesForAutomation.mockResolvedValue([ + { repo_owner: "acme", repo_name: "web-app" }, + ]); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_1"] }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Multi-target selections require a schedule trigger", + }); + }); + + it("rejects an unknown environment on update", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + mockEnvironmentStore.getById.mockResolvedValue(null); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { environmentIds: ["env_missing"] }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "Environment not found: env_missing" }); + }); + + it("clears repository context with an empty repositories list", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [] }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( + "auto-1", + [], + expect.any(Number) + ); + expect(mockBatch).toHaveBeenCalledWith( + expect.arrayContaining([{ sql: "replace-repositories" }]) + ); + }); + + it("rejects clearing repository context on repo-scoped automations", async () => { + mockStore.getById.mockResolvedValue({ + ...sampleRow, + trigger_type: "github_event", + event_type: "pull_request.opened", + schedule_cron: null, + }); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [] }, + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: "Repository-scoped triggers require exactly one repository", + }); + expect(mockBatch).not.toHaveBeenCalled(); + }); + + it("replaces repository context when repo fields are supplied", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [{ repoOwner: "Acme", repoName: "Web-App" }] }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( + "auto-1", + [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], + expect.any(Number) + ); + }); + + it("resets the branch to the resolved default when the repository changes", async () => { + // Existing automation tracks acme/web-app@main; retargeting must take the + // NEW repo's default branch, never carry the previous row's branch over. + mockStore.getById.mockResolvedValue(sampleRow); + vi.mocked(resolveRepoOrError).mockResolvedValue({ + repoId: 777, + repoOwner: "acme", + repoName: "api", + defaultBranch: "trunk", + }); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { repositories: [{ repoOwner: "acme", repoName: "api" }] }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( + "auto-1", + [{ repo_owner: "acme", repo_name: "api", repo_id: 777, base_branch: "trunk" }], + expect.any(Number) + ); + }); + + it("replaces the whole selection from the repositories list", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { + repositories: [ + { repoOwner: "acme", repoName: "web-app" }, + { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, + ], + }, + }); + + expect(res.status).toBe(200); + expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( + "auto-1", + [ + { repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }, + { repo_owner: "acme", repo_name: "api", repo_id: 12345, base_branch: "develop" }, + ], + expect.any(Number) + ); + }); + + it("applies repository-set edits without consulting active runs", async () => { + // Snapshots on runs make edits safe mid-invocation — there is no + // active-run guard on the repository selection. + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { + repositories: [ + { repoOwner: "acme", repoName: "api" }, + { repoOwner: "acme", repoName: "cli" }, + ], + }, + }); + + expect(res.status).toBe(200); + expect(mockStore.getActiveRunForAutomation).not.toHaveBeenCalled(); + expect(mockStore.bindReplaceRepositories).toHaveBeenCalledTimes(1); + }); + + it("returns 400 for invalid reasoning effort in update", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { model: "anthropic/claude-sonnet-4-6", reasoningEffort: "xhigh" }, + }); + + expect(res.status).toBe(400); + const body = await res.json<{ error: string }>(); + expect(body.error).toContain("reasoning"); + }); + + it("returns 404 when automation not found", async () => { + mockStore.getById.mockResolvedValue(null); + + const res = await callRoute("PUT", "/automations/missing", { + body: { name: "Updated" }, + }); + expect(res.status).toBe(404); + }); + + it("returns 400 for invalid cron in update", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { scheduleCron: "bad" }, + }); + expect(res.status).toBe(400); + }); + + it("returns 400 for empty name in update", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + const res = await callRoute("PUT", "/automations/auto-1", { + body: { name: "" }, + }); + expect(res.status).toBe(400); + }); + + it("recomputes next_run_at when schedule changes", async () => { + mockStore.getById.mockResolvedValue(sampleRow); + + await callRoute("PUT", "/automations/auto-1", { + body: { scheduleCron: "0 12 * * *" }, + }); + + expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( + "auto-1", + expect.objectContaining({ + schedule_cron: "0 12 * * *", + next_run_at: expect.any(Number), + }) + ); + }); + }); + + describe("DELETE /automations/:id", () => { + it("soft-deletes automation", async () => { + const res = await callRoute("DELETE", "/automations/auto-1"); + expect(res.status).toBe(200); + + const body = await res.json<{ status: string }>(); + expect(body.status).toBe("deleted"); + }); + + it("returns 404 when not found", async () => { + mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); + + const res = await callRoute("DELETE", "/automations/missing"); + expect(res.status).toBe(404); + }); + }); +}); diff --git a/packages/control-plane/src/routes/automation-validation.ts b/packages/control-plane/src/routes/automation-validation.ts new file mode 100644 index 000000000..7045db1e2 --- /dev/null +++ b/packages/control-plane/src/routes/automation-validation.ts @@ -0,0 +1,344 @@ +/** + * Request validation and target selection shared by the automation create and update routes. + */ + +import { + validateConditions, + conditionRegistry, + isGitHubConditionSupported, + triggerSources, + TRIGGER_TYPE_TO_SOURCE, +} from "@open-inspect/shared/triggers"; +import type { AutomationTriggerType, TriggerConfig } from "@open-inspect/shared/triggers"; +import { createAutomationRequestSchema } from "@open-inspect/shared/types/automations"; +import type { PermissionId } from "@open-inspect/shared/rbac"; +import { isValidReasoningEffort } from "@open-inspect/shared/models"; +import { type AutomationRepositoryInsert } from "../db/automation-store"; +import { EnvironmentStore } from "../db/environments"; +import { MAX_AUTOMATION_REPOSITORIES } from "@open-inspect/shared/types/automations"; +import { type RequestContext, json, resolveRepoOrError } from "./shared"; +import type { Env } from "../types"; +import type { SqlDatabase } from "../db/sql-database"; +import { z } from "zod"; +import { createLogger } from "../logger"; + +const logger = createLogger("router:automations"); + +export function requireTargetPermissions( + ctx: RequestContext, + requiredPermissions: readonly PermissionId[] +): Response | null { + const authorization = ctx.authorization; + if (!authorization) return json({ error: "Authorization unavailable" }, 503); + const missingPermission = requiredPermissions.find( + (permission) => !authorization.permissions.includes(permission) + ); + if (missingPermission) { + return json( + { error: "Forbidden", code: "permission_required", permission: missingPermission }, + 403 + ); + } + return null; +} + +/** Minimum cron interval in minutes. */ +export const MIN_CRON_INTERVAL_MINUTES = 15; + +/** Maximum name length. */ +export const MAX_NAME_LENGTH = 200; + +/** Maximum instructions length. Keep in sync with INSTRUCTIONS_MAX_LENGTH in packages/web/src/components/automations/automation-form.tsx. */ +export const MAX_INSTRUCTIONS_LENGTH = 15_000; + +export const createAutomationBodySchema = createAutomationRequestSchema.extend({ + // Bot-asserted actor display fields are cosmetic only; identity enforcement + // still runs against the raw pre-Zod body before these parsed values are used. + actorDisplayName: z.string().optional(), + actorEmail: z.string().optional(), + actorAvatarUrl: z.string().optional(), +}); + +export type CreateAutomationBody = z.infer; + +export function formatAutomationRequestError(parseError: z.ZodError, rawBody: unknown): string { + const issue = parseError.issues[0]; + const field = issue?.path[0]; + + if (field === "environmentIds") { + return issue.message === "must not contain duplicates" + ? "environmentIds must not contain duplicates" + : "environmentIds must be an array of environment ids (env_…)"; + } + + if (field === "repositories") { + const index = typeof issue.path[1] === "number" ? `[${String(issue.path[1])}]` : ""; + return `repositories${index}: ${issue.message}`; + } + + if (field === "eventType") return "eventType must be a non-empty string"; + + if (field === "triggerConfig") { + if (issue.path.length === 2 && issue.path[1] === "conditions") { + return "triggerConfig.conditions must be an array"; + } + + const path = issue.path.map(String).join("."); + const conditionIndex = issue.path[1] === "conditions" ? issue.path[2] : undefined; + const conditions = + rawBody && + typeof rawBody === "object" && + "triggerConfig" in rawBody && + rawBody.triggerConfig && + typeof rawBody.triggerConfig === "object" && + "conditions" in rawBody.triggerConfig && + Array.isArray(rawBody.triggerConfig.conditions) + ? rawBody.triggerConfig.conditions + : undefined; + const condition = typeof conditionIndex === "number" ? conditions?.[conditionIndex] : undefined; + const conditionType = + condition && + typeof condition === "object" && + "type" in condition && + typeof condition.type === "string" + ? `${condition.type}: ` + : ""; + return `${path}: ${conditionType}${issue.message}`; + } + + return "Invalid automation request"; +} + +interface TriggerConditionError { + condition: TriggerConfig["conditions"][number]; + code: "event_incompatible" | "invalid"; + message: string; +} + +export function getTriggerConditionErrors( + triggerType: AutomationTriggerType, + triggerConfig: TriggerConfig, + eventType?: string +): TriggerConditionError[] { + const source = TRIGGER_TYPE_TO_SOURCE[triggerType]; + if (!source) return []; + return triggerConfig.conditions.flatMap((condition) => { + const code = + source === "github" && + eventType !== undefined && + !isGitHubConditionSupported(eventType, condition.type) + ? "event_incompatible" + : "invalid"; + return validateConditions([condition], source, conditionRegistry, eventType).map((message) => ({ + condition, + code, + message, + })); + }); +} + +export function consumeCondition( + triggerConfig: TriggerConfig, + condition: TriggerConditionError["condition"], + consumedIndexes: Set +): boolean { + const serialized = JSON.stringify(condition); + const index = triggerConfig.conditions.findIndex( + (existing, candidateIndex) => + !consumedIndexes.has(candidateIndex) && JSON.stringify(existing) === serialized + ); + if (index === -1) return false; + consumedIndexes.add(index); + return true; +} + +export function getTriggerEventTypeError( + triggerType: AutomationTriggerType, + eventType: unknown +): string | null { + if (eventType !== undefined && (typeof eventType !== "string" || eventType.trim().length === 0)) { + return "eventType must be a non-empty string"; + } + + const source = triggerSources.find((candidate) => candidate.triggerType === triggerType); + if (!source?.supportsEventTypes) return null; + if (typeof eventType !== "string" || eventType.trim().length === 0) { + return `eventType is required for ${triggerType} triggers`; + } + if (!source.eventTypes.some((candidate) => candidate.eventType === eventType)) { + return `Unsupported eventType for ${triggerType}: ${eventType}`; + } + return null; +} + +/** Warn if next run is more than 31 days away. */ +export const FAR_FUTURE_THRESHOLD_MS = 31 * 24 * 60 * 60 * 1000; + +export function resolveReasoningEffort( + model: string, + reasoningEffort: string | null | undefined +): string | null { + if (reasoningEffort === undefined || reasoningEffort === null) return null; + return isValidReasoningEffort(model, reasoningEffort) ? reasoningEffort : null; +} + +type NormalizedRepositoryInput = NonNullable[number]; + +type RepositorySelectionRequest = + | { kind: "unchanged" } + | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; + +/** + * Thrown when selection semantics cannot be satisfied. Route handlers catch it + * and answer 400 while request shape validation remains in the shared schemas. + */ +export class TargetSelectionError extends Error { + constructor(message: string) { + super(message); + this.name = "TargetSelectionError"; + } +} + +/** + * Select the repositories from an already-parsed create/update body. `unchanged` + * means the body did not touch the selection (create treats that as empty). + */ +export function getRepositorySelection(body: { + repositories?: NormalizedRepositoryInput[]; +}): RepositorySelectionRequest { + if (body.repositories === undefined) return { kind: "unchanged" }; + return { kind: "replace", repositories: body.repositories }; +} + +/** + * Target-count rules across BOTH selections (repositories + environments): + * repo-scoped event triggers need exactly one repository and no environments; + * fan-out over several targets is a schedule/manual-only product scope (event + * fan-out semantics are undefined, not technically prevented). Repositories + * and environments share one combined cap. + */ +export function validateTargetCounts( + triggerType: AutomationTriggerType, + repositoryCount: number, + environmentCount: number +): void { + if (triggerType === "github_event" || triggerType === "linear_event") { + if (repositoryCount === 0) { + throw new TargetSelectionError("Repository-scoped triggers require exactly one repository"); + } + if (environmentCount > 0) { + throw new TargetSelectionError("Repository-scoped triggers cannot target environments"); + } + } + if (repositoryCount + environmentCount > 1 && triggerType !== "schedule") { + throw new TargetSelectionError("Multi-target selections require a schedule trigger"); + } + if (repositoryCount + environmentCount > MAX_AUTOMATION_REPOSITORIES) { + throw new TargetSelectionError( + `At most ${MAX_AUTOMATION_REPOSITORIES} repositories and environments combined` + ); + } +} + +type EnvironmentSelectionRequest = + | { kind: "unchanged" } + | { kind: "replace"; environmentIds: string[] }; + +/** + * Select the environments from an already-parsed create/update body (design + * §13.3). `unchanged` means the body did not touch the selection (create treats + * that as empty); an array replaces it wholesale (empty clears). + */ +export function getEnvironmentSelection(body: { + environmentIds?: string[]; +}): EnvironmentSelectionRequest { + if (body.environmentIds === undefined) return { kind: "unchanged" }; + return { kind: "replace", environmentIds: body.environmentIds }; +} + +/** + * Verify every selected environment exists — a selection must not silently + * point at deleted environments. + * + * @throws TargetSelectionError naming every missing environment. + */ +export async function resolveEnvironmentSelection( + db: SqlDatabase, + environmentIds: string[] +): Promise { + if (environmentIds.length === 0) return; + const store = new EnvironmentStore(db); + const found = await Promise.all(environmentIds.map((id) => store.getById(id))); + const missing = environmentIds.filter((_, index) => !found[index]); + if (missing.length > 0) { + throw new TargetSelectionError(`Environment not found: ${missing.join(", ")}`); + } +} + +/** + * Resolve every requested repository through the SCM provider concurrently. + * The first failure IN INPUT ORDER wins. A repo change always takes the body + * branch or the freshly resolved default — never a previous row's branch. + */ +export async function resolveRepositorySelection( + env: Env, + repositories: NormalizedRepositoryInput[], + ctx: RequestContext +): Promise { + const settled = await Promise.allSettled( + repositories.map((repository) => + resolveRepoOrError(env, repository.repoOwner, repository.repoName, ctx, logger) + ) + ); + const resolved = settled.map((result) => { + if (result.status === "rejected") throw result.reason; + return result.value; + }); + + return repositories.map((repository, index) => { + const access = resolved[index]; + return { + repo_owner: repository.repoOwner, + repo_name: repository.repoName, + repo_id: access.repoId, + base_branch: repository.baseBranch ?? access.defaultBranch, + }; + }); +} + +/** + * Validate an IANA timezone string. + */ +export function isValidTimezone(tz: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: tz }); + return true; + } catch { + return false; + } +} + +/** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ +export function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): string[] { + for (const condition of triggerConfig?.conditions ?? []) { + if (condition.type === "slack_channel") return condition.value; + } + return []; +} + +/** + * Validate a slack_event trigger config before persistence. It must be scoped to + * an explicit channel set (net-new validation; the engine otherwise skips + * condition validation entirely when none are present). A text_match is optional + * — without one the automation fires on every message in the watched channel. + * Returns an error message, or null when valid. + */ +export function validateSlackTriggerConfig( + triggerConfig: TriggerConfig | null | undefined +): string | null { + const conditions = triggerConfig?.conditions ?? []; + if (!conditions.some((c) => c.type === "slack_channel")) { + return "slack_event triggers require a slack_channel condition"; + } + return null; +} diff --git a/packages/control-plane/src/routes/automations.test-support.ts b/packages/control-plane/src/routes/automations.test-support.ts new file mode 100644 index 000000000..f3d3aa535 --- /dev/null +++ b/packages/control-plane/src/routes/automations.test-support.ts @@ -0,0 +1,219 @@ +/** + * Fixtures shared by the automation route suites: the store doubles the + * suites' `vi.mock` factories hand out, the request builder, and the sample + * automation row. Each suite declares its own `vi.mock` calls (they are + * per-file) and points them at the doubles exported here. + */ + +import { vi } from "vitest"; +import { PERMISSION_IDS, type PermissionId } from "@open-inspect/shared/rbac"; +import type { Principal } from "../auth/principal"; +import type { SqlDatabase, SqlStatement } from "../db/sql-database"; +import { + authorizationDatabase, + TEST_BACKGROUND_TASK_CONTEXT, + TEST_SERVICE_SECRETS, + type TestRequestHandler, +} from "../router.test-support"; +import type { Env } from "../types"; + +export const mocks = { authenticate: vi.fn() }; + +export const mockProviderAdapterGet = vi.fn(); +export const mockResolveGitHubCredentialAuthority = vi.fn(); +export const mockResolveGitHubEnrichmentForRequest = vi.fn(); +export const mockSchedulerTrigger = vi.fn(); + +export const mockStore = { + list: vi.fn(), + getById: vi.fn(), + resolveCanonicalOwner: vi.fn(async (automation: unknown) => automation), + update: vi.fn(), + softDelete: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + getActiveRunForAutomation: vi.fn(), + getRunById: vi.fn(), + getRepositoriesForAutomation: vi.fn(), + getRepositoriesForAutomationIds: vi.fn(), + getEnvironmentsForAutomation: vi.fn(), + getEnvironmentsForAutomationIds: vi.fn(), + bindAutomationInsert: vi.fn(), + bindAutomationUpdate: vi.fn(), + bindSoftDelete: vi.fn(), + bindPause: vi.fn(), + bindResume: vi.fn(), + bindRepositoryInserts: vi.fn(), + bindReplaceRepositories: vi.fn(), + bindEnvironmentInserts: vi.fn(), + bindReplaceEnvironments: vi.fn(), + listInvocations: vi.fn(), + listRecentExecutionsForAutomationIds: vi.fn(), +}; + +export const mockProviderAuthStore = { + list: vi.fn(), + listForAutomationIds: vi.fn(), + bindInserts: vi.fn(), + bindReplace: vi.fn(), +}; + +export const mockProviderAccountStore = { + getById: vi.fn(), +}; + +export const mockUserStore = { + resolveOrCreateUser: vi.fn().mockResolvedValue({ id: "resolved-user-1", isNew: false }), +}; + +export const mockEnvironmentStore = { + getById: vi.fn(), +}; + +/** Shared D1 batch spy — createEnv wires it as env.DB.batch. */ +export const mockBatch = vi.fn(); + +/** + * The workspace database as admission and the handlers see it: admission's + * lookups are answered by test support, every other statement goes to a + * statement spy, and `batch` is the shared spy. + */ +function createDatabase(permissions: readonly PermissionId[]): SqlDatabase { + const statement = { + bind: vi.fn(() => statement), + first: vi.fn(async () => ({ satisfied: 1 })), + all: vi.fn(async () => ({ results: [] })), + }; + return authorizationDatabase({ + permissions: permissions.length === PERMISSION_IDS.length ? undefined : permissions, + statement: () => statement as unknown as SqlStatement, + batch: mockBatch, + }); +} + +export function createEnv(permissions: readonly PermissionId[] = PERMISSION_IDS): Env { + return { + ...TEST_SERVICE_SECRETS, + SCM_PROVIDER: "github", + DB: createDatabase(permissions), + SESSION: {} as DurableObjectNamespace, + DEPLOYMENT_NAME: "test", + TOKEN_ENCRYPTION_KEY: "test-key", + } as unknown as Env; +} + +export const USER_PRINCIPAL: Principal = { + kind: "user", + userId: "user-1", +}; + +export const SLACK_BOT_PRINCIPAL: Principal = { + kind: "service", + service: "slack-bot", + actor: { + provider: "slack", + providerUserId: "U0123", + canonicalUserId: null, + participantUserId: "slack:U0123", + }, +}; + +export interface AutomationRequestOptions { + body?: unknown; + query?: Record; + principal?: Principal; + permissions?: readonly PermissionId[]; +} + +/** A request builder over one module's handler, authenticating as the requested principal. */ +export function automationRequest(handleRequest: TestRequestHandler) { + return async ( + method: string, + path: string, + options?: AutomationRequestOptions + ): Promise => { + const url = new URL(`https://test.local${path}`); + if (options?.query) { + for (const [k, v] of Object.entries(options.query)) { + for (const value of Array.isArray(v) ? v : [v]) { + url.searchParams.append(k, value); + } + } + } + const init: RequestInit = { method }; + if (options?.body) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(options.body); + } + const principal = options?.principal ?? USER_PRINCIPAL; + mocks.authenticate.mockImplementation(async (request: Request) => ({ principal, request })); + return handleRequest( + new Request(url, init), + createEnv(options?.permissions), + TEST_BACKGROUND_TASK_CONTEXT + ); + }; +} + +export const now = Date.now(); + +export const sampleRow = { + id: "auto-1", + name: "Daily sync", + instructions: "Run tests", + trigger_type: "schedule", + schedule_cron: "0 9 * * *", + schedule_tz: "UTC", + model: "anthropic/claude-sonnet-4-6", + reasoning_effort: null, + enabled: 1, + next_run_at: now, + consecutive_failures: 0, + created_by: "user-1", + created_at: now, + updated_at: now, + deleted_at: null, +}; + +/** + * Defaults every test can override; re-set per test so per-test overrides + * (mockClear keeps implementations) cannot leak across tests. Admission + * resolves the automation for every manage route, so the lookup must not + * depend on what an earlier test left behind. + */ +export function applyMockDefaults(): void { + mockStore.getById.mockResolvedValue(sampleRow); + mockStore.getRepositoriesForAutomation.mockResolvedValue([]); + mockStore.getRepositoriesForAutomationIds.mockResolvedValue(new Map()); + mockStore.getEnvironmentsForAutomation.mockResolvedValue([]); + mockStore.getEnvironmentsForAutomationIds.mockResolvedValue(new Map()); + mockStore.listRecentExecutionsForAutomationIds.mockResolvedValue(new Map()); + mockProviderAuthStore.list.mockResolvedValue([]); + mockProviderAuthStore.listForAutomationIds.mockResolvedValue(new Map()); + mockStore.bindAutomationInsert.mockReturnValue({ sql: "insert-automation" }); + mockStore.bindAutomationUpdate.mockReturnValue({ sql: "update-automation" }); + mockStore.bindSoftDelete.mockReturnValue({ sql: "delete-automation" }); + mockStore.bindPause.mockReturnValue({ sql: "pause-automation" }); + mockStore.bindResume.mockReturnValue({ sql: "resume-automation" }); + mockStore.bindRepositoryInserts.mockReturnValue([{ sql: "insert-repositories" }]); + mockStore.bindReplaceRepositories.mockReturnValue([{ sql: "replace-repositories" }]); + mockStore.bindEnvironmentInserts.mockReturnValue([{ sql: "insert-environments" }]); + mockStore.bindReplaceEnvironments.mockReturnValue([{ sql: "replace-environments" }]); + mockProviderAuthStore.bindInserts.mockReturnValue([{ sql: "insert-provider-auth" }]); + mockProviderAuthStore.bindReplace.mockReturnValue([{ sql: "replace-provider-auth" }]); + mockBatch.mockResolvedValue([{ meta: { changes: 1 }, results: [] }]); + mockSchedulerTrigger.mockResolvedValue({ + invocationId: "inv-1", + runs: [{ id: "run-1" }], + }); + mockEnvironmentStore.getById.mockResolvedValue({ id: "env_1", name: "Fullstack" }); + mockProviderAccountStore.getById.mockResolvedValue({ + id: "0123456789abcdef0123456789abcdef", + provider: "openai", + status: "active", + archivedAt: null, + }); + mockProviderAdapterGet.mockReturnValue({}); + mockResolveGitHubCredentialAuthority.mockResolvedValue({ kind: "legacy" }); + mockResolveGitHubEnrichmentForRequest.mockResolvedValue(null); +} diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts deleted file mode 100644 index 3e84c37dd..000000000 --- a/packages/control-plane/src/routes/automations.test.ts +++ /dev/null @@ -1,1888 +0,0 @@ -/** - * Unit tests for automation CRUD routes. - * - * Tests run in Node (not workerd) with mocked AutomationStore and source - * control. Requests dispatch through the production `automationRoutes` - * module, so admission (including the automation ownership requirement) - * runs; authentication is mocked to supply the principal. - */ - -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type * as AuthenticateModule from "../auth/authenticate"; -import { automationRoutes, MAX_NAME_LENGTH } from "./automations"; -import { HttpError, resolveRepoOrError } from "./shared"; -import type { Principal } from "../auth/principal"; -import type { SqlDatabase, SqlStatement } from "../db/sql-database"; -import type { Env } from "../types"; -import { - authorizationDatabase, - createTestRequestHandler, - TEST_BACKGROUND_TASK_CONTEXT, - TEST_SERVICE_SECRETS, -} from "../router.test-support"; -import { - AutomationExecutionUnauthorizedError, - AutomationTriggerBlockedError, -} from "../scheduler/scheduler"; -import { PERMISSION_IDS, type PermissionId } from "@open-inspect/shared/rbac"; - -const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); - -vi.mock("../auth/authenticate", async (importOriginal) => ({ - ...(await importOriginal()), - authenticate: mocks.authenticate, -})); - -const mockProviderAdapterGet = vi.hoisted(() => vi.fn()); -const mockResolveGitHubCredentialAuthority = vi.hoisted(() => vi.fn()); -const mockResolveGitHubEnrichmentForRequest = vi.hoisted(() => vi.fn()); - -vi.mock("../auth/model-provider-account-default-adapters", () => ({ - modelProviderAccountAdapterRegistry: { get: mockProviderAdapterGet }, -})); - -vi.mock("../source-control/github-credential-authority", () => ({ - resolveGitHubCredentialAuthority: mockResolveGitHubCredentialAuthority, -})); - -vi.mock("../session/identity", () => ({ - resolveGitHubEnrichmentForRequest: mockResolveGitHubEnrichmentForRequest, -})); - -// ─── Mocks ────────────────────────────────────────────────────────────────── - -const mockStore = { - list: vi.fn(), - getById: vi.fn(), - resolveCanonicalOwner: vi.fn(async (automation: unknown) => automation), - update: vi.fn(), - softDelete: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - getActiveRunForAutomation: vi.fn(), - getRunById: vi.fn(), - getRepositoriesForAutomation: vi.fn(), - getRepositoriesForAutomationIds: vi.fn(), - getEnvironmentsForAutomation: vi.fn(), - getEnvironmentsForAutomationIds: vi.fn(), - bindAutomationInsert: vi.fn(), - bindAutomationUpdate: vi.fn(), - bindSoftDelete: vi.fn(), - bindPause: vi.fn(), - bindResume: vi.fn(), - bindRepositoryInserts: vi.fn(), - bindReplaceRepositories: vi.fn(), - bindEnvironmentInserts: vi.fn(), - bindReplaceEnvironments: vi.fn(), - listInvocations: vi.fn(), - listRecentExecutionsForAutomationIds: vi.fn(), -}; - -const mockProviderAuthStore = { - list: vi.fn(), - listForAutomationIds: vi.fn(), - bindInserts: vi.fn(), - bindReplace: vi.fn(), -}; - -vi.mock("../db/automation-model-provider-auth", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - AutomationModelProviderAuthStore: vi.fn().mockImplementation(function () { - return mockProviderAuthStore; - }), - }; -}); - -const mockProviderAccountStore = { - getById: vi.fn(), -}; - -vi.mock("../db/model-provider-accounts", () => ({ - ModelProviderAccountStore: vi.fn().mockImplementation(function () { - return mockProviderAccountStore; - }), -})); - -/** Shared D1 batch spy — createEnv wires it as env.DB.batch. */ -const mockBatch = vi.fn(); -const mockSchedulerTrigger = vi.hoisted(() => vi.fn()); -const MockAutomationTriggerBlockedError = vi.hoisted( - () => - class AutomationTriggerBlockedError extends Error { - constructor() { - super("An active run already exists"); - this.name = "AutomationTriggerBlockedError"; - } - } -); -const MockAutomationExecutionUnauthorizedError = vi.hoisted( - () => - class AutomationExecutionUnauthorizedError extends Error { - constructor() { - super("Automation owner is not authorized to execute"); - this.name = "AutomationExecutionUnauthorizedError"; - } - } -); - -vi.mock("../scheduler/scheduler", () => ({ - AutomationExecutionUnauthorizedError: MockAutomationExecutionUnauthorizedError, - AutomationTriggerBlockedError: MockAutomationTriggerBlockedError, - Scheduler: vi.fn().mockImplementation(function () { - return { trigger: mockSchedulerTrigger }; - }), -})); - -vi.mock("../db/automation-store", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - AutomationStore: vi.fn().mockImplementation(function () { - return mockStore; - }), - toAutomation: vi.fn((row: unknown) => row), - toAutomationRun: vi.fn((row: unknown) => row), - }; -}); - -const mockUserStore = { - resolveOrCreateUser: vi.fn().mockResolvedValue({ id: "resolved-user-1", isNew: false }), -}; -vi.mock("../db/user-store", () => ({ - UserStore: vi.fn().mockImplementation(function () { - return mockUserStore; - }), -})); - -const mockEnvironmentStore = { - getById: vi.fn(), -}; -vi.mock("../db/environments", () => ({ - EnvironmentStore: vi.fn().mockImplementation(function () { - return mockEnvironmentStore; - }), -})); - -vi.mock("../auth/crypto", () => ({ - generateId: vi.fn(() => "generated-id"), -})); - -vi.mock("./shared", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - resolveRepoOrError: vi.fn().mockResolvedValue({ - repoId: 12345, - repoOwner: "acme", - repoName: "web-app", - defaultBranch: "main", - }), - }; -}); - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -/** - * The workspace database as admission and the handlers see it: admission's - * lookups are answered by test support, every other statement goes to a - * statement spy, and `batch` is the shared spy. - */ -function createDatabase(permissions: readonly PermissionId[]): SqlDatabase { - const statement = { - bind: vi.fn(() => statement), - first: vi.fn(async () => ({ satisfied: 1 })), - all: vi.fn(async () => ({ results: [] })), - }; - return authorizationDatabase({ - permissions: permissions.length === PERMISSION_IDS.length ? undefined : permissions, - statement: () => statement as unknown as SqlStatement, - batch: mockBatch, - }); -} - -function createEnv(permissions: readonly PermissionId[] = PERMISSION_IDS): Env { - return { - ...TEST_SERVICE_SECRETS, - SCM_PROVIDER: "github", - DB: createDatabase(permissions), - SESSION: {} as DurableObjectNamespace, - DEPLOYMENT_NAME: "test", - TOKEN_ENCRYPTION_KEY: "test-key", - } as unknown as Env; -} - -const USER_PRINCIPAL: Principal = { - kind: "user", - userId: "user-1", -}; - -const SLACK_BOT_PRINCIPAL: Principal = { - kind: "service", - service: "slack-bot", - actor: { - provider: "slack", - providerUserId: "U0123", - canonicalUserId: null, - participantUserId: "slack:U0123", - }, -}; - -const handleRequest = createTestRequestHandler([automationRoutes]); - -async function callRoute( - method: string, - path: string, - options?: { - body?: unknown; - query?: Record; - principal?: Principal; - permissions?: readonly PermissionId[]; - } -): Promise { - const url = new URL(`https://test.local${path}`); - if (options?.query) { - for (const [k, v] of Object.entries(options.query)) { - for (const value of Array.isArray(v) ? v : [v]) { - url.searchParams.append(k, value); - } - } - } - const init: RequestInit = { method }; - if (options?.body) { - init.headers = { "Content-Type": "application/json" }; - init.body = JSON.stringify(options.body); - } - const principal = options?.principal ?? USER_PRINCIPAL; - mocks.authenticate.mockImplementation(async (request: Request) => ({ principal, request })); - return handleRequest( - new Request(url, init), - createEnv(options?.permissions), - TEST_BACKGROUND_TASK_CONTEXT - ); -} - -// ─── Sample data ──────────────────────────────────────────────────────────── - -const now = Date.now(); - -const sampleRow = { - id: "auto-1", - name: "Daily sync", - instructions: "Run tests", - trigger_type: "schedule", - schedule_cron: "0 9 * * *", - schedule_tz: "UTC", - model: "anthropic/claude-sonnet-4-6", - reasoning_effort: null, - enabled: 1, - next_run_at: now, - consecutive_failures: 0, - created_by: "user-1", - created_at: now, - updated_at: now, - deleted_at: null, -}; - -// ─── Tests ────────────────────────────────────────────────────────────────── - -describe("automation route handlers", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Defaults every test can override; re-set here so per-test overrides - // (mockClear keeps implementations) cannot leak across tests. - // Admission resolves the automation for every manage route, so the - // lookup must not depend on what an earlier test left behind. - mockStore.getById.mockResolvedValue(sampleRow); - mockStore.getRepositoriesForAutomation.mockResolvedValue([]); - mockStore.getRepositoriesForAutomationIds.mockResolvedValue(new Map()); - mockStore.getEnvironmentsForAutomation.mockResolvedValue([]); - mockStore.getEnvironmentsForAutomationIds.mockResolvedValue(new Map()); - mockStore.listRecentExecutionsForAutomationIds.mockResolvedValue(new Map()); - mockProviderAuthStore.list.mockResolvedValue([]); - mockProviderAuthStore.listForAutomationIds.mockResolvedValue(new Map()); - mockStore.bindAutomationInsert.mockReturnValue({ sql: "insert-automation" }); - mockStore.bindAutomationUpdate.mockReturnValue({ sql: "update-automation" }); - mockStore.bindSoftDelete.mockReturnValue({ sql: "delete-automation" }); - mockStore.bindPause.mockReturnValue({ sql: "pause-automation" }); - mockStore.bindResume.mockReturnValue({ sql: "resume-automation" }); - mockStore.bindRepositoryInserts.mockReturnValue([{ sql: "insert-repositories" }]); - mockStore.bindReplaceRepositories.mockReturnValue([{ sql: "replace-repositories" }]); - mockStore.bindEnvironmentInserts.mockReturnValue([{ sql: "insert-environments" }]); - mockStore.bindReplaceEnvironments.mockReturnValue([{ sql: "replace-environments" }]); - mockProviderAuthStore.bindInserts.mockReturnValue([{ sql: "insert-provider-auth" }]); - mockProviderAuthStore.bindReplace.mockReturnValue([{ sql: "replace-provider-auth" }]); - mockBatch.mockResolvedValue([{ meta: { changes: 1 }, results: [] }]); - mockSchedulerTrigger.mockResolvedValue({ - invocationId: "inv-1", - runs: [{ id: "run-1" }], - }); - mockEnvironmentStore.getById.mockResolvedValue({ id: "env_1", name: "Fullstack" }); - mockProviderAccountStore.getById.mockResolvedValue({ - id: "0123456789abcdef0123456789abcdef", - provider: "openai", - status: "active", - archivedAt: null, - }); - mockProviderAdapterGet.mockReturnValue({}); - mockResolveGitHubCredentialAuthority.mockResolvedValue({ kind: "legacy" }); - mockResolveGitHubEnrichmentForRequest.mockResolvedValue(null); - vi.mocked(resolveRepoOrError).mockResolvedValue({ - repoId: 12345, - repoOwner: "acme", - repoName: "web-app", - defaultBranch: "main", - }); - }); - - describe("GET /automations (list)", () => { - it("returns the first page with default pagination", async () => { - mockStore.list.mockResolvedValue({ - automations: [sampleRow], - hasMore: false, - nextCursor: null, - }); - - const res = await callRoute("GET", "/automations"); - expect(res.status).toBe(200); - - const body = await res.json<{ - automations: unknown[]; - hasMore: boolean; - nextCursor: string | null; - }>(); - expect(body.automations).toHaveLength(1); - expect(body.hasMore).toBe(false); - expect(body.nextCursor).toBeNull(); - expect(mockStore.list).toHaveBeenCalledWith({ limit: 25, cursor: null }); - expect(mockStore.listRecentExecutionsForAutomationIds).toHaveBeenCalledWith(["auto-1"], 10); - expect(body.automations[0]).toMatchObject({ recentExecutions: [] }); - }); - - it.each<{ query: Record; error: string }>([ - { query: { limit: "0" }, error: "Invalid limit" }, - { query: { limit: "abc" }, error: "Invalid limit" }, - { query: { limit: "101" }, error: "Invalid limit" }, - { query: { limit: ["5", "6"] }, error: "Invalid limit" }, - { query: { cursor: "not-a-cursor" }, error: "Invalid cursor" }, - { query: { search: "x".repeat(MAX_NAME_LENGTH + 1) }, error: "Search is too long" }, - ])("rejects list query $query without listing", async ({ query, error }) => { - const res = await callRoute("GET", "/automations", { query }); - - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ error }); - expect(mockStore.list).not.toHaveBeenCalled(); - }); - - it("passes name search and pagination params to the store", async () => { - mockStore.list.mockResolvedValue({ automations: [], hasMore: false, nextCursor: null }); - - await callRoute("GET", "/automations", { - query: { search: " Daily sync ", limit: "10", cursor: "123:auto-9" }, - }); - - expect(mockStore.list).toHaveBeenCalledWith({ - nameSearch: "Daily sync", - limit: 10, - cursor: { createdAt: 123, id: "auto-9" }, - }); - }); - - it("preserves explicit repository filters", async () => { - mockStore.list.mockResolvedValue({ automations: [], hasMore: false, nextCursor: null }); - - await callRoute("GET", "/automations", { - query: { repoOwner: "acme", repoName: "web-app" }, - }); - - expect(mockStore.list).toHaveBeenCalledWith({ - limit: 25, - cursor: null, - repoOwner: "acme", - repoName: "web-app", - }); - }); - - it.each([ - [{ limit: "0" }, "limit"], - [{ limit: "101" }, "limit"], - [{ limit: "ten" }, "limit"], - [{ limit: "1e1" }, "limit"], - [{ limit: " 10 " }, "limit"], - [{ limit: ["10", "20"] }, "limit"], - [{ cursor: "not-a-cursor" }, "cursor"], - [{ search: "a".repeat(201) }, "Search"], - ])("rejects invalid pagination params", async (query, expectedField) => { - const response = await callRoute("GET", "/automations", { query }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining(expectedField), - }); - expect(mockStore.list).not.toHaveBeenCalled(); - }); - }); - - describe("POST /automations (create)", () => { - const validBody = { - name: "Daily sync", - repositories: [{ repoOwner: "acme", repoName: "web-app" }], - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - }; - - it("creates automation with valid input", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { body: validBody }); - expect(res.status).toBe(201); - - // The selection persists as repository rows; the automation row carries - // no repo columns. Both land in a single atomic batch. - expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( - "generated-id", - [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledTimes(1); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "insert-automation" }, { sql: "insert-repositories" }]) - ); - }); - - it("rejects partial create payloads before persistence", async () => { - const res = await callRoute("POST", "/automations", { - body: { instructions: "Run tests" }, - }); - - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ error: "Invalid automation request" }); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("persists a complete provider pin map in the create batch", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - const providerSelections = { - openai: { - mode: "provider_account" as const, - accountId: "0123456789abcdef0123456789abcdef", - }, - xai: { mode: "api_key" as const }, - }; - - const res = await callRoute("POST", "/automations", { - body: { ...validBody, providerSelections }, - }); - - expect(res.status).toBe(201); - expect(mockProviderAuthStore.bindInserts).toHaveBeenCalledWith( - "generated-id", - providerSelections, - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "insert-provider-auth" }]) - ); - }); - - it.each([ - ["missing account", null, 404], - ["wrong provider", { provider: "xai", status: "active", archivedAt: null }, 400], - ["inactive account", { provider: "openai", status: "disabled", archivedAt: null }, 409], - ["archived account", { provider: "openai", status: "active", archivedAt: 123 }, 409], - ])("rejects a provider pin for a %s", async (_label, account, status) => { - mockProviderAccountStore.getById.mockResolvedValue({ - id: "0123456789abcdef0123456789abcdef", - ...account, - }); - if (!account) mockProviderAccountStore.getById.mockResolvedValue(null); - - const res = await callRoute("POST", "/automations", { - body: { - ...validBody, - providerSelections: { - openai: { - mode: "provider_account", - accountId: "0123456789abcdef0123456789abcdef", - }, - }, - }, - }); - - expect(res.status).toBe(status); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("rejects a provider-account pin when its adapter is unavailable", async () => { - mockProviderAdapterGet.mockReturnValue(undefined); - - const res = await callRoute("POST", "/automations", { - body: { - ...validBody, - providerSelections: { - openai: { - mode: "provider_account", - accountId: "0123456789abcdef0123456789abcdef", - }, - }, - }, - }); - - expect(res.status).toBe(409); - expect(mockProviderAccountStore.getById).not.toHaveBeenCalled(); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it.each([{ triggerConfig: {} }, { triggerConfig: { conditions: null } }])( - "rejects malformed trigger config before persistence", - async ({ triggerConfig }) => { - const response = await callRoute("POST", "/automations", { - body: { - name: "Webhook automation", - instructions: "Handle the event", - triggerType: "webhook", - triggerConfig, - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining("triggerConfig.conditions"), - }); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - } - ); - - it("creates a multi-repository automation from the repositories list", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { - body: { - name: "Fan-out sync", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - repositories: [ - { repoOwner: "Acme", repoName: "Web-App" }, - { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, - ], - }, - }); - - expect(res.status).toBe(201); - expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( - "generated-id", - [ - { repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }, - { repo_owner: "acme", repo_name: "api", repo_id: 12345, base_branch: "develop" }, - ], - expect.any(Number) - ); - }); - - it("does not write partial data when repository resolution fails", async () => { - vi.mocked(resolveRepoOrError).mockImplementation(async (_env, owner, name) => { - if (name === "api") { - throw new HttpError("Repository is not installed for the GitHub App", 404); - } - return { - repoId: 12345, - repoOwner: owner, - repoName: name, - defaultBranch: "main", - }; - }); - - const res = await callRoute("POST", "/automations", { - body: { - ...validBody, - repositories: [ - { repoOwner: "acme", repoName: "web-app" }, - { repoOwner: "acme", repoName: "api" }, - ], - }, - }); - - expect(res.status).toBe(404); - await expect(res.json()).resolves.toEqual({ - error: "Repository is not installed for the GitHub App", - }); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - expect(mockStore.bindRepositoryInserts).not.toHaveBeenCalled(); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("reports repository resolution failures in input order", async () => { - vi.mocked(resolveRepoOrError).mockImplementation( - (_env, _owner, name) => - new Promise((_, reject) => { - const delay = name === "first" ? 5 : 0; - setTimeout(() => reject(new HttpError(`failed ${name}`, 404)), delay); - }) - ); - - const res = await callRoute("POST", "/automations", { - body: { - ...validBody, - repositories: [ - { repoOwner: "acme", repoName: "first" }, - { repoOwner: "acme", repoName: "second" }, - ], - }, - }); - - expect(res.status).toBe(404); - await expect(res.json()).resolves.toEqual({ error: "failed first" }); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("rejects duplicate repositories in the list", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Dup", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - repositories: [ - { repoOwner: "acme", repoName: "web-app" }, - { repoOwner: "ACME", repoName: "Web-App" }, - ], - }, - }); - - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("repositories"); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("rejects multi-repository selections on non-schedule triggers", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Webhook fan-out", - instructions: "Run tests", - triggerType: "webhook", - repositories: [ - { repoOwner: "acme", repoName: "web-app" }, - { repoOwner: "acme", repoName: "api" }, - ], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Multi-target selections require a schedule trigger", - }); - }); - - it("creates an environment-targeted automation", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { - body: { - name: "Workspace sync", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - environmentIds: ["env_1", "env_2"], - }, - }); - - expect(res.status).toBe(201); - expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_1"); - expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_2"); - expect(mockStore.bindEnvironmentInserts).toHaveBeenCalledWith( - "generated-id", - ["env_1", "env_2"], - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "insert-automation" }, { sql: "insert-environments" }]) - ); - }); - - it("creates a mixed repository + environment fan-out", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { - body: { ...validBody, environmentIds: ["env_1"] }, - }); - - expect(res.status).toBe(201); - expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( - "generated-id", - [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], - expect.any(Number) - ); - expect(mockStore.bindEnvironmentInserts).toHaveBeenCalledWith( - "generated-id", - ["env_1"], - expect.any(Number) - ); - }); - - it("rejects duplicate environment ids", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Dup envs", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - environmentIds: ["env_1", "env_1"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "environmentIds must not contain duplicates" }); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("rejects unknown environments, naming every missing one", async () => { - mockEnvironmentStore.getById.mockResolvedValue(null); - - const res = await callRoute("POST", "/automations", { - body: { - name: "Workspace sync", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - environmentIds: ["env_a", "env_b"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "Environment not found: env_a, env_b" }); - }); - - it("checks environment-use permission before disclosing whether an environment exists", async () => { - mockEnvironmentStore.getById.mockResolvedValue(null); - - const res = await callRoute("POST", "/automations", { - body: { - name: "Workspace sync", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - environmentIds: ["env_missing"], - }, - permissions: PERMISSION_IDS.filter((permission) => permission !== "environments.use"), - }); - - expect(res.status).toBe(403); - await expect(res.json()).resolves.toMatchObject({ - code: "permission_required", - permission: "environments.use", - }); - expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); - }); - - it("rejects malformed environment ids", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Workspace sync", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - environmentIds: ["not-an-environment"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "environmentIds must be an array of environment ids (env_…)", - }); - expect(mockEnvironmentStore.getById).not.toHaveBeenCalled(); - }); - - it("rejects environments on repo-scoped event triggers", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "PR review", - instructions: "Review", - triggerType: "github_event", - eventType: "pull_request.opened", - repositories: [{ repoOwner: "acme", repoName: "web-app" }], - environmentIds: ["env_1"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Repository-scoped triggers cannot target environments", - }); - }); - - it("rejects multi-target selections on non-schedule triggers", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Webhook fan-out", - instructions: "Run tests", - triggerType: "webhook", - repositories: [{ repoOwner: "acme", repoName: "web-app" }], - environmentIds: ["env_1"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Multi-target selections require a schedule trigger", - }); - }); - - it("enforces the combined target cap", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "Too many targets", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Run tests", - repositories: Array.from({ length: 8 }, (_, i) => ({ - repoOwner: "acme", - repoName: `repo-${i}`, - })), - environmentIds: ["env_1", "env_2", "env_3"], - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "At most 10 repositories and environments combined", - }); - }); - - it("creates repo-less automation without repo fields", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { - body: { - name: "Incident sweep", - scheduleCron: "0 9 * * *", - scheduleTz: "UTC", - instructions: "Check recent incidents and summarize.", - }, - }); - - expect(res.status).toBe(201); - expect(mockStore.bindRepositoryInserts).toHaveBeenCalledWith( - "generated-id", - [], - expect.any(Number) - ); - }); - - it("rejects repo-less repo-scoped triggers", async () => { - const res = await callRoute("POST", "/automations", { - body: { - name: "PR review", - instructions: "Review the PR.", - triggerType: "github_event", - eventType: "pull_request.opened", - }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Repository-scoped triggers require exactly one repository", - }); - }); - - it("rejects conditions that do not apply to the GitHub event type", async () => { - const response = await callRoute("POST", "/automations", { - body: { - name: "PR workflow filter", - instructions: "Review the pull request.", - triggerType: "github_event", - eventType: "pull_request.opened", - repositories: [{ repoOwner: "acme", repoName: "web-app" }], - triggerConfig: { - conditions: [{ type: "workflow_name", operator: "eq", value: "CI" }], - }, - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: 'Condition "workflow_name" does not apply to GitHub event pull_request.opened', - }); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - }); - - it.each([ - [undefined, "eventType is required for github_event triggers"], - ["workflow_run.typo", "Unsupported eventType for github_event: workflow_run.typo"], - ])("rejects an invalid GitHub event type without conditions", async (eventType, message) => { - const response = await callRoute("POST", "/automations", { - body: { - name: "GitHub watcher", - instructions: "Inspect the event.", - triggerType: "github_event", - eventType, - repositories: [{ repoOwner: "acme", repoName: "web-app" }], - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: message }); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - }); - - it("stores the user principal's canonical id without consulting the user store", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { body: validBody }); - - expect(res.status).toBe(201); - expect(mockUserStore.resolveOrCreateUser).not.toHaveBeenCalled(); - expect(mockStore.bindAutomationInsert).toHaveBeenCalledWith( - expect.objectContaining({ created_by: "user-1", user_id: "user-1" }) - ); - }); - - it("refuses a bot actor at admission before any identity is resolved", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("POST", "/automations", { - body: { - ...validBody, - actorDisplayName: "Alice", - actorEmail: "alice@corp.com", - actorAvatarUrl: "https://avatars.test/alice.png", - }, - principal: SLACK_BOT_PRINCIPAL, - }); - - expect(res.status).toBe(403); - await expect(res.json()).resolves.toEqual({ - error: "Forbidden", - code: "service_capability_required", - }); - expect(mockUserStore.resolveOrCreateUser).not.toHaveBeenCalled(); - expect(mockStore.bindAutomationInsert).not.toHaveBeenCalled(); - }); - - it("rejects forbidden body identity fields", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, scmUserId: "12345" }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Field 'scmUserId' is not accepted from verified callers", - }); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("stores reasoning effort when valid for the selected model", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "high" }); - - const res = await callRoute("POST", "/automations", { - body: { ...validBody, model: "anthropic/claude-sonnet-4-6", reasoningEffort: "high" }, - }); - - expect(res.status).toBe(201); - expect(mockStore.bindAutomationInsert).toHaveBeenCalledWith( - expect.objectContaining({ model: "anthropic/claude-sonnet-4-6", reasoning_effort: "high" }) - ); - }); - - it("returns 400 for invalid reasoning effort", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, model: "anthropic/claude-sonnet-4-6", reasoningEffort: "xhigh" }, - }); - - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("reasoning"); - }); - - it("returns 400 when name is missing", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, name: "" }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("name"); - }); - - it("returns 400 when name exceeds 200 chars", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, name: "a".repeat(201) }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("200"); - }); - - it("returns 400 when instructions is missing", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, instructions: "" }, - }); - expect(res.status).toBe(400); - }); - - it("returns 400 when instructions exceeds the maximum length", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, instructions: "x".repeat(15_001) }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("15000"); - }); - - it("returns 400 for invalid cron expression", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, scheduleCron: "not-a-cron" }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("cron"); - }); - - it("returns 400 for cron interval under 15 minutes", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, scheduleCron: "*/5 * * * *" }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("15 minutes"); - }); - - it("returns 400 for invalid timezone", async () => { - const res = await callRoute("POST", "/automations", { - body: { ...validBody, scheduleTz: "Not/A/Timezone" }, - }); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("timezone"); - }); - }); - - describe("GET /automations/:id (get)", () => { - it("returns automation by id", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("GET", "/automations/auto-1"); - expect(res.status).toBe(200); - - const body = await res.json<{ automation: typeof sampleRow }>(); - expect(body.automation.id).toBe("auto-1"); - }); - - it("returns 404 when not found", async () => { - mockStore.getById.mockResolvedValue(null); - - const res = await callRoute("GET", "/automations/nonexistent"); - expect(res.status).toBe(404); - }); - }); - - describe("PUT /automations/:id (update)", () => { - it.each([ - ["repository", { repositories: [] }, "repositories.use"], - ["environment", { environmentIds: [] }, "environments.use"], - ] as const)( - "allows clearing a %s replacement without target-use permission", - async (_target, body, permission) => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body, - permissions: PERMISSION_IDS.filter((candidate) => candidate !== permission), - }); - - expect(res.status).toBe(200); - expect(mockBatch).toHaveBeenCalled(); - } - ); - - it.each([ - [ - "repository", - { repositories: [{ repoOwner: "acme", repoName: "api" }] }, - "repositories.use", - ], - ["environment", { environmentIds: ["env_1"] }, "environments.use"], - ] as const)( - "requires target-use permission for a non-empty %s replacement", - async (_target, body, permission) => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body, - permissions: PERMISSION_IDS.filter((candidate) => candidate !== permission), - }); - - expect(res.status).toBe(403); - await expect(res.json()).resolves.toEqual({ - error: "Forbidden", - code: "permission_required", - permission, - }); - expect(mockBatch).not.toHaveBeenCalled(); - } - ); - - it("updates automation fields", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { name: "Updated" }, - }); - expect(res.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ name: "Updated" }) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "update-automation" }]) - ); - }); - - it("leaves provider pins unchanged when providerSelections is omitted", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { body: { name: "Updated" } }); - - expect(res.status).toBe(200); - expect(mockProviderAuthStore.bindReplace).not.toHaveBeenCalled(); - }); - - it.each([ - [ - "replaces", - { - openai: { - mode: "provider_account" as const, - accountId: "0123456789abcdef0123456789abcdef", - }, - }, - ], - ["clears", {}], - ])( - "%s provider pins when providerSelections is present", - async (_label, providerSelections) => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { providerSelections }, - }); - - expect(res.status).toBe(200); - expect(mockProviderAuthStore.bindReplace).toHaveBeenCalledWith( - "auto-1", - providerSelections, - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "replace-provider-auth" }]) - ); - } - ); - - it.each([{ triggerConfig: {} }, { triggerConfig: { conditions: null } }])( - "rejects malformed trigger config before updating", - async ({ triggerConfig }) => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "webhook", - schedule_cron: null, - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { triggerConfig }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining("triggerConfig.conditions"), - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - } - ); - - it("validates trigger config shape before schedule automation semantics", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { triggerConfig: {} }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining("triggerConfig.conditions"), - }); - }); - - it("rejects an event type change that would leave incompatible conditions", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "workflow_run.completed", - trigger_config: JSON.stringify({ - conditions: [{ type: "workflow_name", operator: "eq", value: "CI" }], - }), - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { eventType: "pull_request.opened" }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: 'Condition "workflow_name" does not apply to GitHub event pull_request.opened', - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - }); - - it.each([null, "", " "])("rejects an invalid explicit event type: %j", async (eventType) => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "workflow_run.completed", - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { eventType }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: "eventType must be a non-empty string", - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - }); - - it("rejects an unsupported explicit event type", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "workflow_run.completed", - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { eventType: "workflow_run.typo" }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: "Unsupported eventType for github_event: workflow_run.typo", - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - }); - - it("allows an unchanged legacy condition on an unrelated edit", async () => { - const legacyTriggerConfig = { - conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], - } as const; - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "pull_request.opened", - trigger_config: JSON.stringify(legacyTriggerConfig), - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { name: "Updated", triggerConfig: legacyTriggerConfig }, - }); - - expect(response.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ - name: "Updated", - trigger_config: JSON.stringify(legacyTriggerConfig), - }) - ); - }); - - it("allows resubmitting the same event type without a legacy trigger config", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "pull_request.opened", - trigger_config: JSON.stringify({ - conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], - }), - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { eventType: "pull_request.opened" }, - }); - - expect(response.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith("auto-1", { - event_type: "pull_request.opened", - }); - }); - - it("rejects modifying a grandfathered incompatible condition", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "pull_request.opened", - trigger_config: JSON.stringify({ - conditions: [{ type: "path_glob", operator: "any_match", value: ["src/**"] }], - }), - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { - triggerConfig: { - conditions: [{ type: "path_glob", operator: "any_match", value: ["packages/**"] }], - }, - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: 'Condition "path_glob" does not apply to github triggers', - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - }); - - it("rejects appending a duplicate grandfathered condition", async () => { - const legacyCondition = { - type: "path_glob", - operator: "any_match", - value: ["src/**"], - } as const; - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - schedule_cron: null, - schedule_tz: null, - event_type: "pull_request.opened", - trigger_config: JSON.stringify({ conditions: [legacyCondition] }), - }); - - const response = await callRoute("PUT", "/automations/auto-1", { - body: { - triggerConfig: { conditions: [legacyCondition, legacyCondition] }, - }, - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: 'Condition "path_glob" does not apply to github triggers', - }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - }); - - it("updates reasoning effort when valid for the selected model", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { reasoningEffort: "high" }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ reasoning_effort: "high" }) - ); - }); - - it("accepts nullable reasoning effort in update payloads", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "high" }); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { reasoningEffort: null }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ reasoning_effort: null }) - ); - }); - - it("rejects malformed update payloads before persistence", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { reasoningEffort: 123 }, - }); - - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ error: "Invalid automation request" }); - expect(mockStore.bindAutomationUpdate).not.toHaveBeenCalled(); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("clears incompatible reasoning effort when model changes", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, reasoning_effort: "max" }); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { model: "openai/gpt-5.4" }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ model: "openai/gpt-5.4", reasoning_effort: null }) - ); - }); - - it("replaces the environment selection", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { environmentIds: ["env_1"] }, - }); - - expect(res.status).toBe(200); - expect(mockEnvironmentStore.getById).toHaveBeenCalledWith("env_1"); - expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( - "auto-1", - ["env_1"], - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "replace-environments" }]) - ); - }); - - it("clears the environment selection with an empty list", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { environmentIds: [] }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindReplaceEnvironments).toHaveBeenCalledWith( - "auto-1", - [], - expect.any(Number) - ); - }); - - it("validates the combined count against the other side's existing rows", async () => { - // A webhook automation with one existing repository row: adding an - // environment makes it multi-target, which requires a schedule trigger. - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "webhook", - schedule_cron: null, - }); - mockStore.getRepositoriesForAutomation.mockResolvedValue([ - { repo_owner: "acme", repo_name: "web-app" }, - ]); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { environmentIds: ["env_1"] }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Multi-target selections require a schedule trigger", - }); - }); - - it("rejects an unknown environment on update", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockEnvironmentStore.getById.mockResolvedValue(null); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { environmentIds: ["env_missing"] }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ error: "Environment not found: env_missing" }); - }); - - it("clears repository context with an empty repositories list", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { repositories: [] }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( - "auto-1", - [], - expect.any(Number) - ); - expect(mockBatch).toHaveBeenCalledWith( - expect.arrayContaining([{ sql: "replace-repositories" }]) - ); - }); - - it("rejects clearing repository context on repo-scoped automations", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - trigger_type: "github_event", - event_type: "pull_request.opened", - schedule_cron: null, - }); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { repositories: [] }, - }); - - expect(res.status).toBe(400); - expect(await res.json()).toEqual({ - error: "Repository-scoped triggers require exactly one repository", - }); - expect(mockBatch).not.toHaveBeenCalled(); - }); - - it("replaces repository context when repo fields are supplied", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { repositories: [{ repoOwner: "Acme", repoName: "Web-App" }] }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( - "auto-1", - [{ repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }], - expect.any(Number) - ); - }); - - it("resets the branch to the resolved default when the repository changes", async () => { - // Existing automation tracks acme/web-app@main; retargeting must take the - // NEW repo's default branch, never carry the previous row's branch over. - mockStore.getById.mockResolvedValue(sampleRow); - vi.mocked(resolveRepoOrError).mockResolvedValue({ - repoId: 777, - repoOwner: "acme", - repoName: "api", - defaultBranch: "trunk", - }); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { repositories: [{ repoOwner: "acme", repoName: "api" }] }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( - "auto-1", - [{ repo_owner: "acme", repo_name: "api", repo_id: 777, base_branch: "trunk" }], - expect.any(Number) - ); - }); - - it("replaces the whole selection from the repositories list", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { - repositories: [ - { repoOwner: "acme", repoName: "web-app" }, - { repoOwner: "acme", repoName: "api", baseBranch: "develop" }, - ], - }, - }); - - expect(res.status).toBe(200); - expect(mockStore.bindReplaceRepositories).toHaveBeenCalledWith( - "auto-1", - [ - { repo_owner: "acme", repo_name: "web-app", repo_id: 12345, base_branch: "main" }, - { repo_owner: "acme", repo_name: "api", repo_id: 12345, base_branch: "develop" }, - ], - expect.any(Number) - ); - }); - - it("applies repository-set edits without consulting active runs", async () => { - // Snapshots on runs make edits safe mid-invocation — there is no - // active-run guard on the repository selection. - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { - repositories: [ - { repoOwner: "acme", repoName: "api" }, - { repoOwner: "acme", repoName: "cli" }, - ], - }, - }); - - expect(res.status).toBe(200); - expect(mockStore.getActiveRunForAutomation).not.toHaveBeenCalled(); - expect(mockStore.bindReplaceRepositories).toHaveBeenCalledTimes(1); - }); - - it("returns 400 for invalid reasoning effort in update", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { model: "anthropic/claude-sonnet-4-6", reasoningEffort: "xhigh" }, - }); - - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("reasoning"); - }); - - it("returns 404 when automation not found", async () => { - mockStore.getById.mockResolvedValue(null); - - const res = await callRoute("PUT", "/automations/missing", { - body: { name: "Updated" }, - }); - expect(res.status).toBe(404); - }); - - it("returns 400 for invalid cron in update", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { scheduleCron: "bad" }, - }); - expect(res.status).toBe(400); - }); - - it("returns 400 for empty name in update", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - const res = await callRoute("PUT", "/automations/auto-1", { - body: { name: "" }, - }); - expect(res.status).toBe(400); - }); - - it("recomputes next_run_at when schedule changes", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - await callRoute("PUT", "/automations/auto-1", { - body: { scheduleCron: "0 12 * * *" }, - }); - - expect(mockStore.bindAutomationUpdate).toHaveBeenCalledWith( - "auto-1", - expect.objectContaining({ - schedule_cron: "0 12 * * *", - next_run_at: expect.any(Number), - }) - ); - }); - }); - - describe("DELETE /automations/:id", () => { - it("soft-deletes automation", async () => { - const res = await callRoute("DELETE", "/automations/auto-1"); - expect(res.status).toBe(200); - - const body = await res.json<{ status: string }>(); - expect(body.status).toBe("deleted"); - }); - - it("returns 404 when not found", async () => { - mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); - - const res = await callRoute("DELETE", "/automations/missing"); - expect(res.status).toBe(404); - }); - }); - - describe("POST /automations/:id/pause", () => { - it("pauses automation", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 }); - - const res = await callRoute("POST", "/automations/auto-1/pause"); - expect(res.status).toBe(200); - expect(mockStore.bindPause).toHaveBeenCalledWith("auto-1"); - }); - - it("returns 404 when not found", async () => { - mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); - - const res = await callRoute("POST", "/automations/missing/pause"); - expect(res.status).toBe(404); - }); - }); - - describe("POST /automations/:id/resume", () => { - it("resumes automation and recomputes next_run_at", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, enabled: 0 }); - - const res = await callRoute("POST", "/automations/auto-1/resume"); - expect(res.status).toBe(200); - expect(mockStore.bindResume).toHaveBeenCalledWith("auto-1", expect.any(Number)); - }); - - it("returns 404 when not found", async () => { - mockStore.getById.mockResolvedValue(null); - - const res = await callRoute("POST", "/automations/missing/resume"); - expect(res.status).toBe(404); - }); - - it("returns 400 when automation has no cron schedule", async () => { - mockStore.getById.mockResolvedValue({ - ...sampleRow, - schedule_cron: null, - }); - - const res = await callRoute("POST", "/automations/auto-1/resume"); - expect(res.status).toBe(400); - const body = await res.json<{ error: string }>(); - expect(body.error).toContain("no cron schedule"); - }); - }); - - describe("POST /automations/:id/regenerate-key", () => { - it.each([123, " "])( - "rejects malformed sentry secret payloads before persistence", - async (sentryClientSecret) => { - mockStore.getById.mockResolvedValue({ ...sampleRow, trigger_type: "sentry" }); - - const res = await callRoute("POST", "/automations/auto-1/regenerate-key", { - body: { sentryClientSecret }, - }); - - expect(res.status).toBe(400); - await expect(res.json()).resolves.toEqual({ error: "sentryClientSecret is required" }); - expect(mockStore.update).not.toHaveBeenCalled(); - } - ); - - it("returns 404 when the key update affects no current automation", async () => { - mockStore.getById.mockResolvedValue({ ...sampleRow, trigger_type: "webhook" }); - mockBatch.mockResolvedValue([{ meta: { changes: 0 }, results: [] }]); - - const res = await callRoute("POST", "/automations/auto-1/regenerate-key"); - - expect(res.status).toBe(404); - await expect(res.json()).resolves.toEqual({ error: "Automation not found" }); - }); - }); - - describe("POST /automations/:id/trigger", () => { - it("triggers automation via the scheduler", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockStore.getActiveRunForAutomation.mockResolvedValue(null); - const enrichment = { - scmUserId: "123", - scmLogin: "requester", - accessTokenEncrypted: "encrypted-access", - }; - mockResolveGitHubEnrichmentForRequest.mockResolvedValue(enrichment); - - const res = await callRoute("POST", "/automations/auto-1/trigger"); - expect(res.status).toBe(201); - expect(await res.json()).toEqual({ - invocationId: "inv-1", - runs: [{ id: "run-1" }], - }); - expect(mockSchedulerTrigger).toHaveBeenCalledWith("auto-1", "user-1", enrichment); - }); - - it("returns 404 when automation not found", async () => { - mockStore.getById.mockResolvedValue(null); - - const res = await callRoute("POST", "/automations/missing/trigger"); - expect(res.status).toBe(404); - }); - - it("returns 409 when the scheduler reports an active run", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - - mockSchedulerTrigger.mockRejectedValue(new AutomationTriggerBlockedError()); - - const res = await callRoute("POST", "/automations/auto-1/trigger"); - expect(res.status).toBe(409); - expect(await res.json()).toEqual({ - error: "A run is already active for this automation", - }); - }); - - it("returns 403 when the owner is unauthorized to execute", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockSchedulerTrigger.mockRejectedValue(new AutomationExecutionUnauthorizedError()); - - const res = await callRoute("POST", "/automations/auto-1/trigger"); - - expect(res.status).toBe(403); - expect(await res.json()).toEqual({ error: "Execution authorization required" }); - }); - - it("returns 500 when the scheduler cannot launch the automation", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockSchedulerTrigger.mockRejectedValue(new Error("launch failed")); - - const res = await callRoute("POST", "/automations/auto-1/trigger"); - - expect(res.status).toBe(500); - expect(await res.json()).toEqual({ error: "Failed to trigger automation" }); - }); - }); - - describe("GET /automations/:id/invocations (list invocations)", () => { - it("returns invocations for automation", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockStore.listInvocations.mockResolvedValue({ - invocations: [{ id: "inv-1", status: "completed", runs: [{ id: "run-1" }] }], - total: 1, - }); - - const res = await callRoute("GET", "/automations/auto-1/invocations"); - expect(res.status).toBe(200); - - const body = await res.json<{ invocations: unknown[]; total: number }>(); - expect(body.invocations).toHaveLength(1); - expect(body.total).toBe(1); - }); - - it("returns 404 when automation not found", async () => { - mockStore.getById.mockResolvedValue(null); - - const res = await callRoute("GET", "/automations/missing/invocations"); - expect(res.status).toBe(404); - }); - - it("respects limit and offset params", async () => { - mockStore.getById.mockResolvedValue(sampleRow); - mockStore.listInvocations.mockResolvedValue({ invocations: [], total: 0 }); - - await callRoute("GET", "/automations/auto-1/invocations", { - query: { limit: "5", offset: "10" }, - }); - - expect(mockStore.listInvocations).toHaveBeenCalledWith("auto-1", { - limit: 5, - offset: 10, - }); - }); - }); - - describe("GET /automations/:id/runs/:runId (get run)", () => { - it("returns a specific run", async () => { - mockStore.getRunById.mockResolvedValue({ id: "run-1", status: "completed" }); - - const res = await callRoute("GET", "/automations/auto-1/runs/run-1"); - expect(res.status).toBe(200); - - const body = await res.json<{ run: { id: string } }>(); - expect(body.run.id).toBe("run-1"); - }); - - it("returns 404 when run not found", async () => { - mockStore.getRunById.mockResolvedValue(null); - - const res = await callRoute("GET", "/automations/auto-1/runs/missing"); - expect(res.status).toBe(404); - }); - }); -}); diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 1d9d0ac52..58c7bedc1 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -1,1449 +1,24 @@ /** - * Automation CRUD routes. + * Automation routes: one module per responsibility, mounted in precedence order. */ -import { isValidCron, nextCronOccurrence, cronIntervalMinutes } from "@open-inspect/shared/cron"; -import { - triggerConfigSchema, - validateConditions, - conditionRegistry, - isGitHubConditionSupported, - triggerSources, - TRIGGER_TYPE_TO_SOURCE, -} from "@open-inspect/shared/triggers"; -import type { AutomationTriggerType, TriggerConfig } from "@open-inspect/shared/triggers"; -import { - createAutomationRequestSchema, - sentryClientSecretSchema, - updateAutomationRequestSchema, -} from "@open-inspect/shared/types/automations"; -import type { ModelProviderSelections } from "@open-inspect/shared/types/provider-accounts"; -import { listChannels } from "@open-inspect/shared/slack"; -import type { PermissionId } from "@open-inspect/shared/rbac"; -import { - getValidModelOrDefault, - isValidModel, - isValidReasoningEffort, -} from "@open-inspect/shared/models"; -import { - AutomationStore, - toAutomation, - toAutomationRun, - type AutomationRow, - type AutomationRepositoryInsert, -} from "../db/automation-store"; -import { - encodeAutomationListCursor, - parseAutomationListCursor, -} from "../db/automation-list-cursor"; -import { EnvironmentStore } from "../db/environments"; -import { SlackChannelStore } from "../db/slack-channel-store"; -import { UserStore } from "../db/user-store"; -import { AutomationModelProviderAuthStore } from "../db/automation-model-provider-auth"; -import { - AutomationProviderSelectionError, - parseAndValidateAutomationProviderSelections, -} from "../model-provider-accounts/automation-provider-selection"; -import { generateId } from "../auth/crypto"; -import { - applyIdentityEnforcement, - requireAdmittedCanonicalUserId, -} from "../routing/identity-enforcement"; -import { generateWebhookApiKey, hashApiKey, encryptSentrySecret } from "../auth/webhook-key"; -import { createLogger } from "../logger"; -import { - AutomationExecutionUnauthorizedError, - AutomationTriggerBlockedError, - Scheduler, -} from "../scheduler/scheduler"; -import { hydrateAutomation } from "../automation/hydrate"; -import { MAX_AUTOMATION_REPOSITORIES } from "@open-inspect/shared/types/automations"; import { Hono } from "hono"; -import { admit, dispatch } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; -import { - type RequestContext, - GITHUB_USER_OR_SERVICE_ROUTE, - json, - error, - parseJsonBody, - resolveRepoOrError, - requireAutomation, - requirePermission, - type AutomationRouteAdmission, -} from "./shared"; -import { parseQuery } from "./query"; -import type { Env } from "../types"; -import type { SqlDatabase, SqlStatement } from "../db/sql-database"; -import { z } from "zod"; -import { ProviderAccountSelectionPolicyError } from "../model-provider-accounts/selection-policy"; -import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; -import { resolveGitHubEnrichmentForRequest } from "../session/identity"; - -const logger = createLogger("router:automations"); - -function requireTargetPermissions( - ctx: RequestContext, - requiredPermissions: readonly PermissionId[] -): Response | null { - const authorization = ctx.authorization; - if (!authorization) return json({ error: "Authorization unavailable" }, 503); - const missingPermission = requiredPermissions.find( - (permission) => !authorization.permissions.includes(permission) - ); - if (missingPermission) { - return json( - { error: "Forbidden", code: "permission_required", permission: missingPermission }, - 403 - ); - } - return null; -} - -function admittedAutomation(ctx: RequestContext): AutomationRouteAdmission { - if (!ctx.automationAdmission) throw new Error("Missing automation route admission"); - return ctx.automationAdmission; -} - -/** Minimum cron interval in minutes. */ -const MIN_CRON_INTERVAL_MINUTES = 15; - -/** Maximum name length. */ -export const MAX_NAME_LENGTH = 200; - -/** Maximum instructions length. Keep in sync with INSTRUCTIONS_MAX_LENGTH in packages/web/src/components/automations/automation-form.tsx. */ -const MAX_INSTRUCTIONS_LENGTH = 15_000; - -const RECENT_EXECUTION_COUNT = 10; - -const createAutomationBodySchema = createAutomationRequestSchema.extend({ - // Bot-asserted actor display fields are cosmetic only; identity enforcement - // still runs against the raw pre-Zod body before these parsed values are used. - actorDisplayName: z.string().optional(), - actorEmail: z.string().optional(), - actorAvatarUrl: z.string().optional(), -}); - -type CreateAutomationBody = z.infer; - -const regenerateSentrySecretBodySchema = z.object({ - sentryClientSecret: sentryClientSecretSchema, -}); - -function formatAutomationRequestError(parseError: z.ZodError, rawBody: unknown): string { - const issue = parseError.issues[0]; - const field = issue?.path[0]; - - if (field === "environmentIds") { - return issue.message === "must not contain duplicates" - ? "environmentIds must not contain duplicates" - : "environmentIds must be an array of environment ids (env_…)"; - } - - if (field === "repositories") { - const index = typeof issue.path[1] === "number" ? `[${String(issue.path[1])}]` : ""; - return `repositories${index}: ${issue.message}`; - } - - if (field === "eventType") return "eventType must be a non-empty string"; - - if (field === "triggerConfig") { - if (issue.path.length === 2 && issue.path[1] === "conditions") { - return "triggerConfig.conditions must be an array"; - } - - const path = issue.path.map(String).join("."); - const conditionIndex = issue.path[1] === "conditions" ? issue.path[2] : undefined; - const conditions = - rawBody && - typeof rawBody === "object" && - "triggerConfig" in rawBody && - rawBody.triggerConfig && - typeof rawBody.triggerConfig === "object" && - "conditions" in rawBody.triggerConfig && - Array.isArray(rawBody.triggerConfig.conditions) - ? rawBody.triggerConfig.conditions - : undefined; - const condition = typeof conditionIndex === "number" ? conditions?.[conditionIndex] : undefined; - const conditionType = - condition && - typeof condition === "object" && - "type" in condition && - typeof condition.type === "string" - ? `${condition.type}: ` - : ""; - return `${path}: ${conditionType}${issue.message}`; - } - - return "Invalid automation request"; -} - -interface TriggerConditionError { - condition: TriggerConfig["conditions"][number]; - code: "event_incompatible" | "invalid"; - message: string; -} - -function getTriggerConditionErrors( - triggerType: AutomationTriggerType, - triggerConfig: TriggerConfig, - eventType?: string -): TriggerConditionError[] { - const source = TRIGGER_TYPE_TO_SOURCE[triggerType]; - if (!source) return []; - return triggerConfig.conditions.flatMap((condition) => { - const code = - source === "github" && - eventType !== undefined && - !isGitHubConditionSupported(eventType, condition.type) - ? "event_incompatible" - : "invalid"; - return validateConditions([condition], source, conditionRegistry, eventType).map((message) => ({ - condition, - code, - message, - })); - }); -} - -function consumeCondition( - triggerConfig: TriggerConfig, - condition: TriggerConditionError["condition"], - consumedIndexes: Set -): boolean { - const serialized = JSON.stringify(condition); - const index = triggerConfig.conditions.findIndex( - (existing, candidateIndex) => - !consumedIndexes.has(candidateIndex) && JSON.stringify(existing) === serialized - ); - if (index === -1) return false; - consumedIndexes.add(index); - return true; -} - -function getTriggerEventTypeError( - triggerType: AutomationTriggerType, - eventType: unknown -): string | null { - if (eventType !== undefined && (typeof eventType !== "string" || eventType.trim().length === 0)) { - return "eventType must be a non-empty string"; - } - - const source = triggerSources.find((candidate) => candidate.triggerType === triggerType); - if (!source?.supportsEventTypes) return null; - if (typeof eventType !== "string" || eventType.trim().length === 0) { - return `eventType is required for ${triggerType} triggers`; - } - if (!source.eventTypes.some((candidate) => candidate.eventType === eventType)) { - return `Unsupported eventType for ${triggerType}: ${eventType}`; - } - return null; -} - -/** Warn if next run is more than 31 days away. */ -const FAR_FUTURE_THRESHOLD_MS = 31 * 24 * 60 * 60 * 1000; - -function resolveReasoningEffort( - model: string, - reasoningEffort: string | null | undefined -): string | null { - if (reasoningEffort === undefined || reasoningEffort === null) return null; - return isValidReasoningEffort(model, reasoningEffort) ? reasoningEffort : null; -} - -type NormalizedRepositoryInput = NonNullable[number]; - -type RepositorySelectionRequest = - | { kind: "unchanged" } - | { kind: "replace"; repositories: NormalizedRepositoryInput[] }; - -/** - * Thrown when selection semantics cannot be satisfied. Route handlers catch it - * and answer 400 while request shape validation remains in the shared schemas. - */ -class TargetSelectionError extends Error { - constructor(message: string) { - super(message); - this.name = "TargetSelectionError"; - } -} - -/** - * Select the repositories from an already-parsed create/update body. `unchanged` - * means the body did not touch the selection (create treats that as empty). - */ -function getRepositorySelection(body: { - repositories?: NormalizedRepositoryInput[]; -}): RepositorySelectionRequest { - if (body.repositories === undefined) return { kind: "unchanged" }; - return { kind: "replace", repositories: body.repositories }; -} - -/** - * Target-count rules across BOTH selections (repositories + environments): - * repo-scoped event triggers need exactly one repository and no environments; - * fan-out over several targets is a schedule/manual-only product scope (event - * fan-out semantics are undefined, not technically prevented). Repositories - * and environments share one combined cap. - */ -function validateTargetCounts( - triggerType: AutomationTriggerType, - repositoryCount: number, - environmentCount: number -): void { - if (triggerType === "github_event" || triggerType === "linear_event") { - if (repositoryCount === 0) { - throw new TargetSelectionError("Repository-scoped triggers require exactly one repository"); - } - if (environmentCount > 0) { - throw new TargetSelectionError("Repository-scoped triggers cannot target environments"); - } - } - if (repositoryCount + environmentCount > 1 && triggerType !== "schedule") { - throw new TargetSelectionError("Multi-target selections require a schedule trigger"); - } - if (repositoryCount + environmentCount > MAX_AUTOMATION_REPOSITORIES) { - throw new TargetSelectionError( - `At most ${MAX_AUTOMATION_REPOSITORIES} repositories and environments combined` - ); - } -} - -type EnvironmentSelectionRequest = - | { kind: "unchanged" } - | { kind: "replace"; environmentIds: string[] }; - -/** - * Select the environments from an already-parsed create/update body (design - * §13.3). `unchanged` means the body did not touch the selection (create treats - * that as empty); an array replaces it wholesale (empty clears). - */ -function getEnvironmentSelection(body: { environmentIds?: string[] }): EnvironmentSelectionRequest { - if (body.environmentIds === undefined) return { kind: "unchanged" }; - return { kind: "replace", environmentIds: body.environmentIds }; -} - -/** - * Verify every selected environment exists — a selection must not silently - * point at deleted environments. - * - * @throws TargetSelectionError naming every missing environment. - */ -async function resolveEnvironmentSelection( - db: SqlDatabase, - environmentIds: string[] -): Promise { - if (environmentIds.length === 0) return; - const store = new EnvironmentStore(db); - const found = await Promise.all(environmentIds.map((id) => store.getById(id))); - const missing = environmentIds.filter((_, index) => !found[index]); - if (missing.length > 0) { - throw new TargetSelectionError(`Environment not found: ${missing.join(", ")}`); - } -} - -/** - * Resolve every requested repository through the SCM provider concurrently. - * The first failure IN INPUT ORDER wins. A repo change always takes the body - * branch or the freshly resolved default — never a previous row's branch. - */ -async function resolveRepositorySelection( - env: Env, - repositories: NormalizedRepositoryInput[], - ctx: RequestContext -): Promise { - const settled = await Promise.allSettled( - repositories.map((repository) => - resolveRepoOrError(env, repository.repoOwner, repository.repoName, ctx, logger) - ) - ); - const resolved = settled.map((result) => { - if (result.status === "rejected") throw result.reason; - return result.value; - }); - - return repositories.map((repository, index) => { - const access = resolved[index]; - return { - repo_owner: repository.repoOwner, - repo_name: repository.repoName, - repo_id: access.repoId, - base_branch: repository.baseBranch ?? access.defaultBranch, - }; - }); -} - -/** - * Validate an IANA timezone string. - */ -function isValidTimezone(tz: string): boolean { - try { - Intl.DateTimeFormat(undefined, { timeZone: tz }); - return true; - } catch { - return false; - } -} - -/** Extract the watched channel IDs from a slack automation's `slack_channel` condition. */ -function extractSlackChannels(triggerConfig: TriggerConfig | null | undefined): string[] { - for (const condition of triggerConfig?.conditions ?? []) { - if (condition.type === "slack_channel") return condition.value; - } - return []; -} - -/** - * Validate a slack_event trigger config before persistence. It must be scoped to - * an explicit channel set (net-new validation; the engine otherwise skips - * condition validation entirely when none are present). A text_match is optional - * — without one the automation fires on every message in the watched channel. - * Returns an error message, or null when valid. - */ -function validateSlackTriggerConfig( - triggerConfig: TriggerConfig | null | undefined -): string | null { - const conditions = triggerConfig?.conditions ?? []; - if (!conditions.some((c) => c.type === "slack_channel")) { - return "slack_event triggers require a slack_channel condition"; - } - return null; -} - -// ─── Handlers ──────────────────────────────────────────────────────────────── - -const DEFAULT_AUTOMATION_LIST_PAGE_SIZE = 25; -const MAX_AUTOMATION_LIST_PAGE_SIZE = 100; - -const automationListLimitSchema = z - .string() - .regex(/^\d+$/, { message: "Invalid limit" }) - .transform(Number) - .refine((limit) => limit >= 1 && limit <= MAX_AUTOMATION_LIST_PAGE_SIZE, { - message: "Invalid limit", - }); - -const automationListQuerySchema = z.object({ - limit: automationListLimitSchema - .optional() - .transform((limit) => limit ?? DEFAULT_AUTOMATION_LIST_PAGE_SIZE), - cursor: z - .string() - .optional() - .transform((raw, context) => { - const parsed = parseAutomationListCursor(raw ?? null); - if (!parsed.ok) { - context.addIssue({ code: "custom", message: parsed.error }); - return z.NEVER; - } - return parsed.cursor; - }), - search: z.string().trim().max(MAX_NAME_LENGTH, { message: "Search is too long" }).optional(), - repoOwner: z.string().optional(), - repoName: z.string().optional(), -}); - -async function handleListAutomations( - request: Request, - env: Env, - ctx: RequestContext -): Promise { - const query = parseQuery(request, automationListQuerySchema); - if (query instanceof Response) return query; - - const store = new AutomationStore(ctx.db); - const providerAuthStore = new AutomationModelProviderAuthStore(ctx.db); - const result = await store.list({ - limit: query.limit, - cursor: query.cursor, - ...(query.search ? { nameSearch: query.search } : {}), - ...(query.repoOwner ? { repoOwner: query.repoOwner } : {}), - ...(query.repoName ? { repoName: query.repoName } : {}), - }); - const automationIds = result.automations.map((row) => row.id); - const [ - repositoriesByAutomation, - environmentsByAutomation, - providerAuthByAutomation, - recentExecutionsByAutomation, - ] = await Promise.all([ - store.getRepositoriesForAutomationIds(automationIds), - store.getEnvironmentsForAutomationIds(automationIds), - providerAuthStore.listForAutomationIds(automationIds), - store.listRecentExecutionsForAutomationIds(automationIds, RECENT_EXECUTION_COUNT), - ]); - - const automations = result.automations.map((row) => ({ - ...toAutomation( - row, - repositoriesByAutomation.get(row.id) ?? [], - environmentsByAutomation.get(row.id) ?? [], - providerAuthByAutomation.get(row.id) ?? [] - ), - recentExecutions: recentExecutionsByAutomation.get(row.id) ?? [], - })); - return json({ - automations, - hasMore: result.hasMore, - nextCursor: result.nextCursor ? encodeAutomationListCursor(result.nextCursor) : null, - }); -} - -async function handleCreateAutomation( - request: Request, - env: Env, - ctx: RequestContext -): Promise { - const rawBody = await parseJsonBody(request); - if (rawBody instanceof Response) return rawBody; - - // Automation attribution comes from the verified principal. The stored - // values are replayed by the scheduler as session identity at fire time, - // so this is where they become trustworthy. - const enforcement = applyIdentityEnforcement(ctx, "automation-create", rawBody); - if (enforcement.rejection) return enforcement.rejection; - const enforced = enforcement.enforced; - - const parsedBody = createAutomationBodySchema.safeParse(rawBody); - if (!parsedBody.success) { - return error(formatAutomationRequestError(parsedBody.error, rawBody), 400); - } - const body: CreateAutomationBody = parsedBody.data; - - // Validate required fields - if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { - return error("name is required", 400); - } - if (body.name.length > MAX_NAME_LENGTH) { - return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); - } - if ( - !body.instructions || - typeof body.instructions !== "string" || - body.instructions.trim().length === 0 - ) { - return error("instructions is required", 400); - } - if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { - return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); - } - - const selection = getRepositorySelection(body); - const requestedRepositories = selection.kind === "replace" ? selection.repositories : []; - - // Validate trigger type - const triggerType: AutomationTriggerType = body.triggerType || "schedule"; - const validTriggerTypes: AutomationTriggerType[] = [ - "schedule", - "sentry", - "webhook", - "github_event", - "linear_event", - "slack_event", - ]; - if (!validTriggerTypes.includes(triggerType)) { - return error(`triggerType must be one of: ${validTriggerTypes.join(", ")}`, 400); - } - let requestedEnvironmentIds: string[]; - try { - const environmentSelection = getEnvironmentSelection(body); - requestedEnvironmentIds = - environmentSelection.kind === "replace" ? environmentSelection.environmentIds : []; - validateTargetCounts(triggerType, requestedRepositories.length, requestedEnvironmentIds.length); - } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); - throw e; - } - if (ctx.principal?.kind === "user") { - const targetAuthorizationError = requireTargetPermissions(ctx, [ - ...(requestedRepositories.length > 0 ? (["repositories.use"] as const) : []), - ...(requestedEnvironmentIds.length > 0 ? (["environments.use"] as const) : []), - ]); - if (targetAuthorizationError) return targetAuthorizationError; - } - try { - await resolveEnvironmentSelection(ctx.db, requestedEnvironmentIds); - } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); - throw e; - } - - const isSchedule = triggerType === "schedule"; - - // Schedule-specific validation - if (isSchedule) { - if (!body.scheduleCron || !isValidCron(body.scheduleCron)) { - return error("scheduleCron must be a valid 5-field cron expression", 400); - } - const interval = cronIntervalMinutes(body.scheduleCron); - if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { - return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); - } - if (!body.scheduleTz || !isValidTimezone(body.scheduleTz)) { - return error("scheduleTz must be a valid IANA timezone", 400); - } - } else { - // Reject schedule fields for non-schedule types - if (body.scheduleCron || body.scheduleTz) { - return error("scheduleCron and scheduleTz are only valid for schedule triggers", 400); - } - } - - const eventTypeError = getTriggerEventTypeError(triggerType, body.eventType); - if (eventTypeError) return error(eventTypeError, 400); - - // Validate conditions - if (body.triggerConfig) { - const conditionErrors = getTriggerConditionErrors( - triggerType, - body.triggerConfig, - body.eventType - ); - if (conditionErrors.length > 0) { - return error(conditionErrors.map(({ message }) => message).join("; "), 400); - } - } - - // Slack triggers require explicit scoping (at least one watched channel). - if (triggerType === "slack_event") { - const slackError = validateSlackTriggerConfig(body.triggerConfig); - if (slackError) return error(slackError, 400); - } - - // Validate model - const model = getValidModelOrDefault(body.model); - const reasoningEffort = resolveReasoningEffort(model, body.reasoningEffort); - if (body.reasoningEffort !== undefined && body.reasoningEffort !== null && !reasoningEffort) { - return error("Invalid reasoning effort for selected model", 400); - } - - const newRepositories = await resolveRepositorySelection(env, requestedRepositories, ctx); - - let providerSelections: ModelProviderSelections; - try { - providerSelections = await parseAndValidateAutomationProviderSelections( - ctx.db, - body.providerSelections ?? {} - ); - } catch (e) { - if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); - if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); - throw e; - } - - // Compute next run (only for schedule triggers) - const nextRunAt = isSchedule - ? nextCronOccurrence(body.scheduleCron!, body.scheduleTz!).getTime() - : null; - - const id = generateId(); - const now = Date.now(); - - // Generate auth data for trigger types that need it - let webhookApiKey: string | undefined; - let triggerAuthData: string | null = null; - if (triggerType === "webhook") { - webhookApiKey = generateWebhookApiKey(); - triggerAuthData = await hashApiKey(webhookApiKey); - } else if (triggerType === "sentry") { - const sentrySecret = body.sentryClientSecret; - if (!sentrySecret || typeof sentrySecret !== "string" || sentrySecret.trim().length === 0) { - return error("sentryClientSecret is required for sentry triggers", 400); - } - if (!env.REPO_SECRETS_ENCRYPTION_KEY) { - return error("Encryption key not configured", 503); - } - triggerAuthData = await encryptSentrySecret(sentrySecret, env.REPO_SECRETS_ENCRYPTION_KEY); - } - - // The scheduler replays user_id as session identity at fire time, so the - // handler may consume only the canonical subject admitted before RBAC. - const resolution = requireAdmittedCanonicalUserId(ctx, enforced); - if (resolution instanceof Response) return resolution; - const resolvedUserId = resolution; - - const db: SqlDatabase = ctx.db; - const store = new AutomationStore(db); - const providerAuthStore = new AutomationModelProviderAuthStore(db); - const row: AutomationRow = { - id, - name: body.name.trim(), - instructions: body.instructions, - trigger_type: triggerType, - schedule_cron: body.scheduleCron ?? null, - schedule_tz: body.scheduleTz ?? "UTC", - model, - reasoning_effort: reasoningEffort, - enabled: 1, - next_run_at: nextRunAt, - consecutive_failures: 0, - created_by: enforced.participantUserId, - user_id: resolvedUserId, - created_at: now, - updated_at: now, - deleted_at: null, - event_type: body.eventType ?? null, - trigger_config: body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, - trigger_auth_data: triggerAuthData, - }; - - // Persist the automation, its repository selection, and (for slack_event) - // its watched-channel index in a single atomic write, so none of the three - // can drift apart on a partial failure. The batch composes the single-table - // stores' prepared statements. - const createStatements = [ - store.bindAutomationInsert(row), - ...store.bindRepositoryInserts(id, newRepositories, now), - ...store.bindEnvironmentInserts(id, requestedEnvironmentIds, now), - ...providerAuthStore.bindInserts(id, providerSelections, now), - ]; - if (triggerType === "slack_event") { - const slackStore = new SlackChannelStore(db); - createStatements.push( - ...slackStore.bindChannelStatements(row.id, extractSlackChannels(body.triggerConfig)) - ); - } - await ctx.db.batch(createStatements); - - const automation = await hydrateAutomation(db, (await store.getById(id))!); - - logger.info("automation.created", { - event: "automation.created", - automation_id: id, - repo: newRepositories.map((repo) => `${repo.repo_owner}/${repo.repo_name}`).join(",") || null, - environments: requestedEnvironmentIds.join(",") || null, - trigger_type: triggerType, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - const workerUrl = env.WORKER_URL || ""; - const result: { - automation: typeof automation; - warning?: string; - webhookApiKey?: string; - webhookUrl?: string; - sentryWebhookUrl?: string; - } = { automation }; - - if (webhookApiKey) { - result.webhookApiKey = webhookApiKey; - result.webhookUrl = `${workerUrl}/webhooks/automation/${id}`; - } - - if (triggerType === "sentry") { - result.sentryWebhookUrl = `${workerUrl}/webhooks/sentry/${id}`; - } - - if (nextRunAt && nextRunAt - now > FAR_FUTURE_THRESHOLD_MS) { - result.warning = "Next scheduled run is more than 31 days away"; - } - - return json(result, 201); -} - -async function handleGetAutomation( - _request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - const row = await store.getById(id); - if (!row) return error("Automation not found", 404); - - return json({ automation: await hydrateAutomation(ctx.db, row) }); -} - -async function handleUpdateAutomation( - request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const db: SqlDatabase = ctx.db; - const store = new AutomationStore(db); - const providerAuthStore = new AutomationModelProviderAuthStore(db); - const admission = admittedAutomation(ctx); - const { automation: existing } = admission; - - const rawBody = await parseJsonBody(request); - if (rawBody instanceof Response) return rawBody; - const parsedBody = updateAutomationRequestSchema.safeParse(rawBody); - if (!parsedBody.success) { - return error(formatAutomationRequestError(parsedBody.error, rawBody), 400); - } - const body = parsedBody.data; - - if (body.triggerConfig !== undefined && existing.trigger_type === "schedule") { - return error("Cannot set triggerConfig on schedule automations", 400); - } - - let replacementProviderSelections: ModelProviderSelections | null = null; - if (body.providerSelections !== undefined) { - try { - replacementProviderSelections = await parseAndValidateAutomationProviderSelections( - ctx.db, - body.providerSelections - ); - } catch (e) { - if (e instanceof AutomationProviderSelectionError) return error(e.message, 400); - if (e instanceof ProviderAccountSelectionPolicyError) return error(e.message, e.status); - throw e; - } - } - - // Validate fields if provided - if (body.name !== undefined) { - if (typeof body.name !== "string" || body.name.trim().length === 0) { - return error("name cannot be empty", 400); - } - if (body.name.length > MAX_NAME_LENGTH) { - return error(`name must be at most ${MAX_NAME_LENGTH} characters`, 400); - } - } - - if (body.instructions !== undefined) { - if (typeof body.instructions !== "string" || body.instructions.trim().length === 0) { - return error("instructions cannot be empty", 400); - } - if (body.instructions.length > MAX_INSTRUCTIONS_LENGTH) { - return error(`instructions must be at most ${MAX_INSTRUCTIONS_LENGTH} characters`, 400); - } - } - - if (body.scheduleCron !== undefined) { - if (!isValidCron(body.scheduleCron)) { - return error("scheduleCron must be a valid 5-field cron expression", 400); - } - const interval = cronIntervalMinutes(body.scheduleCron); - if (interval !== null && interval < MIN_CRON_INTERVAL_MINUTES) { - return error(`Schedule interval must be at least ${MIN_CRON_INTERVAL_MINUTES} minutes`, 400); - } - } - - if (body.scheduleTz !== undefined && !isValidTimezone(body.scheduleTz)) { - return error("scheduleTz must be a valid IANA timezone", 400); - } - - if (body.model !== undefined && !isValidModel(body.model)) { - return error("Invalid model", 400); - } - - const nextModel = body.model !== undefined ? getValidModelOrDefault(body.model) : existing.model; - const requestedReasoningEffort = body.reasoningEffort; - const resolvedReasoningEffort = - requestedReasoningEffort !== undefined - ? resolveReasoningEffort(nextModel, requestedReasoningEffort) - : body.model !== undefined && existing.reasoning_effort !== null - ? resolveReasoningEffort(nextModel, existing.reasoning_effort) - : existing.reasoning_effort; - - if ( - requestedReasoningEffort !== undefined && - requestedReasoningEffort !== null && - resolvedReasoningEffort === null - ) { - return error("Invalid reasoning effort for selected model", 400); - } - - // Build update fields - const updateFields: Record = {}; - if (body.name !== undefined) updateFields.name = body.name.trim(); - if (body.instructions !== undefined) updateFields.instructions = body.instructions; - if (body.scheduleCron !== undefined) updateFields.schedule_cron = body.scheduleCron; - if (body.scheduleTz !== undefined) updateFields.schedule_tz = body.scheduleTz; - if (body.model !== undefined) updateFields.model = nextModel; - if (body.reasoningEffort !== undefined || body.model !== undefined) { - updateFields.reasoning_effort = resolvedReasoningEffort; - } - - // Repository-set edits are UNCONDITIONAL — no cardinality freeze and no - // active-invocation guard. In-flight invocations already materialized their - // children from their firing-time snapshot, so an edit cannot corrupt them; - // it simply applies from the next invocation. - const selection = getRepositorySelection(body); - const environmentSelection = getEnvironmentSelection(body); - const requiredTargetPermissions: PermissionId[] = [ - ...(selection.kind === "replace" && selection.repositories.length > 0 - ? (["repositories.use"] as const) - : []), - ...(environmentSelection.kind === "replace" && environmentSelection.environmentIds.length > 0 - ? (["environments.use"] as const) - : []), - ]; - if (requiredTargetPermissions.length > 0) { - const targetAuthorizationError = requireTargetPermissions(ctx, requiredTargetPermissions); - if (targetAuthorizationError) return targetAuthorizationError; - } - - // The count rules span both selections, so when EITHER is replaced they are - // validated against the automation's FINAL state (the replacement plus the - // other side's existing rows). Edits that touch neither selection skip this - // — count rules stay write-time so a stored selection predating a rule can - // never brick unrelated edits. - let replacementRepositories: AutomationRepositoryInsert[] | null = null; - const replacementEnvironmentIds: string[] | null = - environmentSelection.kind === "replace" ? environmentSelection.environmentIds : null; - if (selection.kind === "replace" || replacementEnvironmentIds !== null) { - try { - const finalRepositoryCount = - selection.kind === "replace" - ? selection.repositories.length - : (await store.getRepositoriesForAutomation(id)).length; - const finalEnvironmentCount = - replacementEnvironmentIds !== null - ? replacementEnvironmentIds.length - : (await store.getEnvironmentsForAutomation(id)).length; - validateTargetCounts( - existing.trigger_type as AutomationTriggerType, - finalRepositoryCount, - finalEnvironmentCount - ); - if (replacementEnvironmentIds !== null) { - await resolveEnvironmentSelection(ctx.db, replacementEnvironmentIds); - } - } catch (e) { - if (e instanceof TargetSelectionError) return error(e.message, 400); - throw e; - } - if (selection.kind === "replace") { - replacementRepositories = await resolveRepositorySelection(env, selection.repositories, ctx); - } - } - - // Update event type — only for non-schedule types - if (body.eventType !== undefined) { - if (existing.trigger_type === "schedule") { - return error("Cannot set eventType on schedule automations", 400); - } - updateFields.event_type = body.eventType; - } - - const effectiveEventType = - body.eventType !== undefined ? body.eventType : (existing.event_type ?? undefined); - const eventTypeError = getTriggerEventTypeError( - existing.trigger_type as AutomationTriggerType, - effectiveEventType - ); - if (eventTypeError) return error(eventTypeError, 400); - - let triggerConfigToValidate = body.triggerConfig; - if ( - body.eventType !== undefined && - triggerConfigToValidate === undefined && - existing.trigger_config - ) { - // This column was written through parseTriggerConfig, so a failure here is a - // corrupt row, not user input — parseTriggerConfig's per-condition messages - // would have no one to help. - try { - triggerConfigToValidate = triggerConfigSchema.parse(JSON.parse(existing.trigger_config)); - } catch { - return error("Stored triggerConfig is invalid", 500); - } - } - - // A slack_event's trigger_config holds its required channel scope. Clearing it - // would leave the automation enabled but untriggerable. - if (body.triggerConfig === null && existing.trigger_type === "slack_event") { - return error( - "Cannot clear triggerConfig on slack_event automations; pause or delete instead", - 400 - ); - } - if (body.triggerConfig && existing.trigger_type === "slack_event") { - const slackError = validateSlackTriggerConfig(body.triggerConfig); - if (slackError) return error(slackError, 400); - } - - if (triggerConfigToValidate) { - let conditionErrors = getTriggerConditionErrors( - existing.trigger_type as AutomationTriggerType, - triggerConfigToValidate, - effectiveEventType - ); - - // Existing source-wide GitHub conditions predate event-scoped validation. - // Preserve an unchanged condition on unrelated edits, but validate strictly - // when its value or the selected event changes. - const eventTypeChanged = body.eventType !== undefined && body.eventType !== existing.event_type; - if (existing.trigger_type === "github_event" && !eventTypeChanged && existing.trigger_config) { - try { - const parsedExisting = triggerConfigSchema.safeParse(JSON.parse(existing.trigger_config)); - if (parsedExisting.success) { - const consumedIndexes = new Set(); - conditionErrors = conditionErrors.filter(({ code, condition }) => { - if (code !== "event_incompatible") return true; - return !consumeCondition(parsedExisting.data, condition, consumedIndexes); - }); - } - } catch { - // A valid replacement should be able to repair malformed stored JSON. - } - } - - if (conditionErrors.length > 0) { - return error(conditionErrors.map(({ message }) => message).join("; "), 400); - } - } - - // trigger_config is a single source-interpreted JSON blob (the conditions), - // so a PUT replaces it wholesale (null clears it). The caller owns the full - // blob; the web form always re-submits the conditions within triggerConfig. - if (body.triggerConfig === null) { - updateFields.trigger_config = null; - } else if (body.triggerConfig !== undefined) { - updateFields.trigger_config = JSON.stringify(body.triggerConfig); - } - - // Recompute next_run_at if schedule changed (only for schedule types) - if ( - existing.trigger_type === "schedule" && - (body.scheduleCron !== undefined || body.scheduleTz !== undefined) - ) { - const cron = body.scheduleCron ?? existing.schedule_cron; - const tz = body.scheduleTz ?? existing.schedule_tz; - if (!cron) { - return error("Cannot compute schedule: no cron expression", 400); - } - updateFields.next_run_at = nextCronOccurrence(cron, tz).getTime(); - } - - // Apply the field update, the repository-selection replacement (which - // carries the transitional scalar-mirror dual-write), and any slack - // watched-channel re-sync in ONE atomic batch so none of them can drift - // apart on a partial failure. Tolerates a null update statement (e.g. a - // repositories-only edit). - const resyncSlackChannels = - existing.trigger_type === "slack_event" && body.triggerConfig !== undefined; - const statements: SqlStatement[] = []; - const updateStatement = store.bindAutomationUpdate(id, updateFields); - if (updateStatement) statements.push(updateStatement); - if (replacementRepositories !== null) { - statements.push(...store.bindReplaceRepositories(id, replacementRepositories, Date.now())); - } - if (replacementEnvironmentIds !== null) { - statements.push(...store.bindReplaceEnvironments(id, replacementEnvironmentIds, Date.now())); - } - if (replacementProviderSelections !== null) { - statements.push( - ...providerAuthStore.bindReplace(id, replacementProviderSelections, Date.now()) - ); - } - if (resyncSlackChannels) { - const slackStore = new SlackChannelStore(db); - statements.push( - ...slackStore.bindChannelStatements(id, extractSlackChannels(body.triggerConfig)) - ); - } - if (statements.length > 0) { - await ctx.db.batch(statements); - } - const updated = await store.getById(id); - if (!updated) return error("Automation not found", 404); - - logger.info("automation.updated", { - event: "automation.updated", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return json({ automation: await hydrateAutomation(db, updated) }); -} - -async function handleDeleteAutomation( - _request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - admittedAutomation(ctx); - const result = await ctx.db.batch([store.bindSoftDelete(id)]); - const deleted = result[0]?.meta.changes === 1; - if (!deleted) return error("Automation not found", 404); - - logger.info("automation.deleted", { - event: "automation.deleted", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return json({ status: "deleted", automationId: id }); -} - -async function handlePauseAutomation( - _request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - admittedAutomation(ctx); - const result = await ctx.db.batch([store.bindPause(id)]); - const paused = result[0]?.meta.changes === 1; - if (!paused) return error("Automation not found", 404); - - logger.info("automation.paused", { - event: "automation.paused", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - const row = await store.getById(id); - return json({ - automation: row ? await hydrateAutomation(ctx.db, row) : null, - }); -} - -async function handleResumeAutomation( - _request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - const { automation: existing } = admittedAutomation(ctx); - - // For schedule automations, compute the next run time. - // For event-driven automations, resume with null next_run_at. - let nextRunAt: number | null; - if (existing.trigger_type === "schedule") { - if (!existing.schedule_cron) { - return error("Cannot resume: automation has no cron schedule", 400); - } - nextRunAt = nextCronOccurrence(existing.schedule_cron, existing.schedule_tz).getTime(); - } else { - nextRunAt = null; - } - - const result = await ctx.db.batch([store.bindResume(id, nextRunAt)]); - const resumed = result[0]?.meta.changes === 1; - if (!resumed) return error("Automation not found", 404); - - logger.info("automation.resumed", { - event: "automation.resumed", - automation_id: id, - next_run_at: nextRunAt, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - const row = await store.getById(id); - return json({ - automation: row ? await hydrateAutomation(ctx.db, row) : null, - }); -} - -async function handleTriggerAutomation( - request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - admittedAutomation(ctx); - const requesterUserId = ctx.authorization?.userId; - if (!requesterUserId) return error("Authorization unavailable", 503); - - let requesterEnrichment; - try { - requesterEnrichment = await resolveGitHubEnrichmentForRequest( - env, - ctx.db, - new UserStore(ctx.db), - requesterUserId, - await resolveGitHubCredentialAuthority(ctx, request.headers) - ); - } catch (enrichmentError) { - logger.warn("Failed to enrich manual automation trigger with GitHub identity", { - error: - enrichmentError instanceof Error ? enrichmentError : new Error(String(enrichmentError)), - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - } - - // The scheduler performs the authoritative D1-backed concurrency check. - let triggerResult; - try { - triggerResult = await new Scheduler(ctx.db, env, ctx.executionCtx).trigger( - id, - requesterUserId, - requesterEnrichment ?? undefined - ); - } catch (triggerError) { - logger.error("automation.trigger_failed", { - event: "automation.trigger_failed", - automation_id: id, - error: triggerError instanceof Error ? triggerError : new Error(String(triggerError)), - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - if (triggerError instanceof AutomationTriggerBlockedError) { - return error("A run is already active for this automation", 409); - } - if (triggerError instanceof AutomationExecutionUnauthorizedError) { - return json({ error: "Execution authorization required" }, 403); - } - return error("Failed to trigger automation", 500); - } - - logger.info("automation.triggered", { - event: "automation.triggered", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return json({ invocationId: triggerResult.invocationId, runs: triggerResult.runs }, 201); -} - -function parseRunListParams(request: Request): { limit: number; offset: number } { - const url = new URL(request.url); - const limit = Math.max(1, Math.min(parseInt(url.searchParams.get("limit") || "20") || 20, 100)); - const offset = Math.max(0, parseInt(url.searchParams.get("offset") || "0") || 0); - return { limit, offset }; -} - -/** GET /automations/:id/invocations — one row per firing; `total` counts invocations. */ -async function handleListInvocations( - request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const automationId = params.id; - - const store = new AutomationStore(ctx.db); - const automation = await store.getById(automationId); - if (!automation) return error("Automation not found", 404); - - const { limit, offset } = parseRunListParams(request); - const result = await store.listInvocations(automationId, { limit, offset }); - - return json({ - invocations: result.invocations, - total: result.total, - }); -} - -async function handleGetRun( - _request: Request, - env: Env, - params: { id: string; runId: string }, - ctx: RequestContext -): Promise { - const { id: automationId, runId } = params; - - const store = new AutomationStore(ctx.db); - const run = await store.getRunById(automationId, runId); - if (!run) return error("Run not found", 404); - - return json({ run: toAutomationRun(run) }); -} - -async function handleRegenerateKey( - request: Request, - env: Env, - params: { id: string }, - ctx: RequestContext -): Promise { - const id = params.id; - - const store = new AutomationStore(ctx.db); - const { automation } = admittedAutomation(ctx); - - const workerUrl = env.WORKER_URL || ""; - - if (automation.trigger_type === "sentry") { - // Sentry: user provides a new client secret - const rawBody = await parseJsonBody(request); - if (rawBody instanceof Response) return rawBody; - const parsedBody = regenerateSentrySecretBodySchema.safeParse(rawBody); - if (!parsedBody.success) { - return error("sentryClientSecret is required", 400); - } - if (!env.REPO_SECRETS_ENCRYPTION_KEY) { - return error("Encryption key not configured", 503); - } - const encrypted = await encryptSentrySecret( - parsedBody.data.sentryClientSecret, - env.REPO_SECRETS_ENCRYPTION_KEY - ); - const statement = store.bindAutomationUpdate(id, { - trigger_auth_data: encrypted, - } as Record); - if (!statement) return error("Automation not found", 404); - const result = await ctx.db.batch([statement]); - if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404); - - logger.info("automation.secret_updated", { - event: "automation.secret_updated", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return json({ - sentryWebhookUrl: `${workerUrl}/webhooks/sentry/${id}`, - }); - } - - if (automation.trigger_type !== "webhook") { - return error("Only webhook and sentry automations support key regeneration", 400); - } - - // Webhook: generate a new API key - const apiKey = generateWebhookApiKey(); - const hash = await hashApiKey(apiKey); - - const statement = store.bindAutomationUpdate(id, { - trigger_auth_data: hash, - } as Record); - if (!statement) return error("Automation not found", 404); - const result = await ctx.db.batch([statement]); - if ((result[0]?.meta.changes ?? 0) === 0) return error("Automation not found", 404); - - logger.info("automation.key_regenerated", { - event: "automation.key_regenerated", - automation_id: id, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return json({ - webhookApiKey: apiKey, - webhookUrl: `${workerUrl}/webhooks/automation/${id}`, - }); -} - -/** - * GET /integration-settings/slack/watched-channels - * - * Returns the distinct set of Slack channel IDs referenced by enabled - * `slack_event` automations. The slack-bot polls this (cached) to pre-filter - * channel messages before normalizing and forwarding them — only messages in a - * watched channel are worth forwarding to the scheduler. - * - * Grouped under the `/integration-settings/slack` prefix the bot already uses - * for its runtime config (routing rules), even though the data is sourced from - * the automations store. Internal-auth gated by the router (non-public route). - */ -async function handleGetWatchedSlackChannels( - _request: Request, - env: Env, - ctx: RequestContext -): Promise { - const channels = await new SlackChannelStore(ctx.db).getWatchedSlackChannels(); - return json({ channels }); -} - -/** - * GET /integration-settings/slack/channels - * - * Lists the workspace's channels (public + private the bot can see) so the - * automation form can offer a channel picker instead of a raw channel ID. Sourced - * live from Slack via `conversations.list` using the bot token. - * - * Returns `{ channels }` on success, or `{ channels: [], error }` when the token - * is unset or Slack rejects the call (e.g. missing `channels:read`/`groups:read` - * scope) — the form then degrades to manual channel-ID entry. Internal-auth gated - * by the router (non-public route). - */ -async function handleGetSlackChannels( - request: Request, - env: Env, - _ctx: RequestContext -): Promise { - if (!env.SLACK_BOT_TOKEN) { - return json({ channels: [], error: "not_configured" }); - } - const result = await listChannels(env.SLACK_BOT_TOKEN, { signal: request.signal }); - if (!result.ok) { - logger.warn("slack.channels.list_failed", { slack_error: result.error }); - return json({ channels: [], error: result.error }); - } - return json({ channels: result.channels }); -} - -// ─── Route exports ─────────────────────────────────────────────────────────── - -const AUTOMATIONS_READ = admit({ - ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("automations.read"), -}); -const AUTOMATION_MANAGE = admit({ - ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requireAutomation("manage"), -}); +import { automationCrudRoutes } from "./automation-crud"; +import { automationKeyRoutes } from "./automation-keys"; +import { automationLifecycleRoutes } from "./automation-lifecycle"; +import { automationListRoutes } from "./automation-list"; +import { automationRunRoutes } from "./automation-runs"; +import { automationSlackSettingsRoutes } from "./automation-slack-settings"; export const automationRoutes = new Hono(); - -automationRoutes.get( - "/integration-settings/slack/watched-channels", - admit({ - ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("automations.read", { - actorlessGrants: [{ service: "slack-bot" }], - }), - }), - (c) => handleGetWatchedSlackChannels(c.var.admitted.request, c.env, c.var.admitted.ctx) -); -automationRoutes.get("/integration-settings/slack/channels", AUTOMATIONS_READ, (c) => - handleGetSlackChannels(c.var.admitted.request, c.env, c.var.admitted.ctx) -); -automationRoutes.get("/automations", AUTOMATIONS_READ, (c) => - handleListAutomations(c.var.admitted.request, c.env, c.var.admitted.ctx) -); -automationRoutes.post( - "/automations", - admit({ - ...GITHUB_USER_OR_SERVICE_ROUTE, - authorization: requirePermission("automations.create"), - }), - (c) => handleCreateAutomation(c.var.admitted.request, c.env, c.var.admitted.ctx) -); -automationRoutes.get("/automations/:id", AUTOMATIONS_READ, (c) => dispatch(c, handleGetAutomation)); -automationRoutes.put("/automations/:id", AUTOMATION_MANAGE, (c) => - dispatch(c, handleUpdateAutomation) -); -automationRoutes.delete("/automations/:id", AUTOMATION_MANAGE, (c) => - dispatch(c, handleDeleteAutomation) -); -automationRoutes.post("/automations/:id/pause", AUTOMATION_MANAGE, (c) => - dispatch(c, handlePauseAutomation) -); -automationRoutes.post("/automations/:id/resume", AUTOMATION_MANAGE, (c) => - dispatch(c, handleResumeAutomation) -); -automationRoutes.post( - "/automations/:id/trigger", - admit({ ...GITHUB_USER_OR_SERVICE_ROUTE, authorization: requireAutomation("trigger") }), - (c) => dispatch(c, handleTriggerAutomation) -); -automationRoutes.get("/automations/:id/invocations", AUTOMATIONS_READ, (c) => - dispatch(c, handleListInvocations) -); -automationRoutes.get("/automations/:id/runs/:runId", AUTOMATIONS_READ, (c) => - dispatch(c, handleGetRun) -); -automationRoutes.post("/automations/:id/regenerate-key", AUTOMATION_MANAGE, (c) => - dispatch(c, handleRegenerateKey) -); +for (const module of [ + automationSlackSettingsRoutes, + automationListRoutes, + automationCrudRoutes, + automationLifecycleRoutes, + automationRunRoutes, + automationKeyRoutes, +]) { + automationRoutes.route("/", module); +}