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
43 changes: 39 additions & 4 deletions src/app/api/automation/sync/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@ vi.mock("@/lib/config", () => ({
parseExcludedLabels: vi.fn().mockReturnValue([]),
}));

const { mocks } = vi.hoisted(() => ({
const { mocks, mockTxClient } = vi.hoisted(() => ({
mockTxClient: {
syncLock: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "lock-1" }),
},
issueSyncRun: {
create: vi.fn().mockResolvedValue({ id: "run-1", status: "running", syncType: "automation" }),
},
},
mocks: {
syncLockFindUnique: vi.fn().mockResolvedValue(null),
syncLockCreate: vi.fn().mockResolvedValue({ id: "lock-1" }),
syncLockDelete: vi.fn().mockResolvedValue(undefined),
syncLockDeleteMany: vi.fn().mockResolvedValue({ count: 0 }),
transactionFn: vi.fn(async (fn: any) => {
Expand All @@ -34,12 +44,21 @@ vi.mock("@/lib/prisma", () => ({
prisma: {
syncLock: {
findUnique: mocks.syncLockFindUnique,
create: mocks.syncLockCreate,
delete: mocks.syncLockDelete,
deleteMany: mocks.syncLockDeleteMany,
},
$transaction: mocks.transactionFn,
issueSyncRun: {
create: vi.fn().mockResolvedValue({ id: "run-1", status: "running", syncType: "automation" }),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
automationSyncRun: {
create: vi.fn().mockResolvedValue({ id: "auto-run-1" }),
update: vi.fn().mockResolvedValue(undefined),
},
automationRepo: {
upsert: mocks.upsert,
update: vi.fn().mockResolvedValue({ id: "repo-1" }),
},
githubWorkflow: {
upsert: mocks.upsert,
Expand All @@ -56,16 +75,32 @@ vi.mock("@/lib/prisma", () => ({
githubRelease: {
upsert: mocks.upsert,
},
githubPullRequest: {
githubPackage: {
upsert: mocks.upsert,
},
githubPackage: {
githubPullRequest: {
upsert: mocks.upsert,
},
automationEvent: {
create: mocks.create,
},
$transaction: mocks.transactionFn,
},
asPrFixQueueClient: (client: unknown): unknown => client,
}));

vi.mock("@/lib/github", () => ({
fetchRepo: vi.fn().mockResolvedValue({
name: "test",
owner: { login: "test" },
default_branch: "main",
}),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchRecentRunsAllWorkflows: vi.fn().mockResolvedValue([]),
fetchReleases: vi.fn().mockResolvedValue([]),
fetchPullRequests: vi.fn().mockResolvedValue([]),
fetchLatestCommit: vi.fn().mockResolvedValue({ sha: "abc123" }),
fetchPackages: vi.fn().mockResolvedValue([]),
}));

import { POST } from "./route";
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/pr-followup/sync/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { prisma, asPrFixQueueClient } from "@/lib/prisma";
import { authorizeRequest } from "@/lib/auth";
import { getTrackedRepos } from "@/lib/config";
import { processPrFollowupEvents, isAllowedBotAuthor } from "@/lib/pr-followup-ingestion";

Expand Down Expand Up @@ -85,7 +86,12 @@ async function fetchWithGithub(url: string, token: string): Promise<any> {
return res.json();
}

export async function POST() {
export async function POST(request: NextRequest) {
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
const token = process.env.GITHUB_TOKEN;
if (!token) {
Expand Down
35 changes: 28 additions & 7 deletions src/app/api/pr-followup/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "node:crypto";
import { authorizeRequest } from "@/lib/auth";
import { prisma, asPrFixQueueClient } from "@/lib/prisma";
import { processPrFollowupEvents, PrFollowupEvent } from "@/lib/pr-followup-ingestion";

Expand All @@ -25,11 +26,22 @@ function extractLinkedIssueFromPr(pr: Record<string, unknown>): number | null {
* - pull_request (merge_state_status changes, etc.)
*
* Signature verification: validates X-Hub-Signature-256 using HMAC-SHA256
* with the WEBHOOK_SECRET environment variable. Set this to your GitHub
* webhook secret in deployment configuration. If not set, verification is
* skipped (e.g. when behind an API gateway that handles auth).
* with the WEBHOOK_SECRET environment variable.
*
* Default behavior is fail-closed: if WEBHOOK_SECRET is not configured,
* requests are rejected unless WEBHOOK_GATEWAY_MODE is explicitly set to "true",
* which indicates the endpoint is behind a gateway that performs its own
* authentication and signature verification.
*/

/** Is webhook signature verification enabled (fail-closed default)? */
function isSignatureVerificationEnabled(): boolean {
const secret = process.env.WEBHOOK_SECRET;
if (secret) return true;
// Gateway mode: caller explicitly opts out of local signature verification
return process.env.WEBHOOK_GATEWAY_MODE === "true";
}

function verifyWebhookSignature(secret: string, payload: Buffer, signature: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = signature.slice(9);
Expand Down Expand Up @@ -179,18 +191,27 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing x-github-event header" }, { status: 400 });
}

// Authenticate the request (Bearer token, Basic Auth, or OIDC session)
const auth = await authorizeRequest(request);
if (!auth.authorized) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Read raw body once for signature verification and parsing
const rawBody = await request.arrayBuffer();
const payload = Buffer.from(rawBody);

// Validate webhook signature if WEBHOOK_SECRET is configured
const webhookSecret = process.env.WEBHOOK_SECRET;
if (webhookSecret) {
// Webhook signature verification: fail-closed by default.
// If WEBHOOK_SECRET is set, always verify. If not set, only skip when
// WEBHOOK_GATEWAY_MODE=true (explicit opt-out for gateway deployments).
const sigVerificationEnabled = isSignatureVerificationEnabled();
if (sigVerificationEnabled) {
const webhookSecret = process.env.WEBHOOK_SECRET;
const signature = request.headers.get("x-hub-signature-256");
if (!signature) {
return NextResponse.json({ error: "Missing x-hub-signature-256 header" }, { status: 401 });
}
if (!verifyWebhookSignature(webhookSecret, payload, signature)) {
if (!verifyWebhookSignature(webhookSecret!, payload, signature)) {
return NextResponse.json({ error: "Invalid webhook signature" }, { status: 401 });
}
}
Expand Down
31 changes: 25 additions & 6 deletions src/app/api/sync/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const { mocks } = vi.hoisted(() => ({
mocks: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({ id: "issue-1" }),
create: vi.fn().mockImplementation(({ data }) => {
return Promise.resolve({ id: "issue-1", ...data });
}),
auth: vi.fn(),
syncLockFindUnique: vi.fn().mockResolvedValue(null),
syncLockDelete: vi.fn().mockResolvedValue(undefined),
Expand All @@ -32,22 +34,39 @@ vi.mock("@/lib/auth-next", () => ({
auth: mocks.auth,
}));

// Build a mock transaction client that mirrors the prisma structure
const mockTxClient = {
syncLock: {
findUnique: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: "lock-1" }),
},
issueSyncRun: {
create: vi.fn().mockResolvedValue({ id: "run-1", status: "running", syncType: "manual" }),
},
};

vi.mock("@/lib/prisma", () => ({
prisma: {
issue: {
findUnique: mocks.findUnique,
update: mocks.update,
create: mocks.create,
},
syncLock: {
findUnique: mocks.syncLockFindUnique,
create: vi.fn().mockResolvedValue({ id: "lock-1" }),
delete: mocks.syncLockDelete,
deleteMany: mocks.syncLockDeleteMany,
},
issueSyncRun: {
create: vi.fn().mockResolvedValue({ id: "run-1", status: "running", syncType: "manual" }),
updateMany: mocks.issueSyncRunUpdateMany,
},
automationSyncRun: {
create: vi.fn().mockResolvedValue({ id: "auto-run-1" }),
update: vi.fn().mockResolvedValue(undefined),
},
$transaction: mocks.transactionFn,
issue: {
findUnique: mocks.findUnique,
update: mocks.update,
create: mocks.create,
},
},
}));

Expand Down