mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
feat: migrate to Nuxt 4 (Nitro) and Tailwind CSS v3
This commit is contained in:
parent
f4959eebf8
commit
b139712a91
166 changed files with 19790 additions and 5539 deletions
243
tests/admin-api.test.ts
Normal file
243
tests/admin-api.test.ts
Normal 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
337
tests/admin.test.ts
Normal 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
133
tests/audit.test.ts
Normal 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
467
tests/discord.test.ts
Normal 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
362
tests/formatter.test.ts
Normal 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
118
tests/groups.test.ts
Normal 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
64
tests/helpers.ts
Normal 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
355
tests/install.test.ts
Normal 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
148
tests/invites.test.ts
Normal 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
226
tests/providers.test.ts
Normal 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
78
tests/send-log.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
209
tests/telegram-commands.test.ts
Normal file
209
tests/telegram-commands.test.ts
Normal 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
202
tests/telegram.test.ts
Normal 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("<script>alert(1)</script>");
|
||||
});
|
||||
|
||||
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
62
tests/token-store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
391
tests/webhook-tenant.test.ts
Normal file
391
tests/webhook-tenant.test.ts
Normal 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
174
tests/webhook.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue