mirror of
https://github.com/ReCloudStudio/WebHooker.git
synced 2026-09-22 16:11:29 +00:00
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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");
|
|
});
|
|
});
|