test: cover admin, discord REST, send logs, token-store links and dedup

This commit is contained in:
RhenCloud 2026-08-02 06:51:32 +08:00
parent befed0267a
commit d68aeb20d5
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
5 changed files with 321 additions and 53 deletions

139
src/__tests__/admin.test.ts Normal file
View file

@ -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<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(),
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");
});
});

View file

@ -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<Response> =>
Promise.resolve(handler(String(input), init));
}
function createEnv(overrides: Partial<Env> = {}): 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<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("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);
});
});

View file

@ -0,0 +1,52 @@
import { describe, it, expect } from "bun:test";
import { recordSend, getSendLog } from "../send-log";
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("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([]);
});
});

View file

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

View file

@ -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);
});
});