diff --git a/modules/gitea/README.md b/modules/gitea/README.md index e0410c81..853e59a1 100644 --- a/modules/gitea/README.md +++ b/modules/gitea/README.md @@ -284,8 +284,12 @@ repository it cannot read: one repository at a time, no organisation membership, - **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. + `forge.fork` at once, and the first event of any repository this process has not seen readable (the forge may not + send a creation for every owner). Nothing polls for it. +- **Not caught:** a repository made private after it was seen readable. The forge sends no event for a change of + visibility, and the start's pass already saw it readable, so its events ask nothing. It is granted at the module's + next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. A repository whose permission + the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on. - **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 diff --git a/modules/gitea/readall.ts b/modules/gitea/readall.ts index 869d5762..cd9d411d 100644 --- a/modules/gitea/readall.ts +++ b/modules/gitea/readall.ts @@ -10,8 +10,11 @@ // - **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. +// send a repository's creation for every owner. Nothing polls for it. +// +// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public +// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at +// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. // // **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 @@ -76,8 +79,14 @@ export class ReadAll { 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); + try { + const u = await this.judge(repo); + if (u) out.push(u); + } catch (err) { + // A permission the forge would not answer is said for that repository; the rest are still looked at. + out.push({ repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived, + why: `the forge did not answer ${this.account}'s permission on it — ${err instanceof Error ? err.message : String(err)}` }); + } } return { checked: repos.length, unreadable: out }; } @@ -97,7 +106,14 @@ export class ReadAll { } 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); + let u: Unreadable | null; + try { + u = await this.judge(repo); + } catch (err) { + // One repository whose permission cannot be read is a refusal of its own, never the end of the pass. + result.refused.push({ repository: repo.full_name, why: `its permission could not be read — ${err instanceof Error ? err.message : String(err)}` }); + continue; + } if (!u) continue; try { await this.forge.addReadCollaborator(repo.owner, repo.name, this.account); @@ -106,7 +122,13 @@ export class ReadAll { 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); + let after: string; + try { + after = await this.forge.repositoryPermission(repo.owner, repo.name, this.account); + } catch (err) { + result.refused.push({ repository: repo.full_name, why: `granted, but its permission could not be read back — ${err instanceof Error ? err.message : String(err)}` }); + continue; + } if (READS.has(after)) { this.readable.add(repo.full_name); result.granted.push(repo.full_name); @@ -136,6 +158,8 @@ export class ReadAll { return; } const made = event.name === "forge.repository.created" || event.name === "forge.fork"; + // Seen readable once, its events ask nothing: so a repository made private after that is not caught here (the + // forge says no change of visibility), only at the next start or by gitea_grant_read. 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}`); diff --git a/modules/gitea/test/tools-account.test.ts b/modules/gitea/test/tools-account.test.ts index f31139b6..2e4c1083 100644 --- a/modules/gitea/test/tools-account.test.ts +++ b/modules/gitea/test/tools-account.test.ts @@ -28,7 +28,7 @@ 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() }; +const repoFaults = { refuse: new Set(), permission: 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() }; @@ -209,6 +209,7 @@ function fakeForge() { if (req.method === "GET" && !m[3]) return json(res, 200, repoJSON(repo)); const user = decodeURIComponent(m[3] ?? ""); if (req.method === "GET" && m[4]) { + if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" }); if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" }); return json(res, 200, { permission: permissionOf(user, repo) }); } @@ -511,16 +512,18 @@ test("on the forge's events: a repository made is granted read at once, a known 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 }[] = [ + const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: 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" }, + { name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: 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); + if (c.unanswered) repoFaults.permission.add(c.repo); const from = forge.calls.length; const [owner, repo] = c.repo.split("/"); await readAll.onEvent({ name: c.event, body: { owner, repo } }); @@ -531,5 +534,14 @@ test("on the forge's events: a repository made is granted read at once, a known 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/); + assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/); + // A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it. + addRepo("jschoubben/after-mute", true); + const pass = await readAll.grant(); + assert.ok(pass.granted.includes("jschoubben/after-mute"), "the pass did not stop at the repository it could not judge"); + assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why))); + const listed = await readAllTools(() => readAll)[0].run({}) as any; + assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown"); repoFaults.refuse.clear(); + repoFaults.permission.clear(); });