gitea: the tools' token carries write:admin, and a kept token is re-minted when it lacks a scope
The forge's own users are the mesh's to settle — making the builder's login a site admin so private repos build (hq 229) — and the tools' token had no write:admin. A token kept from before a scope was added lacks it, so the client now treats the forge's 403 "required scope" like a 401: the source re-mints by name with the whole list and retries once. The fake forge in the tests learns /repos/search, which the client has used since 2026-09-28 and which had left 9 of the 11 token tests failing on main.
This commit is contained in:
@@ -29,6 +29,7 @@ interface Forge {
|
||||
mints: number;
|
||||
lastScopes: string[] | null;
|
||||
tokens: Map<string, string>;
|
||||
scopesOf: Map<string, string[]>;
|
||||
admins: Map<string, string>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -85,6 +86,20 @@ function fakeForge(): Promise<Forge> {
|
||||
}
|
||||
return json(res, 405, { message: "method not allowed" });
|
||||
}
|
||||
if (url.pathname === "/api/v1/repos/search") {
|
||||
// The client lists through the search endpoint since 2026-09-28 (the forge's whole view);
|
||||
// it sits under `repository`, which write:repository covers.
|
||||
const h = req.headers.authorization ?? "";
|
||||
const value = h.startsWith("token ") ? h.slice(6) : "";
|
||||
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
|
||||
if (!covers(forge.scopesOf.get(value) ?? [], "read:repository")) {
|
||||
return json(res, 403, { message: `token does not have at least one of required scope(s), required=[read:repository]` });
|
||||
}
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
data: [{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }],
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/v1/user/repos") {
|
||||
const h = req.headers.authorization ?? "";
|
||||
const value = h.startsWith("token ") ? h.slice(6) : "";
|
||||
@@ -101,6 +116,21 @@ function fakeForge(): Promise<Forge> {
|
||||
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
|
||||
]);
|
||||
}
|
||||
const adminUser = url.pathname.match(/^\/api\/v1\/admin\/users\/([^/]+)$/);
|
||||
if (adminUser && req.method === "PATCH") {
|
||||
const h = req.headers.authorization ?? "";
|
||||
const value = h.startsWith("token ") ? h.slice(6) : "";
|
||||
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" });
|
||||
if (!covers(forge.scopesOf.get(value) ?? [], "write:admin")) {
|
||||
return json(res, 403, {
|
||||
message: `token does not have at least one of required scope(s), required=[write:admin]`,
|
||||
});
|
||||
}
|
||||
const login = decodeURIComponent(adminUser[1]);
|
||||
if (login === "untouchable") return json(res, 403, { message: "user untouchable may not be edited" });
|
||||
const patch = await body(req);
|
||||
return json(res, 200, { login, is_admin: patch?.admin === true });
|
||||
}
|
||||
return json(res, 404, { message: "no such route in the fake" });
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
@@ -111,6 +141,7 @@ function fakeForge(): Promise<Forge> {
|
||||
get mints() { return forge.mints; },
|
||||
get lastScopes() { return forge.lastScopes; },
|
||||
tokens: forge.tokens,
|
||||
scopesOf: forge.scopesOf,
|
||||
admins: forge.admins,
|
||||
close: () => new Promise((r) => server.close(() => r())),
|
||||
});
|
||||
@@ -152,14 +183,14 @@ function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
|
||||
const forge = await fakeForge();
|
||||
after(() => forge.close());
|
||||
|
||||
test("first start: mints with the admin account, keeps the token at 0600, asks for two scopes only", async () => {
|
||||
test("first start: mints with the admin account, keeps the token at 0600, asks for the tools' scopes only", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
|
||||
const repos = await minted(env, logs).listRepos();
|
||||
|
||||
assert.equal(repos[0]?.full_name, "novox/hq");
|
||||
assert.equal(forge.mints, 1);
|
||||
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
|
||||
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]);
|
||||
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
|
||||
const token = forge.tokens.get("mesh-tools")!;
|
||||
assert.equal(await readFile(file, "utf8"), token + "\n");
|
||||
@@ -197,6 +228,34 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o
|
||||
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
|
||||
});
|
||||
|
||||
test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
const client = minted(env, logs);
|
||||
await client.listRepos();
|
||||
const before = forge.mints;
|
||||
const old = forge.tokens.get("mesh-tools")!;
|
||||
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build
|
||||
|
||||
const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ admin: true }),
|
||||
});
|
||||
|
||||
assert.equal(user.is_admin, true);
|
||||
assert.equal(forge.mints, before + 1);
|
||||
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
|
||||
assert.notEqual(forge.tokens.get("mesh-tools"), old);
|
||||
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
|
||||
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
|
||||
// A 403 that is not about scopes is the forge's answer, not a reason to mint.
|
||||
const again = forge.mints;
|
||||
await assert.rejects(
|
||||
client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }),
|
||||
/403 .*untouchable/,
|
||||
);
|
||||
assert.equal(forge.mints, again);
|
||||
});
|
||||
|
||||
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
|
||||
const { env, file, logs } = await delivered(forge);
|
||||
await minted(env, logs).listRepos();
|
||||
|
||||
Reference in New Issue
Block a user