Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
760 changes: 760 additions & 0 deletions packages/control-plane/src/routes/automation-create.test.ts

Large diffs are not rendered by default.

682 changes: 682 additions & 0 deletions packages/control-plane/src/routes/automation-crud.ts

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions packages/control-plane/src/routes/automation-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof AuthenticateModule>()),
authenticate: (...args: Parameters<typeof mocks.authenticate>) => mocks.authenticate(...args),
}));

vi.mock("../db/automation-store", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
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<string, unknown>;
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" });
});
});
});
102 changes: 102 additions & 0 deletions packages/control-plane/src/routes/automation-keys.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<unknown>(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<string, unknown>);
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<string, unknown>);
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}`,
});
Comment thread
ColeMurray marked this conversation as resolved.
}

export const automationKeyRoutes = new Hono<ControlPlaneHonoEnv>();

automationKeyRoutes.post("/automations/:id/regenerate-key", AUTOMATION_MANAGE, (c) =>
dispatch(c, handleRegenerateKey)
);
Loading
Loading