| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { Directory, type Account, type RepoGrants } from "../../src/lib/directory.js"; |
| 3 | import { testD1 } from "./stubs/d1.js"; |
| 4 | import type { Env } from "../../src/env.js"; |
| 5 | |
| 6 | /** roleFor with the grants loadRepo reads alongside the repository (grantsFor) gives the same role as reading them itself. */ |
| 7 | function world() { |
| 8 | const DB = testD1(); |
| 9 | const t = "2026-10-01T00:00:00.000Z"; |
| 10 | const acct = DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, owner_id, agent_policy, base_role, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); |
| 11 | acct.run("ada", "ada", "user", "active", 0, null, null, null, t); |
| 12 | acct.run("bob", "bob", "user", "active", 0, null, null, null, t); |
| 13 | acct.run("cy", "cy", "user", "active", 0, null, null, null, t); |
| 14 | acct.run("dee", "dee", "user", "disabled", 0, null, null, null, t); |
| 15 | acct.run("root", "root", "user", "active", 1, null, null, null, t); |
| 16 | acct.run("eve", "eve", "user", "active", 0, null, null, null, t); |
| 17 | acct.run("acme", "acme", "org", "active", 0, null, null, "write", t); |
| 18 | acct.run("shut", "shut", "org", "active", 0, null, null, "none", t); |
| 19 | acct.run("ada-bot", "ada-bot", "agent", "active", 0, "ada", null, null, t); |
| 20 | acct.run("bob-bot", "bob-bot", "agent", "active", 0, "bob", JSON.stringify({ maxRole: "triage" }), null, t); |
| 21 | acct.run("cy-bot", "cy-bot", "agent", "active", 0, "cy", null, null, t); |
| 22 | acct.run("acme-bot", "acme-bot", "agent", "active", 0, "acme", null, null, t); |
| 23 | acct.run("dee-bot", "dee-bot", "agent", "active", 0, "dee", null, null, t); |
| 24 | const member = DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, '2026-10-01')"); |
| 25 | member.run("acme", "bob", "member"); |
| 26 | member.run("acme", "ada", "owner"); |
| 27 | member.run("shut", "bob", "member"); |
| 28 | member.run("shut", "cy", "admin"); |
| 29 | const repo = DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, visibility, created_at, updated_at, pushed_at) VALUES (?, ?, ?, ?, ?, ?, ?)"); |
| 30 | repo.run("r-ada", "ada", "notes", "private", t, t, t); |
| 31 | repo.run("r-acme", "acme", "app", "private", t, t, t); |
| 32 | repo.run("r-acme-pub", "acme", "site", "public", t, t, t); |
| 33 | repo.run("r-shut", "shut", "vault", "private", t, t, t); |
| 34 | repo.run("r-pub", "cy", "tool", "public", t, t, t); |
| 35 | const grant = DB.sqlite.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)"); |
| 36 | grant.run("r-ada", "cy", "read"); |
| 37 | grant.run("r-acme", "eve", "maintain"); |
| 38 | grant.run("r-shut", "bob", "write"); |
| 39 | grant.run("r-acme", "bob-bot", "admin"); |
| 40 | grant.run("r-ada", "ada-bot", "read"); |
| 41 | grant.run("r-shut", "cy-bot", "triage"); |
| 42 | return new Directory({ DB } as unknown as Env); |
| 43 | } |
| 44 | |
| 45 | describe("roles from grants read alongside the repository", () => { |
| 46 | it("match roleFor reading them itself, for people and agents", async () => { |
| 47 | const dir = world(); |
| 48 | const everyone = (await dir.accountsByIds(["ada", "bob", "cy", "dee", "root", "eve", "ada-bot", "bob-bot", "cy-bot", "acme-bot", "dee-bot"])) as Account[]; |
| 49 | let compared = 0; |
| 50 | for (const id of ["r-ada", "r-acme", "r-acme-pub", "r-shut", "r-pub"]) { |
| 51 | const repo = (await dir.repoById(id))!; |
| 52 | for (const a of everyone) { |
| 53 | const owner = a.owner_id ? await dir.account(a.owner_id) : null; |
| 54 | const ids = a.kind === "agent" ? [a.id, ...(owner?.kind === "user" ? [owner.id] : [])] : [a.id]; |
| 55 | const grants: RepoGrants[] = (await Promise.all(ids.map((x) => dir.grantsFor(repo.owner_login, repo.name, x)))).flat(); |
| 56 | expect(grants.every((g) => g.repoId === repo.id)).toBe(true); |
| 57 | expect(await dir.roleFor(a, repo, owner, grants), `${a.login} on ${id}`).toBe(await dir.roleFor(a, repo)); |
| 58 | compared++; |
| 59 | } |
| 60 | } |
| 61 | expect(compared).toBe(55); |
| 62 | }); |
| 63 | |
| 64 | it("are ignored for another repository, and read by name with .git and any case", async () => { |
| 65 | const dir = world(); |
| 66 | const bob = (await dir.account("bob"))!; |
| 67 | const shut = (await dir.repoById("r-shut"))!; |
| 68 | // Grants read for acme/app don't stand in for shut/vault: roleFor reads shut/vault's own. |
| 69 | const other = await dir.grantsFor("acme", "app", "bob"); |
| 70 | expect(other[0]!.repoId).toBe("r-acme"); |
| 71 | expect(await dir.roleFor(bob, shut, undefined, other)).toBe("write"); |
| 72 | expect((await dir.grantsFor("SHUT", "vault.git", "bob"))[0]).toMatchObject({ repoId: "r-shut", access: "write", memberRole: "member", baseRole: "none" }); |
| 73 | expect(await dir.grantsFor("shut", "missing", "bob")).toEqual([]); |
| 74 | }); |
| 75 | }); |