feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3

This commit is contained in:
RhenCloud 2026-08-13 17:43:33 +08:00
parent f4959eebf8
commit b139712a91
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
166 changed files with 19790 additions and 5539 deletions

243
tests/admin-api.test.ts Normal file
View file

@ -0,0 +1,243 @@
import { describe, it, expect } from "bun:test";
import {
adminGroupRename,
adminGroupRoutesGet,
adminApiMe,
} from "../server/lib/web/admin";
import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups";
import { loadRoutes } from "../server/lib/config";
import { createInvite, listInvites } from "../server/lib/web/invites";
import { getTenantSecret, setTenantSecret } from "../server/lib/web/tenants";
import { makeEvent, responseStatus } from "./helpers";
import type { Env, Route } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
describe("admin handlers (h3)", () => {
it("renames an owned group and follows routes, secret and invites", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "old-team",
name: "Old Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
},
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "r1",
name: "R1",
enabled: true,
groupId: "old-team",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
await setTenantSecret(kv, "old-team");
const originalSecret = (await getTenantSecret(kv, "old-team"))!;
await createInvite(kv, {
groupId: "old-team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "alice",
});
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = makeEvent("/api/groups/old-team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
const result = (await adminGroupRename(event, "old-team")) as { ok?: boolean };
expect(responseStatus(event)).toBe(200);
expect(result.ok).toBe(true);
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.id).toBe("new-team");
expect(groups[0]!.name).toBe("Old Team");
const routes = await loadRoutes(kv);
expect(routes[0]!.groupId).toBe("new-team");
expect(await getTenantSecret(kv, "new-team")).toBe(originalSecret);
expect(await getTenantSecret(kv, "old-team")).toBeNull();
const invites = await listInvites(kv, "new-team");
expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("new-team");
expect(await listInvites(kv, "old-team")).toHaveLength(0);
});
it("forbids non-owner members from renaming", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "bob", role: "admin" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "2002", "bob");
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
await adminGroupRename(event, "team");
expect(responseStatus(event)).toBe(403);
});
it("rejects invalid, duplicate or unchanged ids", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
{ id: "other", name: "Other", adminIds: [] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const make = (newId: string) => {
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId }),
env,
});
return adminGroupRename(event, "team").then(() => responseStatus(event));
};
expect(await make("Bad ID!")).toBe(400);
expect(await make("team")).toBe(400);
expect(await make("other")).toBe(400);
});
it("returns 401 without a session", async () => {
const env = createEnv();
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
await expect(adminGroupRename(event, "team")).rejects.toMatchObject({ statusCode: 401 });
});
it("returns 404 for an unknown group", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "Mine", adminIds: [], members: [{ login: "alice", role: "owner" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = makeEvent("/api/groups/nope/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
const result = (await adminGroupRename(event, "nope")) as { error?: string };
expect(responseStatus(event)).toBe(404);
expect(result.error).toBe("Group not found");
});
it("me and group routes scoping work", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "Mine", adminIds: [], members: [{ login: "alice", role: "owner" }] },
{ id: "theirs", name: "Theirs", adminIds: [], members: [{ login: "bob", role: "owner" }] },
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "mine-r",
name: "Mine R",
enabled: true,
groupId: "mine",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const meEvent = makeEvent("/api/me", { headers: { cookie: adminCookie(sessionId) }, env });
const me = (await adminApiMe(meEvent)) as { groups?: Array<{ id: string }>; isSuper?: boolean };
expect(me.groups?.map((g) => g.id)).toEqual(["mine"]);
expect(me.isSuper).toBe(false);
const routesEvent = makeEvent("/api/groups/mine/routes", {
headers: { cookie: adminCookie(sessionId) },
env,
});
const groupRoutes = (await adminGroupRoutesGet(routesEvent, "mine")) as {
routes?: Array<{ id: string }>;
};
expect(groupRoutes.routes?.map((r) => r.id)).toEqual(["mine-r"]);
});
});

337
tests/admin.test.ts Normal file
View file

@ -0,0 +1,337 @@
import { describe, it, expect, beforeEach } from "bun:test";
import {
isAdminUser,
createAdminSession,
getAdminSession,
destroyAdminSession,
adminCookie,
clearAdminCookie,
} from "../server/lib/web/session";
import {
groupAcceptsProvider,
groupAcceptsInstallation,
ensureInstallationGroup,
loadGroups,
saveGroups,
} from "../server/lib/web/groups";
import { validateGroups } from "../server/lib/web/admin";
import {
getTenantSecret,
setTenantSecret,
deleteTenantSecret,
generateTenantSecret,
} from "../server/lib/web/tenants";
import { loadRoutes, saveRoutes, loadConfig } from "../server/lib/config";
import type { Env, Route, Group } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();
return {
get: async (key: string, type?: string) => {
const entry = store.get(key);
if (!entry) return null;
if (entry.expiration && Date.now() / 1000 > entry.expiration) {
store.delete(key);
return null;
}
if (type === "json") return JSON.parse(entry.value);
return entry.value;
},
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined;
store.set(key, { value, expiration });
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: createMockKV(),
...overrides,
};
}
const sampleRoutes: Route[] = [
{
id: "backend-prs",
name: "Backend PRs",
enabled: true,
filters: [{ type: "event", match: "pull_request" }],
targets: [{ channelId: "111" }],
},
];
describe("isAdminUser", () => {
it("allows matching user id", () => {
const env = createEnv({ ADMIN_USER_IDS: "12345,67890" });
expect(isAdminUser(env, "12345", "other")).toBe(true);
});
it("allows matching login case-insensitively", () => {
const env = createEnv({ ADMIN_USER_IDS: "RhenCloud" });
expect(isAdminUser(env, "1", "rhencloud")).toBe(true);
});
it("rejects non-admin users", () => {
const env = createEnv({ ADMIN_USER_IDS: "12345" });
expect(isAdminUser(env, "99999", "someone")).toBe(false);
});
it("rejects when whitelist is empty", () => {
const env = createEnv();
expect(isAdminUser(env, "12345", "rhencloud")).toBe(false);
});
});
describe("admin-session", () => {
let kv: KVNamespace;
beforeEach(() => {
kv = createMockKV();
});
it("creates and retrieves a session from cookie", async () => {
const sessionId = await createAdminSession(kv, "12345", "rhencloud");
const session = await getAdminSession(kv, adminCookie(sessionId));
expect(session).toEqual({ userId: "12345", login: "rhencloud" });
});
it("returns null without a cookie", async () => {
expect(await getAdminSession(kv, undefined)).toBeNull();
});
it("returns null for unknown session id", async () => {
expect(await getAdminSession(kv, "wh_admin_session=nope")).toBeNull();
});
it("destroys a session", async () => {
const sessionId = await createAdminSession(kv, "12345", "rhencloud");
await destroyAdminSession(kv, adminCookie(sessionId));
expect(await getAdminSession(kv, adminCookie(sessionId))).toBeNull();
});
it("clear cookie is an expired cookie", () => {
expect(clearAdminCookie()).toContain("wh_admin_session=;");
expect(clearAdminCookie()).toContain("Max-Age=0");
});
});
describe("groupAcceptsProvider", () => {
const base: Group = { id: "g", name: "G", adminIds: [] };
it("accepts every provider when none are configured", () => {
expect(groupAcceptsProvider(base, "github")).toBe(true);
expect(groupAcceptsProvider(base, "gitea")).toBe(true);
expect(groupAcceptsProvider(base, undefined)).toBe(true);
});
it("restricts to the configured providers", () => {
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "gitea")).toBe(true);
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, "github")).toBe(false);
expect(groupAcceptsProvider({ ...base, providers: ["gitea"] }, undefined)).toBe(false);
});
it("matches case-insensitively and trims whitespace", () => {
expect(groupAcceptsProvider({ ...base, providers: [" Gitea "] }, "gitea")).toBe(true);
});
});
describe("validateGroups logTarget", () => {
const baseGroup = {
id: "g",
name: "G",
members: [{ login: "boss", role: "owner" }],
};
it("accepts and normalizes a discord log target", () => {
const res = validateGroups([
{
...baseGroup,
logTarget: { platform: "discord", channelId: "111", threadId: "222" },
},
]);
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.groups[0]!.logTarget).toEqual({
platform: "discord",
channelId: "111",
threadId: "222",
chatId: undefined,
topicId: undefined,
});
}
});
it("accepts a telegram log target", () => {
const res = validateGroups([
{
...baseGroup,
logTarget: { platform: "telegram", chatId: "-100123", topicId: "999" },
},
]);
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.groups[0]!.logTarget).toEqual({
platform: "telegram",
channelId: undefined,
threadId: undefined,
chatId: "-100123",
topicId: "999",
});
}
});
it("rejects a log target without a channel id", () => {
const res = validateGroups([
{ ...baseGroup, logTarget: { platform: "discord", channelId: "" } },
]);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("logTarget.channelId");
});
it("rejects a log target with an unknown platform", () => {
const res = validateGroups([{ ...baseGroup, logTarget: { platform: "slack" } }]);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toContain("logTarget.platform");
});
it("drops a null log target", () => {
const res = validateGroups([{ ...baseGroup, logTarget: null }]);
expect(res.ok).toBe(true);
if (res.ok) expect(res.groups[0]!.logTarget).toBeUndefined();
});
});
describe("validateGroups installationId", () => {
const baseGroup = {
id: "g",
name: "G",
members: [{ login: "boss", role: "owner" }],
};
it("accepts a positive integer installation id", () => {
const res = validateGroups([{ ...baseGroup, installationId: 42 }]);
expect(res.ok).toBe(true);
if (res.ok) expect(res.groups[0]!.installationId).toBe(42);
});
it("rejects non-integer installation ids", () => {
expect(validateGroups([{ ...baseGroup, installationId: "42" }]).ok).toBe(false);
expect(validateGroups([{ ...baseGroup, installationId: 42.5 }]).ok).toBe(false);
});
it("drops a null installation id", () => {
const res = validateGroups([{ ...baseGroup, installationId: null }]);
expect(res.ok).toBe(true);
if (res.ok) expect(res.groups[0]!.installationId).toBeUndefined();
});
});
describe("groupAcceptsInstallation", () => {
const base: Group = { id: "g", name: "G", adminIds: [] };
it("accepts everything when the group is not bound to an installation", () => {
expect(groupAcceptsInstallation(base, 101)).toBe(true);
expect(groupAcceptsInstallation(base, undefined)).toBe(true);
});
it("only accepts events from the bound installation", () => {
const bound = { ...base, installationId: 101 };
expect(groupAcceptsInstallation(bound, 101)).toBe(true);
expect(groupAcceptsInstallation(bound, 202)).toBe(false);
expect(groupAcceptsInstallation(bound, undefined)).toBe(false);
});
});
describe("ensureInstallationGroup", () => {
it("creates an inst-{id} group when nothing is bound", async () => {
const kv = createMockKV();
const group = await ensureInstallationGroup(kv, 555, "myorg");
expect(group?.id).toBe("inst-555");
expect(group?.installationId).toBe(555);
expect(group?.name).toBe("myorg");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.installationId).toBe(555);
});
it("binds existing groups whose owners match the installing account", async () => {
const kv = createMockKV();
await saveGroups(kv, [
{ id: "backend", name: "Backend", adminIds: [], owners: ["myorg"], members: [] },
{ id: "other", name: "Other", adminIds: [], owners: ["another-org"], members: [] },
]);
await ensureInstallationGroup(kv, 555, "MyOrg");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(2);
expect(groups.find((g) => g.id === "backend")?.installationId).toBe(555);
expect(groups.find((g) => g.id === "other")?.installationId).toBeUndefined();
expect(groups.some((g) => g.id.startsWith("inst-"))).toBe(false);
});
it("is idempotent when a group is already bound", async () => {
const kv = createMockKV();
await ensureInstallationGroup(kv, 555, "myorg");
await ensureInstallationGroup(kv, 555, "myorg");
expect((await loadGroups(kv)).filter((g) => g.installationId === 555)).toHaveLength(1);
});
});
describe("tenant webhook secrets", () => {
it("generates 64-char hex secrets", () => {
const s = generateTenantSecret();
expect(s).toMatch(/^[0-9a-f]{64}$/);
});
it("creates, reads, and deletes a tenant secret", async () => {
const kv = createMockKV();
expect(await getTenantSecret(kv, "g1")).toBeNull();
const secret = await setTenantSecret(kv, "g1");
expect(await getTenantSecret(kv, "g1")).toBe(secret);
await deleteTenantSecret(kv, "g1");
expect(await getTenantSecret(kv, "g1")).toBeNull();
});
it("regenerating replaces the previous secret", async () => {
const kv = createMockKV();
const a = await setTenantSecret(kv, "g1");
const b = await setTenantSecret(kv, "g1");
expect(a).not.toBe(b);
expect(await getTenantSecret(kv, "g1")).toBe(b);
});
});
describe("config routes persistence", () => {
it("saves and loads routes from KV", async () => {
const kv = createMockKV();
await saveRoutes(kv, sampleRoutes);
expect(await loadRoutes(kv)).toEqual(sampleRoutes);
});
it("returns empty routes when KV is empty", async () => {
const kv = createMockKV();
const routes = await loadRoutes(kv);
expect(routes).toHaveLength(0);
});
it("loadConfig picks up saved routes after cache invalidation", async () => {
const env = createEnv();
const first = await loadConfig(env);
expect(first.routes).toHaveLength(0);
await saveRoutes(env.KV, sampleRoutes);
const second = await loadConfig(env);
expect(second.routes).toHaveLength(1);
expect(second.routes[0]!.id).toBe("backend-prs");
expect(second.routes[0]!.targets[0]!.channelId).toBe("111");
});
});

