Files
mesh-catalog/modules/gitea/test/team-rights.test.ts
T
jschoubben 36d5993323
mesh/delivery merged: source.main moved to novox/mesh-catalog@bf94af0a820f
mesh/check checking 36d59933239e on bf94af0a820f
gitea_team_rights: refuse a forge user that does not exist; keep ForgeRepository's doc comment on it (issue 1563)
2026-10-27 21:54:51 +01:00

103 lines
6.2 KiB
TypeScript

// 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<string, unknown>) => ({
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<string, string[]> = { "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<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((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<string, unknown>) => tools.find((t) => t.name === "gitea_team_rights")!.run(args) as Promise<any>;
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/);
});