Skip to content
Open
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
40 changes: 40 additions & 0 deletions e2e/dashboard-widgets.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test.beforeEach(async ({ page }) => {
accessToken: "test-token",
},
maxAge: 60 * 60,
cookieName: "next-auth.session-token",
});

await page.context().addCookies([
Expand Down Expand Up @@ -93,6 +94,45 @@ test.beforeEach(async ({ page }) => {
});
});

await page.route("**/api/goals/sync", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({ updated: 1, commitCount: 4 }),
});
});

await page.route("**/api/ai-insights**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
insights: [
{
id: "insight-1",
type: "productivity",
title: "High Consistency",
description: "You have coded 5 days this week!",
severity: "positive",
},
],
trend: { direction: "up", percentage: 15 },
aiSummary: "Great job shipping features this week. Keep up the high standard!",
generatedAt: "2026-05-18T12:00:00.000Z",
},
}),
});
});

await page.route("**/api/notifications**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
notifications: [],
unreadCount: 0,
}),
});
});

const metricRoutes = [
"**/api/metrics/prs**",
"**/api/metrics/pr-breakdown**",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
Expand Down Expand Up @@ -41,4 +42,4 @@
"typescript": "^5",
"vitest": "^1.6.0"
}
}
}
29 changes: 1 addition & 28 deletions src/app/api/webhooks/github/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHmac, timingSafeEqual } from "crypto";
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";
import { logError } from "@/lib/error-handler";
import { verifyGitHubSignature } from "@/lib/crypto";

export const dynamic = "force-dynamic";

Expand All @@ -23,33 +23,6 @@ interface GitHubPushPayload {
};
}

function getExpectedSignature(secret: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}

function safeCompare(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");

if (left.length !== right.length) {
return false;
}

return timingSafeEqual(left, right); // timingSafeEqual prevents timing attack vulnerabilities
}

function verifyGitHubSignature(
body: string,
signature: string | null,
secret: string
): boolean {
if (!signature?.startsWith("sha256=")) {
return false;
}

return safeCompare(signature, getExpectedSignature(secret, body));
}

function getPushActor(payload: GitHubPushPayload): string | null {
return payload.sender?.login ?? payload.pusher?.name ?? null;
}
Expand Down
31 changes: 31 additions & 0 deletions src/lib/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
createCipheriv,
createDecipheriv,
randomBytes,
timingSafeEqual,
createHmac,
} from "crypto";

const ALGORITHM = "aes-256-gcm";
Expand Down Expand Up @@ -108,3 +110,32 @@ export function decryptToken(
return null;
}
}

export function safeCompare(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");

if (left.length !== right.length) {
return false;
}

return timingSafeEqual(left, right);
}

export function getExpectedSignature(secret: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}

export function verifyGitHubSignature(
body: string,
signature: string | null,
secret: string
): boolean {
if (!signature?.startsWith("sha256=")) {
return false;
}

return safeCompare(signature, getExpectedSignature(secret, body));
}


147 changes: 44 additions & 103 deletions test/github-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createHmac, timingSafeEqual } from 'crypto';

// We test the pure functions from the webhook route by re-implementing them
// so we don't need to import from a Next.js route module

function getExpectedSignature(secret: string, body: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}

function safeCompare(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");

if (left.length !== right.length) {
return false;
}

return timingSafeEqual(left, right);
}

function verifyGitHubSignature(
body: string,
signature: string | null,
secret: string
): boolean {
if (!signature?.startsWith("sha256=")) {
return false;
}

return safeCompare(signature, getExpectedSignature(secret, body));
}