133
tests/audit.test.ts Normal file
View file

@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { recordAudit, getAuditLog, pruneAuditLogs } from "../server/lib/lib/audit";
function createMockD1(): D1Database {
let rows: Array<Record<string, unknown>> = [];
let id = 1;
function queryAll(sql: string, args: unknown[]): Array<Record<string, unknown>> {
if (sql.startsWith("INSERT")) return [];
let filtered = rows;
if (sql.includes("group_id = ?")) {
const gid = args[0] as string;
filtered = rows.filter((r) => r.group_id === gid);
}
const limit = args[args.length - 1] as number;
return [...filtered]
.sort((a, b) => (b.ts as number) - (a.ts as number))
.slice(0, limit)
.map((r) => ({
id: r.id,
ts: r.ts ?? 0,
actor_id: r.actor_id ?? null,
actor_login: r.actor_login ?? null,
action: r.action ?? "",
target_type: r.target_type ?? null,
target_id: r.target_id ?? null,
group_id: r.group_id ?? null,
detail: r.detail ?? null,
ip: r.ip ?? null,
}));
}
const db = {
prepare: (sql: string): { bind: (args: unknown[]) => unknown } => ({
bind: (...args: unknown[]) => ({
run: async (): Promise<{ meta: { changes: number }; results?: unknown[] }> => {
if (sql.startsWith("INSERT")) {
const row: Record<string, unknown> = { id: id++ };
for (const [key, value] of [
["ts", args[0]],
["actor_id", args[1]],
["actor_login", args[2]],
["action", args[3]],
["target_type", args[4]],
["target_id", args[5]],
["group_id", args[6]],
["detail", args[7]],
["ip", args[8]],
] as const) {
if (value != null) row[key] = value;
}
rows.push(row);
return { meta: { changes: 1 } };
}
if (sql.startsWith("DELETE")) {
const cutoff = args[0] as number;
const before = rows.length;
rows = rows.filter((r) => (r.ts as number) >= cutoff);
return { meta: { changes: before - rows.length } };
}
return { meta: { changes: 0 }, results: queryAll(sql, args) };
},
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => ({
results: queryAll(sql, args),
}),
}),
}),
} as unknown as D1Database;
return db;
}
describe("audit log", () => {
let db: D1Database;
beforeEach(() => {
db = createMockD1();
});
it("records and loads entries newest-first", async () => {
await recordAudit(db, {
ts: 1000,
actorId: "1",
actorLogin: "a",
action: "group.create",
targetType: "group",
targetId: "g1",
groupId: "g1",
});
await recordAudit(db, {
ts: 2000,
actorId: "2",
actorLogin: "b",
action: "invite.create",
groupId: "g2",
detail: { role: "viewer" },
});
const entries = await getAuditLog(db, { limit: 10 });
expect(entries).toHaveLength(2);
expect(entries[0]!.action).toBe("invite.create");
expect(entries[0]!.detail).toEqual({ role: "viewer" });
expect(entries[1]!.actorLogin).toBe("a");
});
it("filters by group", async () => {
await recordAudit(db, { ts: 1, actorLogin: "a", action: "x", groupId: "g1" });
await recordAudit(db, { ts: 2, actorLogin: "b", action: "y", groupId: "g2" });
const entries = await getAuditLog(db, { groupId: "g2" });
expect(entries).toHaveLength(1);
expect(entries[0]!.action).toBe("y");
});
it("prunes entries older than the retention window", async () => {
const now = Date.now();
await recordAudit(db, { ts: now - 100 * 86400_000, actorLogin: "a", action: "old" });
await recordAudit(db, { ts: now, actorLogin: "b", action: "new" });
const removed = await pruneAuditLogs(db, 90);
expect(removed).toBe(1);
expect(await getAuditLog(db)).toHaveLength(1);
});
it("never throws on write failures", async () => {
const broken = {
prepare: (): { bind: () => { run: () => Promise<unknown> } } => ({
bind: () => ({
run: async (): Promise<never> => {
throw new Error("boom");
},
}),
}),
} as unknown as D1Database;
await expect(recordAudit(broken, { ts: 1, action: "x" })).resolves.toBeUndefined();
});
});

467
tests/discord.test.ts Normal file
View file

