- Rename all KB_* environment variables to FUSION_* prefix across CLI and dashboard - Update CLI runtime files (native-patch.ts) and build configuration - Update dashboard server, badge-pubsub, GitHub webhooks, and terminal service - Update test files to use new FUSION_API_KEY and related env vars - Update documentation in README, AGENTS.md, STANDALONE.md, and dashboard README - Update changeset files with new environment variable names
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
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.FUSION_GITHUB_APP_ID;
|
|
delete process.env.FUSION_GITHUB_APP_PRIVATE_KEY;
|
|
delete process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH;
|
|
delete process.env.FUSION_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.FUSION_GITHUB_APP_ID = "12345";
|
|
expect(getGitHubAppConfig()).toBeNull();
|
|
});
|
|
|
|
it("returns config when all required vars are set via direct key", () => {
|
|
process.env.FUSION_GITHUB_APP_ID = "12345";
|
|
process.env.FUSION_GITHUB_APP_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----";
|
|
process.env.FUSION_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.FUSION_GITHUB_APP_ID = "12345";
|
|
process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
|
|
process.env.FUSION_GITHUB_WEBHOOK_SECRET = "webhook-secret";
|
|
|
|
expect(getGitHubAppConfig()).toBeNull();
|
|
});
|
|
|
|
it("prefers direct key over file path when both are set", () => {
|
|
process.env.FUSION_GITHUB_APP_ID = "12345";
|
|
process.env.FUSION_GITHUB_APP_PRIVATE_KEY = "direct-key-content";
|
|
process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
|
|
process.env.FUSION_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.FUSION_GITHUB_APP_ID = "12345";
|
|
process.env.FUSION_GITHUB_APP_PRIVATE_KEY = "private-key";
|
|
process.env.FUSION_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();
|
|
});
|
|
});
|
|
});
|