import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { Viewer } from "~/lib/types"; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "https://forge.example.com" }, waitUntil: () => undefined, WorkflowEntrypoint: StubEntrypoint, DurableObject: StubEntrypoint, WorkerEntrypoint: StubEntrypoint, })); type Member = { userId: string; role: "owner" | "member" }; const users: Record = { alice: "u-alice", bob: "u-bob", carol: "u-carol" }; let members: Member[] = []; let invitations: { userId: string; role: string; invitedBy?: string | null }[] = []; const d1 = { first: async (sql: string, ...params: unknown[]) => { if (sql.includes("kind = 'org'")) return params[0] === "acme" ? { id: "org-acme" } : null; if (sql.includes("JOIN users u")) { const id = users[String(params[0])]; return id ? { id } : null; } if (sql.includes("COUNT(*) AS count FROM org_invitations")) return { count: invitations.length }; if (sql.includes("COUNT(*)")) { return { count: members.filter((item) => item.role === "owner" && item.userId !== params[1]).length }; } if (sql.includes("FROM org_invitations WHERE org_id = ? AND user_id = ?")) { const row = invitations.find((item) => item.userId === params[1]); return row ? { role: row.role, invited_by: row.invitedBy ?? null, expires_at: Date.now() + 1000 } : null; } if (sql.includes("is_admin = 1")) { return members.some((item) => item.userId === params[1] && item.role === "owner") ? { ok: 1 } : null; } if (sql.includes("SELECT role FROM org_members")) { const userId = params.length > 1 ? params[1] : params[0]; const row = members.find((item) => item.userId === userId); return row ? { role: row.role } : null; } return null; }, run: async (sql: string, ...params: unknown[]) => { const target = members.find((item) => item.userId === params[1]); const keepsOwner = !sql.includes("o2.role = 'owner'") || target?.role !== "owner" || (sql.startsWith("UPDATE") && params[2] === "owner") || members.some((item) => item.role === "owner" && item.userId !== params[1]); if (!keepsOwner) return { meta: { changes: 0 } }; if (sql.startsWith("DELETE FROM org_members")) members = members.filter((item) => item.userId !== params[1]); else if (sql.startsWith("DELETE FROM org_invitations")) { invitations = invitations.filter((item) => item.userId !== params[1]); } else if (sql.startsWith("UPDATE org_members SET role")) { members = members.map((item) => item.userId === params[1] ? { ...item, role: params[2] as Member["role"] } : item, ); } else if (sql.includes("INSERT INTO org_members")) { members = members.filter((item) => item.userId !== params[1]); members.push({ userId: String(params[1]), role: params[2] as Member["role"] }); } else if (sql.includes("INSERT INTO org_invitations")) { invitations = invitations.filter((item) => item.userId !== params[2]); invitations.push({ userId: String(params[2]), role: String(params[3]) }); } return { meta: { changes: 1 } }; }, all: async (sql: string, ...params: unknown[]) => sql.includes("invited_by = ?") ? invitations.filter((item) => item.invitedBy === params[1]).map((item) => ({ user_id: item.userId })) : [], batch: async (statements: { sql: string; params: unknown[] }[]) => { for (const { sql, params } of statements) { if (sql.startsWith("DELETE FROM org_invitations")) { invitations = invitations.filter((item) => item.userId !== params[1]); } else if (sql.includes("INSERT INTO org_members")) { members = members.filter((item) => item.userId !== params[1]); members.push({ userId: String(params[1]), role: params[2] as Member["role"] }); } else if (sql.includes("INSERT INTO org_invitations")) { invitations = invitations.filter((item) => item.userId !== params[2]); invitations.push({ userId: String(params[2]), role: String(params[3]), invitedBy: (params[4] as string | null) ?? null, }); } } return []; }, stmt: (sql: string, ...params: unknown[]) => ({ sql, params }), placeholders: () => "", chunks: (items: T[]) => [items], MAX_IN_LIST: 90, }; mock.module("./db", () => d1); // Invitations live in AccountState objects now; the same fake answers for them. mock.module("./scoped-db", () => ({ accountDb: () => d1, repoDb: () => d1, stmt: d1.stmt })); const { createOrg, setOrgMember, parseSignupsEnabled, respondToOrgInvitation } = await import("./accounts"); const { HttpError } = await import("./errors"); function viewer(name: string): Viewer { return { id: users[name], name, displayName: null, email: `${name}@example.com`, isAdmin: false, emailVerified: true, }; } async function statusOf(promise: Promise): Promise { try { await promise; return 200; } catch (error) { if (error instanceof HttpError) return error.status; throw error; } } beforeEach(() => { members = [ { userId: "u-alice", role: "owner" }, { userId: "u-bob", role: "member" }, ]; invitations = []; }); describe("parseSignupsEnabled (AUTH-A7)", () => { test("unset keeps signups open", () => { expect(parseSignupsEnabled(undefined)).toBe(true); expect(parseSignupsEnabled("")).toBe(true); }); test("accepts the usual truthy spellings case-insensitively", () => { for (const value of ["true", "TRUE", "True", "1", "yes", "on", " true "]) { expect(parseSignupsEnabled(value)).toBe(true); } }); test("every other value closes signups", () => { for (const value of ["false", "FALSE", "False", "0", "no", "off", "nope", "disabled"]) { expect(parseSignupsEnabled(value)).toBe(false); } }); }); describe("setOrgMember", () => { test("a plain member can leave", async () => { expect(await statusOf(setOrgMember({ org: "acme", username: "bob", role: "none", viewer: viewer("bob") }))).toBe( 200, ); expect(members.map((item) => item.userId)).toEqual(["u-alice"]); }); test("a plain member cannot remove someone else or change roles", async () => { expect(await statusOf(setOrgMember({ org: "acme", username: "alice", role: "none", viewer: viewer("bob") }))).toBe( 403, ); expect(await statusOf(setOrgMember({ org: "acme", username: "bob", role: "owner", viewer: viewer("bob") }))).toBe( 403, ); }); test("the last owner cannot leave or be removed", async () => { expect( await statusOf(setOrgMember({ org: "acme", username: "alice", role: "none", viewer: viewer("alice") })), ).toBe(409); expect(members).toHaveLength(2); }); test("the last owner cannot be demoted to member", async () => { expect( await statusOf(setOrgMember({ org: "acme", username: "alice", role: "member", viewer: viewer("alice") })), ).toBe(409); expect(members).toContainEqual({ userId: "u-alice", role: "owner" }); }); test("an owner can leave once another owner exists", async () => { await setOrgMember({ org: "acme", username: "bob", role: "owner", viewer: viewer("alice") }); expect( await statusOf(setOrgMember({ org: "acme", username: "alice", role: "none", viewer: viewer("alice") })), ).toBe(200); expect(members).toEqual([{ userId: "u-bob", role: "owner" }]); }); test("two owners demoting each other at once cannot leave the org ownerless (AUTH-P11)", async () => { members = [ { userId: "u-alice", role: "owner" }, { userId: "u-bob", role: "owner" }, ]; const results = await Promise.all([ statusOf(setOrgMember({ org: "acme", username: "alice", role: "member", viewer: viewer("bob") })), statusOf(setOrgMember({ org: "acme", username: "bob", role: "none", viewer: viewer("alice") })), ]); expect(results.sort()).toEqual([200, 409]); expect(members.some((item) => item.role === "owner")).toBe(true); }); test("adding a non-member creates an invitation instead of a membership", async () => { const result = await setOrgMember({ org: "acme", username: "carol", role: "member", viewer: viewer("alice") }); expect(result.status).toBe("invited"); expect(members.map((item) => item.userId)).toEqual(["u-alice", "u-bob"]); expect(invitations).toEqual([{ userId: "u-carol", role: "member", invitedBy: "u-alice" }]); }); test("accepting an invitation joins with the invited role; declining consumes it", async () => { await setOrgMember({ org: "acme", username: "carol", role: "owner", viewer: viewer("alice") }); await respondToOrgInvitation({ org: "acme", accept: true, viewer: viewer("carol") }); expect(members).toContainEqual({ userId: "u-carol", role: "owner" }); expect(invitations).toEqual([]); await setOrgMember({ org: "acme", username: "carol", role: "none", viewer: viewer("alice") }); await setOrgMember({ org: "acme", username: "carol", role: "member", viewer: viewer("alice") }); await respondToOrgInvitation({ org: "acme", accept: false, viewer: viewer("carol") }); expect(members.some((item) => item.userId === "u-carol")).toBe(false); expect(await statusOf(respondToOrgInvitation({ org: "acme", accept: true, viewer: viewer("carol") }))).toBe(404); }); test("a removed owner's pending invitations are withdrawn", async () => { members.push({ userId: "u-bob", role: "owner" }); members = members.filter((item) => !(item.userId === "u-bob" && item.role === "member")); await setOrgMember({ org: "acme", username: "carol", role: "owner", viewer: viewer("bob") }); await setOrgMember({ org: "acme", username: "bob", role: "none", viewer: viewer("alice") }); expect(invitations).toEqual([]); expect(await statusOf(respondToOrgInvitation({ org: "acme", accept: true, viewer: viewer("carol") }))).toBe(404); expect(members.some((item) => item.userId === "u-carol")).toBe(false); }); test("an invitation whose inviter is no longer an owner can't be accepted", async () => { invitations = [{ userId: "u-carol", role: "owner", invitedBy: "u-bob" }]; expect(await statusOf(respondToOrgInvitation({ org: "acme", accept: true, viewer: viewer("carol") }))).toBe(404); expect(members.some((item) => item.userId === "u-carol")).toBe(false); expect(invitations).toEqual([]); }); test("leaving an org you are not in is a 404", async () => { expect( await statusOf(setOrgMember({ org: "acme", username: "carol", role: "none", viewer: viewer("carol") })), ).toBe(404); }); }); describe("createOrg reserved names", () => { test("project-reserved names are refused to everyone but site admins", async () => { await expect(createOrg("gitfor", null, "u-alice")).rejects.toThrow("That name is reserved"); await expect(createOrg("gitfor", null, "u-alice", { isAdmin: false })).rejects.toThrow("That name is reserved"); const asAdmin = await createOrg("gitfor", null, "u-alice", { isAdmin: true }).then( () => null, (error: Error) => error.message, ); expect(asAdmin).not.toBe("That name is reserved"); }); test("route names stay reserved even for site admins", async () => { await expect(createOrg("settings", null, "u-alice", { isAdmin: true })).rejects.toThrow("That name is reserved"); }); });