// Issue 1563: no tool read a forge team's members and rights, so the build keys' read-only user (nox-builder, team // builders, ADR 0466 ยง3) could not be checked live. The tools' token answers /orgs/novox/teams 403; the read goes with // the administrator's login, GET only. // // The answers below keep the shape of the forge's own API (gitea's swagger, "Team": id, name, description, // organization, includes_all_repositories, permission, units, units_map, can_create_org_repo; "User": id, login, ...; // repos/search: {ok, data:[Repository]}; Repository.permissions {admin, push, pull}). The live answer is read once the // tool is delivered (issue 1563 AC2). import { test, after } from "node:test"; import assert from "node:assert/strict"; import { createServer } from "node:http"; import { GiteaAdmin, GiteaClient } from "../dist/client.js"; import { ConfiguredToken } from "../dist/token.js"; import { getGiteaTools } from "../dist/tools/index.js"; const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64"); const org = { id: 2, username: "novox", name: "novox" }; const team = (id: number, name: string, over: Record) => ({ id, name, description: "", organization: org, includes_all_repositories: true, permission: "read", can_create_org_repo: false, units: ["repo.code"], units_map: { "repo.code": "read" }, ...over, }); const teams = [ team(5, "builders", { description: "the build modules' fetch" }), team(3, "mesh-tools", { permission: "write", units: ["repo.code", "repo.issues", "repo.pulls"], units_map: { "repo.code": "write", "repo.issues": "write", "repo.pulls": "write" } }), ]; const members: Record = { "5": ["nox-builder"], "3": ["mesh-tools"] }; const user = (login: string) => ({ id: 23, login, username: login, email: `${login}@noreply.localhost` }); const repo = (owner: string, name: string) => ({ full_name: `${owner}/${name}`, name, owner: { login: owner }, private: true, archived: false }); const repos = [repo("novox", "mesh-catalog"), repo("jschoubben", "car-hunter")]; const seen: string[] = []; const server = createServer((req, res) => { const url = new URL(req.url ?? "", "http://x"); const path = url.pathname.replace(/^\/api\/v1/, ""); const as = req.headers.authorization === adminBasic ? "admin" : "tools"; seen.push(`${req.method} ${path} as ${as}`); const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); }; if (as !== "admin" && path.startsWith("/orgs/")) return send(403, { message: "token does not have at least one of required scope(s): [read:organization]" }); if (path === "/orgs/novox/teams") return send(200, teams); const m = path.match(/^\/teams\/(\d+)\/members$/); if (m) return send(200, (members[m[1]] ?? []).map(user)); const u = path.match(/^\/users\/([^/]+)$/); if (u) return send(u[1] === "nobody" ? 404 : 200, u[1] === "nobody" ? { message: "not found" } : user(u[1])); if (path === "/repos/search") return send(200, { ok: true, data: repos }); const r = path.match(/^\/repos\/([^/]+)\/([^/]+)$/); if (r) { const sudo = url.searchParams.get("sudo"); const found = repos.find((x) => x.full_name === `${r[1]}/${r[2]}`); // nox-builder reads the organisation's repositories through its team and the other one not at all; mesh-tools writes. if (!found || (sudo === "nox-builder" && r[1] !== "novox")) return send(404, { message: "not found" }); const push = sudo === "mesh-tools"; return send(200, { ...found, permissions: { admin: false, push, pull: true } }); } send(404, { message: "not found" }); }); await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); after(() => new Promise((r) => server.close(() => r()))); const { port } = server.address() as { port: number }; const url = `http://127.0.0.1:${port}`; const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret")); const run = (args: Record) => tools.find((t) => t.name === "gitea_team_rights")!.run(args) as Promise; test("gitea_team_rights answers every team of the organisation with its members and units, read as the administrator", async () => { const out = await run({}); assert.equal(out.organisation, "novox"); assert.deepEqual(out.teams.map((t: any) => t.team), ["builders", "mesh-tools"]); const builders = out.teams[0]; assert.deepEqual(builders.members, ["nox-builder"]); assert.deepEqual(builders.units, { "repo.code": "read" }); assert.equal(builders.includes_all_repositories, true); assert.equal(builders.read_only, true); assert.equal(out.teams[1].read_only, false); assert.ok(seen.every((s) => s.startsWith("GET ") && s.endsWith("as admin")), `only reads, as the administrator: ${seen.join("; ")}`); }); test("gitea_team_rights answers one team by name, and refuses an unknown one naming the teams there are", async () => { const out = await run({ team: "builders" }); assert.deepEqual(out.teams.map((t: any) => t.team), ["builders"]); await assert.rejects(run({ team: "nobody" }), /no team "nobody" in novox: it holds builders, mesh-tools/); }); test("gitea_team_rights answers a user's teams and effective right on each repository: nox-builder writes nowhere", async () => { const out = await run({ user: "nox-builder" }); assert.deepEqual(out.user.teams, ["builders"]); assert.deepEqual(out.user.repositories, { read: 1, none: 1 }); assert.deepEqual(out.user.writes, []); const tools2 = await run({ user: "mesh-tools", list: true }); assert.deepEqual(tools2.user.writes, [{ repository: "novox/mesh-catalog", permission: "write" }, { repository: "jschoubben/car-hunter", permission: "write" }]); assert.equal(tools2.user.every.length, 2); }); test("the tools' own token is refused the teams, which is why the administrator's login reads them", async () => { const res = await fetch(`${url}/api/v1/orgs/novox/teams`, { headers: { Authorization: "token tools-token" } }); assert.equal(res.status, 403); }); test("a user the forge does not know is refused, not answered as writing nowhere", async () => { await assert.rejects(run({ user: "nobody" }), /no forge user nobody/); });