Files
mesh-catalog/modules/gitea/test/tools-account.test.ts
T

536 lines
31 KiB
TypeScript

// 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<string> }
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
const forgeRepos: ForgeRepo[] = [];
const repoFaults = { refuse: new Set<string>() };
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<string, string>() };
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<string, Account>([[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<string, any[]>([["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<string, any>([["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<any> =>
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<string>() };
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 (!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<void> }>((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<NodeJS.ProcessEnv & { MESH_GITEA_STATE_DIR: string }> {
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; 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" },
];
for (const c of cases) {
if (c.make) addRepo(c.repo, true);
if (c.refuse) repoFaults.refuse.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/);
repoFaults.refuse.clear();
});