WebHooker/tests/webhook.test.ts

238 lines
8.6 KiB
TypeScript

import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { verifySignature } from "../server/lib/providers/github/verify";
import { parseEvent } from "../server/lib/providers/github/parse";
import { matchRoute } from "../server/lib/events/match";
import type { Route, WebhookEvent } from "../server/lib/types";
function sign(body: string, secret: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}
describe("verifySignature", () => {
const secret = "test-secret";
it("returns true for valid signature", async () => {
const body = '{"hello":"world"}';
expect(await verifySignature(body, sign(body, secret), secret)).toBe(true);
});
it("returns false for invalid signature", async () => {
expect(await verifySignature("body", "sha256=invalid", secret)).toBe(false);
});
it("returns false for missing signature", async () => {
expect(await verifySignature("body", undefined, secret)).toBe(false);
});
});
describe("parseEvent", () => {
it("parses valid push event", () => {
const headers = { "x-github-event": "push", "x-hub-signature-256": "sha256=abc" };
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const event = parseEvent(headers, body);
expect(event).not.toBeNull();
expect(event!.event).toBe("push");
expect(event!.payload.ref).toBe("refs/heads/main");
});
it("returns null for missing event header", () => {
expect(parseEvent({}, "{}")).toBeNull();
});
it("returns null for invalid JSON", () => {
expect(parseEvent({ "x-github-event": "push" }, "not json")).toBeNull();
});
});
describe("matchRoute", () => {
const baseRoute: Route = {
id: "test",
name: "Test",
enabled: true,
filters: [],
targets: [{ channelId: "123" }],
};
it("matches all events when no filters", () => {
const event: WebhookEvent = { event: "push", payload: {} };
expect(matchRoute(baseRoute, event)).toBe(true);
});
it("rejects disabled routes", () => {
const route = { ...baseRoute, enabled: false };
const event: WebhookEvent = { event: "push", payload: {} };
expect(matchRoute(route, event)).toBe(false);
});
it("matches event filter", () => {
const route = { ...baseRoute, filters: [{ type: "event" as const, match: "push" }] };
expect(matchRoute(route, { event: "push", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(false);
});
it("matches event exclude filter", () => {
const route = {
...baseRoute,
filters: [{ type: "event" as const, match: "push", exclude: true }],
};
expect(matchRoute(route, { event: "push", payload: {} })).toBe(false);
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(true);
});
it("matches repo filter", () => {
const route = { ...baseRoute, filters: [{ type: "repo" as const, match: "owner/repo" }] };
const event: WebhookEvent = {
event: "push",
payload: { repository: { full_name: "owner/repo" } },
};
expect(matchRoute(route, event)).toBe(true);
});
it("matches actor filter", () => {
const route = { ...baseRoute, filters: [{ type: "actor" as const, match: "octocat" }] };
const event: WebhookEvent = { event: "push", payload: { sender: { login: "octocat" } } };
expect(matchRoute(route, event)).toBe(true);
});
it("matches action filter", () => {
const route = { ...baseRoute, filters: [{ type: "action" as const, match: "opened" }] };
const event: WebhookEvent = { event: "issues", payload: { action: "opened" } };
expect(matchRoute(route, event)).toBe(true);
});
it("matches branch filter for push events", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "main" }] };
const event: WebhookEvent = { event: "push", payload: { ref: "refs/heads/main" } };
expect(matchRoute(route, event)).toBe(true);
expect(matchRoute(route, { event: "push", payload: { ref: "refs/heads/dev" } })).toBe(false);
});
it("matches branch filter for pull_request events", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-x" }] };
const event: WebhookEvent = {
event: "pull_request",
payload: { pull_request: { head: { ref: "feature-x" } } },
};
expect(matchRoute(route, event)).toBe(true);
expect(
matchRoute(route, {
event: "pull_request",
payload: { pull_request: { head: { ref: "other" } } },
}),
).toBe(false);
});
it("matches keyword filter with regex (//-wrapped)", () => {
const route = {
...baseRoute,
filters: [{ type: "keyword" as const, match: "/fix(es|ed)\\s+bug/" }],
};
const event: WebhookEvent = {
event: "push",
payload: { commits: [{ message: "fixes bug #123" }] },
};
expect(matchRoute(route, event)).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { commits: [{ message: "adds feature" }] } }),
).toBe(false);
});
it("matches keyword filter with plain text fallback", () => {
const route = { ...baseRoute, filters: [{ type: "keyword" as const, match: "deploy" }] };
const event: WebhookEvent = {
event: "push",
payload: { commits: [{ message: "deploy to prod" }] },
};
expect(matchRoute(route, event)).toBe(true);
});
it("matches keyword filter with glob search", () => {
const route = { ...baseRoute, filters: [{ type: "keyword" as const, match: "*deploy*" }] };
const event: WebhookEvent = {
event: "push",
payload: { commits: [{ message: "auto deploy pipeline" }] },
};
expect(matchRoute(route, event)).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { commits: [{ message: "build only" }] } }),
).toBe(false);
});
it("matches event filter with glob", () => {
const route = { ...baseRoute, filters: [{ type: "event" as const, match: "pull_*" }] };
expect(matchRoute(route, { event: "pull_request", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "pull_request_review", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "push", payload: {} })).toBe(false);
});
it("matches repo filter with glob across slashes", () => {
const route = { ...baseRoute, filters: [{ type: "repo" as const, match: "myorg/*" }] };
expect(
matchRoute(route, {
event: "push",
payload: { repository: { full_name: "myorg/backend" } },
}),
).toBe(true);
expect(
matchRoute(route, {
event: "push",
payload: { repository: { full_name: "other/backend" } },
}),
).toBe(false);
});
it("matches branch filter with single-char ? wildcard", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "feature-?" }] };
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-xy" } }),
).toBe(false);
});
it("matches branch filter with //-wrapped regex", () => {
const route = { ...baseRoute, filters: [{ type: "branch" as const, match: "/^feat/" }] };
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/feature-x" } }),
).toBe(true);
expect(
matchRoute(route, { event: "push", payload: { ref: "refs/heads/main" } }),
).toBe(false);
});
it("treats glob special chars literally when not wrapped", () => {
const route = {
...baseRoute,
filters: [{ type: "event" as const, match: "release-" }],
};
expect(matchRoute(route, { event: "release-x", payload: {} })).toBe(false);
expect(matchRoute(route, { event: "release-", payload: {} })).toBe(true);
});
it("matches multiple filters (AND logic)", () => {
const route = {
...baseRoute,
filters: [
{ type: "event" as const, match: "push" },
{ type: "actor" as const, match: "octocat" },
],
};
const event: WebhookEvent = { event: "push", payload: { sender: { login: "octocat" } } };
expect(matchRoute(route, event)).toBe(true);
expect(matchRoute(route, { event: "push", payload: { sender: { login: "other" } } })).toBe(
false,
);
});
it("matches array of patterns", () => {
const route = {
...baseRoute,
filters: [{ type: "event" as const, match: ["push", "pull_request"] }],
};
expect(matchRoute(route, { event: "push", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "pull_request", payload: {} })).toBe(true);
expect(matchRoute(route, { event: "issues", payload: {} })).toBe(false);
});
});