Files
mesh-catalog/modules/gitea/test/token.test.ts
T
jschoubben ed094031fd The forge mints its own API token with the admin account the vault delivers
The runtime beside the forge served 0 tools: GiteaClient.fromEnv required a token
(settings or MESH_GITEA_TOKEN), nobody had one to give — the mesh raised the forge —
and putting one in settings would store a secret in plaintext in the inventory. So
the fifteen tools registered nothing and the watcher logged "not watching".

What the mesh does deliver is the admin account: a login the manifest names and a
password the vault minted and the host unsealed into a file (ADR 0086). That is
enough to mint a token, so the module does (hq issue 100, the forge's tools):
POST /users/{admin}/tokens over basic auth, scoped to write:repository and
write:issue — the least the tools and the repo watcher need — kept at 0600 in the
module's own state (/var/lib/mesh/gitea/state, a new directory resource the runtime
mounts writable), read back on the next start, and minted afresh when the forge
answers 401 to it or the kept file is gone. A forge whose data came from the
predecessor has no mesh-admin: that is reported in plain words on every poll until
it clears, once per reason, not crash-looped. A configured token still wins and is
never minted over.

The mint happens on the first call, not at registration: a contributor is
synchronous, and a forge not yet answering must not keep the runtime from serving.
One source per kept file in a process, or the watcher and the tools would each
renew on a 401 and drop the other's token by name.
2026-09-23 23:48:36 +02:00

301 lines
12 KiB
TypeScript

