diff --git a/modules/gitea/README.md b/modules/gitea/README.md index 4e51081c..e0410c81 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -275,6 +275,25 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:` a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes. `go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge. +## The tools' account reads every repository (the operator, 2026-10-26) + +The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox` +through its team. Any other repository it saw only when public, so a private repository of a user answered every tool +404 and was missing from every list. `readall.ts` makes the account a collaborator that **reads only** on each +repository it cannot read: one repository at a time, no organisation membership, the least the forge offers. + +- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is + asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and + `forge.fork` at once, and the first event of any repository this process has not seen readable (a repository made + private later sends no creation). Nothing polls for it. +- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a + repository's administration, which on another user's repository only the site administrator holds. It is used for + four calls alone: list every repository, read one, read the account's permission on one, add the account as a read + collaborator. The tools' own calls keep their token. +- **Tools:** `gitea_unreadable_repos` (every repository the account cannot read, the permission the forge gives it + there and why) and `gitea_grant_read` (the same grant by hand, now, on every repository or on those named; each grant + read back from the forge, a refusal answered in the forge's own words). + ## Files it does not know Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index db1faaa8..c62dc857 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -853,6 +853,25 @@ interface AdminResponse { readonly body: any; } +/** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */ +export interface ForgeRepository { + full_name: string; + owner: string; + name: string; + private: boolean; + archived: boolean; + /** The owner's own visibility: public, limited or private. A public repository of an owner not public is hidden + * from whoever is no member. */ + owner_visibility: string; +} + +function forgeRepository(r: any): ForgeRepository { + const owner = String(r?.owner?.login ?? r?.owner?.username ?? ""); + const name = String(r?.name ?? ""); + return { full_name: String(r?.full_name ?? `${owner}/${name}`), owner, name, private: r?.private === true, + archived: r?.archived === true, owner_visibility: String(r?.owner?.visibility ?? "public") }; +} + /** * The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only * by the provisioner (novox/hq ADR 0048/0076). @@ -970,6 +989,47 @@ export class GiteaAdmin { return repos; } + // ---- The tools' account reads every repository (readall.ts) ---- + + /** Every repository on the forge, of every user and organisation: the search as the site administrator, whom the + * forge answers every repository, private ones and those of private owners included. Only a read. */ + async everyRepository(): Promise { + const all: ForgeRepository[] = []; + for (let page = 1; page <= 200; page++) { + const res = await this.request(`/repos/search?limit=50&page=${page}`); + if (res.status !== 200) GiteaAdmin.fail("/repos/search", res); + const list: any[] = Array.isArray(res.body?.data) ? res.body.data : []; + all.push(...list.map(forgeRepository)); + if (list.length < 50) break; + } + return all; + } + + /** One repository as the site administrator sees it, or null when the forge holds none by that name. */ + async repository(owner: string, repo: string): Promise { + const res = await this.request(repoPath(owner, repo)); + if (res.status === 404) return null; + if (res.status !== 200) GiteaAdmin.fail(repoPath(owner, repo), res); + return forgeRepository(res.body); + } + + /** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */ + async repositoryPermission(owner: string, repo: string, user: string): Promise { + const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`; + const res = await this.request(path); + if (res.status !== 200) GiteaAdmin.fail(path, res); + return String(res.body?.permission ?? "none"); + } + + /** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for + * it, which on a repository of another user only the site administrator is. Idempotent: the forge answers 204 again. */ + async addReadCollaborator(owner: string, repo: string, user: string): Promise { + const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`; + const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission: "read" }) }); + if (res.status === 204 || res.status === 200) return; + GiteaAdmin.fail(path, res); + } + /** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent * create won the race) are all success. */ async ensureOrg(name: string): Promise { diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index d1e29d8d..4d2e39fb 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -60,6 +60,8 @@ import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, ke import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; import { bindInBackground } from "./bind.js"; import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js"; +import { ReadAll, readAllTools } from "./readall.js"; +import { TOOLS_ACCOUNT } from "./tools-account.js"; // Without a way to a token — configured, or mintable with the admin account (token.ts) — there is // nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints @@ -500,8 +502,14 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false); // Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852). await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE)); +// **The tools' account reads every repository** (readall.ts; the operator, 2026-10-26): granted with the site +// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on +// first use, so a module whose admin login is missing still serves the rest and the tools say why. +let readAllOnce: ReadAll | null = null; +const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)); + // gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729). -registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({ +registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...(gitea ? [commitStatusTool({ setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status), gates, }), mergeTool(gitea, { gates })] : [])]); @@ -543,7 +551,12 @@ async function listenForForge(client: GiteaClient): Promise { const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery), refused: (reason, from) => pace.refused(reason, from), publish: (event) => emit(event.name, event.body), - act: (event) => actOn(client, event) }); + act: async (event) => { + // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). + await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => + console.error(`[gitea] whether ${TOOLS_ACCOUNT} reads the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`)); + await actOn(client, event); + } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, () => resolve()); @@ -594,6 +607,18 @@ if (gitea) { // of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is // listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure. void listenForForge(client); + // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet + // is asked again, a minute apart, until one pass has run; after that only the forge's events grant. + const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).catch((err) => { + console.error(`[gitea] whether ${TOOLS_ACCOUNT} reads every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`); + setTimeout(readAllAtStart, 60_000); + }); + try { + readAll(); + readAllAtStart(); + } catch (err) { + console.error(`[gitea] ${TOOLS_ACCOUNT} is not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`); + } // Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is // minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2). // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. diff --git a/modules/gitea/readall.ts b/modules/gitea/readall.ts new file mode 100644 index 00000000..869d5762 --- /dev/null +++ b/modules/gitea/readall.ts @@ -0,0 +1,186 @@ +// The tools' account reads every repository on the forge (the operator, 2026-10-26: "the mesh's forge account should +// get read access to all repositories"). +// +// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's +// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private +// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every +// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on +// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership. +// +// - **at the start**, one pass over every repository of every user and organisation; +// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at +// once, and the first event of any repository this process has not yet seen readable, since the forge may not +// send a repository's creation for every owner, and a repository made private later sends no creation at all. +// Nothing polls for it. +// +// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration, +// which on another user's repository only the site administrator holds; the module holds that login for setting the +// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read +// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools' +// own calls, which keep their token. +// +// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator +// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and +// `gitea_grant_read` grants again now. + +import type { ForgeRepository } from "./client.js"; + +/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */ +export interface ReadAllForge { + everyRepository(): Promise; + repository(owner: string, repo: string): Promise; + repositoryPermission(owner: string, repo: string, user: string): Promise; + addReadCollaborator(owner: string, repo: string, user: string): Promise; +} + +/** A repository the account cannot read, and why. */ +export interface Unreadable { + repository: string; + permission: string; + private: boolean; + archived: boolean; + why: string; +} + +export interface Granted { + /** Repositories looked at. */ + checked: number; + /** Made readable now, by a read collaborator. */ + granted: string[]; + /** The forge refused the grant: each repository and the forge's own words. */ + refused: { repository: string; why: string }[]; +} + +const READS = new Set(["read", "write", "admin", "owner"]); + +/** Why the account cannot read a repository, from what the forge says of it. */ +export function whyUnreadable(repo: ForgeRepository, account: string): string { + const parts: string[] = []; + if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`); + else if (repo.owner_visibility !== "public") { + parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`); + } else parts.push(`the forge answers ${account} no read on it`); + if (repo.archived) parts.push("archived"); + return parts.join("; "); +} + +export class ReadAll { + /** Repositories this process has seen the account read, by full name: an event of one of them asks nothing more. */ + readonly readable = new Set(); + + constructor(private readonly forge: ReadAllForge, readonly account: string, + private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {} + + /** Every repository the account cannot read, and why, as the forge says it now. */ + async unreadable(): Promise<{ checked: number; unreadable: Unreadable[] }> { + const repos = await this.forge.everyRepository(); + const out: Unreadable[] = []; + for (const repo of repos) { + const u = await this.judge(repo); + if (u) out.push(u); + } + return { checked: repos.length, unreadable: out }; + } + + /** Grant read on every repository the account cannot read (or on those named, `owner/name`). */ + async grant(only?: string[]): Promise { + let repos: ForgeRepository[]; + if (only && only.length) { + repos = []; + for (const full of only) { + const [owner, name, ...rest] = full.split("/"); + if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`); + const repo = await this.forge.repository(owner, name); + if (!repo) throw new Error(`the forge holds no repository ${full}`); + repos.push(repo); + } + } else repos = await this.forge.everyRepository(); + const result: Granted = { checked: repos.length, granted: [], refused: [] }; + for (const repo of repos) { + const u = await this.judge(repo); + if (!u) continue; + try { + await this.forge.addReadCollaborator(repo.owner, repo.name, this.account); + } catch (err) { + result.refused.push({ repository: repo.full_name, why: err instanceof Error ? err.message : String(err) }); + continue; + } + // Read back: the forge's own word that the account reads it now, never the grant's answer alone. + const after = await this.forge.repositoryPermission(repo.owner, repo.name, this.account); + if (READS.has(after)) { + this.readable.add(repo.full_name); + result.granted.push(repo.full_name); + } else { + result.refused.push({ repository: repo.full_name, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` }); + } + } + return result; + } + + /** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */ + async atStart(): Promise { + const r = await this.grant(); + this.log(`${this.account} reads every repository: ${r.checked} looked at, ${r.granted.length} granted read now` + + `${r.granted.length ? ` (${r.granted.join(", ")})` : ""}${r.refused.length ? `, ${r.refused.length} refused: ` + + r.refused.map((x) => `${x.repository} — ${x.why}`).join("; ") : ""}`); + return r; + } + + /** A forge event: a repository made (or forked), or the first event of one not seen readable, is granted read now. */ + async onEvent(event: { name: string; body: unknown }): Promise { + const b = (event.body ?? {}) as { owner?: string; repo?: string }; + if (!b.owner || !b.repo) return; + const full = `${b.owner}/${b.repo}`; + if (event.name === "forge.repository.deleted") { + this.readable.delete(full); + return; + } + const made = event.name === "forge.repository.created" || event.name === "forge.fork"; + if (!made && this.readable.has(full)) return; + const r = await this.grant([full]); + if (r.granted.length) this.log(`${this.account} was given read on ${full}, on the forge's ${event.name}`); + for (const x of r.refused) this.log(`${this.account} could not be given read on ${x.repository} — ${x.why}`); + } + + private async judge(repo: ForgeRepository): Promise { + const permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account); + if (READS.has(permission)) { + this.readable.add(repo.full_name); + return null; + } + this.readable.delete(repo.full_name); + return { repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, this.account) }; + } +} + +type Tool = { name: string; description: string; input: Record; run: (args: Readonly>) => Promise }; + +/** The two tools: what the account cannot read and why, and the grant now. */ +export function readAllTools(readAll: () => ReadAll): Tool[] { + return [ + { + name: "gitea_unreadable_repos", + description: "Every repository on the forge the tools' own account (mesh-tools) cannot read, and why: the " + + "permission the forge gives it there, whether the repository is private or archived, and in words the reason " + + "(a private repository of a user with the account no collaborator, an owner not public). Empty when it reads " + + "every repository. Read with the site administrator's login, which alone sees every repository.", + input: {}, + run: async () => readAll().unreadable(), + }, + { + name: "gitea_grant_read", + description: "Give the tools' own account (mesh-tools) read on every repository it cannot read now, or on the " + + "repositories named: each becomes a collaborator that reads only, the least the forge offers. The module does " + + "this itself at its start and on the forge's event of a repository made; this is the same act by hand. Answers " + + "how many were looked at, which were granted (each read back from the forge) and which the forge refused, in its " + + "own words. Needs the site administrator's login, used for this grant alone.", + input: { + repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" }, + }, + run: async (args) => { + const named = Array.isArray(args.repositories) ? args.repositories.map(String) : undefined; + return readAll().grant(named); + }, + }, + ]; +} diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index 6d27aefd..f31139b6 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -26,6 +26,18 @@ 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() }; +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[] = []; @@ -61,6 +73,13 @@ function fakeForge() { 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"); @@ -176,7 +195,31 @@ function fakeForge() { 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: [] }); + // 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; @@ -402,3 +445,91 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio 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(); +});