@ -0,0 +1,467 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { sendMessage } from "../server/lib/drivers/discord/rest";
import { renderNeutralMessage } from "../server/lib/drivers/discord/render";
import { dispatchEvent } from "../server/lib/core/dispatch";
import type { Env, Route } from "../server/lib/types";
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
Promise.resolve(handler(String(input), init));
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: {} as KVNamespace,
DB: {} as D1Database,
...overrides,
};
}
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
describe("discord-rest sendMessage", () => {
beforeEach(() => {
mockFetch(() => new Response("{}", { status: 200 }));
});
it("posts to the channel URL with bot auth", async () => {
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
mockFetch((url, init) => {
capturedUrl = url;
capturedInit = init;
return new Response("{}", { status: 200 });
});
const result = await sendMessage("token-abc", "111", { embeds: [] });
expect(result.ok).toBe(true);
expect(capturedUrl).toBe("https://discord.com/api/v10/channels/111/messages");
expect(capturedInit!.method).toBe("POST");
expect((capturedInit!.headers as Record<string, string>).Authorization).toBe("Bot token-abc");
expect((capturedInit!.headers as Record<string, string>)["Content-Type"]).toBe(
"application/json",
);
});
it("posts to the thread URL when threadId is given", async () => {
let capturedUrl = "";
mockFetch((url) => {
capturedUrl = url;
return new Response("{}", { status: 200 });
});
await sendMessage("t", "111", {}, "999");
expect(capturedUrl).toBe("https://discord.com/api/v10/channels/999/messages");
});
it("returns error on non-ok response", async () => {
mockFetch(() => new Response("Missing Permissions", { status: 403 }));
const result = await sendMessage("t", "111", {});
expect(result.ok).toBe(false);
expect(result.error).toContain("Missing Permissions");
});
});
describe("discord role mentions", () => {
it("renders mentionRoleIds into the message content", () => {
const out = renderNeutralMessage({
title: "T",
mentionRoleIds: ["111", "222"],
});
expect(out.content).toBe("<@&111> <@&222>");
expect(out.embeds?.[0]?.title).toBe("T");
});
it("omits content when no roles are mentioned", () => {
const out = renderNeutralMessage({ title: "T" });
expect(out.content).toBeUndefined();
});
});
describe("dispatchEvent fallback routing", () => {
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
const baseConfig = {
baseUrl: "https://example.com",
github: {
webhookSecret: "s",
appId: 1,
privateKey: "",
clientId: "",
clientSecret: "",
},
discord: { token: "t" },
routes: [] as Route[],
};
it("fires a filter-less fallback route only when no regular route matched", async () => {
const sent: string[] = [];
mockFetch((url) => {
sent.push(url);
return new Response("{}", { status: 200 });
});
const env = createEnv({ KV: createMockKV(), DB: createMockDB() });
const routes: Route[] = [
{
id: "regular-push",
name: "Regular Push",
enabled: true,
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "catch-all",
name: "Catch all",
enabled: true,
filters: [],
fallback: true,
targets: [{ channelId: "222" }],
},
];
await dispatchEvent({ ...baseConfig, routes }, { event: "push", payload: {} }, env);
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(1);
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(0);
sent.length = 0;
await dispatchEvent(
{ ...baseConfig, routes },
{
event: "issues",
payload: {
action: "opened",
issue: {
number: 1,
title: "Test issue",
body: "body",
state: "open",
html_url: "https://example.com/i/1",
user: { login: "octocat" },
},
repository: { full_name: "owner/repo" },
sender: { login: "octocat" },
},
},
env,
);
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
});
it("prepends role mentions to the Discord message content", async () => {
const bodies: string[] = [];
mockFetch((url, init) => {
bodies.push(String(init?.body ?? ""));
return new Response("{}", { status: 200 });
});
const env = createEnv({ KV: createMockKV(), DB: createMockDB() });
const routes: Route[] = [
{
id: "mention-push",
name: "Mention Push",
enabled: true,
discordRoleIds: ["111", "222"],
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "333" }],
},
];
await dispatchEvent({ ...baseConfig, routes }, { event: "push", payload: {} }, env);
expect(bodies).toHaveLength(1);
const parsed = JSON.parse(bodies[0]!) as {
content?: string;
embeds?: Array<{ title?: string }>;
};
expect(parsed.content).toBe("<@&111> <@&222>");
expect(parsed.embeds?.[0]).toBeDefined();
});
it("filters events by the group's source provider", async () => {
const sent: string[] = [];
mockFetch((url) => {
sent.push(url);
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "gh", name: "GH", adminIds: [], providers: ["github"] },
{ id: "gitea", name: "Gitea", adminIds: [], providers: ["gitea"] },
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "gh-push",
name: "GH",
enabled: true,
groupId: "gh",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "gitea-push",
name: "Gitea",
enabled: true,
groupId: "gitea",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "222" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{ event: "push", payload: {}, provider: "gitea" },
env,
);
expect(sent.filter((u) => u.includes("/222/"))).toHaveLength(1);
expect(sent.filter((u) => u.includes("/111/"))).toHaveLength(0);
});
it("sends a summary of dispatched webhooks to the group log target", async () => {
const sent: Array<{ url: string; body: string }> = [];
mockFetch((url, init) => {
sent.push({ url, body: String(init?.body ?? "") });
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "gh",
name: "GH",
adminIds: [],
logTarget: { platform: "discord", channelId: "777" },
},
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "push-route",
name: "Push Route",
enabled: true,
groupId: "gh",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{
event: "push",
deliveryId: "deliv-1",
payload: {
repository: { full_name: "owner/repo" },
ref: "refs/heads/main",
commits: [{ id: "abc", message: "fix", author: { name: "a" } }],
},
},
env,
);
expect(sent).toHaveLength(2);
expect(sent[1]!.url).toContain("/777/");
const logBody = JSON.parse(sent[1]!.body) as {
embeds?: Array<{ title?: string; color?: number; fields?: Array<{ value: string }> }>;
};
expect(logBody.embeds?.[0]?.title).toBe("owner/repo: push");
expect(logBody.embeds?.[0]?.color).toBe(0x3fb950);
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("✅ Push Route → 111");
expect(logBody.embeds?.[0]?.fields?.[1]?.value).toBe("deliv-1");
});
it("reports failed dispatches in the group log", async () => {
const sent: Array<{ url: string; body: string }> = [];
mockFetch((url, init) => {
if (url.includes("/111/")) return new Response("Missing Permissions", { status: 403 });
sent.push({ url, body: String(init?.body ?? "") });
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "gh",
name: "GH",
adminIds: [],
logTarget: { platform: "discord", channelId: "777" },
},
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "push-route",
name: "Push Route",
enabled: true,
groupId: "gh",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{
event: "push",
payload: {
repository: { full_name: "owner/repo" },
ref: "refs/heads/main",
commits: [{ id: "abc", message: "fix", author: { name: "a" } }],
},
},
env,
);
expect(sent).toHaveLength(1);
expect(sent[0]!.url).toContain("/777/");
const logBody = JSON.parse(sent[0]!.body) as {
embeds?: Array<{ color?: number; fields?: Array<{ value: string }> }>;
};
expect(logBody.embeds?.[0]?.color).toBe(0xf85149);
expect(logBody.embeds?.[0]?.fields?.[0]?.value).toContain("❌ Push Route → 111");
});
it("sends no group log when no route matched the event", async () => {
const sent: string[] = [];
mockFetch((url, init) => {
sent.push(String(init?.body ?? ""));
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "gh",
name: "GH",
adminIds: [],
logTarget: { platform: "discord", channelId: "777" },
},
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "push-route",
name: "Push Route",
enabled: true,
groupId: "gh",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{ event: "issues", payload: { action: "opened", repository: { full_name: "o/r" } } },
env,
);
expect(sent).toHaveLength(0);
});
it("uses the group's message language (not the route's)", async () => {
const bodies: string[] = [];
mockFetch((url, init) => {
bodies.push(String(init?.body ?? ""));
return new Response("{}", { status: 200 });
});
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "zh-group", name: "中文组", adminIds: [], lang: "zh" },
{ id: "en-group", name: "English", adminIds: [] },
]),
);
const env = createEnv({ KV: kv, DB: createMockDB() });
const routes: Route[] = [
{
id: "zh-push",
name: "ZH",
enabled: true,
groupId: "zh-group",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "en-push",
name: "EN",
enabled: true,
groupId: "en-group",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "222" }],
},
];
await dispatchEvent(
{ ...baseConfig, routes },
{
event: "push",
payload: {
repository: { full_name: "owner/repo" },
commits: [{ id: "abc", message: "fix", author: { name: "a" } }],
ref: "refs/heads/main",
compare: "https://example.com/compare",
},
},
env,
);
expect(bodies).toHaveLength(2);
const titles = bodies.map((b) => {
const parsed = JSON.parse(b) as { embeds?: Array<{ title?: string }> };
return parsed.embeds?.[0]?.title ?? "";
});
expect(titles.sort()).toEqual(
["owner/repo: 推送了 1 个提交", "owner/repo: Pushed 1 commit"].sort(),
);
});
});

362
tests/formatter.test.ts Normal file
View file

