// 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 } function fakeForge() { const users = new Map([[ADMIN, { admin: true, password: PASSWORD, active: true }]]); const tokens: Token[] = []; const teams: Team[] = []; const calls: string[] = []; // 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 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); } } // 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" }); 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 } }); } if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] }); 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[]; 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, close: () => new Promise((r) => server.close(() => r())) }); }); }); } const forge = await fakeForge(); after(() => forge.close()); async function environment(): Promise { const dir = await mkdtemp(join(tmpdir(), "gitea-tools-account-")); const passwordFile = join(dir, "admin.secret"); await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 }); return { 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", { statusChecks: ["mesh/merge-gate"] }), /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 merge-ready authors name it rather than the administrator", 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); const authors = String(manifest.settings["ready-authors"].default).split(/\s+/); assert.ok(authors.includes(TOOLS_ACCOUNT)); // Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as // their own keeps counting as ready after the delivery. assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting"); // 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)); });