mesh/merge-gate head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 48f0662fdea2 merged into trunk 5ce2d07c7f47 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The tools' token reached everything the administrator may do, so their boundary rested on their own guards. The gitea module now makes a plain account, mesh-tools, with the admin login, gives it write on code, issues and pull requests of every novox repository through one team, and mints the tools' token for it; the admin's old token goes only after the new one is kept. The Go forge bundle sends that token, never the admin login. The forge shows protection rules only to a repository administrator, so every check reads a branch's protection from the branch. mesh-admin stays a ready author, so marks made before the move keep counting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
18 KiB
TypeScript
333 lines
18 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> }
|
|
|
|
function fakeForge() {
|
|
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
|
|
const tokens: Token[] = [];
|
|
const teams: Team[] = [];
|
|
const calls: string[] = [];
|
|
// 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 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); }
|
|
}
|
|
// 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" });
|
|
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 } });
|
|
}
|
|
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
|
|
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[];
|
|
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,
|
|
close: () => new Promise((r) => server.close(() => r())) });
|
|
});
|
|
});
|
|
}
|
|
|
|
const forge = await fakeForge();
|
|
after(() => forge.close());
|
|
|
|
async function environment(): 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 });
|
|
return {
|
|
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", { statusChecks: ["mesh/merge-gate"] }),
|
|
/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 merge-ready authors name it rather than the administrator", 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);
|
|
const authors = String(manifest.settings["ready-authors"].default).split(/\s+/);
|
|
assert.ok(authors.includes(TOOLS_ACCOUNT));
|
|
// Kept for now (review of issue 469): a pull request the admin account marked ready before the tools acted as
|
|
// their own keeps counting as ready after the delivery.
|
|
assert.ok(authors.includes(ADMIN), "marks made by the admin account before ADR 0365 keep counting");
|
|
// 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));
|
|
});
|