@ -0,0 +1,362 @@
import { describe, it, expect } from "bun:test";
import { formatEvent } from "../server/lib/formatters";
import type { Route, WebhookEvent } from "../server/lib/types";
const route: Route = {
id: "test",
name: "Test",
enabled: true,
filters: [],
targets: [{ channelId: "111" }],
};
function event(ev: string, payload: Record<string, unknown>): WebhookEvent {
return { event: ev, payload };
}
const repo = { full_name: "acme/widget", html_url: "https://github.com/acme/widget" };
const sender = { login: "octocat" };
describe("message title spec", () => {
it("push title starts with the repo", () => {
const msg = formatEvent(
route,
event("push", {
ref: "refs/heads/main",
compare: "https://github.com/acme/widget/compare/abc...def",
created: false,
forced: false,
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
repository: repo,
sender,
}),
);
expect(msg.title).toBe("acme/widget: Pushed 1 commit");
});
it("pull_request title is repo#number: title", () => {
const msg = formatEvent(
route,
event("pull_request", {
action: "opened",
number: 7,
pull_request: {
title: "Add feature",
number: 7,
state: "open",
merged: false,
draft: false,
html_url: "https://github.com/acme/widget/pull/7",
body: null,
user: sender,
head: { ref: "feat", repo: { full_name: "acme/widget" } },
base: { ref: "main" },
},
repository: repo,
sender,
}),
);
expect(msg.title).toBe("acme/widget#7: Add feature");
});
it("issue_comment title has no 'Comment on' prefix", () => {
const msg = formatEvent(
route,
event("issue_comment", {
action: "created",
issue: {
number: 3,
title: "Bug report",
html_url: "https://github.com/acme/widget/issues/3",
},
comment: {
body: "thanks",
html_url: "https://github.com/acme/widget/issues/3#issuecomment-1",
},
repository: repo,
sender,
}),
);
expect(msg.title).toBe("acme/widget#3: Bug report");
expect(msg.title).not.toContain("Comment on");
});
it("workflow_run title is repo: name — conclusion", () => {
const msg = formatEvent(
route,
event("workflow_run", {
action: "completed",
workflow_run: {
name: "CI",
conclusion: "success",
html_url: "https://github.com/acme/widget/actions/runs/42",
head_branch: "main",
run_number: 42,
jobs: [{ name: "build", conclusion: "success" }],
},
repository: repo,
sender,
}),
);
expect(msg.title).toBe("acme/widget: CI — success");
expect(msg.fields![1].value).toBe("✅ build");
});
it("workflow_run distinguishes queued and running from pending", () => {
const run = {
name: "CI",
conclusion: null as string | null,
html_url: "https://github.com/acme/widget/actions/runs/42",
head_branch: "main",
run_number: 42,
};
const queued = formatEvent(
route,
event("workflow_run", {
action: "requested",
workflow_run: run,
repository: repo,
sender,
}),
);
expect(queued.title).toBe("acme/widget: CI — queued");
expect(queued.fields![0].value).toBe("⏳ queued");
const running = formatEvent(
route,
event("workflow_run", {
action: "in_progress",
workflow_run: run,
repository: repo,
sender,
}),
);
expect(running.title).toBe("acme/widget: CI — running");
expect(running.fields![0].value).toBe("🔄 running");
});
it("check_run uses status for queued and running", () => {
const checkRun = {
id: 42,
name: "Lint",
html_url: "https://github.com/acme/widget/runs/1",
conclusion: null as string | null,
status: "in_progress",
};
const msg = formatEvent(
route,
event("check_run", { check_run: checkRun, repository: repo, sender }),
);
expect(msg.title).toBe("acme/widget: Lint — running");
expect(msg.fields![0].value).toBe("🔄 running");
expect(msg.updateKey).toBe("check_run:acme/widget:42");
});
it("check_run omits updateKey without a run id", () => {
const msg = formatEvent(
route,
event("check_run", {
check_run: { name: "Lint", status: "completed", conclusion: "success" },
repository: repo,
sender,
}),
);
expect(msg.updateKey).toBeUndefined();
});
it("check_suite shows conclusion, service, branch and commit", () => {
const suite = {
html_url: "https://github.com/acme/widget/runs/2",
conclusion: "success",
status: "completed",
app: { name: "Cloudflare Pages" },
head_branch: "main",
head_sha: "abc123def456",
};
const msg = formatEvent(
route,
event("check_suite", { check_suite: suite, repository: repo, sender }),
);
expect(msg.title).toBe("acme/widget: Check suite success");
expect(msg.url).toBe("https://github.com/acme/widget/runs/2");
expect(msg.fields![0].value).toBe("✅ success");
expect(msg.fields![1].value).toBe("Cloudflare Pages");
expect(msg.fields![2].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)");
expect(msg.fields![3].value).toBe(
"[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)",
);
});
it("workflow_job shows job status, workflow, branch and commit", () => {
const job = {
html_url: "https://github.com/acme/widget/actions/runs/2/job/9",
name: "test",
status: "completed",
conclusion: "failure",
workflow_name: "CI",
head_branch: "main",
head_sha: "abc123def456",
};
const msg = formatEvent(
route,
event("workflow_job", { workflow_job: job, repository: repo, sender }),
);
expect(msg.title).toBe("acme/widget: Job test — failure");
expect(msg.fields![0].value).toBe("❌ failure");
expect(msg.fields![1].value).toBe("test");
expect(msg.fields![2].value).toBe("CI");
expect(msg.fields![3].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)");
expect(msg.fields![4].value).toBe(
"[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)",
);
});
it("status shows context, state and commit", () => {
const msg = formatEvent(
route,
event("status", {
state: "pending",
context: "continuous-integration/travis-ci",
description: "The Travis CI build is in progress",
target_url: "https://travis-ci.org/acme/widget/builds/1",
sha: "abc123def456",
repository: repo,
sender,
}),
);
expect(msg.title).toBe("acme/widget: continuous-integration/travis-ci — pending");
expect(msg.fields![0].value).toBe("⏳ pending");
expect(msg.fields![1].value).toBe("continuous-integration/travis-ci");
expect(msg.fields![2].value).toBe(
"[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)",
);
expect(msg.fields![3].value).toBe("The Travis CI build is in progress");
});
it("deployment shows environment, branch and commit", () => {
const deployment = {
environment: "production",
ref: "refs/heads/main",
sha: "abc123def456",
description: "Deploy request from octocat",
html_url: "https://github.com/acme/widget/deployments/1",
};
const msg = formatEvent(route, event("deployment", { deployment, repository: repo, sender }));
expect(msg.title).toBe("acme/widget: Deployment to `production` — created");
expect(msg.fields![0].value).toBe("🚀 created");
expect(msg.fields![1].value).toBe("production");
expect(msg.fields![2].value).toBe("[\`main\`](https://github.com/acme/widget/tree/main)");
expect(msg.fields![3].value).toBe(
"[\`abc123d\`](https://github.com/acme/widget/commit/abc123def456)",
);
});
it("ping shows the webhook confirmation", () => {
const msg = formatEvent(
route,
event("ping", { zen: "Keep it logically awesome.", hook_id: 1, repository: repo, sender }),
);
expect(msg.title).toBe("acme/widget: Webhook ping");
expect(msg.fields![0].value).toBe("1");
});
it("unknown events fall back to repo: event: action", () => {
const msg = formatEvent(
route,
event("custom_event", { action: "ran", repository: repo, sender }),
);
expect(msg.title).toBe("acme/widget: custom_event: ran");
});
});
describe("group emoji toggle", () => {
it("includes emoji by default", () => {
const msg = formatEvent(
route,
event("repository", {
action: "created",
repository: { ...repo, visibility: "public", description: "a widget", fork: false },
sender,
}),
);
expect(msg.title).toBe("acme/widget: 📦 Repository Created");
expect(msg.description).toContain("🔗");
});
it("strips emoji when showEmoji is false", () => {
const msg = formatEvent(
route,
event("repository", {
action: "created",
repository: { ...repo, visibility: "public", description: "a widget", fork: false },
sender,
}),
undefined,
false,
);
expect(msg.title).toBe("acme/widget: Repository Created");
expect(msg.title).not.toContain("📦");
expect(msg.description).not.toContain("🔗");
});
it("push commit renders linked short hash with plain-text message", () => {
const msg = formatEvent(
route,
event("push", {
ref: "refs/heads/main",
compare: "https://github.com/acme/widget/compare/abc...def",
created: false,
forced: false,
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
repository: repo,
sender,
}),
);
expect(msg.fields![0].name).toBe("\u200b");
expect(msg.fields![0].value).toBe(
"[`abcd123`](https://github.com/acme/widget/commit/abcd1234ef) fix stuff",
);
});
it("strips emoji from push description when disabled", () => {
const msg = formatEvent(
route,
event("push", {
ref: "refs/heads/main",
compare: "https://github.com/acme/widget/compare/abc...def",
created: true,
forced: true,
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
repository: repo,
sender,
}),
undefined,
false,
);
expect(msg.description).not.toContain("⚠️");
expect(msg.description).not.toContain("🆕");
});
it("strips emoji from workflow_run status when disabled", () => {
const msg = formatEvent(
route,
event("workflow_run", {
action: "completed",
workflow_run: {
name: "CI",
conclusion: "failure",
html_url: "https://github.com/acme/widget/actions/runs/42",
head_branch: "main",
run_number: 42,
jobs: [{ name: "build", conclusion: "failure" }],
},
repository: repo,
sender,
}),
undefined,
false,
);
expect(msg.fields![0].value).toBe("failure");
expect(msg.fields![1].value).toBe("build");
});
});

118
tests/groups.test.ts Normal file
View file

@ -0,0 +1,118 @@
import { describe, it, expect } from "bun:test";
import {
normalizeGroupMembers,
memberRole,
resolveScope,
roleAt,
roleAtLeast,
canEditRoutes,
canEditGroup,
} from "../server/lib/web/groups";
import type { Env, Group } from "../server/lib/types";
function env(overrides: Partial<Env> = {}): Env {
return { GITHUB_WEBHOOK_SECRET: "s", KV: {} as KVNamespace, ...overrides };
}
const legacy: Group = { id: "g1", name: "Legacy", adminIds: ["octocat", "12345"] };
const mixed: Group = {
id: "g2",
name: "Mixed",
adminIds: ["octocat"],
members: [
{ login: "octocat", role: "owner" },
{ login: "admin-bot", role: "admin" },
{ login: "Reader", role: "viewer" },
],
};
describe("normalizeGroupMembers", () => {
it("derives owners from legacy adminIds", () => {
expect(normalizeGroupMembers(legacy)).toEqual([
{ login: "octocat", role: "owner" },
{ login: "12345", role: "owner" },
]);
});
it("keeps members when present and validates roles", () => {
expect(normalizeGroupMembers(mixed)).toEqual([
{ login: "octocat", role: "owner" },
{ login: "admin-bot", role: "admin" },
{ login: "Reader", role: "viewer" },
]);
});
it("deduplicates case-insensitively", () => {
const g: Group = {
id: "g",
name: "G",
adminIds: [],
members: [
{ login: "Dup", role: "admin" },
{ login: "dup", role: "owner" },
],
};
expect(normalizeGroupMembers(g)).toEqual([{ login: "Dup", role: "admin" }]);
});
it("falls back to owner for unknown roles", () => {
const g: Group = {
id: "g",
name: "G",
adminIds: [],
members: [{ login: "x", role: "sneaky" as never }],
};
expect(normalizeGroupMembers(g)[0]!.role).toBe("owner");
});
});
describe("memberRole / resolveScope roles", () => {
it("resolves owner for legacy admins", () => {
expect(memberRole(legacy, "999", "octocat")).toBe("owner");
expect(memberRole(legacy, "12345", "other")).toBe("owner");
});
it("resolves admin/viewer for members", () => {
expect(memberRole(mixed, "1", "admin-bot")).toBe("admin");
expect(memberRole(mixed, "1", "reader")).toBe("viewer");
});
it("returns undefined for non-members", () => {
expect(memberRole(mixed, "1", "nobody")).toBeUndefined();
});
it("super admins get owner everywhere", () => {
const scope = resolveScope(env({ ADMIN_USER_IDS: "1" }), [legacy, mixed], "1", "boss");
expect(scope.isSuper).toBe(true);
expect(roleAt(scope, "g1")).toBe("owner");
expect(roleAt(scope, "nope")).toBe("owner");
});
it("regular users only see groups they belong to, with their role", () => {
const scope = resolveScope(env(), [legacy, mixed], "5", "admin-bot");
expect(scope.groupIds.has("g1")).toBe(false);
expect(scope.groupIds.has("g2")).toBe(true);
expect(roleAt(scope, "g2")).toBe("admin");
});
});
describe("role helpers", () => {
it("ranks viewer < admin < owner", () => {
expect(roleAtLeast("viewer", "viewer")).toBe(true);
expect(roleAtLeast("admin", "viewer")).toBe(true);
expect(roleAtLeast("admin", "owner")).toBe(false);
expect(roleAtLeast(undefined, "viewer")).toBe(false);
});
it("canEditRoutes allows owner and admin", () => {
const scope = resolveScope(env(), [mixed], "1", "reader");
expect(canEditRoutes(scope, "g2")).toBe(false);
expect(canEditGroup(scope, "g2")).toBe(false);
const scope2 = resolveScope(env(), [mixed], "1", "admin-bot");
expect(canEditRoutes(scope2, "g2")).toBe(true);
expect(canEditGroup(scope2, "g2")).toBe(false);
const scope3 = resolveScope(env(), [mixed], "1", "octocat");
expect(canEditRoutes(scope3, "g2")).toBe(true);
expect(canEditGroup(scope3, "g2")).toBe(true);
});
});

64
tests/helpers.ts Normal file
View file

