git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
147 lines
8.4 KiB
JavaScript
147 lines
8.4 KiB
JavaScript
// 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: "[email protected]", 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: "[email protected]", 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:[email protected]/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");
|
|
});
|