// What holds the module to its own token (token.ts; hq issue 100, the forge's tools): minted with
// the delivered admin account on the first call and kept at 0600, reused on the next start, minted
// afresh when the forge rejects it or the kept file is gone, and a refused admin account reported in
// plain words rather than crash-looped. A configured token still wins. And the tools register once
// there is a way to a token at all — before one exists.
//
// The forge is a fake: the four routes the module touches, with the same status codes gitea gives.
// Run against the compiled module (npm test builds first), the way the runtime loads it.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import "../dist/tools/index.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
// ---- A fake forge: what the module sends, and what gitea would answer. ----
interface Forge {
url: string;
mints: number;
lastScopes: string[] | null;
tokens: Map<string, string>;
admins: Map<string, string>;
close(): Promise<void>;
}
function fakeForge(): Promise<Forge> {
const forge = {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
admins: new Map([[ADMIN, PASSWORD]]),
};
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const body = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const basic = (req: IncomingMessage): string | null => {
const h = req.headers.authorization ?? "";
if (!h.startsWith("Basic ")) return null;
const [user, pass] = Buffer.from(h.slice(6), "base64").toString().split(":");
return forge.admins.get(user) === pass ? user : null;
};
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const tokens = url.pathname.match(/^\/api\/v1\/users\/([^/]+)\/tokens(?:\/([^/]+))?$/);
if (tokens) {
const user = basic(req);
if (user === null || user !== decodeURIComponent(tokens[1])) return json(res, 401, { message: "auth required" });
if (req.method === "POST") {
const { name, scopes } = await body(req);
if (forge.tokens.has(name)) return json(res, 400, { message: "token name has already been used" });
forge.mints++;
forge.lastScopes = scopes;
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1);
return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
}
if (req.method === "DELETE" && tokens[2]) {
const name = decodeURIComponent(tokens[2]);
if (!forge.tokens.has(name)) return json(res, 404, { message: "token not found" });
forge.tokens.delete(name);
return json(res, 204, null);
}
return json(res, 405, { message: "method not allowed" });
}
if (url.pathname === "/api/v1/user/repos") {
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" });
return json(res, 200, [
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" },
]);
}
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${port}`,
get mints() { return forge.mints; },
get lastScopes() { return forge.lastScopes; },
tokens: forge.tokens,
admins: forge.admins,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
// ---- What the runtime's environment gives the module. ----
async function delivered(forge: Forge): Promise<{ env: NodeJS.ProcessEnv; file: string; logs: string[] }> {
const dir = await mkdtemp(join(tmpdir(), "gitea-"));
const passwordFile = join(dir, "admin.secret");
await writeFile(passwordFile, PASSWORD + "\n", { mode: 0o600 });
const state = join(dir, "state");
return {
env: {
MESH_GITEA_URL: forge.url,
MESH_GITEA_ADMIN_USER: ADMIN,
MESH_GITEA_ADMIN_PASSWORD_FILE: passwordFile,
MESH_GITEA_STATE_DIR: state,
},
file: join(state, "token"),
logs: [],
};
}
/** A client as a fresh process would build it: a new source over the kept file, its log captured. */
function minted(env: NodeJS.ProcessEnv, logs: string[]): GiteaClient {
const source = new MintedToken({
url: env.MESH_GITEA_URL!,
admin: env.MESH_GITEA_ADMIN_USER!,
passwordFile: env.MESH_GITEA_ADMIN_PASSWORD_FILE!,
file: join(env.MESH_GITEA_STATE_DIR!, "token"),
log: (l) => logs.push(l),
});
return new GiteaClient(env.MESH_GITEA_URL!, source);
}
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 () => {
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"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
assert.ok(logs.every((l) => !l.includes(token) && !l.includes(PASSWORD)), logs.join("\n"));
});
test("second start: reuses the kept token, mints nothing", async () => {
const { env, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const before = forge.mints;
const again: string[] = [];
await minted(env, again).listRepos();
assert.equal(forge.mints, before);
assert.ok(again.some((l) => l.startsWith("reusing the token kept at")), again.join("\n"));
assert.ok(again.every((l) => !l.includes(forge.tokens.get("mesh-tools")!)), again.join("\n"));
});
test("the forge rejects the kept token (its data was restored): minted afresh, once, 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;
forge.tokens.clear(); // the forge no longer knows any token — a restore from the predecessor
const repos = await client.listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
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"));
});
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();
const before = forge.mints;
await rm(file);
const repos = await minted(env, logs).listRepos();
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.equal([...forge.tokens.keys()].filter((n) => n === "mesh-tools").length, 1);
assert.ok(logs.some((l) => l.includes('already holds a token named "mesh-tools"')), logs.join("\n"));
});
test("concurrent first calls share one mint", async () => {
const { env, logs } = await delivered(forge);
const client = minted(env, logs);
const before = forge.mints;
await Promise.all([client.listRepos(), client.listRepos(), client.listRepos()]);
assert.equal(forge.mints, before + 1);
});
test("the admin account is refused: said plainly, nothing kept, and the next call fails the same way rather than crashing", async () => {
const { env, file, logs } = await delivered(forge);
forge.admins.delete(ADMIN); // the forge's data came from a predecessor; mesh-admin was never created there
try {
const client = minted(env, logs);
const before = forge.mints;
await assert.rejects(client.listRepos(), (err: unknown) => {
assert.ok(err instanceof AdminRefused, String(err));
assert.match(err.message, /refused the admin account "mesh-admin" \(401\)/);
assert.match(err.message, /admin-bootstrap step creates it/);
assert.match(err.message, /came from a predecessor/);
assert.ok(!err.message.includes(PASSWORD));
return true;
});
await assert.rejects(client.listRepos(), AdminRefused);
assert.equal(forge.mints, before);
await assert.rejects(stat(file), /ENOENT/);
// The account appears (the operator created it): the very next call mints and works.
forge.admins.set(ADMIN, PASSWORD);
assert.equal((await client.listRepos()).length, 1);
assert.equal(forge.mints, before + 1);
} finally {
forge.admins.set(ADMIN, PASSWORD);
}
});
test("one process shares one source per kept file — the watcher and the tools never renew against each other", async () => {
const { env } = await delivered(forge);
assert.equal(MintedToken.fromEnv(env.MESH_GITEA_URL!, env), MintedToken.fromEnv(env.MESH_GITEA_URL!, env));
});
test("a second process finds the token the first renewed, and reuses it instead of minting over it", async () => {
const { env, logs } = await delivered(forge);
const first = minted(env, logs);
const second = minted(env, logs);
await first.listRepos();
await second.listRepos(); // both hold the same kept token
const before = forge.mints;
forge.tokens.clear();
await first.listRepos(); // renews: one mint
await second.listRepos(); // rejected too — but the kept file already carries the renewed one
assert.equal(forge.mints, before + 1);
assert.ok(logs.some((l) => l.includes("is newer — reusing it")), logs.join("\n"));
});
test("a configured token wins, and is reported rather than minted over when the forge rejects it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const client = GiteaClient.fromEnv({ ...env, MESH_GITEA_TOKEN: "one-somebody-pasted-in" });
await assert.rejects(client.listRepos(), /rejected the configured Gitea token \(401\)/);
assert.equal(forge.mints, before);
});
test("nothing to mint with and no token: the client says what is missing", async () => {
assert.throws(
() => GiteaClient.fromEnv({ MESH_GITEA_URL: forge.url, MESH_GITEA_ADMIN_USER: ADMIN }),
/set MESH_GITEA_TOKEN, or MESH_GITEA_ADMIN_PASSWORD_FILE, MESH_GITEA_STATE_DIR/,
);
});
test("the tools register once there is a way to a token, and the first call mints it", async () => {
const { env } = await delivered(forge);
const before = forge.mints;
const withAdmin = collectTools(env).find((c) => c.module === "gitea")!;
const withNothing = collectTools({}).find((c) => c.module === "gitea")!;
assert.equal(withNothing.tools.length, 0);
assert.deepEqual(
withAdmin.tools.map((t) => t.name),
[
"gitea_list_repos", "gitea_create_repo", "gitea_delete_repo",
"gitea_list_issues", "gitea_get_issue", "gitea_create_issue", "gitea_close_issue", "gitea_add_comment",
"gitea_list_pull_requests", "gitea_get_pull_request", "gitea_create_pull_request", "gitea_merge_pull_request",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
);
assert.equal(forge.mints, before, "registering must not mint — the forge may not be up yet");
const result = (await withAdmin.tools.find((t) => t.name === "gitea_list_repos")!.run({})) as { repos: unknown[] };
assert.equal(result.repos.length, 1);
assert.equal(forge.mints, before + 1);
});