@ -0,0 +1,64 @@
import { createEvent, type H3Event } from "h3";
import type { Env } from "../server/lib/types";
export interface TestEventOptions {
method?: string;
headers?: Record<string, string>;
body?: string;
env: Env;
waitUntil?: (promise: Promise<unknown>) => void;
}
/** Build an H3 event stubbed with Cloudflare bindings for handler-level tests. */
export function makeEvent(path: string, opts: TestEventOptions): H3Event {
const headers: Record<string, string> = {};
const res = {
headers,
statusCode: 200,
writableEnded: false,
headersSent: false,
setHeader(name: string, value: string): void {
headers[name.toLowerCase()] = value;
},
getHeader(name: string): string | undefined {
return headers[name.toLowerCase()];
},
end(): void {},
write(): void {},
};
const req = {
method: opts.method ?? "GET",
url: path,
headers: opts.headers ?? {},
rawHeaders: [] as string[],
body: opts.body,
};
const event = createEvent(req as never, res as never);
event.context.cloudflare = {
env: opts.env,
ctx: opts.waitUntil ? { waitUntil: opts.waitUntil } : undefined,
};
return event;
}
export function responseStatus(event: H3Event): number {
return (event.node.res as { statusCode?: number }).statusCode ?? 200;
}
export function responseHeader(event: H3Event, name: string): string | undefined {
return (event.node.res as { getHeader?: (n: string) => string | undefined }).getHeader?.(name);
}
/** waitUntil collector: lets the test await dispatched work. */
export function waitCollector(): {
waitUntil: (promise: Promise<unknown>) => void;
flush: () => Promise<void>;
} {
const pending: Promise<unknown>[] = [];
return {
waitUntil: (promise: Promise<unknown>): void => {
pending.push(promise);
},
flush: (): Promise<void> => Promise.allSettled(pending).then(() => undefined),
};
}

355
tests/install.test.ts Normal file
View file

@ -0,0 +1,355 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
handleOAuthStart,
handleInstallPage,
handleInstallBind,
handleOAuthCallback,
handleTokenDelete,
} from "../server/lib/web/oauth";
import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups";
import { getInstallationAccount } from "../server/lib/github/oauth";
import { makeEvent, responseStatus, responseHeader } from "./helpers";
import type { Env } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
function bufToPem(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
const b64 = btoa(s);
return `-----BEGIN PRIVATE KEY-----\n${b64.replace(/(.{64})/g, "$1\n")}\n-----END PRIVATE KEY-----`;
}
async function makeAppKeyPair(): Promise<{ appId: string; pem: string }> {
const pair = await crypto.subtle.generateKey(
{
name: "RSASSA-PKCS1-v1_5",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["sign", "verify"],
);
const pkcs8 = await crypto.subtle.exportKey("pkcs8", pair.privateKey);
return { appId: "12345", pem: bufToPem(pkcs8) };
}
describe("getInstallationAccount", () => {
let calledUrls: string[];
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function mockApi(status: number, body: unknown): void {
calledUrls = [];
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
calledUrls.push(String(input));
void init;
return Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
}),
);
};
}
it("returns the installation account login using an App JWT", async () => {
const { appId, pem } = await makeAppKeyPair();
mockApi(200, { id: 555, account: { login: "myorg" } });
const login = await getInstallationAccount(appId, pem, 555);
expect(login).toBe("myorg");
expect(calledUrls[0]).toContain("/app/installations/555");
});
it("returns null when the App credentials are missing", async () => {
expect(await getInstallationAccount("", "", 555)).toBeNull();
});
it("returns null on API errors", async () => {
const { appId, pem } = await makeAppKeyPair();
mockApi(404, { message: "not found" });
expect(await getInstallationAccount(appId, pem, 555)).toBeNull();
});
});
describe("GET /auth/github/install", () => {
const restoredFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
void init;
return Promise.resolve(
new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
afterEach(() => {
globalThis.fetch = restoredFetch;
});
it("requires login first and preserves the installation id", async () => {
const env = createEnv();
const event = makeEvent("/auth/github/install?installation_id=555", {
headers: { accept: "text/html" },
env,
});
await handleInstallPage(event);
expect(responseStatus(event)).toBe(302);
const location = responseHeader(event, "location") ?? "";
expect(location).toContain("/auth/github?redirect=");
expect(decodeURIComponent(location)).toContain("/auth/github/install?installation_id=555");
});
it("renders a choice page with the groups the user owns", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "mine",
name: "My Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
},
{
id: "theirs",
name: "Other Team",
adminIds: [],
members: [{ login: "bob", role: "owner" }],
},
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = makeEvent("/auth/github/install?installation_id=555&setup_action=install", {
headers: { cookie: adminCookie(sessionId), accept: "text/html" },
env,
});
const html = (await handleInstallPage(event)) as string;
expect(html).toContain("inst-555");
expect(html).toContain("My Team");
expect(html).toContain('value="mine"');
expect(html).not.toContain('value="theirs"');
});
it("rejects a missing installation id", async () => {
const env = createEnv();
const event = makeEvent("/auth/github/install", { headers: { accept: "text/html" }, env });
await expect(handleInstallPage(event)).rejects.toMatchObject({ statusCode: 400 });
});
});
describe("POST /auth/github/install/bind", () => {
const restoredFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
void init;
return Promise.resolve(
new Response(JSON.stringify({ id: 555, account: { login: "myorg" } }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
};
});
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function formBody(params: Record<string, string>): string {
return new URLSearchParams(params).toString();
}
function bindEvent(env: Env, cookie: string, params: Record<string, string>) {
return makeEvent("/auth/github/install/bind", {
method: "POST",
headers: {
cookie,
"content-type": "application/x-www-form-urlencoded",
},
body: formBody(params),
env,
});
}
it("binds the installation to an existing group the user owns", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "mine",
name: "My Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
},
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "mine" });
await handleInstallBind(event);
expect(responseStatus(event)).toBe(302);
expect(responseHeader(event, "location")).toBe("/admin?install=ok");
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.id).toBe("mine");
expect(groups[0]!.installationId).toBe(555);
});
it("refuses to bind to a group the user does not own", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "theirs",
name: "Other Team",
adminIds: [],
members: [{ login: "bob", role: "owner" }],
},
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "theirs" });
await handleInstallBind(event);
expect(responseStatus(event)).toBe(302);
expect(responseHeader(event, "location")).toBe("/admin?error=forbidden");
const groups = await loadGroups(kv);
expect(groups[0]!.installationId).toBeUndefined();
});
it("auto-creates inst-{id} and joins the installer as owner (self-signup)", async () => {
const kv = createMockKV();
const { appId, pem } = await makeAppKeyPair();
const env = createEnv({
KV: kv,
ALLOW_SELF_SIGNUP: "1",
GITHUB_APP_ID: appId,
GITHUB_PRIVATE_KEY: pem,
});
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "" });
await handleInstallBind(event);
expect(responseStatus(event)).toBe(302);
expect(responseHeader(event, "location")).toBe("/admin?install=ok");
const groups = await loadGroups(kv);
const group = groups.find((g) => g.id === "inst-555");
expect(group).toBeDefined();
expect(group?.installationId).toBe(555);
expect(group?.name).toBe("myorg");
expect(group?.members).toContainEqual({ login: "alice", role: "owner" });
});
it("auto-creates inst-{id} without joining members when self-signup is off", async () => {
const kv = createMockKV();
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = bindEvent(env, adminCookie(sessionId), { installation_id: "555", group: "" });
await handleInstallBind(event);
expect(responseStatus(event)).toBe(302);
const groups = await loadGroups(kv);
const group = groups.find((g) => g.installationId === 555);
expect(group).toBeDefined();
expect(group?.members ?? []).toHaveLength(0);
});
it("requires login to bind", async () => {
const env = createEnv();
const event = bindEvent(env, "", { installation_id: "555", group: "" });
await handleInstallBind(event);
expect(responseStatus(event)).toBe(302);
expect(responseHeader(event, "location")).toBe("/admin?error=forbidden");
});
});
describe("oauth misc", () => {
it("starts the OAuth flow with a state token", async () => {
const kv = createMockKV();
const env = createEnv({ KV: kv, GITHUB_CLIENT_ID: "client-1" });
const event = makeEvent("/auth/github?redirect=/admin", { headers: { accept: "text/html" }, env });
await handleOAuthStart(event);
expect(responseStatus(event)).toBe(302);
const location = responseHeader(event, "location") ?? "";
expect(location).toContain("https://github.com/login/oauth/authorize?client_id=client-1");
expect(await kv.list({ prefix: "state:" })).toBeDefined();
});
it("rejects the callback without code or state", async () => {
const env = createEnv();
const event = makeEvent("/auth/github/callback", { headers: { accept: "text/html" }, env });
const result = (await handleOAuthCallback(event)) as { error?: string };
expect(responseStatus(event)).toBe(400);
expect(result.error).toBe("Missing code or state");
});
it("requires a session to delete a token", async () => {
const env = createEnv();
const event = makeEvent("/auth/token/123", { method: "DELETE", env });
const result = (await handleTokenDelete(event, "123")) as { error?: string };
expect(responseStatus(event)).toBe(401);
expect(result.error).toBe("Unauthorized");
});
});

148
tests/invites.test.ts Normal file
View file

