// The forge's tools act as an account of their own, not the site administrator (novox/hq ADR 0365 §2, issue 469): // the token the tools and the watcher use is minted for `mesh-tools`, a plain account made by the module with the // administrator's login and given write on the mesh's repositories through one team; the administrator's own // token by that name goes; a token kept from before, the administrator's, is minted again for the account. // // The forge is a fake that keeps accounts, their tokens and the organisation's teams apart, and answers as gitea // does: an administrator may mint for another account (reqSelfOrAdmin), anyone else only for itself; a protection // edit needs the repository's administration, which neither a plain account nor its write team holds. import { test, after } from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { mkdtemp, readFile, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MintedToken } from "../dist/token.js"; import { GiteaClient } from "../dist/client.js"; import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js"; import { repoCheckFacts } from "../dist/pulls.js"; const ADMIN = "mesh-admin"; const PASSWORD = "the-vault-minted-this"; interface Account { admin: boolean; password: string; active: boolean } interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] } interface Team { id: number; name: string; units: any; members: Set } interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map } const forgeRepos: ForgeRepo[] = []; const repoFaults = { refuse: new Set(), permission: new Set() }; function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo { const [owner, name] = full.split("/"); const r = { owner, name, full, private: isPrivate, ownerVisibility, collaborators: new Map() }; forgeRepos.push(r); return r; } const repoJSON = (r: ForgeRepo) => ({ full_name: r.full, name: r.name, owner: { login: r.owner, visibility: r.ownerVisibility }, private: r.private, archived: r.archived === true }); function fakeForge() { const users = new Map([[ADMIN, { admin: true, password: PASSWORD, active: true }]]); const tokens: Token[] = []; const teams: Team[] = []; const calls: string[] = []; // The organisation's repositories, and the rule each has for main, which the administrator's setup puts right // (issue 948, ADR 0398 §2). const repos = ["hq", "mesh-catalog", "globbed"]; const globRules = new Map([["globbed", [{ rule_name: "m*", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true, required_approvals: 1 }]]]); const mainRules = new Map([["hq", { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate"] }]]); // What the forge does wrong when a test says so: a token mint or delete it fails, a team another process made // between this one's look and its create (gitea answers the create 422). const faults = { mint: false, deleteToken: false, teamRace: false }; let next = 0; const json = (res: ServerResponse, status: number, body: unknown): void => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body === null ? "" : JSON.stringify(body)); }; const read = (req: IncomingMessage): Promise => new Promise((resolve) => { let t = ""; req.on("data", (c) => (t += c)); req.on("end", () => resolve(t ? JSON.parse(t) : null)); }); const who = (req: IncomingMessage): string | null => { const h = req.headers.authorization ?? ""; if (h.startsWith("Basic ")) { const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":"); return users.get(u)?.password === p ? u : null; } if (h.startsWith("token ")) return tokens.find((t) => t.sha1 === h.slice(6))?.user ?? null; return null; }; const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true; const permissionOf = (u: string | null, r: ForgeRepo): string => { if (isAdmin(u)) return "admin"; if (u === r.owner) return "owner"; if (r.owner === "novox" && teams.some((t) => t.members.has(u ?? "") && t.units.units_map?.["repo.code"] === "write")) return "write"; if (u && r.collaborators.has(u)) return r.collaborators.get(u)!; return !r.private && r.ownerVisibility === "public" ? "read" : "none"; }; const server = createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://fake"); const p = url.pathname.replace(/^\/api\/v1/, ""); const me = who(req); calls.push(`${req.method} ${p} as ${me}`); if (me === null) return json(res, 401, { message: "unauthorized" }); let m = p.match(/^\/users\/([^/]+)\/tokens(?:\/(\d+))?$/); if (m) { const owner = decodeURIComponent(m[1]); if (!(req.headers.authorization ?? "").startsWith("Basic ")) return json(res, 401, { message: "basic auth required" }); if (owner !== me && !isAdmin(me)) return json(res, 403, { message: "reqSelfOrAdmin" }); if (!users.has(owner)) return json(res, 404, { message: "user does not exist" }); if (req.method === "POST") { if (faults.mint) return json(res, 500, { message: "the forge is not well" }); const { name, scopes } = await read(req); if (tokens.some((t) => t.user === owner && t.name === name)) return json(res, 400, { message: "token name has already been used" }); const t = { id: ++next, name, sha1: `sha-${owner}-${next}`, user: owner, scopes }; tokens.push(t); return json(res, 201, { id: t.id, name, sha1: t.sha1, scopes }); } if (req.method === "GET") { const page = Number(url.searchParams.get("page") ?? "1"); const mine = tokens.filter((t) => t.user === owner); return json(res, 200, page === 1 ? mine.map((t) => ({ id: t.id, name: t.name, sha1: "" })) : []); } if (req.method === "DELETE" && m[2]) { if (faults.deleteToken && owner === ADMIN) return json(res, 500, { message: "the forge is not well" }); const i = tokens.findIndex((t) => t.user === owner && t.id === Number(m![2])); if (i < 0) return json(res, 404, { message: "not found" }); tokens.splice(i, 1); return json(res, 204, null); } } if (req.method === "GET" && p === "/user") return json(res, 200, { login: me, is_admin: isAdmin(me) }); m = p.match(/^\/users\/([^/]+)$/); if (m && req.method === "GET") { const u = users.get(decodeURIComponent(m[1])); if (!u) return json(res, 404, { message: "user does not exist" }); return json(res, 200, { login: m[1], is_admin: u.admin, active: u.active, prohibit_login: false }); } if (p.startsWith("/admin/") && !isAdmin(me)) return json(res, 403, { message: "must be a site administrator" }); if (req.method === "POST" && p === "/admin/users") { const b = await read(req); if (users.has(b.username)) return json(res, 422, { message: "user already exists" }); if (!b.password || b.password.length < 8) return json(res, 422, { message: "password is too short" }); users.set(b.username, { admin: b.admin === true, password: b.password, active: true }); return json(res, 201, { login: b.username, is_admin: b.admin === true }); } m = p.match(/^\/admin\/users\/([^/]+)$/); if (m && req.method === "PATCH") { const u = users.get(decodeURIComponent(m[1])); if (!u) return json(res, 404, { message: "user does not exist" }); const b = await read(req); if (b.admin !== undefined) u.admin = b.admin; if (b.active !== undefined) u.active = b.active; return json(res, 200, { login: m[1], is_admin: u.admin }); } if (p === "/orgs/novox/teams") { if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); if (req.method === "GET") return json(res, 200, teams.map((t) => ({ id: t.id, name: t.name }))); const b = await read(req); const t = { id: ++next, name: b.name, units: b, members: new Set() }; teams.push(t); if (faults.teamRace) return json(res, 422, { message: "team already exists" }); return json(res, 201, { id: t.id, name: t.name }); } m = p.match(/^\/teams\/(\d+)(?:\/members\/([^/]+))?$/); if (m) { if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); const t = teams.find((x) => x.id === Number(m![1])); if (!t) return json(res, 404, { message: "team does not exist" }); if (req.method === "PATCH") { t.units = await read(req); return json(res, 200, { id: t.id }); } if (req.method === "PUT" && m[2]) { t.members.add(decodeURIComponent(m[2])); return json(res, 204, null); } } if (req.method === "GET" && p === "/orgs/novox/repos") { if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" }); return json(res, 200, url.searchParams.get("page") === "1" ? repos.map((name) => ({ name })) : []); } // Repository administration: a site administrator, or a team member whose team administers the repository. m = p.match(/^\/repos\/novox\/([^/]+)\/branches\/(.+)$/); if (m && req.method === "GET") { if (m[2] === "main") return json(res, 200, { name: "main", protected: true, effective_branch_protection_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], user_can_push: false }); if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false }); return json(res, 404, { message: "branch does not exist" }); } // gitea's reqAdmin on the whole group: reading the rules is administration too. m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/); if (m) { const admins = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.permission === "admin"); if (!admins) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); if (req.method === "GET" && m[2] === undefined) { return json(res, 200, [...(mainRules.has(m[1]) ? [mainRules.get(m[1])] : []), ...(globRules.get(m[1]) ?? [])]); } if (req.method === "PATCH" && m[2] === "main") { const rule = mainRules.get(m[1]); if (!rule) return json(res, 404, { message: "branch protection does not exist" }); Object.assign(rule, await read(req)); return json(res, 200, rule); } if (req.method === "POST" && p.endsWith("/branch_protections")) { const b = await read(req); mainRules.set(m[1], b); return json(res, 201, b); } return json(res, 201, { rule_name: "main" }); } m = p.match(/^\/repos\/novox\/([^/]+)\/statuses\/([0-9a-f]+)$/); if (m && req.method === "POST") { const writes = isAdmin(me) || teams.some((t) => t.members.has(me) && t.units.units_map?.["repo.code"] === "write"); if (!writes) return json(res, 403, { message: "write on code required" }); return json(res, 201, { context: (await read(req)).context, creator: { login: me } }); } // Every repository of every owner, and who reads it (readall.ts): the site administrator reads all; the tools' account // a repository of novox through its team, a public one of a public owner, and one it is a collaborator of. if (req.method === "GET" && p === "/repos/search") { const page = Number(url.searchParams.get("page") ?? "1"); const seen = forgeRepos.filter((r) => permissionOf(me, r) !== "none"); return json(res, 200, { data: page === 1 ? seen.map(repoJSON) : [] }); } m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/); if (m) { const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2])); if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" }); if (req.method === "GET" && !m[3]) return json(res, 200, repoJSON(repo)); const user = decodeURIComponent(m[3] ?? ""); if (req.method === "GET" && m[4]) { if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" }); if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" }); return json(res, 200, { permission: permissionOf(user, repo) }); } if (req.method === "PUT" && !m[4]) { // gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds. if (!isAdmin(me) && me !== repo.owner) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); if (repoFaults.refuse.has(repo.full)) return json(res, 422, { message: "the forge would not have it" }); repo.collaborators.set(user, (await read(req))?.permission ?? "write"); return json(res, 204, null); } } return json(res, 404, { message: "no such route in the fake" }); }); return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules; faults: typeof faults; close(): Promise }>((resolve) => { server.listen(0, "127.0.0.1", () => { const { port } = server.address() as { port: number }; resolve({ url: `http://127.0.0.1:${port}`, users, tokens, teams, calls, faults, mainRules, close: () => new Promise((r) => server.close(() => r())) }); }); }); } const forge = await fakeForge(); after(() => forge.close()); async function environment(keepMainRules = false): Promise { const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-")); const passwordFile = join(dir, "admin.secret"); await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 }); const config = join(dir, "config.json"); await writeFile(config, JSON.stringify({ "keep-main-rules": keepMainRules })); return { MESH_GITEA_CONFIG_FILE: config, MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN, MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile, MESH_GITEA_STATE_DIR: join(dir, "state"), }; } /** A source as a fresh process builds it from the manifest's environment (fromEnv shares one per file). */ function fresh(env: NodeJS.ProcessEnv): GiteaClient { return GiteaClient.fromEnv(env); } test("the tools' token is minted for an account of their own, which is no administrator and writes through its team", async () => { const env = await environment(); await fresh(env).listRepos(); const account = forge.users.get(TOOLS_ACCOUNT); assert.ok(account, "the module made the tools' account"); assert.equal(account!.admin, false, "the tools' account is no administrator"); const held = forge.tokens.filter((t) => t.name === "mesh-tools"); assert.deepEqual(held.map((t) => t.user), [TOOLS_ACCOUNT], "the one token is the tools' account's, none is the administrator's"); assert.equal(await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8"), held[0].sha1 + "\n"); const team = forge.teams.find((t) => t.name === "mesh-tools"); assert.ok(team, "the team is made in the mesh's organisation"); assert.ok(team!.members.has(TOOLS_ACCOUNT)); assert.equal(team!.units.permission, "write"); assert.equal(team!.units.includes_all_repositories, true); assert.equal(team!.units.can_create_org_repo, false, "a repository's maker would administer it"); assert.deepEqual(team!.units.units_map, TOOLS_TEAM_UNITS.units_map); assert.ok(!Object.values(team!.units.units_map).includes("admin")); }); test("with the tools' token, the forge writes a status and refuses a protection edit — the forge itself, not a guard", async () => { const env = await environment(); const client = fresh(env); await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any); await assert.rejects( client.setBranchProtection("novox", "hq", "main", {}), /403/, ); }); test("what a base's protection asks is read from the branch, which the tools' account may read, not from the rules' list it may not", async () => { const env = await environment(); const client = fresh(env); await assert.rejects(client.branchProtections("novox", "hq"), /403/); const rule = await client.appliedProtection("novox", "hq", "main"); assert.deepEqual(rule?.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]); assert.equal(rule?.enable_status_check, true); assert.equal(rule?.rule_name, "main"); assert.equal(await client.appliedProtection("novox", "hq", "feat/unguarded"), null); assert.equal(await client.appliedProtection("novox", "hq", "no/such-branch"), null); }); test("the administrator's token from before goes: a kept admin token is minted again for the tools' account, and the forge drops the old one", async () => { const env = await environment(); // What the module kept before ADR 0365: a token of the administrator's own, by the same name, its scopes beside it. const old = { id: 900, name: "mesh-tools", sha1: "sha-admin-old", user: ADMIN, scopes: ["write:repository", "write:issue", "read:user"] }; forge.tokens.push(old); await mkdir(env.MESH_GITEA_STATE_DIR, { recursive: true }); await writeFile(join(env.MESH_GITEA_STATE_DIR, "token"), old.sha1 + "\n", { mode: 0o600 }); await writeFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "read:user,write:issue,write:repository\n", { mode: 0o600 }); await fresh(env).listRepos(); assert.ok(!forge.tokens.some((t) => t.user === ADMIN), "the administrator holds no tools' token any more"); const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); assert.notEqual(kept, old.sha1); assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT); assert.match(await readFile(join(env.MESH_GITEA_STATE_DIR, "token.scopes"), "utf8"), / for mesh-tools\n$/); }); test("a tools' account someone made an administrator is made a plain account again at the next mint", async () => { const env = await environment(); forge.users.get(TOOLS_ACCOUNT)!.admin = true; await fresh(env).listRepos(); assert.equal(forge.users.get(TOOLS_ACCOUNT)!.admin, false); }); test("the tools never act as the administrator: MESH_GITEA_TOOLS_USER naming it is refused", async () => { const env = await environment(); assert.throws(() => MintedToken.fromEnv(forge.url, { ...env, MESH_GITEA_TOOLS_USER: ADMIN }), /names the admin account/); }); test("the manifest hands the tools their account, and the Go forge bundle holds no administrator password", async () => { const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); const code = manifest.build.artifacts.find((a: any) => a.name === "code"); assert.equal(code.env.MESH_GITEA_TOOLS_USER, TOOLS_ACCOUNT); // The Go forge bundle reads the token the tools' account holds, never the administrator's password. const forgeBundle = manifest.build.artifacts.find((a: any) => a.name === "forge"); assert.ok(forgeBundle.env.MESH_GITEA_TOKEN_FILE); assert.equal(forgeBundle.env.MESH_GITEA_ADMIN_PASSWORD_FILE, undefined); }); // Review of issue 469: what reads the rules' list is moved to the branch, and nothing leaves the tools without a token. test("a repository check said as a warning on a head without merge-check.sh reads the base's protection from the branch", async () => { const client = fresh(await environment()); const forgeOf = { holdsFile: async () => false, appliedProtection: client.appliedProtection.bind(client) }; const c = { owner: "novox", repo: "hq", commit: "abc123def4567890abc123def4567890abc12345", "repo-check": { verdict: "warning" } } as any; // main requires mesh/repo-check: required, so a head that defines no check fails it for a person to see. assert.deepEqual(await repoCheckFacts(forgeOf, c, "main"), { defined: false, required: true }); // A base nothing guards: not required, so the head's repository check passes rather than fails. assert.deepEqual(await repoCheckFacts(forgeOf, c, "feat/unguarded"), { defined: false, required: false }); }); test("a mint that fails leaves the administrator's old token in place: the tools are never left without one", async () => { const env = await environment(); const old = { id: 901, name: "mesh-tools", sha1: "sha-admin-kept", user: ADMIN, scopes: ["read:user"] }; forge.tokens.push(old); forge.faults.mint = true; try { await assert.rejects(fresh(env).listRepos(), /500/); } finally { forge.faults.mint = false; } assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's token was deleted before a new one was kept"); forge.tokens.splice(forge.tokens.indexOf(old), 1); }); test("an old token that cannot be deleted after the mint is said, the new token kept, and deleted at the next start", async () => { const env = await environment(); const old = { id: 902, name: "mesh-tools", sha1: "sha-admin-stuck", user: ADMIN, scopes: ["read:user"] }; forge.tokens.push(old); forge.faults.deleteToken = true; try { await fresh(env).listRepos(); } finally { forge.faults.deleteToken = false; } const kept = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); assert.equal(forge.tokens.find((t) => t.sha1 === kept)?.user, TOOLS_ACCOUNT, "the account's token is kept and works"); assert.ok(forge.tokens.some((t) => t.sha1 === old.sha1)); // The next process reuses the kept token and retires the old one it could not before. const next = new MintedToken({ url: forge.url, admin: ADMIN, passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!, file: join(env.MESH_GITEA_STATE_DIR, "token"), account: TOOLS_ACCOUNT, log: () => {} }); assert.equal(await next.current(), kept); assert.ok(!forge.tokens.some((t) => t.sha1 === old.sha1), "the admin's old token was deleted at the next start"); }); test("a team another process made between the look and the create is found, not a failure", async () => { forge.teams.splice(0); forge.faults.teamRace = true; try { await fresh(await environment()).listRepos(); } finally { forge.faults.teamRace = false; } const team = forge.teams.find((t) => t.name === "mesh-tools"); assert.ok(team?.members.has(TOOLS_ACCOUNT)); }); test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges, no status (issue 948, ADR 0534 §1)", async () => { const env = await environment(true); const from = forge.calls.length; await fresh(env).listRepos(); const hq = forge.mainRules.get("hq"); assert.equal(hq.enable_push, false); assert.equal(hq.enable_merge_whitelist, true); assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]); assert.deepEqual(hq.status_check_contexts, [], "a rule found has its required statuses taken off (ADR 0534 §1)"); assert.equal(hq.enable_status_check, false); const catalog = forge.mainRules.get("mesh-catalog"); assert.ok(catalog, "a repository with no rule for main is given one"); assert.equal(catalog.rule_name, "main"); assert.equal(catalog.enable_push, false); assert.deepEqual(catalog.merge_whitelist_usernames, [TOOLS_ACCOUNT]); assert.ok(forge.calls.slice(from).filter((c) => /branch_protections/.test(c)).every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login touches a rule: the tools' account is no repository's administrator"); }); test("a glob rule covering main gives its approvals and override, not its statuses, when main is given a rule of its own name (issue 948, ADR 0534 §1)", async () => { const env = await environment(true); await fresh(env).listRepos(); const rule = forge.mainRules.get("globbed"); assert.ok(rule, "main is given its own rule"); assert.deepEqual(rule.status_check_contexts, []); assert.equal(rule.enable_status_check, false); assert.equal(rule.block_admin_merge_override, true); assert.equal(rule.required_approvals, 1); assert.equal(rule.enable_push, false); assert.deepEqual(rule.merge_whitelist_usernames, [TOOLS_ACCOUNT]); }); test("a start with the token kept still puts main's rules right: the module's start asks for it each time (issue 948, review round 1)", async () => { const src = await readFile(new URL("../index.ts", import.meta.url), "utf8"); assert.match(src, /ensureMainMerges\("novox"\)/, "index.ts calls it outside the token's mint"); const { GiteaAdmin } = await import("../dist/client.js"); const env = await environment(); forge.mainRules.delete("hq"); await GiteaAdmin.fromEnv(env).ensureMainMerges("novox"); assert.equal(forge.mainRules.get("hq").enable_push, false, "a rule gone is made again with no token minted"); }); test("a mint with keep-main-rules false (the default) writes no branch protection", async () => { const env = await environment(); const from = forge.calls.length; await fresh(env).listRepos(); const writes = forge.calls.slice(from).filter((c: string) => /branch_protections/.test(c) && !/^GET /.test(c) && !/^(GET|HEAD) /.test(c)); assert.deepEqual(writes, []); }); // The tools' account reads every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and // jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen. test("the start's pass gives the tools' account read, as a collaborator that reads only, on each repository it could not read, and on no other", async () => { const env = await environment(); await fresh(env).listRepos(); // the account and its team exist, as after any mint forgeRepos.splice(0); addRepo("novox/hq", true); addRepo("jschoubben/txt-game", false); addRepo("jschoubben/car-hunter", true); addRepo("jschoubben/snake", true); addRepo("shy/open", false, "limited"); addRepo("elsewhere/secret", true); const { GiteaAdmin } = await import("../dist/client.js"); const { ReadAll, readAllTools } = await import("../dist/readall.js"); const lines: string[] = []; const readAll = new ReadAll(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, (l: string) => lines.push(l)); const [unreadableTool, grantTool] = readAllTools(() => readAll); // What the tool says before: the four the account cannot read, each with why. const before = await unreadableTool.run({}) as any; assert.equal(before.checked, 6); assert.deepEqual(before.unreadable.map((u: any) => u.repository).sort(), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]); assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why, /a private repository of jschoubben, on which mesh-tools is no collaborator/); assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/); assert.deepEqual((await fresh(env).listAllRepos()).map((r) => r.full_name).sort(), ["jschoubben/txt-game", "novox/hq"]); // The account's own token cannot grant itself: the forge asks the repository's administration (the reason for the admin login). const own = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim(); const self = await fetch(`${forge.url}/api/v1/repos/jschoubben/car-hunter/collaborators/${TOOLS_ACCOUNT}`, { method: "PUT", headers: { Authorization: `token ${own}`, "Content-Type": "application/json" }, body: JSON.stringify({ permission: "read" }) }); assert.equal(self.status, 404, "a repository the account cannot read is not even there for it"); const from = forge.calls.length; const r = await readAll.atStart(); assert.deepEqual(r.granted.sort(), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]); assert.deepEqual(r.refused, []); const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT ")); assert.equal(puts.length, 4, "the repositories it read already were left alone"); assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants"); for (const repo of forgeRepos.filter((x) => x.collaborators.size)) assert.equal(repo.collaborators.get(TOOLS_ACCOUNT), "read", `${repo.full} reads only`); assert.match(lines.join("\n"), /6 looked at, 4 granted read now/); // After: the account's own token sees every repository, and the tool names none. assert.equal((await fresh(env).listAllRepos()).length, 6); assert.deepEqual(((await unreadableTool.run({})) as any).unreadable, []); // Granting again asks nothing of the forge but reads. const again = forge.calls.length; assert.deepEqual(((await grantTool.run({})) as any).granted, []); assert.ok(forge.calls.slice(again).every((c) => c.startsWith("GET "))); }); test("on the forge's events: a repository made is granted read at once, a known readable one asks nothing, a refusal is said, not thrown", async () => { const env = await environment(); await fresh(env).listRepos(); forgeRepos.splice(0); addRepo("jschoubben/txt-game", false); const { GiteaAdmin } = await import("../dist/client.js"); const { ReadAll, readAllTools } = await import("../dist/readall.js"); const lines: string[] = []; const readAll = new ReadAll(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, (l: string) => lines.push(l)); await readAll.atStart(); const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [ { name: "a repository made", event: "forge.repository.created", repo: "jschoubben/new-private", make: true, asks: true, reads: "read" }, { name: "a fork made", event: "forge.fork", repo: "someone/fork", make: true, asks: true, reads: "read" }, { name: "a push to a repository seen readable", event: "forge.push", repo: "jschoubben/txt-game", asks: false, reads: "read" }, { name: "a push to a private repository never seen", event: "forge.push", repo: "jschoubben/quiet", make: true, asks: true, reads: "read" }, { name: "a repository the forge refuses the grant on", event: "forge.repository.created", repo: "jschoubben/stuck", make: true, refuse: true, asks: true, reads: "none" }, { name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: true, asks: true, reads: "none" }, ]; for (const c of cases) { if (c.make) addRepo(c.repo, true); if (c.refuse) repoFaults.refuse.add(c.repo); if (c.unanswered) repoFaults.permission.add(c.repo); const from = forge.calls.length; const [owner, repo] = c.repo.split("/"); await readAll.onEvent({ name: c.event, body: { owner, repo } }); assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`); assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(TOOLS_ACCOUNT) ?? (c.make ? "none" : "read"), c.reads, c.name); } assert.match(lines.join("\n"), /could not be given read on jschoubben\/stuck — .*422/); const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any; assert.deepEqual(named.refused.map((x: any) => x.repository), ["jschoubben/stuck"]); await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/); assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/); // A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it. addRepo("jschoubben/after-mute", true); const pass = await readAll.grant(); assert.ok(pass.granted.includes("jschoubben/after-mute"), "the pass did not stop at the repository it could not judge"); assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why))); const listed = await readAllTools(() => readAll)[0].run({}) as any; assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown"); repoFaults.refuse.clear(); repoFaults.permission.clear(); });