The review finishes before the push and its record is a Reviewed-by line in the description, so the mark repeated what the push meant. mesh-delivery asks the operator when a pull request's checks pass, keeps an ask through a trunk move by asking the check again at once, and hands the merge tool the approved heads, which replace the forge review of each sibling.
393 lines
22 KiB
TypeScript
393 lines
22 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[] = [];
|
|
// The organisation's repositories, and the rule each has for main, which the administrator's setup puts right
|
|
// (issue 948, ADR 0398 §2).
|
|
const repos = ["hq", "mesh-catalog", "globbed"];
|
|
const globRules = new Map<string, any[]>([["globbed", [{ rule_name: "m*", enable_status_check: true,
|
|
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true, required_approvals: 1 }]]]);
|
|
const mainRules = new Map<string, any>([["hq", { rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate"] }]]);
|
|
// 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); }
|
|
}
|
|
if (req.method === "GET" && p === "/orgs/novox/repos") {
|
|
if (!isAdmin(me)) return json(res, 403, { message: "must be an owner of the organisation" });
|
|
return json(res, 200, url.searchParams.get("page") === "1" ? repos.map((name) => ({ name })) : []);
|
|
}
|
|
// 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" });
|
|
if (req.method === "GET" && m[2] === undefined) {
|
|
return json(res, 200, [...(mainRules.has(m[1]) ? [mainRules.get(m[1])] : []), ...(globRules.get(m[1]) ?? [])]);
|
|
}
|
|
if (req.method === "PATCH" && m[2] === "main") {
|
|
const rule = mainRules.get(m[1]);
|
|
if (!rule) return json(res, 404, { message: "branch protection does not exist" });
|
|
Object.assign(rule, await read(req));
|
|
return json(res, 200, rule);
|
|
}
|
|
if (req.method === "POST" && p.endsWith("/branch_protections")) {
|
|
const b = await read(req);
|
|
mainRules.set(m[1], b);
|
|
return json(res, 201, b);
|
|
}
|
|
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[]; mainRules: typeof mainRules;
|
|
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, mainRules,
|
|
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 Go forge bundle holds no administrator password", 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);
|
|
// 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));
|
|
});
|
|
|
|
test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges (issue 948)", async () => {
|
|
const env = await environment();
|
|
const from = forge.calls.length;
|
|
await fresh(env).listRepos();
|
|
const hq = forge.mainRules.get("hq");
|
|
assert.equal(hq.enable_push, false);
|
|
assert.equal(hq.enable_merge_whitelist, true);
|
|
assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
|
|
assert.deepEqual(hq.status_check_contexts, ["mesh/merge-gate"], "a rule found keeps the statuses it requires");
|
|
const catalog = forge.mainRules.get("mesh-catalog");
|
|
assert.ok(catalog, "a repository with no rule for main is given one");
|
|
assert.equal(catalog.rule_name, "main");
|
|
assert.equal(catalog.enable_push, false);
|
|
assert.deepEqual(catalog.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
|
|
assert.ok(forge.calls.slice(from).filter((c) => /branch_protections/.test(c)).every((c) => c.endsWith(` as ${ADMIN}`)),
|
|
"only the administrator's login touches a rule: the tools' account is no repository's administrator");
|
|
});
|
|
|
|
test("a glob rule covering main keeps its statuses when main is given a rule of its own name (issue 948, review round 1)", async () => {
|
|
const env = await environment();
|
|
await fresh(env).listRepos();
|
|
const rule = forge.mainRules.get("globbed");
|
|
assert.ok(rule, "main is given its own rule");
|
|
assert.deepEqual(rule.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
|
|
assert.equal(rule.enable_status_check, true);
|
|
assert.equal(rule.block_admin_merge_override, true);
|
|
assert.equal(rule.required_approvals, 1);
|
|
assert.equal(rule.enable_push, false);
|
|
assert.deepEqual(rule.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
|
|
});
|
|
|
|
test("a start with the token kept still puts main's rules right: the module's start asks for it each time (issue 948, review round 1)", async () => {
|
|
const src = await readFile(new URL("../index.ts", import.meta.url), "utf8");
|
|
assert.match(src, /ensureMainMerges\("novox"\)/, "index.ts calls it outside the token's mint");
|
|
const { GiteaAdmin } = await import("../dist/client.js");
|
|
const env = await environment();
|
|
forge.mainRules.delete("hq");
|
|
await GiteaAdmin.fromEnv(env).ensureMainMerges("novox");
|
|
assert.equal(forge.mainRules.get("hq").enable_push, false, "a rule gone is made again with no token minted");
|
|
});
|