@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach } from "bun:test";
import { createInvite, getInvite, listInvites, revokeInvite, acceptInvite } from "../server/lib/web/invites";
import { saveGroups, loadGroups } from "../server/lib/web/groups";
import type { Group } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();
return {
get: async (key: string, type?: string) => {
const entry = store.get(key);
if (!entry) return null;
if (entry.expiration && Date.now() / 1000 > entry.expiration) {
store.delete(key);
return null;
}
if (type === "json") return JSON.parse(entry.value);
return entry.value;
},
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined;
store.set(key, { value, expiration });
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
const group: Group = {
id: "team",
name: "Team",
adminIds: ["boss"],
members: [{ login: "boss", role: "owner" }],
};
describe("invites", () => {
let kv: KVNamespace;
beforeEach(() => {
kv = createMockKV();
});
it("creates and reads back an invite", async () => {
const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const invite = await getInvite(kv, token);
expect(invite).toMatchObject({ groupId: "team", role: "admin", createdBy: "boss" });
});
it("lists pending invites of a group only", async () => {
await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await createInvite(kv, {
groupId: "other",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const invites = await listInvites(kv, "team");
expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("team");
});
it("revokes an invite", async () => {
const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await revokeInvite(kv, token);
expect(await getInvite(kv, token)).toBeNull();
});
it("accept adds the user as a member and consumes the token", async () => {
await saveGroups(kv, [group]);
const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
const result = await acceptInvite(kv, token, "777", "newbie");
expect(result).toEqual({ ok: true, groupId: "team", role: "admin" });
expect(await getInvite(kv, token)).toBeNull();
const groups = await loadGroups(kv);
expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" });
// adminIds stays in sync with owners only.
expect(groups[0]!.adminIds).toEqual(["boss"]);
});
it("upgrades an existing viewer to admin", async () => {
await saveGroups(kv, [
{
...group,
members: [
{ login: "boss", role: "owner" },
{ login: "newbie", role: "viewer" },
],
},
]);
const token = await createInvite(kv, {
groupId: "team",
role: "admin",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
await acceptInvite(kv, token, "777", "newbie");
const groups = await loadGroups(kv);
expect(groups[0]!.members).toContainEqual({ login: "newbie", role: "admin" });
});
it("rejects expired or unknown invites", async () => {
await saveGroups(kv, [group]);
const token = await createInvite(kv, {
groupId: "team",
role: "viewer",
expiresAt: Date.now() - 1000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "invalid" });
expect(await acceptInvite(kv, "deadbeef", "1", "x")).toEqual({ ok: false, reason: "invalid" });
});
it("rejects invites for missing groups", async () => {
await saveGroups(kv, []);
const token = await createInvite(kv, {
groupId: "ghost",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "boss",
});
expect(await acceptInvite(kv, token, "1", "x")).toEqual({ ok: false, reason: "group-missing" });
});
});

226
tests/providers.test.ts Normal file
View file

@ -0,0 +1,226 @@
import { describe, it, expect } from "bun:test";
import { createHmac } from "crypto";
import { detectProvider } from "../server/lib/providers";
import { customProvider } from "../server/lib/providers/custom";
import { verifyGiteaSignature } from "../server/lib/providers/gitea/verify";
import { parseGiteaEvent } from "../server/lib/providers/gitea/parse";
import { parseEvent } from "../server/lib/providers/github/parse";
import { formatEvent } from "../server/lib/formatters";
import type { Env, Route } from "../server/lib/types";
function giteaSign(body: string, secret: string): string {
return createHmac("sha256", secret).update(body).digest("hex");
}
describe("provider detection", () => {
it("detects github by X-GitHub-Event header", () => {
const p = detectProvider({ "x-github-event": "push" });
expect(p?.id).toBe("github");
});
it("detects gitea by X-Gitea-Event header", () => {
const p = detectProvider({ "x-gitea-event": "push" });
expect(p?.id).toBe("gitea");
});
it("prefers gitea when both gitea and github headers are present", () => {
// Gitea webhooks also send GitHub-compatible headers (X-GitHub-Event,
// X-Hub-Signature-256, X-Gogs-*), so detection must not misclassify them.
const p = detectProvider({
"x-gitea-event": "push",
"x-github-event": "push",
"x-gitea-signature": "abc",
"x-hub-signature-256": "sha256=abc",
});
expect(p?.id).toBe("gitea");
});
it("returns null for unknown providers", () => {
expect(detectProvider({})).toBeNull();
expect(detectProvider({ "x-gitlab-event": "Push Hook" })).toBeNull();
});
});
describe("gitea signature", () => {
const secret = "gitea-secret";
it("accepts a valid hex HMAC-SHA256 signature (no sha256= prefix)", async () => {
const body = '{"ref":"refs/heads/main"}';
const sig = giteaSign(body, secret);
expect(await verifyGiteaSignature(body, sig, secret)).toBe(true);
});
it("rejects an invalid signature", async () => {
expect(await verifyGiteaSignature("body", "deadbeef", secret)).toBe(false);
});
it("rejects when signature or secret is missing", async () => {
expect(await verifyGiteaSignature("body", undefined, secret)).toBe(false);
expect(await verifyGiteaSignature("body", "abc", undefined)).toBe(false);
});
});
describe("gitea event parsing", () => {
it("parses a push event and sets the provider", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "push", "x-gitea-delivery": "d-1" },
JSON.stringify({
ref: "refs/heads/main",
compare_url: "https://git.example.com/org/repo/compare/abc...def",
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
}),
);
expect(event).not.toBeNull();
expect(event!.provider).toBe("gitea");
expect(event!.event).toBe("push");
expect(event!.deliveryId).toBe("d-1");
expect(event!.payload.compare).toBe("https://git.example.com/org/repo/compare/abc...def");
expect((event!.payload.sender as { login?: string }).login).toBe("octo");
});
it("maps pull_request_comment to pull_request_review_comment and pulls the PR", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "pull_request_comment" },
JSON.stringify({
action: "created",
issue: {
number: 7,
title: "Add feature",
html_url: "https://git.example.com/org/repo/pulls/7",
},
comment: {
body: "looks good",
line: 12,
html_url: "https://git.example.com/org/repo/pulls/7#issuecomment-1",
},
repository: { full_name: "org/repo" },
sender: { login: "octo" },
}),
);
expect(event!.event).toBe("pull_request_review_comment");
const pr = event!.payload.pull_request as { number?: number; title?: string };
expect(pr.number).toBe(7);
const comment = event!.payload.comment as { position?: number };
expect(comment.position).toBe(12);
});
it("copies top-level commit_id onto the comment for commit_comment events", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "commit_comment" },
JSON.stringify({
action: "created",
commit_id: "abcd1234ef",
comment: {
body: "why?",
html_url: "https://git.example.com/org/repo/commit/abcd1234ef#commitcomment-1",
},
repository: { full_name: "org/repo" },
sender: { login: "octo" },
}),
);
expect(event!.event).toBe("commit_comment");
expect((event!.payload.comment as { commit_id?: string }).commit_id).toBe("abcd1234ef");
});
it("returns null for missing event header or invalid JSON", () => {
expect(parseGiteaEvent({}, "{}")).toBeNull();
expect(parseGiteaEvent({ "x-gitea-event": "push" }, "not json")).toBeNull();
});
it("formats a normalized gitea push with gitea commit links", () => {
const event = parseGiteaEvent(
{ "x-gitea-event": "push" },
JSON.stringify({
ref: "refs/heads/main",
compare_url: "https://git.example.com/org/repo/compare/abc...def",
pusher: { login: "octo", html_url: "https://git.example.com/octo" },
commits: [{ id: "abcd1234ef", message: "fix stuff", added: [], removed: [], modified: [] }],
repository: { full_name: "org/repo", html_url: "https://git.example.com/org/repo" },
}),
);
const route: Route = {
id: "test",
name: "Test",
enabled: true,
filters: [],
targets: [{ channelId: "111" }],
};
const msg = formatEvent(route, event!);
expect(msg.title).toContain("org/repo");
expect(msg.fields![0].value).toBe(
"[`abcd123`](https://git.example.com/org/repo/commit/abcd1234ef) fix stuff",
);
});
});
describe("custom provider", () => {
const secret = "tenant-secret";
const body = JSON.stringify({
title: "Deploy failed",
repo: "acme/widget",
color: "red",
description: "prod down",
});
function env(overrides: Record<string, unknown> = {}): Env {
return { GITHUB_WEBHOOK_SECRET: secret, ...overrides } as Env;
}
it("matches only requests with X-WebHooker-Signature and no forge headers", () => {
expect(customProvider.matches({ "x-webhooker-signature": "sha256=abc" })).toBe(true);
expect(customProvider.matches({})).toBe(false);
expect(customProvider.matches({ "x-github-event": "push" })).toBe(false);
expect(customProvider.matches({ "x-gitea-event": "push" })).toBe(false);
});
it("detectProvider finds the custom provider", () => {
expect(detectProvider({ "x-webhooker-signature": "sha256=abc" })?.id).toBe("custom");
});
it("accepts a valid sha256 HMAC signature", async () => {
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
expect(await customProvider.verify(body, { "x-webhooker-signature": sig }, env())).toBe(true);
});
it("rejects an invalid signature", async () => {
expect(
await customProvider.verify(body, { "x-webhooker-signature": "sha256=wrong" }, env()),
).toBe(false);
});
it("rejects when the secret is missing", async () => {
const sig = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
expect(
(await customProvider.verify(body, { "x-webhooker-signature": sig }, {})) as unknown as Env,
).toBe(false);
});
it("parses the body into a custom event with optional deliveryId", () => {
const event = customProvider.parse(body, {});
expect(event).not.toBeNull();
expect(event!.event).toBe("custom");
expect(event!.provider).toBe("custom");
expect((event!.payload as { title: string }).title).toBe("Deploy failed");
const withId = customProvider.parse(JSON.stringify({ title: "x", deliveryId: "alert-1" }), {});
expect(withId!.deliveryId).toBe("alert-1");
});
it("returns null for invalid JSON", () => {
expect(customProvider.parse("not json", {})).toBeNull();
});
it("extracts the GitHub App installation id on github events", () => {
const event = parseEvent(
{ "x-github-event": "push", "x-github-delivery": "d1" },
JSON.stringify({ installation: { id: 42 }, repository: { full_name: "a/b" } }),
);
expect(event!.installationId).toBe(42);
const noInstall = parseEvent(
{ "x-github-event": "push" },
JSON.stringify({ repository: { full_name: "a/b" } }),
);
expect(noInstall!.installationId).toBeUndefined();
});
});

78
tests/send-log.test.ts Normal file
View file

@ -0,0 +1,78 @@
import { describe, it, expect } from "bun:test";
import { recordSend, getSendLog } from "../server/lib/lib/send-log";
function createMockDB(): D1Database {
const rows: Array<Record<string, unknown>> = [];
const insertCols = [
"ts",
"route_id",
"group_id",
"event",
"repo",
"target",
"ok",
"error",
"status",
"message_id",
"delivery_id",
"platform",
"actor",
"action",
"duration_ms",
"error_code",
"attempts",
"detail",
];
return {
prepare: (sql: string) => ({
bind: (..._args: unknown[]) => ({
run: async (): Promise<{ success: boolean }> => {
if (sql.startsWith("INSERT")) {
const row: Record<string, unknown> = {};
insertCols.forEach((col, i) => {
row[col] = _args[i];
});
rows.push(row);
}
return { success: true };
},
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
const args = _args as unknown[];
const limit = (args[0] as number) ?? 50;
return {
results: rows
.slice()
.sort((a, b) => (b.ts as number) - (a.ts as number))
.slice(0, limit),
};
},
}),
}),
} as unknown as D1Database;
}
describe("send-log", () => {
it("records and returns logs sorted newest first", async () => {
const db = createMockDB();
await recordSend(db, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true });
await recordSend(db, {
ts: 2000,
routeId: "b",
event: "issues",
target: "222",
ok: false,
error: "Missing Permissions",
});
const logs = await getSendLog(db);
expect(logs).toHaveLength(2);
expect(logs[0]!.routeId).toBe("b");
expect(logs[0]!.ok).toBe(false);
expect(logs[0]!.error).toBe("Missing Permissions");
expect(logs[1]!.routeId).toBe("a");
});
it("returns empty when no logs", async () => {
const db = createMockDB();
expect(await getSendLog(db)).toEqual([]);
});
});