describe('webhook signature verification', () => {
describe('safeCompare', () => {
it('returns true for identical strings', () => {
expect(safeCompare('abc', 'abc')).toBe(true);
import { vi, describe, it, expect } from "vitest";

// Mock @/lib/supabase to prevent environment variable errors on import
vi.mock("@/lib/supabase", () => ({
supabaseAdmin: {
from: vi.fn(),
},
}));

import { safeCompare, getExpectedSignature, verifyGitHubSignature } from "@/lib/crypto";

describe("webhook signature verification", () => {
describe("safeCompare", () => {
it("returns true for identical strings", () => {
expect(safeCompare("abc", "abc")).toBe(true);
});

it('returns false for different-length strings', () => {
expect(safeCompare('abc', 'abcd')).toBe(false);
it("returns false for different-length strings", () => {
expect(safeCompare("abc", "abcd")).toBe(false);
});

it('returns false for same-length but different content', () => {
expect(safeCompare('abc', 'xyz')).toBe(false);
it("returns false for same-length but different content", () => {
expect(safeCompare("abc", "xyz")).toBe(false);
});

it('returns true for empty strings', () => {
expect(safeCompare('', '')).toBe(true);
it("returns true for empty strings", () => {
expect(safeCompare("", "")).toBe(true);
});

it('returns false for empty vs non-empty', () => {
expect(safeCompare('', 'x')).toBe(false);
it("returns false for empty vs non-empty", () => {
expect(safeCompare("", "x")).toBe(false);
});

it('handles long strings correctly', () => {
const long1 = 'a'.repeat(1000);
const long2 = 'a'.repeat(1000);
it("handles long strings correctly", () => {
const long1 = "a".repeat(1000);
const long2 = "a".repeat(1000);
expect(safeCompare(long1, long2)).toBe(true);
expect(safeCompare(long1, 'b' + 'a'.repeat(999))).toBe(false);
expect(safeCompare(long1, "b" + "a".repeat(999))).toBe(false);
});
});

describe('verifyGitHubSignature', () => {
const secret = 'test-webhook-secret';
describe("verifyGitHubSignature", () => {
const secret = "test-webhook-secret";
const body = '{"action":"push","repository":"test"}';

it('returns true for valid signature matching computed HMAC', () => {
it("returns true for valid signature matching computed HMAC", () => {
const validSignature = getExpectedSignature(secret, body);
expect(verifyGitHubSignature(body, validSignature, secret)).toBe(true);
});

it('returns false for invalid signature', () => {
const invalidSignature = 'sha256=0000000000000000000000000000000000000000000000000000000000000000';
it("returns false for invalid signature", () => {
const invalidSignature = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
expect(verifyGitHubSignature(body, invalidSignature, secret)).toBe(false);
});

it('returns false for missing signature', () => {
it("returns false for missing signature", () => {
expect(verifyGitHubSignature(body, null, secret)).toBe(false);
});

it('returns false for undefined signature', () => {
expect(verifyGitHubSignature(body, null, secret)).toBe(false);
it("returns false for empty string signature", () => {
expect(verifyGitHubSignature(body, "", secret)).toBe(false);
});

it('returns false for empty string signature', () => {
expect(verifyGitHubSignature(body, '', secret)).toBe(false);
});

it('returns false for signature without sha256= prefix', () => {
const sigWithoutPrefix = createHmac("sha256", secret).update(body).digest("hex");
it("returns false for signature without sha256= prefix", () => {
const sigWithoutPrefix = "0000000000000000000000000000000000000000000000000000000000000000";
expect(verifyGitHubSignature(body, sigWithoutPrefix, secret)).toBe(false);
});

it('returns false for signature with wrong prefix', () => {
expect(verifyGitHubSignature(body, 'sha1=abc', secret)).toBe(false);
});

it('returns false for tampered body', () => {
const validSig = getExpectedSignature(secret, body);
const tamperedBody = '{"action":"delete","repository":"test"}';
expect(verifyGitHubSignature(tamperedBody, validSig, secret)).toBe(false);
});

it('returns false for wrong secret', () => {
const validSig = getExpectedSignature(secret, body);
expect(verifyGitHubSignature(body, validSig, 'wrong-secret')).toBe(false);
});

it('handles empty body correctly', () => {
const emptyBody = '';
const validSig = getExpectedSignature(secret, emptyBody);
expect(verifyGitHubSignature(emptyBody, validSig, secret)).toBe(true);
});
});

describe('getExpectedSignature', () => {
it('produces consistent HMAC for same inputs', () => {
const sig1 = getExpectedSignature('secret', 'body');
const sig2 = getExpectedSignature('secret', 'body');
describe("getExpectedSignature", () => {
it("produces consistent HMAC for same inputs", () => {
const sig1 = getExpectedSignature("secret", "body");
const sig2 = getExpectedSignature("secret", "body");
expect(sig1).toBe(sig2);
});

it('produces different HMAC for different secrets', () => {
const sig1 = getExpectedSignature('secret1', 'body');
const sig2 = getExpectedSignature('secret2', 'body');
expect(sig1).not.toBe(sig2);
});

it('produces different HMAC for different bodies', () => {
const sig1 = getExpectedSignature('secret', 'body1');
const sig2 = getExpectedSignature('secret', 'body2');
expect(sig1).not.toBe(sig2);
});

it('starts with sha256= prefix', () => {
const sig = getExpectedSignature('secret', 'body');
expect(sig.startsWith('sha256=')).toBe(true);
it("starts with sha256= prefix", () => {
const sig = getExpectedSignature("secret", "body");
expect(sig.startsWith("sha256=")).toBe(true);
});
});
});
Loading