feat(KB-137): add GitHub App webhooks for real-time badge updates
- Add verified POST /api/github/webhooks endpoint with signature verification - Implement GitHub App installation token authentication for API calls - Add webhook handlers for pull_request, issues, and issue_comment events - Support multi-task resource matching by parsed badge URL - Retire live GitHub polling service in favor of push-based updates - Add comprehensive webhook integration and unit tests - Keep 5-minute REST refresh endpoints as fallback
This commit is contained in:
@@ -1,304 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PrInfo, TaskStore } from "@kb/core";
|
||||
import { GitHubPollingService, GitHubRateLimiter } from "../github-poll.js";
|
||||
import { GitHubRateLimiter } from "../github-poll.js";
|
||||
|
||||
const getBadgeStatusesBatch = vi.fn();
|
||||
|
||||
vi.mock("../github.js", () => ({
|
||||
GitHubClient: vi.fn(() => ({
|
||||
getBadgeStatusesBatch,
|
||||
})),
|
||||
}));
|
||||
|
||||
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
updatePrInfo: vi.fn(),
|
||||
updateIssueInfo: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
function createPrInfo(overrides: Partial<PrInfo> = {}): PrInfo {
|
||||
return {
|
||||
url: "https://github.com/owner/repo/pull/1",
|
||||
number: 1,
|
||||
status: "open",
|
||||
title: "Test PR",
|
||||
headBranch: "feature/test",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastCheckedAt: "2026-03-30T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("GitHubPollingService", () => {
|
||||
describe("GitHubRateLimiter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("adds and removes task watches", () => {
|
||||
const poller = new GitHubPollingService();
|
||||
it("allows requests within the rate limit", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 3, windowMs: 60000 });
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
poller.watchTask("KB-063", "issue", "owner", "repo", 2);
|
||||
|
||||
expect(poller.getWatch("KB-063")?.pr?.number).toBe(1);
|
||||
expect(poller.getWatch("KB-063")?.issue?.number).toBe(2);
|
||||
|
||||
poller.unwatchTaskType("KB-063", "pr");
|
||||
expect(poller.getWatch("KB-063")?.pr).toBeUndefined();
|
||||
expect(poller.getWatch("KB-063")?.issue?.number).toBe(2);
|
||||
|
||||
poller.unwatchTaskType("KB-063", "issue");
|
||||
expect(poller.getWatch("KB-063")).toBeUndefined();
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not write to the store when fetched badge data is unchanged", async () => {
|
||||
const task = {
|
||||
id: "KB-063",
|
||||
prInfo: createPrInfo(),
|
||||
issueInfo: undefined,
|
||||
};
|
||||
const updatePrInfo = vi.fn();
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updatePrInfo,
|
||||
});
|
||||
it("denies requests when rate limit is exceeded", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: {
|
||||
type: "pr",
|
||||
prInfo: createPrInfo({ lastCheckedAt: undefined }),
|
||||
},
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(updatePrInfo).not.toHaveBeenCalled();
|
||||
expect(poller.getLastCheckedAt("KB-063", "pr")).toMatch(/^2026|^20/);
|
||||
limiter.canMakeRequest("owner/repo");
|
||||
limiter.canMakeRequest("owner/repo");
|
||||
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
|
||||
});
|
||||
|
||||
it("updates PR info when comment metadata changes", async () => {
|
||||
const task = {
|
||||
id: "KB-063",
|
||||
prInfo: createPrInfo({ commentCount: 0 }),
|
||||
issueInfo: undefined,
|
||||
};
|
||||
const updatePrInfo = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockResolvedValue(task),
|
||||
updatePrInfo,
|
||||
});
|
||||
it("resets rate limit after window expires", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: {
|
||||
type: "pr",
|
||||
prInfo: createPrInfo({ commentCount: 2, lastCommentAt: "2026-03-30T12:00:00.000Z", lastCheckedAt: undefined }),
|
||||
},
|
||||
});
|
||||
limiter.canMakeRequest("owner/repo");
|
||||
limiter.canMakeRequest("owner/repo");
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
// Advance time past the window
|
||||
vi.advanceTimersByTime(61000);
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(updatePrInfo).toHaveBeenCalledTimes(1);
|
||||
expect(updatePrInfo.mock.calls[0][1]).toMatchObject({
|
||||
commentCount: 2,
|
||||
lastCommentAt: "2026-03-30T12:00:00.000Z",
|
||||
});
|
||||
expect(updatePrInfo.mock.calls[0][1]?.lastCheckedAt).toBeTruthy();
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
|
||||
});
|
||||
|
||||
it("deduplicates repo batch requests for shared resources", async () => {
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockImplementation(async (taskId: string) => ({
|
||||
id: taskId,
|
||||
prInfo: createPrInfo(),
|
||||
issueInfo: undefined,
|
||||
})),
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
it("tracks different repositories independently", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: {
|
||||
type: "pr",
|
||||
prInfo: createPrInfo({ lastCheckedAt: undefined }),
|
||||
},
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
poller.watchTask("KB-064", "pr", "owner", "repo", 1);
|
||||
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(getBadgeStatusesBatch).toHaveBeenCalledTimes(1);
|
||||
expect(getBadgeStatusesBatch.mock.calls[0][2]).toEqual([
|
||||
{ alias: "pr_1", type: "pr", number: 1 },
|
||||
]);
|
||||
limiter.canMakeRequest("owner/repo1");
|
||||
limiter.canMakeRequest("owner/repo1");
|
||||
|
||||
// repo1 is at limit
|
||||
expect(limiter.canMakeRequest("owner/repo1")).toBe(false);
|
||||
|
||||
// repo2 is not affected
|
||||
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
|
||||
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
|
||||
expect(limiter.canMakeRequest("owner/repo2")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips polling when the shared rate limiter denies the request", async () => {
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "KB-063",
|
||||
prInfo: createPrInfo(),
|
||||
issueInfo: undefined,
|
||||
}),
|
||||
updatePrInfo: vi.fn(),
|
||||
});
|
||||
it("returns null reset time when no requests have been made", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 0 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(getBadgeStatusesBatch).not.toHaveBeenCalled();
|
||||
expect(limiter.getResetTime("owner/repo")).toBeNull();
|
||||
});
|
||||
|
||||
it("updates issue info when badge-relevant issue fields change", async () => {
|
||||
const updateIssueInfo = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "KB-063",
|
||||
prInfo: undefined,
|
||||
issueInfo: {
|
||||
url: "https://github.com/owner/repo/issues/2",
|
||||
number: 2,
|
||||
state: "closed",
|
||||
title: "Tracked issue",
|
||||
stateReason: "reopened",
|
||||
lastCheckedAt: "2026-03-30T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
updateIssueInfo,
|
||||
});
|
||||
it("returns correct reset time after requests", () => {
|
||||
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
issue_2: {
|
||||
type: "issue",
|
||||
issueInfo: {
|
||||
url: "https://github.com/owner/repo/issues/2",
|
||||
number: 2,
|
||||
state: "closed",
|
||||
title: "Tracked issue",
|
||||
stateReason: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "issue", "owner", "repo", 2);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(updateIssueInfo).toHaveBeenCalledTimes(1);
|
||||
expect(updateIssueInfo.mock.calls[0][1]).toMatchObject({
|
||||
stateReason: "completed",
|
||||
});
|
||||
expect(updateIssueInfo.mock.calls[0][1]?.lastCheckedAt).toBeTruthy();
|
||||
const before = Date.now();
|
||||
limiter.canMakeRequest("owner/repo");
|
||||
|
||||
const resetTime = limiter.getResetTime("owner/repo");
|
||||
expect(resetTime).not.toBeNull();
|
||||
expect(resetTime!.getTime()).toBeGreaterThan(before);
|
||||
expect(resetTime!.getTime()).toBeLessThanOrEqual(before + 60000);
|
||||
});
|
||||
|
||||
it("keeps watches on transient task load failures", async () => {
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockRejectedValue(new Error("temporary parse error")),
|
||||
updatePrInfo: vi.fn(),
|
||||
});
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: {
|
||||
type: "pr",
|
||||
prInfo: createPrInfo({ lastCheckedAt: undefined }),
|
||||
},
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(poller.getWatch("KB-063")).toBeDefined();
|
||||
expect(poller.getLastCheckedAt("KB-063", "pr")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not clear badge links on ambiguous null batch responses", async () => {
|
||||
const updatePrInfo = vi.fn();
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: "KB-063",
|
||||
prInfo: createPrInfo(),
|
||||
issueInfo: undefined,
|
||||
}),
|
||||
updatePrInfo,
|
||||
});
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: null,
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(updatePrInfo).not.toHaveBeenCalled();
|
||||
expect(poller.getWatch("KB-063")).toBeDefined();
|
||||
expect(poller.getLastCheckedAt("KB-063", "pr")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unwatches tasks that can no longer be loaded", async () => {
|
||||
const store = createStore({
|
||||
getTask: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })),
|
||||
updatePrInfo: vi.fn(),
|
||||
});
|
||||
|
||||
getBadgeStatusesBatch.mockResolvedValue({
|
||||
pr_1: {
|
||||
type: "pr",
|
||||
prInfo: createPrInfo({ lastCheckedAt: undefined }),
|
||||
},
|
||||
});
|
||||
|
||||
const poller = new GitHubPollingService({
|
||||
store,
|
||||
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
|
||||
});
|
||||
|
||||
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
|
||||
await poller.pollOnce();
|
||||
|
||||
expect(poller.getWatch("KB-063")).toBeUndefined();
|
||||
it("uses default values when not specified", () => {
|
||||
const limiter = new GitHubRateLimiter();
|
||||
|
||||
// Default is 90 requests per hour
|
||||
for (let i = 0; i < 90; i++) {
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
|
||||
}
|
||||
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
539
packages/dashboard/src/__tests__/github-webhooks.test.ts
Normal file
539
packages/dashboard/src/__tests__/github-webhooks.test.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
getGitHubAppConfig,
|
||||
isGitHubAppConfigured,
|
||||
verifyWebhookSignature,
|
||||
classifyWebhookEvent,
|
||||
parseBadgeUrl,
|
||||
isSameResource,
|
||||
hasPrBadgeFieldsChanged,
|
||||
hasIssueBadgeFieldsChanged,
|
||||
fetchInstallationToken,
|
||||
fetchCanonicalPrInfo,
|
||||
fetchCanonicalIssueInfo,
|
||||
} from "../github-webhooks.js";
|
||||
|
||||
describe("GitHub Webhooks Module", () => {
|
||||
// Save original env vars
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.KB_GITHUB_APP_ID;
|
||||
delete process.env.KB_GITHUB_APP_PRIVATE_KEY;
|
||||
delete process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH;
|
||||
delete process.env.KB_GITHUB_WEBHOOK_SECRET;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getGitHubAppConfig", () => {
|
||||
it("returns null when no env vars are set", () => {
|
||||
expect(getGitHubAppConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when only appId is set", () => {
|
||||
process.env.KB_GITHUB_APP_ID = "12345";
|
||||
expect(getGitHubAppConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns config when all required vars are set via direct key", () => {
|
||||
process.env.KB_GITHUB_APP_ID = "12345";
|
||||
process.env.KB_GITHUB_APP_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----";
|
||||
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
|
||||
|
||||
const config = getGitHubAppConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.appId).toBe("12345");
|
||||
expect(config?.privateKey).toContain("RSA PRIVATE KEY");
|
||||
expect(config?.webhookSecret).toBe("webhook-secret");
|
||||
});
|
||||
|
||||
it("returns null when key file cannot be read", () => {
|
||||
process.env.KB_GITHUB_APP_ID = "12345";
|
||||
process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
|
||||
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
|
||||
|
||||
expect(getGitHubAppConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers direct key over file path when both are set", () => {
|
||||
process.env.KB_GITHUB_APP_ID = "12345";
|
||||
process.env.KB_GITHUB_APP_PRIVATE_KEY = "direct-key-content";
|
||||
process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
|
||||
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
|
||||
|
||||
const config = getGitHubAppConfig();
|
||||
expect(config?.privateKey).toBe("direct-key-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGitHubAppConfigured", () => {
|
||||
it("returns false when not configured", () => {
|
||||
expect(isGitHubAppConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when fully configured", () => {
|
||||
process.env.KB_GITHUB_APP_ID = "12345";
|
||||
process.env.KB_GITHUB_APP_PRIVATE_KEY = "private-key";
|
||||
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
|
||||
|
||||
expect(isGitHubAppConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyWebhookSignature", () => {
|
||||
it("returns invalid when signature header is missing", () => {
|
||||
const result = verifyWebhookSignature(
|
||||
Buffer.from('{"test": "payload"}'),
|
||||
undefined,
|
||||
"secret",
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Missing signature header");
|
||||
});
|
||||
|
||||
it("returns invalid when signature does not match", () => {
|
||||
const result = verifyWebhookSignature(
|
||||
Buffer.from('{"test": "payload"}'),
|
||||
"sha256=invalidsignature",
|
||||
"secret",
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Signature mismatch");
|
||||
});
|
||||
|
||||
it("returns valid when signature matches", () => {
|
||||
// Compute correct signature for test
|
||||
const { createHmac } = require("node:crypto");
|
||||
const payload = '{"test": "payload"}';
|
||||
const correctSignature = "sha256=" + createHmac("sha256", "secret").update(payload).digest("hex");
|
||||
|
||||
const result = verifyWebhookSignature(
|
||||
Buffer.from(payload),
|
||||
correctSignature,
|
||||
"secret",
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("uses constant-time comparison to prevent timing attacks", () => {
|
||||
const { createHmac } = require("node:crypto");
|
||||
const payload = '{"test": "payload"}';
|
||||
const correctSignature = "sha256=" + createHmac("sha256", "secret").update(payload).digest("hex");
|
||||
|
||||
// Should not throw and should return valid
|
||||
const result = verifyWebhookSignature(
|
||||
Buffer.from(payload),
|
||||
correctSignature,
|
||||
"secret",
|
||||
);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyWebhookEvent", () => {
|
||||
it("classifies ping as supported but not relevant", () => {
|
||||
const result = classifyWebhookEvent("ping", {
|
||||
repository: { owner: { login: "test-owner" }, name: "test-repo" },
|
||||
installation: { id: 12345 },
|
||||
});
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.relevant).toBe(false);
|
||||
expect(result.owner).toBe("test-owner");
|
||||
expect(result.repo).toBe("test-repo");
|
||||
expect(result.installationId).toBe(12345);
|
||||
});
|
||||
|
||||
it("classifies pull_request as supported and relevant", () => {
|
||||
const result = classifyWebhookEvent("pull_request", {
|
||||
number: 42,
|
||||
repository: { owner: { login: "test-owner" }, name: "test-repo" },
|
||||
installation: { id: 12345 },
|
||||
});
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.relevant).toBe(true);
|
||||
expect(result.resourceType).toBe("pr");
|
||||
expect(result.number).toBe(42);
|
||||
expect(result.owner).toBe("test-owner");
|
||||
expect(result.repo).toBe("test-repo");
|
||||
});
|
||||
|
||||
it("classifies issues as supported and relevant", () => {
|
||||
const result = classifyWebhookEvent("issues", {
|
||||
issue: { number: 123 },
|
||||
repository: { owner: { login: "test-owner" }, name: "test-repo" },
|
||||
installation: { id: 12345 },
|
||||
});
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.relevant).toBe(true);
|
||||
expect(result.resourceType).toBe("issue");
|
||||
expect(result.number).toBe(123);
|
||||
});
|
||||
|
||||
it("classifies issue_comment on PR as supported and relevant", () => {
|
||||
const result = classifyWebhookEvent("issue_comment", {
|
||||
issue: { number: 42, pull_request: {} },
|
||||
repository: { owner: { login: "test-owner" }, name: "test-repo" },
|
||||
installation: { id: 12345 },
|
||||
});
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.relevant).toBe(true);
|
||||
expect(result.resourceType).toBe("pr");
|
||||
expect(result.number).toBe(42);
|
||||
});
|
||||
|
||||
it("classifies issue_comment on regular issue as supported but not relevant", () => {
|
||||
const result = classifyWebhookEvent("issue_comment", {
|
||||
issue: { number: 123 }, // No pull_request field
|
||||
repository: { owner: { login: "test-owner" }, name: "test-repo" },
|
||||
installation: { id: 12345 },
|
||||
});
|
||||
|
||||
expect(result.supported).toBe(true);
|
||||
expect(result.relevant).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies unknown events as unsupported", () => {
|
||||
const result = classifyWebhookEvent("push", {});
|
||||
expect(result.supported).toBe(false);
|
||||
expect(result.relevant).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing event type", () => {
|
||||
const result = classifyWebhookEvent(undefined, {});
|
||||
expect(result.supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBadgeUrl", () => {
|
||||
it("parses PR URL correctly", () => {
|
||||
const result = parseBadgeUrl("https://github.com/owner/repo/pull/42");
|
||||
expect(result).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
number: 42,
|
||||
resourceType: "pr",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses issue URL correctly", () => {
|
||||
const result = parseBadgeUrl("https://github.com/owner/repo/issues/123");
|
||||
expect(result).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
number: 123,
|
||||
resourceType: "issue",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub URLs", () => {
|
||||
expect(parseBadgeUrl("https://gitlab.com/owner/repo/pull/42")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid paths", () => {
|
||||
expect(parseBadgeUrl("https://github.com/owner/repo")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid number", () => {
|
||||
expect(parseBadgeUrl("https://github.com/owner/repo/pull/abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles URLs with trailing slash", () => {
|
||||
const result = parseBadgeUrl("https://github.com/owner/repo/pull/42/");
|
||||
expect(result?.number).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameResource", () => {
|
||||
it("returns true for identical resources", () => {
|
||||
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
expect(isSameResource(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for different owners", () => {
|
||||
const a = { owner: "owner-a", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner-b", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
expect(isSameResource(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for different repos", () => {
|
||||
const a = { owner: "owner", repo: "repo-a", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner", repo: "repo-b", number: 42, resourceType: "pr" as const };
|
||||
expect(isSameResource(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for different numbers", () => {
|
||||
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner", repo: "repo", number: 43, resourceType: "pr" as const };
|
||||
expect(isSameResource(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for different types", () => {
|
||||
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "issue" as const };
|
||||
expect(isSameResource(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("is case insensitive for owner and repo", () => {
|
||||
const a = { owner: "Owner", repo: "Repo", number: 42, resourceType: "pr" as const };
|
||||
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
|
||||
expect(isSameResource(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPrBadgeFieldsChanged", () => {
|
||||
const basePrInfo = {
|
||||
url: "https://github.com/owner/repo/pull/42",
|
||||
number: 42,
|
||||
status: "open" as const,
|
||||
title: "Test PR",
|
||||
headBranch: "feature",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastCommentAt: undefined,
|
||||
};
|
||||
|
||||
it("returns true when current is undefined", () => {
|
||||
expect(hasPrBadgeFieldsChanged(undefined, basePrInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when fields are identical", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
expect(hasPrBadgeFieldsChanged(current, basePrInfo)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when status changes", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...basePrInfo, status: "closed" as const };
|
||||
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when title changes", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...basePrInfo, title: "Updated Title" };
|
||||
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when commentCount changes", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...basePrInfo, commentCount: 5 };
|
||||
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when lastCommentAt changes", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...basePrInfo, lastCommentAt: "2026-01-02T00:00:00.000Z" };
|
||||
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores lastCheckedAt differences", () => {
|
||||
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...basePrInfo, lastCheckedAt: "2026-01-02T00:00:00.000Z" };
|
||||
expect(hasPrBadgeFieldsChanged(current, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasIssueBadgeFieldsChanged", () => {
|
||||
const baseIssueInfo = {
|
||||
url: "https://github.com/owner/repo/issues/123",
|
||||
number: 123,
|
||||
state: "open" as const,
|
||||
title: "Test Issue",
|
||||
stateReason: undefined,
|
||||
};
|
||||
|
||||
it("returns true when current is undefined", () => {
|
||||
expect(hasIssueBadgeFieldsChanged(undefined, baseIssueInfo)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when fields are identical", () => {
|
||||
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
expect(hasIssueBadgeFieldsChanged(current, baseIssueInfo)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when state changes", () => {
|
||||
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...baseIssueInfo, state: "closed" as const };
|
||||
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when stateReason changes", () => {
|
||||
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...baseIssueInfo, stateReason: "completed" as const };
|
||||
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores lastCheckedAt differences", () => {
|
||||
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
|
||||
const next = { ...baseIssueInfo, lastCheckedAt: "2026-01-02T00:00:00.000Z" };
|
||||
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchInstallationToken (integration)", () => {
|
||||
it("returns null when API request fails", async () => {
|
||||
// Mock a failed fetch response
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const token = await fetchInstallationToken(12345, "app-id", "fake-private-key");
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it("returns token on successful API request", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: "ghs_installation_token" }),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
// Note: This will fail JWT generation with a fake key, but we can verify the attempt
|
||||
const token = await fetchInstallationToken(12345, "app-id", "fake-key");
|
||||
// Expect null because JWT signing will fail with invalid key
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCanonicalPrInfo (integration)", () => {
|
||||
it("fetches PR data from GitHub API", async () => {
|
||||
const mockPrData = {
|
||||
number: 42,
|
||||
html_url: "https://github.com/owner/repo/pull/42",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
merged: false,
|
||||
head: { ref: "feature-branch" },
|
||||
base: { ref: "main" },
|
||||
comments: 5,
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockPrData),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.number).toBe(42);
|
||||
expect(result?.status).toBe("open");
|
||||
expect(result?.title).toBe("Test PR");
|
||||
expect(result?.headBranch).toBe("feature-branch");
|
||||
expect(result?.commentCount).toBe(5);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://api.github.com/repos/owner/repo/pulls/42",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer fake-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns merged status for merged PRs", async () => {
|
||||
const mockPrData = {
|
||||
number: 42,
|
||||
html_url: "https://github.com/owner/repo/pull/42",
|
||||
title: "Test PR",
|
||||
state: "closed",
|
||||
merged: true,
|
||||
head: { ref: "feature-branch" },
|
||||
base: { ref: "main" },
|
||||
comments: 10,
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockPrData),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
|
||||
|
||||
expect(result?.status).toBe("merged");
|
||||
});
|
||||
|
||||
it("returns null on API error", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCanonicalIssueInfo (integration)", () => {
|
||||
it("fetches issue data from GitHub API", async () => {
|
||||
const mockIssueData = {
|
||||
number: 123,
|
||||
html_url: "https://github.com/owner/repo/issues/123",
|
||||
title: "Test Issue",
|
||||
state: "open",
|
||||
state_reason: null,
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockIssueData),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalIssueInfo("owner", "repo", 123, "fake-token");
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.number).toBe(123);
|
||||
expect(result?.state).toBe("open");
|
||||
expect(result?.title).toBe("Test Issue");
|
||||
});
|
||||
|
||||
it("returns null for PRs (which come through issues endpoint)", async () => {
|
||||
const mockPrData = {
|
||||
number: 42,
|
||||
html_url: "https://github.com/owner/repo/pull/42",
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
pull_request: {}, // This marks it as a PR
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockPrData),
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalIssueInfo("owner", "repo", 42, "fake-token");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on API error", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const result = await fetchCanonicalIssueInfo("owner", "repo", 123, "fake-token");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
382
packages/dashboard/src/__tests__/server-webhook.test.ts
Normal file
382
packages/dashboard/src/__tests__/server-webhook.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import http from "node:http";
|
||||
import type { Task, TaskStore, PrInfo, IssueInfo } from "@kb/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { getGitHubAppConfig } from "../github-webhooks.js";
|
||||
|
||||
// Mock the github-webhooks module
|
||||
vi.mock("../github-webhooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../github-webhooks.js")>("../github-webhooks.js");
|
||||
return {
|
||||
...actual,
|
||||
getGitHubAppConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetGitHubAppConfig = vi.mocked(getGitHubAppConfig);
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
private tasks = new Map<string, Task>();
|
||||
private rootDir: string;
|
||||
|
||||
constructor(rootDir: string = process.cwd()) {
|
||||
super();
|
||||
this.rootDir = rootDir;
|
||||
}
|
||||
|
||||
getRootDir(): string {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
return Array.from(this.tasks.values());
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task> {
|
||||
const task = this.tasks.get(id);
|
||||
if (!task) {
|
||||
const error = Object.assign(new Error("Task not found"), { code: "ENOENT" });
|
||||
throw error;
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
async updatePrInfo(taskId: string, prInfo: PrInfo): Promise<Task> {
|
||||
const task = await this.getTask(taskId);
|
||||
const updated = { ...task, prInfo };
|
||||
this.tasks.set(taskId, updated);
|
||||
this.emit("task:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateIssueInfo(taskId: string, issueInfo: IssueInfo): Promise<Task> {
|
||||
const task = await this.getTask(taskId);
|
||||
const updated = { ...task, issueInfo };
|
||||
this.tasks.set(taskId, updated);
|
||||
this.emit("task:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
addTask(task: Task): void {
|
||||
this.tasks.set(task.id, task);
|
||||
this.emit("task:created", task);
|
||||
}
|
||||
}
|
||||
|
||||
function createHmacSignature(payload: string, secret: string): string {
|
||||
const { createHmac } = require("node:crypto");
|
||||
return "sha256=" + createHmac("sha256", secret).update(payload).digest("hex");
|
||||
}
|
||||
|
||||
function createPrTask(id: string, url: string, number: number, status: PrInfo["status"] = "open"): Task {
|
||||
return {
|
||||
id,
|
||||
title: "Test task",
|
||||
description: "Test description",
|
||||
column: "in-review",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-03-30T00:00:00.000Z",
|
||||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||||
columnMovedAt: "2026-03-30T00:00:00.000Z",
|
||||
prInfo: {
|
||||
url,
|
||||
number,
|
||||
status,
|
||||
title: "Test PR",
|
||||
headBranch: "feature",
|
||||
baseBranch: "main",
|
||||
commentCount: 0,
|
||||
lastCheckedAt: "2026-03-30T00:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueTask(id: string, url: string, number: number, state: IssueInfo["state"] = "open"): Task {
|
||||
return {
|
||||
id,
|
||||
title: "Test task",
|
||||
description: "Test description",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-03-30T00:00:00.000Z",
|
||||
updatedAt: "2026-03-30T00:00:00.000Z",
|
||||
columnMovedAt: "2026-03-30T00:00:00.000Z",
|
||||
issueInfo: {
|
||||
url,
|
||||
number,
|
||||
state,
|
||||
title: "Test Issue",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/github/webhooks", () => {
|
||||
const mockConfig = {
|
||||
appId: "12345",
|
||||
privateKey: "mock-private-key",
|
||||
webhookSecret: "webhook-secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetGitHubAppConfig.mockReturnValue(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 503 when GitHub App is not configured", async () => {
|
||||
mockGetGitHubAppConfig.mockReturnValue(null);
|
||||
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/github/webhooks", method: "POST", headers: { "Content-Type": "application/json" } },
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(JSON.stringify({ action: "opened" }));
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("not configured");
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 403 for invalid signature", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const payload = JSON.stringify({ action: "opened", number: 42 });
|
||||
const invalidSignature = "sha256=invalid";
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": invalidSignature,
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toMatch(/Signature mismatch/i);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 200 for valid ping event", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const payload = JSON.stringify({ zen: "Keep it logically awesome" });
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "ping",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.message).toBe("Pong");
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 202 for unsupported event types", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const payload = JSON.stringify({ action: "pushed" });
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "push",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain("not supported");
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 202 for issue_comment on regular issues (not PRs)", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
// Issue comment without pull_request field
|
||||
const payload = JSON.stringify({
|
||||
action: "created",
|
||||
issue: { number: 123 }, // No pull_request field → regular issue
|
||||
repository: { owner: { login: "owner" }, name: "repo" },
|
||||
installation: { id: 12345 },
|
||||
comment: { id: 456, body: "Issue comment" },
|
||||
});
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "issue_comment",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
expect(response.body.message).toContain("not relevant");
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 500 when installation token cannot be fetched", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
// Valid PR event with missing installation data
|
||||
const payload = JSON.stringify({
|
||||
action: "opened",
|
||||
number: 42,
|
||||
repository: { owner: { login: "owner" }, name: "repo" },
|
||||
// No installation field - will cause token fetch to fail
|
||||
});
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "pull_request",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
// Should return 400 for missing installation data
|
||||
expect([400, 500]).toContain(response.status);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import type { Task } from "@kb/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { githubPoller } from "../github-poll.js";
|
||||
import { WebSocketManager } from "../websocket.js";
|
||||
import { InMemoryBadgePubSub, type BadgePubSub } from "../badge-pubsub.js";
|
||||
import { GitHubPollingService } from "../github-poll.js";
|
||||
|
||||
class MockSocket extends EventEmitter {
|
||||
readyState: number = WebSocket.OPEN;
|
||||
@@ -190,23 +188,7 @@ describe("WebSocketManager", () => {
|
||||
});
|
||||
|
||||
describe("/api/ws integration", () => {
|
||||
let startSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stopSpy: ReturnType<typeof vi.spyOn>;
|
||||
let replaceSpy: ReturnType<typeof vi.spyOn>;
|
||||
let unwatchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
startSpy = vi.spyOn(githubPoller, "start").mockImplementation(() => {});
|
||||
stopSpy = vi.spyOn(githubPoller, "stop").mockImplementation(() => {});
|
||||
replaceSpy = vi.spyOn(githubPoller, "replaceTaskWatches").mockImplementation(() => {});
|
||||
unwatchSpy = vi.spyOn(githubPoller, "unwatchTask").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("delivers badge updates to subscribed websocket clients and manages poller lifecycle", async () => {
|
||||
it("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
|
||||
const initialTask = createTask();
|
||||
const store = new MockStore(initialTask);
|
||||
const app = createServer(store as any, { githubToken: "test-token" });
|
||||
@@ -222,22 +204,10 @@ describe("/api/ws integration", () => {
|
||||
});
|
||||
|
||||
await once(client, "open");
|
||||
await waitForExpectation(() => {
|
||||
expect(startSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
client.send(JSON.stringify({ type: "subscribe", taskId: initialTask.id }));
|
||||
await waitForExpectation(() => {
|
||||
expect(replaceSpy).toHaveBeenCalledWith(initialTask.id, [
|
||||
{
|
||||
taskId: initialTask.id,
|
||||
type: "pr",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
number: 42,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Wait for subscription to be established
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const updatedTask = createTask({
|
||||
prInfo: {
|
||||
@@ -262,12 +232,6 @@ describe("/api/ws integration", () => {
|
||||
|
||||
client.close();
|
||||
await once(client, "close");
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(unwatchSpy).toHaveBeenCalledWith(initialTask.id);
|
||||
expect(stopSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
@@ -302,15 +266,10 @@ describe("multi-instance /api/ws integration", () => {
|
||||
const storeA = new MockStore(taskA);
|
||||
const storeB = new MockStore(taskA); // Instance B starts with same task data
|
||||
|
||||
// Create separate pollers for each instance
|
||||
const pollerA = new GitHubPollingService();
|
||||
const pollerB = new GitHubPollingService();
|
||||
|
||||
// Create server A with zero local websocket subscribers (no local ws clients)
|
||||
const appA = createServer(storeA as any, {
|
||||
githubToken: "test-token",
|
||||
badgePubSub: sharedPubSub,
|
||||
githubPoller: pollerA,
|
||||
});
|
||||
const serverA = appA.listen(0);
|
||||
await once(serverA, "listening");
|
||||
@@ -320,7 +279,6 @@ describe("multi-instance /api/ws integration", () => {
|
||||
const appB = createServer(storeB as any, {
|
||||
githubToken: "test-token",
|
||||
badgePubSub: sharedPubSub,
|
||||
githubPoller: pollerB,
|
||||
});
|
||||
const serverB = appB.listen(0);
|
||||
await once(serverB, "listening");
|
||||
@@ -388,12 +346,10 @@ describe("multi-instance /api/ws integration", () => {
|
||||
|
||||
const task = createTask({ id: "KB-ECHO-001" });
|
||||
const store = new MockStore(task);
|
||||
const poller = new GitHubPollingService();
|
||||
|
||||
const app = createServer(store as any, {
|
||||
githubToken: "test-token",
|
||||
badgePubSub: sharedPubSub,
|
||||
githubPoller: poller,
|
||||
});
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
@@ -477,14 +433,11 @@ describe("multi-instance /api/ws integration", () => {
|
||||
|
||||
const storeA = new MockStore(task);
|
||||
const storeB = new MockStore(task);
|
||||
const pollerA = new GitHubPollingService();
|
||||
const pollerB = new GitHubPollingService();
|
||||
|
||||
// Create both instances
|
||||
const appA = createServer(storeA as any, {
|
||||
githubToken: "test-token",
|
||||
badgePubSub: sharedPubSub,
|
||||
githubPoller: pollerA,
|
||||
});
|
||||
const serverA = appA.listen(0);
|
||||
await once(serverA, "listening");
|
||||
@@ -493,7 +446,6 @@ describe("multi-instance /api/ws integration", () => {
|
||||
const appB = createServer(storeB as any, {
|
||||
githubToken: "test-token",
|
||||
badgePubSub: sharedPubSub,
|
||||
githubPoller: pollerB,
|
||||
});
|
||||
const serverB = appB.listen(0);
|
||||
await once(serverB, "listening");
|
||||
@@ -546,79 +498,4 @@ describe("multi-instance /api/ws integration", () => {
|
||||
expect(badgeMessage).toBeDefined();
|
||||
expect(badgeMessage.prInfo.status).toBe("merged");
|
||||
}, 5000);
|
||||
|
||||
it("maintains separate poller watch state per server instance", async () => {
|
||||
// Create two completely separate server instances
|
||||
const taskA = createTask({ id: "KB-POLLER-001" });
|
||||
const taskB = createTask({ id: "KB-POLLER-002" });
|
||||
|
||||
const storeA = new MockStore(taskA);
|
||||
const storeB = new MockStore(taskB);
|
||||
|
||||
// Create separate pollers
|
||||
const pollerA = new GitHubPollingService();
|
||||
const pollerB = new GitHubPollingService();
|
||||
|
||||
// Create servers with separate pollers
|
||||
const appA = createServer(storeA as any, {
|
||||
githubToken: "test-token",
|
||||
githubPoller: pollerA,
|
||||
});
|
||||
const serverA = appA.listen(0);
|
||||
await once(serverA, "listening");
|
||||
const portA = (serverA.address() as import("node:net").AddressInfo).port;
|
||||
|
||||
const appB = createServer(storeB as any, {
|
||||
githubToken: "test-token",
|
||||
githubPoller: pollerB,
|
||||
});
|
||||
const serverB = appB.listen(0);
|
||||
await once(serverB, "listening");
|
||||
const portB = (serverB.address() as import("node:net").AddressInfo).port;
|
||||
|
||||
// Connect clients to both servers
|
||||
const clientA = new WebSocket(`ws://127.0.0.1:${portA}/api/ws`);
|
||||
await once(clientA, "open");
|
||||
|
||||
const clientB = new WebSocket(`ws://127.0.0.1:${portB}/api/ws`);
|
||||
await once(clientB, "open");
|
||||
|
||||
// Wait for connections to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Subscribe to different tasks on each server
|
||||
clientA.send(JSON.stringify({ type: "subscribe", taskId: taskA.id }));
|
||||
clientB.send(JSON.stringify({ type: "subscribe", taskId: taskB.id }));
|
||||
|
||||
// Wait for subscriptions to establish
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Verify pollerA is watching taskA but not taskB
|
||||
expect(pollerA.getWatchedTaskIds()).toContain(taskA.id);
|
||||
expect(pollerA.getWatchedTaskIds()).not.toContain(taskB.id);
|
||||
|
||||
// Verify pollerB is watching taskB but not taskA
|
||||
expect(pollerB.getWatchedTaskIds()).toContain(taskB.id);
|
||||
expect(pollerB.getWatchedTaskIds()).not.toContain(taskA.id);
|
||||
|
||||
// Now unsubscribe from B and verify A's poller is unaffected
|
||||
clientB.send(JSON.stringify({ type: "unsubscribe", taskId: taskB.id }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// PollerA should still be watching taskA
|
||||
expect(pollerA.getWatchedTaskIds()).toContain(taskA.id);
|
||||
|
||||
// PollerB should have stopped watching taskB
|
||||
expect(pollerB.getWatchedTaskIds()).not.toContain(taskB.id);
|
||||
|
||||
// Cleanup
|
||||
clientA.close();
|
||||
clientB.close();
|
||||
await Promise.race([once(clientA, "close"), new Promise(r => setTimeout(r, 500))]);
|
||||
await Promise.race([once(clientB, "close"), new Promise(r => setTimeout(r, 500))]);
|
||||
serverA.close();
|
||||
serverB.close();
|
||||
await Promise.race([once(serverA, "close"), new Promise(r => setTimeout(r, 500))]);
|
||||
await Promise.race([once(serverB, "close"), new Promise(r => setTimeout(r, 500))]);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user