View file

@ -0,0 +1,209 @@
import { describe, it, expect, afterEach } from "bun:test";
import { handleTelegramUpdate } from "../server/lib/drivers/telegram/commands";
import { handleTelegramWebhookRequest } from "../server/lib/drivers/telegram/updates";
import type { Env } from "../server/lib/types";
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
Promise.resolve(handler(String(input), init));
}
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v === undefined) return null;
return type === "json" ? JSON.parse(v) : v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({ keys: [] }),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
const links = new Map<string, string>();
return {
prepare: (sql: string) => ({
bind: (...args: unknown[]) => ({
run: async (): Promise<{ success: boolean }> => {
const m = sql.match(
/INSERT OR REPLACE INTO telegram_links \(telegram_user_id, github_user_id\) VALUES \(\?, \?\)/,
);
if (m) links.set(String(args[0]), String(args[1]));
const del = sql.match(/DELETE FROM telegram_links WHERE telegram_user_id = \?/);
if (del) links.delete(String(args[0]));
return { success: true };
},
all: async (): Promise<{ results: Array<Record<string, unknown>> }> => {
const sel = sql.match(
/SELECT github_user_id FROM telegram_links WHERE telegram_user_id = \?/,
);
if (sel) {
const val = links.get(String(args[0]));
return { results: val ? [{ github_user_id: val }] : [] };
}
return { results: [] };
},
first: async () => null,
}),
}),
} as unknown as D1Database;
}
function createEnv(): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
GITHUB_CLIENT_ID: "client-id",
TELEGRAM_TOKEN: "tg-token",
TELEGRAM_WEBHOOK_SECRET: "wh-secret",
KV: createMockKV(),
DB: createMockDB(),
} as Env;
}
function reply(chatId: string, topicId?: number): Record<string, unknown> {
return {
message_id: 1,
from: { id: 111, first_name: "Rhen" },
chat: { id: chatId, type: "supergroup" },
message_thread_id: topicId,
};
}
describe("telegram-commands /gh login", () => {
it("stores state with telegramUserId and replies with OAuth URL", async () => {
let sentBody: Record<string, unknown> | undefined;
mockFetch((_url, init) => {
sentBody = JSON.parse(String(init!.body)) as Record<string, unknown>;
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
});
const env = createEnv();
await handleTelegramUpdate(env, {
message: { ...reply("-100123"), text: "/gh login" },
});
expect(sentBody?.chat_id).toBe("-100123");
expect(String(sentBody?.text)).toContain("github.com/login/oauth/authorize");
});
});
describe("telegram-commands /gh logout", () => {
it("replies bound/unbound message", async () => {
let sentText = "";
mockFetch((_url, init) => {
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
});
const env = createEnv();
await handleTelegramUpdate(env, {
message: { ...reply("-100123"), text: "/gh logout" },
});
expect(sentText).toContain("已解绑");
});
});
describe("telegram-commands /gh comment", () => {
it("replies when no reply_to_message link present", async () => {
let sentText = "";
mockFetch((_url, init) => {
sentText = String((JSON.parse(String(init!.body)) as Record<string, unknown>).text);
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 });
});
const env = createEnv();
await handleTelegramUpdate(env, {
message: { ...reply("-100123"), text: "/gh comment hello" },
});
expect(sentText).toContain("还没有绑定");
});
it("parses the replied-to GitHub link as target", async () => {
const env = createEnv();
const { saveTelegramLink } = await import("../server/lib/github/store");
await saveTelegramLink(env.DB, "111", "111980217");
await env.KV.put(
"token:111980217",
JSON.stringify({
userId: "111980217",
accessToken: "ghu_test",
expiresAt: Date.now() + 3600_000,
}),
);
const calls: Array<{ url: string; body: string }> = [];
mockFetch((url, init) => {
calls.push({ url, body: String(init!.body) });
if (String(url).endsWith("/sendMessage")) {
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), {
status: 200,
});
}
return new Response(
JSON.stringify({ html_url: "https://github.com/acme/widget/issues/7#issuecomment-9" }),
{ status: 200 },
);
});
await handleTelegramUpdate(env, {
message: {
...reply("-100123"),
text: "/gh comment hello",
reply_to_message: {
...reply("-100123"),
text: "acme/widget#7: Add feature",
entities: [{ type: "text_link", url: "https://github.com/acme/widget/issues/7" }],
},
},
});
const ghCall = calls.find((c) => c.url.includes("/repos/"));
expect(ghCall).toBeDefined();
expect(ghCall!.url).toContain("/repos/acme/widget/issues/7/comments");
const ghBody = JSON.parse(ghCall!.body) as Record<string, unknown>;
expect(ghBody.body).toBe("hello");
});
});
describe("telegram-updates webhook", () => {
it("rejects requests without the secret token", async () => {
const env = createEnv();
const res = await handleTelegramWebhookRequest(
new Request("https://example.com/telegram/webhook", { method: "POST", body: "{}" }),
env,
);
expect(res.status).toBe(401);
});
it("accepts requests with the correct secret token", async () => {
mockFetch(
() => new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), { status: 200 }),
);
const env = createEnv();
const res = await handleTelegramWebhookRequest(
new Request("https://example.com/telegram/webhook", {
method: "POST",
headers: { "X-Telegram-Bot-Api-Secret-Token": "wh-secret" },
body: JSON.stringify({ update_id: 1 }),
}),
env,
);
expect(res.status).toBe(200);
expect(await res.text()).toBe("ok");
});
});

202
tests/telegram.test.ts Normal file
View file

@ -0,0 +1,202 @@
import { describe, it, expect, afterEach } from "bun:test";
import { sendMessage, sendPhoto } from "../server/lib/drivers/telegram/rest";
import { renderNeutralMessage } from "../server/lib/drivers/telegram/render";
import { TelegramDriver } from "../server/lib/drivers/telegram";
import type { NeutralMessage } from "../server/lib/types";
function mockFetch(handler: (url: string, init?: RequestInit) => Response): void {
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
Promise.resolve(handler(String(input), init));
}
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
describe("telegram renderNeutralMessage", () => {
it("renders title, fields and footer as HTML", () => {
const message: NeutralMessage = {
title: "acme/widget: Add feature",
url: "https://github.com/acme/widget",
fields: [{ name: "Status", value: "success" }],
footer: "acme/widget",
};
const out = renderNeutralMessage(message);
expect(out).toContain(
'<b><a href="https://github.com/acme/widget">acme/widget: Add feature</a></b>',
);
expect(out).toContain("<b>Status</b>: success");
expect(out).toContain("<i>acme/widget</i>");
});
it("escapes HTML special characters", () => {
const out = renderNeutralMessage({
title: 'a <b> & "c"',
fields: [{ name: "body", value: "<script>alert(1)</script>" }],
});
expect(out).not.toContain("<b>acme");
expect(out).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});
it("converts Discord markdown to Telegram HTML", () => {
const out = renderNeutralMessage({
title: "acme/widget#7: Add feature",
description:
"**1** commit pushed to `main`\n[View comparison](https://github.com/acme/widget/compare/a...b)",
fields: [{ name: "Status", value: "**ok** and `done`" }],
});
expect(out).toContain("<b>1</b> commit pushed to <code>main</code>");
expect(out).toContain(
'<a href="https://github.com/acme/widget/compare/a...b">View comparison</a>',
);
expect(out).toContain("<b>Status</b>: <b>ok</b> and <code>done</code>");
});
it("renders a code-formatted commit hash inside a link", () => {
const out = renderNeutralMessage({
title: "acme/widget: Pushed 1 commit",
fields: [
{
name: "\u200b",
value: "[`abcd123`](https://github.com/acme/widget/commit/abcd1234ef) fix stuff",
},
],
});
expect(out).toContain(
'<a href="https://github.com/acme/widget/commit/abcd1234ef"><code>abcd123</code></a> fix stuff',
);
});
it("formats ISO timestamps into a readable UTC string", () => {
const out = renderNeutralMessage({
title: "acme/widget: t",
timestamp: "2026-08-02T21:26:04.042Z",
});
expect(out).toContain("<i>2026-08-02 21:26 UTC</i>");
});
});
describe("telegram-rest sendMessage", () => {
it("posts to the bot API with chat_id and parse_mode", async () => {
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
mockFetch((url, init) => {
capturedUrl = url;
capturedInit = init;
return new Response(JSON.stringify({ ok: true, result: { message_id: 42 } }), {
status: 200,
});
});
const result = await sendMessage("token-abc", "-100123", "hello");
expect(result.ok).toBe(true);
expect(result.messageId).toBe("42");
expect(capturedUrl).toBe("https://api.telegram.org/bottoken-abc/sendMessage");
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
expect(body.chat_id).toBe("-100123");
expect(body.parse_mode).toBe("HTML");
expect(body.message_thread_id).toBeUndefined();
});
it("includes message_thread_id when topicId is given", async () => {
let capturedInit: RequestInit | undefined;
mockFetch((_url, init) => {
capturedInit = init;
return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }), {
status: 200,
});
});
await sendMessage("t", "-100123", "hello", "999");
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
expect(body.message_thread_id).toBe(999);
});
it("returns error on non-ok response", async () => {
mockFetch(
() =>
new Response(JSON.stringify({ ok: false, description: "chat not found" }), { status: 400 }),
);
const result = await sendMessage("t", "-100123", "hello");
expect(result.ok).toBe(false);
expect(result.error).toContain("chat not found");
});
it("returns error when token is missing", async () => {
const result = await sendMessage("", "-100123", "hello");
expect(result.ok).toBe(false);
expect(result.errorCode).toBe("NO_TOKEN");
});
});
describe("telegram-rest sendPhoto", () => {
it("posts to the bot API with photo, caption and thread id", async () => {
let capturedUrl = "";
let capturedInit: RequestInit | undefined;
mockFetch((url, init) => {
capturedUrl = url;
capturedInit = init;
return new Response(JSON.stringify({ ok: true, result: { message_id: 7 } }), {
status: 200,
});
});
const result = await sendPhoto("t", "-100123", "https://avatars/1.png", "caption here", "999");
expect(result.ok).toBe(true);
expect(result.messageId).toBe("7");
expect(capturedUrl).toBe("https://api.telegram.org/bott/sendPhoto");
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
expect(body.chat_id).toBe("-100123");
expect(body.photo).toBe("https://avatars/1.png");
expect(body.caption).toBe("caption here");
expect(body.message_thread_id).toBe(999);
});
});
describe("TelegramDriver", () => {
it("sends a small avatar photo (s=64) when the author has an icon", async () => {
let capturedInit: RequestInit | undefined;
mockFetch((url, init) => {
capturedInit = init;
return new Response(JSON.stringify({ ok: true, result: { message_id: 9 } }), { status: 200 });
});
const driver = new TelegramDriver();
const result = await driver.send(
{
title: "acme/widget: Add feature",
author: { name: "alice", iconUrl: "https://avatars.githubusercontent.com/u/1?v=4" },
},
{ platform: "telegram", chatId: "-100123" },
{ TELEGRAM_TOKEN: "t", KV: {} as never, DB: {} as never, GITHUB_WEBHOOK_SECRET: "s" },
);
expect(result.ok).toBe(true);
const body = JSON.parse(String(capturedInit!.body)) as Record<string, unknown>;
expect(body.photo).toBe("https://avatars.githubusercontent.com/u/1?v=4&s=64");
});
it("sends a plain message when the author has no icon", async () => {
let capturedUrl = "";
mockFetch((url) => {
capturedUrl = url;
return new Response(JSON.stringify({ ok: true, result: { message_id: 10 } }), {
status: 200,
});
});
const driver = new TelegramDriver();
const result = await driver.send(
{ title: "acme/widget: Add feature" },
{ platform: "telegram", chatId: "-100123" },
{ TELEGRAM_TOKEN: "t", KV: {} as never, DB: {} as never, GITHUB_WEBHOOK_SECRET: "s" },
);
expect(result.ok).toBe(true);
expect(capturedUrl).toContain("/sendMessage");
});
});

62
tests/token-store.test.ts Normal file
View file

@ -0,0 +1,62 @@
import { describe, it, expect } from "bun:test";
import { saveToken, getToken, removeToken, findUserIdByToken } from "../server/lib/github/store";
function createMockKV(): KVNamespace {
const store = new Map<string, { value: string; expiration?: number }>();
return {
get: async (key: string, type?: string) => {
const entry = store.get(key);
if (!entry) return null;
if (entry.expiration && Date.now() / 1000 > entry.expiration) {
store.delete(key);
return null;
}
if (type === "json") return JSON.parse(entry.value);
return entry.value;
},
put: async (key: string, value: string, opts?: { expirationTtl?: number }) => {
const expiration = opts?.expirationTtl ? Date.now() / 1000 + opts.expirationTtl : undefined;
store.set(key, { value, expiration });
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
describe("token-store", () => {
it("saves and retrieves token", async () => {
const kv = createMockKV();
await saveToken(kv, "user1", "token-abc", 3600);
expect(await getToken(kv, "user1")).toBe("token-abc");
});
it("returns null for nonexistent user", async () => {
const kv = createMockKV();
expect(await getToken(kv, "nobody")).toBeNull();
});
it("removes token and reverse index", async () => {
const kv = createMockKV();
await saveToken(kv, "user1", "token-abc", 3600);
await removeToken(kv, "user1");
expect(await getToken(kv, "user1")).toBeNull();
expect(await findUserIdByToken(kv, "token-abc")).toBeNull();
});
it("finds userId by token via reverse index", async () => {
const kv = createMockKV();
await saveToken(kv, "user1", "token-abc", 3600);
expect(await findUserIdByToken(kv, "token-abc")).toBe("user1");
});
it("returns null for unknown token", async () => {
const kv = createMockKV();
expect(await findUserIdByToken(kv, "unknown")).toBeNull();
});
});

View file

@ -0,0 +1,391 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import { createHmac } from "crypto";
import { processWebhook } from "../server/lib/webhook";
import { invalidateConfigCache } from "../server/lib/config";
import { loadGroups } from "../server/lib/web/groups";
import type { Env, Route } from "../server/lib/types";
function githubSign(body: string, secret: string): string {
return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
}
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "global-secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
describe("processWebhook", () => {
let fetched: Array<{ url: string; body: string }>;
const restoredFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = restoredFetch;
});
function mockFetch(): void {
fetched = [];
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
fetched.push({ url: String(input), body: String(init?.body ?? "") });
return Promise.resolve(new Response("{}", { status: 200 }));
};
}
beforeEach(() => {
invalidateConfigCache();
});
/** waitUntil collector: lets the test await the dispatched work. */
function makeWait(): {
waitUntil: (p: Promise<unknown>) => void;
flush: () => Promise<void>;
} {
const pending: Promise<unknown>[] = [];
return {
waitUntil: (p: Promise<unknown>): void => {
pending.push(p);
},
flush: (): Promise<void> => Promise.allSettled(pending).then(() => undefined),
};
}
async function setupTenant(secret: string, extraRoutes: Route[] = []): Promise<Env> {
const kv = createMockKV();
await kv.put("config:groups", JSON.stringify([{ id: "team-a", name: "Team A", adminIds: [] }]));
await kv.put("tenant:team-a", secret);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "team-a-push",
name: "Team A Push",
enabled: true,
groupId: "team-a",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
{
id: "other-push",
name: "Other Push",
enabled: true,
groupId: "other",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "222" }],
},
...extraRoutes,
] as Route[]),
);
return createEnv({ KV: kv });
}
it("dispatches only the tenant group's routes on a valid signature", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret);
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, secret),
"x-github-delivery": "deliv-1",
},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
});
it("rejects an invalid signature with 401", async () => {
const env = await setupTenant("tenant-secret-1");
const body = JSON.stringify({ ref: "refs/heads/main" });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": "sha256=wrong",
"x-github-delivery": "deliv-2",
},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(401);
});
it("returns 404 for an unknown group", async () => {
const env = await setupTenant("tenant-secret-1");
const wait = makeWait();
const res = await processWebhook(
env,
"{}",
{ "x-github-event": "push" },
wait.waitUntil,
"nope",
);
expect(res.status).toBe(404);
});
it("returns 404 when the group has no tenant secret", async () => {
const kv = createMockKV();
await kv.put("config:groups", JSON.stringify([{ id: "g", name: "G", adminIds: [] }]));
const env = createEnv({ KV: kv });
const wait = makeWait();
const res = await processWebhook(
env,
"{}",
{ "x-github-event": "push", "x-hub-signature-256": "sha256=x" },
wait.waitUntil,
"g",
);
expect(res.status).toBe(404);
});
it("dedupes delivery ids per tenant", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret);
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const headers = {
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, secret),
"x-github-delivery": "same-deliv",
};
const first = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
const second = await processWebhook(env, body, headers, makeWait().waitUntil, "team-a");
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(second.body).toEqual({ ok: true, duplicate: true });
expect(fetched).toHaveLength(1);
});
it("accepts custom webhooks signed with the tenant secret", async () => {
mockFetch();
const secret = "tenant-secret-1";
const env = await setupTenant(secret, [
{
id: "custom-alerts",
name: "Custom Alerts",
enabled: true,
groupId: "team-a",
filters: [{ type: "event", match: "custom" }],
targets: [{ channelId: "333" }],
},
]);
const body = JSON.stringify({
title: "Deploy failed",
repo: "acme/widget",
color: "red",
description: "prod down",
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{ "x-webhooker-signature": githubSign(body, secret) },
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(200);
await wait.flush();
const sent = fetched.filter((f) => f.url.includes("/333/"));
expect(sent).toHaveLength(1);
const parsed = JSON.parse(sent[0]!.body) as {
embeds?: Array<{ title?: string; color?: number; description?: string }>;
};
expect(parsed.embeds?.[0]?.title).toBe("acme/widget: Deploy failed");
expect(parsed.embeds?.[0]?.color).toBe(0xf85149);
expect(parsed.embeds?.[0]?.description).toBe("prod down");
});
it("rejects custom webhooks without a signature header", async () => {
const env = await setupTenant("tenant-secret-1");
const wait = makeWait();
const res = await processWebhook(
env,
JSON.stringify({ title: "x" }),
{},
wait.waitUntil,
"team-a",
);
expect(res.status).toBe(400);
});
it("keeps the legacy global endpoint working with the global secret", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:routes",
JSON.stringify([
{
id: "all",
name: "All",
enabled: true,
filters: [],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const body = JSON.stringify({ ref: "refs/heads/main", commits: [] });
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "g-1",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
});
it("keeps a group's routes isolated by GitHub App installation id", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "inst-1", name: "Install 1", adminIds: [], installationId: 101 },
{ id: "inst-2", name: "Install 2", adminIds: [], installationId: 202 },
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "r1",
name: "R1",
enabled: true,
groupId: "inst-1",
filters: [],
targets: [{ channelId: "111" }],
},
{
id: "r2",
name: "R2",
enabled: true,
groupId: "inst-2",
filters: [],
targets: [{ channelId: "222" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
// Global endpoint: installation 101's event may only reach group inst-1.
const body = JSON.stringify({
installation: { id: 101 },
repository: { full_name: "org-a/repo" },
ref: "refs/heads/main",
commits: [],
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "push",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "inst-1-deliv",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
expect(fetched.some((f) => f.url.includes("/111/"))).toBe(true);
expect(fetched.some((f) => f.url.includes("/222/"))).toBe(false);
});
it("auto-provisions a group on installation.created", async () => {
mockFetch();
const kv = createMockKV();
await kv.put(
"config:routes",
JSON.stringify([
{
id: "install-events",
name: "Install Events",
enabled: true,
filters: [{ type: "event", match: "installation" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const body = JSON.stringify({
action: "created",
installation: { id: 555, account: { login: "myorg", type: "Organization" } },
repositories: [{ full_name: "myorg/repo" }],
sender: { login: "admin" },
});
const wait = makeWait();
const res = await processWebhook(
env,
body,
{
"x-github-event": "installation",
"x-hub-signature-256": githubSign(body, "global-secret"),
"x-github-delivery": "inst-event-1",
},
wait.waitUntil,
);
expect(res.status).toBe(200);
await wait.flush();
const groups = await loadGroups(kv);
const bound = groups.find((g) => g.installationId === 555);
expect(bound).toBeDefined();
expect(bound?.id).toBe("inst-555");
expect(bound?.name).toBe("myorg");
// The auto-created group is only visible to super admins (no members).
expect(bound?.members ?? []).toHaveLength(0);
});
});

174
tests/webhook.test.ts Normal file
View file

@ -0,0 +1,174 @@
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", () => {
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 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);
});
});