import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { Viewer } from "~/lib/types"; import type { RequestCredential, TokenGrant } from "../token-scope"; class StubEntrypoint {} mock.module("cloudflare:workers", () => ({ env: { PUBLIC_URL: "https://forge.example.com" }, waitUntil: () => undefined, WorkflowEntrypoint: StubEntrypoint, DurableObject: StubEntrypoint, WorkerEntrypoint: StubEntrypoint, })); mock.module("../repo-provisioning", () => ({ describeProvisioningError: () => "", markProvisioningFailed: async () => undefined, startProvisioning: async () => undefined, RepoProvisionWorkflow: StubEntrypoint, })); const people: Record = Object.fromEntries( ["owner", "admin", "writer", "reader"].map((name) => [ name, { id: `u-${name}`, name, displayName: null, email: `${name}@example.com`, isAdmin: false, emailVerified: true, }, ]), ); const collaborators: Record = { "u-admin": "admin", "u-writer": "write", "u-reader": "read", }; const repo = { id: "repo-1", owner_id: "u-owner", owner_name: "owner", name: "app", visibility: "public", fork_of_id: null, is_archived: 0, default_branch: "main", artifacts_name: "art-1", description: null, status: "ready", }; function fakeFirst(sql: string, ...params: unknown[]): unknown { if (sql.includes("WHERE a.name = ? AND r.name = ?")) return params[0] === "owner" && params[1] === "app" ? repo : null; if (sql.includes("FROM org_members")) return null; if (sql.includes("FROM collaborators")) { const permission = collaborators[String(params[1])]; return permission ? { permission } : null; } if (sql.includes("COUNT(*)")) return { count: 0 }; return null; } mock.module("../db", () => ({ first: async (sql: string, ...params: unknown[]) => fakeFirst(sql, ...params), all: async () => [], run: async () => ({ meta: { changes: 1 } }), batch: async () => [], stmt: () => ({}), placeholders: (count: number) => Array.from({ length: count }, () => "?").join(", "), chunks: (items: T[]) => [items], MAX_IN_LIST: 90, })); const { memoryScopes } = await import("../scoped-db/testing"); const scopes = memoryScopes(); mock.module("../scoped-db", () => scopes.module); scopes .raw("repo", "repo-1") .exec( "INSERT INTO deploy_tokens (id, repo_id, name, token_hash, token_prefix, permission, created_at) VALUES ('d-1', 'repo-1', 'ci', 'h', 'p', 'read', 0)", ); scopes.raw("account", "u-owner").exec("INSERT INTO access_token_repos (token_id, repo_id) VALUES ('t-oro', 'repo-1')"); const limited: string[] = []; const actualLimits = await import("../limits"); mock.module("../limits", () => ({ ...actualLimits, limitWrite: async (viewer: Viewer) => { limited.push(viewer.id); }, limitAccountWrite: async (viewer: Viewer) => { limited.push(viewer.id); }, limitBrowse: async () => undefined, })); const reviewed: string[] = []; const actualDeployments = await import("../ci/deployments"); mock.module("../ci/deployments", () => ({ ...actualDeployments, reviewDeployment: async (_repo: string, id: string) => { reviewed.push(id); return true; }, })); const actualRunners = await import("../ci/runner-registration"); mock.module("../ci/runner-registration", () => ({ ...actualRunners, listRepoRunners: async () => [ { id: "r-repo", scope: "repository" }, { id: "r-org", scope: "owner" }, ], })); mock.module("../ci/runner-policy", () => ({ forkRunsAllowed: async () => false, setForkRunsAllowed: async () => {}, })); const actualSecretAlerts = await import("../security/secret-alerts"); mock.module("../security/secret-alerts", () => ({ ...actualSecretAlerts, listSecretAlerts: async () => ({ alerts: [], total: 0 }), secretAlertCounts: async () => ({ open: 0, resolved: 0 }), })); const actualAdvisoryPackages = await import("../security/advisory-packages"); mock.module("../security/advisory-packages", () => ({ ...actualAdvisoryPackages, syncRepoAdvisoryIndex: async () => undefined, })); mock.module("../user-inbox", () => ({ invalidateUnread: async () => undefined, })); const ALL_WRITE = { contents: "write", issues: "write", pulls: "write", actions: "write", admin: "write", } as const; function grant(id: string, permissions: Record, allRepos = true): TokenGrant { return { kind: "fine", tokenId: id, allRepos, permissions }; } const credentials: Record = { owner: { viewer: people.owner, grant: grant("t-owner", ALL_WRITE) }, admin: { viewer: people.admin, grant: grant("t-admin", ALL_WRITE) }, writer: { viewer: people.writer, grant: grant("t-writer", ALL_WRITE) }, "writer-read-only": { viewer: people.writer, grant: grant("t-wro", { contents: "read" }), }, "reader-read-only": { viewer: people.reader, grant: grant("t-rro", { contents: "read" }), }, "owner-repo-only": { viewer: people.owner, grant: grant("t-oro", ALL_WRITE, false) }, "writer-uncovered": { viewer: people.writer, grant: grant("t-wun", ALL_WRITE, false) }, "writer-actions-read": { viewer: people.writer, grant: grant("t-war", { actions: "read", contents: "read" }), }, }; const actualScope = await import("../token-scope"); mock.module("../token-scope", () => ({ ...actualScope, tokenCredential: async (request: Request) => credentials[(request.headers.get("authorization") ?? "").replace(/^Bearer /, "")] ?? null, })); const { handleApiV1 } = await import("./router"); const { HttpError } = await import("../errors"); async function call(token: string, method: string, path: string, body?: unknown): Promise { const url = new URL(`https://forge.example.com/api/v1${path}`); const request = new Request(url, { method, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }); try { return await handleApiV1(request, url); } catch (error) { if (error instanceof HttpError) return error.status; throw error; } } async function status(token: string, method: string, path: string, body?: unknown): Promise { const result = await call(token, method, path, body); return typeof result === "number" ? result : result.status; } const R = "/repos/owner/app"; beforeEach(() => { repo.is_archived = 0; limited.length = 0; reviewed.length = 0; }); describe("deployment review needs actions:write (authz-P3)", () => { test("an actions:read token cannot approve", async () => { expect(await status("writer-actions-read", "POST", `${R}/deployments/d-1/review`, { approve: true })).toBe(403); expect(reviewed).toEqual([]); }); test("an actions:write token can", async () => { expect( await status("writer", "POST", `${R}/deployments/d-1/review`, { approve: true, }), ).toBe(204); expect(reviewed).toEqual(["d-1"]); }); }); describe("archived repositories reject admin-level writes (authz-P4)", () => { test("secrets and protections cannot change, but still list", async () => { repo.is_archived = 1; expect( await status("owner", "PUT", `${R}/actions/secrets/TOKEN`, { value: "x", }), ).toBe(403); expect( await status("owner", "POST", `${R}/protections`, { pattern: "main", kind: "branch", }), ).toBe(403); expect(await status("owner", "GET", `${R}/actions/secrets`)).toBe(200); }); test("webhooks, repo settings and collaborator grants cannot change", async () => { repo.is_archived = 1; const hook = { url: "https://example.com/hook", events: ["push"] }; expect(await status("owner", "POST", `${R}/hooks`, hook)).toBe(403); expect(await status("owner", "PATCH", R, { description: "x" })).toBe(403); expect(await status("owner", "PUT", `${R}/collaborators/writer`, { permission: "write" })).toBe(403); }); test("pure make-private and deploy-token revocation stay allowed", async () => { repo.is_archived = 1; expect(await status("owner", "PATCH", R, { private: true })).toBe(200); expect(await status("owner", "PATCH", R, { private: true, description: "x" })).toBe(403); expect(await status("owner", "DELETE", `${R}/deploy-tokens/d-1`)).toBe(204); expect(await status("owner", "DELETE", `${R}/actions/secrets/TOKEN`)).toBe(204); expect( await status("owner", "POST", `${R}/deploy-tokens`, { name: "ci", permission: "read", expires_in_days: 30 }), ).toBe(403); }); }); describe("rate limits on settings and inbox writes (authz-P9)", () => { test("createProtection, markNotifications and revokeToken spend the write budget", async () => { await call("owner", "POST", `${R}/protections`, { pattern: "main", kind: "branch", }); await call("owner", "PUT", "/notifications", { ids: null }); await call("owner", "DELETE", "/user/tokens/t-x"); expect(limited).toEqual(["u-owner", "u-owner", "u-owner"]); }); }); describe("runner listings (authz-P13)", () => { async function runnerIds(token: string): Promise { const result = await call(token, "GET", `${R}/actions/runners`); if (typeof result === "number") throw new Error(`status ${result}`); const body = (await result.json()) as { runners: { id: string }[] }; return body.runners.map((runner) => runner.id); } test("a repo admin who does not administer the owner only sees repository runners", async () => { expect(await runnerIds("admin")).toEqual(["r-repo"]); expect(await runnerIds("owner")).toEqual(["r-repo", "r-org"]); }); test("a token limited to this repository never sees or manages owner-wide runners", async () => { expect(await runnerIds("owner-repo-only")).toEqual(["r-repo"]); const body = { scope: "owner", labels: ["self-hosted"] }; expect(await status("owner-repo-only", "POST", `${R}/actions/runners/registration-token`, body)).toBe(403); }); }); describe("secret alert reads", () => { test("need a writer, but only a contents:read token", async () => { expect(await status("writer-read-only", "GET", `${R}/secret-scanning/alerts`)).toBe(200); expect(await status("reader-read-only", "GET", `${R}/secret-scanning/alerts`)).toBe(403); }); test("still work on archived repositories", async () => { repo.is_archived = 1; expect(await status("writer-read-only", "GET", `${R}/secret-scanning/alerts`)).toBe(200); }); test("need a token that covers the repository, even on a public one the user writes to", async () => { expect(await status("writer-uncovered", "GET", `${R}/secret-scanning/alerts`)).toBe(403); }); }); describe("issue writes on public repositories", () => { test("a token outside its repository list cannot open issues", async () => { expect(await status("writer-uncovered", "POST", `${R}/issues`, { title: "x", body: "y" })).toBe(403); }); });