diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts new file mode 100644 index 0000000..0c14df0 --- /dev/null +++ b/src/__tests__/admin.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { + isAdminUser, + createAdminSession, + getAdminSession, + destroyAdminSession, + adminCookie, + clearAdminCookie, +} from "../admin-session"; +import { loadRoutes, saveRoutes, loadConfig } from "../config"; +import type { Env, Route } from "../types"; + +function createMockKV(): KVNamespace { + const store = new Map(); + 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 { + return { + GITHUB_WEBHOOK_SECRET: "secret", + KV: createMockKV(), + DISCORD_GATEWAY: {} as DurableObjectNamespace, + ...overrides, + }; +} + +const sampleRoutes: Route[] = [ + { + id: "backend-prs", + name: "Backend PRs", + enabled: true, + filters: [{ type: "event", match: "pull_request" }], + target: { 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("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]!.target.channelId).toBe("111"); + }); +}); diff --git a/src/__tests__/discord.test.ts b/src/__tests__/discord.test.ts new file mode 100644 index 0000000..193f0b1 --- /dev/null +++ b/src/__tests__/discord.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { sendMessage } from "../discord-rest"; +import { isGatewayEnabled } from "../discord"; +import type { Env } from "../types"; + +function mockFetch( + handler: (url: string, init?: RequestInit) => Response, +): void { + globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise => + Promise.resolve(handler(String(input), init)); +} + +function createEnv(overrides: Partial = {}): Env { + return { + GITHUB_WEBHOOK_SECRET: "secret", + KV: {} as KVNamespace, + DISCORD_GATEWAY: {} as DurableObjectNamespace, + ...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).Authorization).toBe("Bot token-abc"); + expect((capturedInit!.headers as Record)["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("isGatewayEnabled", () => { + it("is enabled only when set to true", () => { + expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "true" }))).toBe(true); + expect(isGatewayEnabled(createEnv({ DISCORD_GATEWAY_ENABLED: "false" }))).toBe(false); + expect(isGatewayEnabled(createEnv())).toBe(false); + }); +}); diff --git a/src/__tests__/send-log.test.ts b/src/__tests__/send-log.test.ts new file mode 100644 index 0000000..084aadc --- /dev/null +++ b/src/__tests__/send-log.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "bun:test"; +import { recordSend, getSendLog } from "../send-log"; + +function createMockKV(): KVNamespace { + const store = new Map(); + 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("send-log", () => { + it("records and returns logs sorted newest first", async () => { + const kv = createMockKV(); + await recordSend(kv, { ts: 1000, routeId: "a", event: "push", target: "111", ok: true }); + await recordSend(kv, { + ts: 2000, + routeId: "b", + event: "issues", + target: "222", + ok: false, + error: "Missing Permissions", + }); + const logs = await getSendLog(kv); + 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 kv = createMockKV(); + expect(await getSendLog(kv)).toEqual([]); + }); +}); diff --git a/src/__tests__/token-store.test.ts b/src/__tests__/token-store.test.ts index 84d3063..b5cc9d6 100644 --- a/src/__tests__/token-store.test.ts +++ b/src/__tests__/token-store.test.ts @@ -1,60 +1,58 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { existsSync, unlinkSync, mkdirSync } from "fs"; -import { dirname } from "path"; -import { - saveToken, - getToken, - removeToken, - findUserIdByToken, - initTokenStore, -} from "../token-store"; +import { describe, it, expect } from "bun:test"; +import { saveToken, getToken, removeToken, findUserIdByToken } from "../token-store"; -const TEST_STORE = "./data/test-tokens.json"; - -beforeEach(() => { - const dir = dirname(TEST_STORE); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - if (existsSync(TEST_STORE)) unlinkSync(TEST_STORE); - initTokenStore(TEST_STORE); -}); - -afterEach(() => { - if (existsSync(TEST_STORE)) unlinkSync(TEST_STORE); -}); +function createMockKV(): KVNamespace { + const store = new Map(); + 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", () => { - saveToken("user1", "token-abc", 3600); - expect(getToken("user1")).toBe("token-abc"); + 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 expired token", () => { - saveToken("user1", "token-abc", -1); - expect(getToken("user1")).toBeNull(); + it("returns null for nonexistent user", async () => { + const kv = createMockKV(); + expect(await getToken(kv, "nobody")).toBeNull(); }); - it("returns null for nonexistent user", () => { - expect(getToken("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("removes token", () => { - saveToken("user1", "token-abc", 3600); - removeToken("user1"); - expect(getToken("user1")).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("finds userId by token", () => { - saveToken("user1", "token-abc", 3600); - expect(findUserIdByToken("token-abc")).toBe("user1"); - }); - - it("returns null for unknown token", () => { - expect(findUserIdByToken("unknown")).toBeNull(); - }); - - it("persists across reload", () => { - saveToken("user1", "token-abc", 3600); - initTokenStore(TEST_STORE); - expect(getToken("user1")).toBe("token-abc"); + it("returns null for unknown token", async () => { + const kv = createMockKV(); + expect(await findUserIdByToken(kv, "unknown")).toBeNull(); }); }); diff --git a/src/__tests__/webhook.test.ts b/src/__tests__/webhook.test.ts index 7f99b1f..62448d1 100644 --- a/src/__tests__/webhook.test.ts +++ b/src/__tests__/webhook.test.ts @@ -10,17 +10,17 @@ function sign(body: string, secret: string): string { describe("verifySignature", () => { const secret = "test-secret"; - it("returns true for valid signature", () => { + it("returns true for valid signature", async () => { const body = '{"hello":"world"}'; - expect(verifySignature(body, sign(body, secret), secret)).toBe(true); + expect(await verifySignature(body, sign(body, secret), secret)).toBe(true); }); - it("returns false for invalid signature", () => { - expect(verifySignature("body", "sha256=invalid", secret)).toBe(false); + it("returns false for invalid signature", async () => { + expect(await verifySignature("body", "sha256=invalid", secret)).toBe(false); }); - it("returns false for missing signature", () => { - expect(verifySignature("body", undefined, secret)).toBe(false); + it("returns false for missing signature", async () => { + expect(await verifySignature("body", undefined, secret)).toBe(false); }); });