// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the // credential store reference. Design D1, D8, D9 step 2 and D9c. import { test } from "node:test"; import assert from "node:assert/strict"; import { join } from "node:path"; import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs"; import { forkProblem, forkUrl } from "../src/vocabulary.mjs"; import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs"; function refuses(fn, pattern) { assert.throws(fn, (error) => { assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); assert.equal(error.exitCode, 2); assert.match(error.message, pattern); return true; }); } const IDENTITY = { name: "Coder", email: "coder@example.com", gitea: { url: "https://git.example", user: "acme-coder" } }; // The fixture with coder granted publish on stack, whose upstream is mosaic/stack. function check(mutate) { const root = tmp(); const doc = businessDoc(root); doc.projects.stack.remote = "https://git.example/mosaic/stack"; doc.roles.coder.identity = structuredClone(IDENTITY); doc.roles.coder.publish = ["stack"]; mutate?.(doc, root); return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); } test("today's files load unchanged: identity, publish and remote are optional", () => { const root = tmp(); const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES }); assert.equal(business.roles.coder.identity, undefined); assert.equal(business.roles.coder.publish, undefined); assert.equal(business.projects.stack.remote, undefined); }); test("a granted agent loads with its identity, grant and the project's upstream", () => { const business = check(); assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY); assert.deepEqual([...business.roles.coder.publish], ["stack"]); assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack"); }); test("N10: publish needs identity and a gitea definition, and the error names the grant", () => { refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/); // Row 285 (mos-1333 item 3): the chief's definition now declares gitea, so this case names an // instance whose definition has none. refuses(() => check((d) => { d.roles.helper = { definition: "assistant", holder: "mos", identity: structuredClone(IDENTITY), publish: ["stack"] }; }), /roles\.helper\.publish needs identity and a gitea definition/); }); // Row 285 (mos-1333 item 4, brief :1829): the chief's grant needs identity and publish on the // instance, so a chief with its gitea reference loads with both. X-G1 (gitea-grant.test) shows the // same chief refuses at raise. test("row 285: a chief with its gitea reference, identity and publish loads", async () => { // Amendment 1 (mos-1580, sage-4060): this read reference belongs to a library business. const { mappingEntry } = await import("../../library/tests/helpers.mjs"); const identity = { name: "Chief", email: "chief@example.com", gitea: { url: "https://git.example", user: "acme-chief" } }; const business = check((d) => { d.projects.books = mappingEntry(); d.roles.chief.credentials = { gitea: { store: true, rotateBy: FAR_DATE } }; d.roles.chief.identity = structuredClone(identity); d.roles.chief.publish = ["stack"]; }); assert.deepEqual({ ...business.roles.chief.identity, gitea: { ...business.roles.chief.identity.gitea } }, identity); assert.deepEqual([...business.roles.chief.publish], ["stack"]); assert.deepEqual(Object.keys(business.roles.chief.credentials), ["gitea"]); }); test("publish names declared projects that have a remote, each once", () => { refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/); refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/); refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/); refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/); }); test("identity fields are checked", () => { refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/); refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/); refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/); refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/); refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/); }); test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => { const bad = { "a dot segment": "https://git.example/acme-coder/../mosaic/stack", "an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack", "an extra segment": "https://git.example/mosaic/stack/more", "a missing segment": "https://git.example/stack", "a trailing slash": "https://git.example/mosaic/stack/", userinfo: "https://u:p@git.example/mosaic/stack", "a query": "https://git.example/mosaic/stack?x=1", "a fragment": "https://git.example/mosaic/stack#f", "an explicit :443": "https://git.example:443/mosaic/stack", "an upper-case host": "https://GIT.example/mosaic/stack", "a backslash": "https://git.example\\mosaic\\stack", "a name outside Gitea's rule": "https://git.example/mosaic/st%41ck", "a dot-dot repo": "https://git.example/mosaic/...git", http: "http://git.example/mosaic/stack", }; for (const [what, remote] of Object.entries(bad)) { assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what); } }); test("D9c: one .git is stripped, and the fork is built from the stripped name", () => { assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote, "https://git.example/mosaic/stack.git"); assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack"); assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack"); }); test("D9c: gitea.url names the instance only", () => { for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) { assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url); } check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; }); }); test("the upstream's host and port must be the agent's Gitea's", () => { refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/); refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/); assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null); }); test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => { refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/); refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/); }); test("store references: own path only (design D1, D3a)", () => { const store = { configDir: "/cfg", business: "acme", agent: "coder" }; const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store }); assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea"); assert.equal(ref.store, true); refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/); refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/); refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }), /credential-not-yours/); assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file, "/cfg/secrets/agents/acme/coder/gitea"); });