Files
stack/packages/cli/tests/org-selector.test.mjs
jason.woltje f198cfd47b selector: --org and --business in each CLI parser's own scope
Rename design r2, "Selector scopes and parser matrix". packages/business/src/selector.mjs
and scripts/selector.sh carry one rule set: in a parser's scope each spelling appears once,
and a second spelling must carry exactly the same value; a repeated spelling, a missing or
flag-shaped value, an invalid id and unequal values refuse. The file family
(--business-file/--org-file) compares paths as given.

Every CLI parser that took --business now takes --org through orgSelector, and the old
global --org rewrite in main() is gone, so a value another option consumes is never read as
a selector. The id is checked while parsing (OrgError org-invalid, exit 3), so grants admin
set no longer reads the token and proxy init no longer prompts for the key before an
invalid id refuses. access gains the same local id check.
2026-11-08 21:14:11 -06:00

212 lines
11 KiB
JavaScript

// The organization selector on every CLI parser that takes --business (rename design r2, "Selector
// scopes and parser matrix"): --org is the same option in that parser's scope. Each entry point runs
// through main() with a stub transport that records the organization callFor binds, then stops; a
// refused selector binds nothing.
import { test } from "node:test";
import assert from "node:assert/strict";
import { main } from "../src/cli.mjs";
import { accessCommand } from "../src/access.mjs";
import { parseLimits } from "../src/limits.mjs";
import { parseArgs } from "../src/thread.mjs";
import { CliError } from "../src/errors.mjs";
import { OrgError } from "../../business/src/org-registry.mjs";
import { io, orgFixture, tmp } from "./helpers.mjs";
const STOP = "selector test stop";
const stop = () => { throw new CliError(STOP, 99); };
function stub(t) {
const h = { bound: [], calls: [], prompted: 0 };
const transport = b => { h.bound.push(b); return async (verb, args) => { h.calls.push({ verb, args }); stop(); }; };
h.deps = {
system: { dataRoot: tmp(t, "sel-") },
transport,
testerTransport: transport,
promptProxyKey: async () => { h.prompted++; stop(); },
};
return h;
}
// What one argv does: reached the bus (or the key prompt), refused, or failed some other way.
async function outcome(t, argv, { tty = false, input } = {}) {
const h = stub(t), term = io();
if (tty) { term.stdin.isTTY = true; term.stdout.isTTY = true; }
if (input !== undefined) term.stdin.end(input);
const seen = { bound: h.bound, calls: h.calls, get prompted() { return h.prompted; }, stdin: term.stdin };
try {
await main(argv, term, h.deps);
return { ...seen, kind: "done" };
} catch (e) {
if (e instanceof CliError && e.message === STOP) return { ...seen, kind: "reached" };
if (e instanceof OrgError) return { ...seen, kind: "org", code: e.code };
if (e instanceof CliError) return { ...seen, kind: "refused", exitCode: e.exitCode, code: e.code, message: e.message };
throw e;
}
}
// The design's per-parser cases. The selector goes after the verb's own arguments.
const CASES = [
["old alone", ["--business", "acme"], "acme"],
["new alone", ["--org", "acme"], "acme"],
["matching, old first", ["--business", "acme", "--org", "acme"], "acme"],
["matching, new first", ["--org", "acme", "--business", "acme"], "acme"],
["unequal, old first", ["--business", "acme", "--org", "beta"], "conflict"],
["unequal, new first", ["--org", "acme", "--business", "beta"], "conflict"],
["old repeated", ["--business", "acme", "--business", "acme"], "usage"],
["new repeated", ["--org", "acme", "--org", "acme"], "usage"],
["old missing", ["--business"], "usage"],
["new missing", ["--org"], "usage"],
["new empty", ["--org", ""], "usage"],
["new flag-shaped", ["--org", "--json"], "usage"],
["old flag-shaped", ["--business", "--org", "acme"], "usage"],
["new invalid", ["--org", "Acme"], "invalid"],
["old invalid", ["--business", "acme/../x"], "invalid"],
];
const ENTRIES = [
["inbox"], ["decide", "d1234567", "yes"], ["tasks"], ["agents"], ["trail", "t1"],
["talk", "pm", "hi"], ["follow"], ["names"], ["name", "coder", "Bob"],
["halt"], ["resume"], ["processing", "status"], ["launches", "off"], ["launches", "on"],
["project", "list"], ["project", "create", "work"], ["threads"], ["thread", "read", "work/t1"], ["session", "list", "work/t1"],
["limits"], ["limits", "set", "--file", "patch.json"], ["usage"], ["plugins"], ["plugins", "set", "--file", "patch.json"],
["credentials", "status"], ["credentials", "delete", "coder", "--reason", "gone"],
["hosts", "status"], ["hosts", "revoke", "coder", "h1", "--reason", "gone"],
["grants", "status", "gitea"], ["grants", "revoke", "coder", "gitea", "--reason", "gone"],
];
function check(r, expected, label) {
if (expected === "acme") {
assert.deepEqual(r.bound, ["acme"], `${label}: ${r.kind} ${r.message ?? r.code ?? ""}`);
return;
}
assert.deepEqual(r.bound, [], `${label} bound nothing`);
assert.equal(r.calls.length, 0, `${label} made no call`);
if (expected === "invalid") {
assert.equal(r.kind, "org", label);
assert.equal(r.code, "org-invalid", label);
} else {
assert.equal(r.kind, "refused", `${label}: ${r.kind}`);
assert.equal(r.exitCode, 4, label);
}
}
for (const base of ENTRIES) {
test(`selector on mosaic ${base.join(" ")}`, async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, [...base, ...selector]);
check(r, expected, `${base.join(" ")} ${name}`);
// The verbs whose usage error carries the reason say which spellings conflict.
if (expected === "conflict" && !["limits", "usage", "plugins", "credentials", "hosts", "grants"].includes(base[0])) assert.match(r.message, /conflict/i, name);
}
});
}
test("selector on mosaic proxy init: the key prompt waits for a valid selector", async (t) => {
for (const [name, selector, expected] of CASES) {
const r = await outcome(t, ["proxy", "init", ...selector], { tty: true });
check(r, expected, `proxy init ${name}`);
assert.equal(r.prompted, expected === "acme" ? 1 : 0, `proxy init ${name} prompted`);
if (r.kind === "refused") assert.equal(r.code, "proxy-usage", name);
}
// fetch takes no selector.
assert.equal((await outcome(t, ["proxy", "fetch", "--org", "acme"])).code, "proxy-usage");
});
test("selector on mosaic access: the human proof binds the selected organization", async (t) => {
for (const verb of [["list"], ["user", "add", "ana"]]) {
for (const [name, selector, expected] of CASES) {
const bound = [], term = io();
const deps = { system: { dataRoot: tmp(t, "sel-") }, transport: b => { bound.push(b); return async () => stop(); }, contextFor: () => { throw new Error("context read before the proof"); } };
let r;
try { await accessCommand([...verb, ...selector], term, deps); r = { kind: "done" }; }
catch (e) {
if (e instanceof CliError && e.message === STOP) r = { kind: "reached" };
else if (e instanceof OrgError) r = { kind: "org", code: e.code };
else if (e instanceof CliError) r = { kind: "refused", exitCode: e.exitCode, message: e.message };
else throw e;
}
check({ ...r, bound, calls: [] }, expected, `access ${verb.join(" ")} ${name}`);
if (expected === "conflict") assert.match(r.message, /conflict/i);
}
}
});
test("a selector token taken as another option's value is that option's text", async (t) => {
const note = await outcome(t, ["decide", "d1234567", "yes", "--note", "--org", "--business", "acme"]);
assert.deepEqual(note.bound, ["acme"]);
const project = await outcome(t, ["threads", "--project", "--org", "--business", "acme"]);
assert.deepEqual(project.bound, ["acme"]);
// The channel's first call reads plugins, so the repo shows in the shared parser's result.
const repo = await outcome(t, ["project", "create", "work", "--repo", "--org", "--business", "acme"]);
assert.deepEqual(repo.bound, ["acme"]);
assert.deepEqual(parseArgs(["work", "--repo", "--org", "--business", "acme"], "u", { values: ["--repo", "--business"] }), { positional: ["work"], flags: new Set(), values: { "--repo": "--org", "--business": "acme" } });
for (const argv of [
["credentials", "delete", "coder", "--reason", "--org", "--business", "acme"],
["hosts", "revoke", "coder", "h1", "--reason", "--business", "--org", "acme"],
["grants", "revoke", "coder", "gitea", "--reason", "--org", "--business", "acme"],
]) {
const r = await outcome(t, argv);
assert.deepEqual(r.bound, ["acme"], argv.join(" "));
assert.equal(r.calls[0].args.reason, argv[argv.indexOf("--reason") + 1], argv.join(" "));
}
// callFor binds before the file is read, so the bound organization and the file name both show.
const plugins = await outcome(t, ["plugins", "set", "--file", "--org", "--business", "acme"]);
assert.deepEqual(plugins.bound, ["acme"]);
assert.match(plugins.message, /--org/);
assert.deepEqual(parseLimits(["set", "--file", "--org", "--business", "acme"]).values, { "--file": "--org", "--business": "acme" });
// proxy init: --port takes --org, so the port check refuses, not the selector.
const port = await outcome(t, ["proxy", "init", "--port", "--org", "--business", "acme"], { tty: true });
assert.equal(port.code, "proxy-config-invalid");
// access keeps its own grammar: an option value may not start with --.
await assert.rejects(accessCommand(["list", "--business", "acme", "--user", "--org"], io(), { transport: stop, contextFor: stop }), /--user needs one value/);
});
test("positional text next to a selector stays positional", async (t) => {
const r = await outcome(t, ["talk", "pm", "--org", "acme", "--business", "acme", "hi"]);
assert.deepEqual(r.bound, ["acme"]);
assert.deepEqual(r.calls[0], { verb: "message.send", args: { to: "pm", body: "hi", class: "REQUEST" } });
});
test("an invalid selector refuses before stdin is read", async (t) => {
const argv = ["grants", "admin", "set", "gitea", "--url", "https://git.example", "--account", "a", "--rotate-by", "2027-01-01"];
const bad = await outcome(t, [...argv, "--org", "Bad"], { input: "tok" });
assert.equal(bad.code, "org-invalid");
assert.ok(bad.stdin.readableLength > 0, "the token is still unread");
const good = await outcome(t, [...argv, "--org", "acme"], { input: "tok" });
assert.equal(good.kind, "reached");
assert.deepEqual(good.bound, ["acme"]);
assert.equal(good.calls[0].args.token, "tok");
});
test("--org is no longer a global option: a verb without a selector refuses it", async (t) => {
for (const argv of [["console", "start", "--org", "acme"], ["bus", "status", "--org", "acme"]]) {
const r = await outcome(t, argv);
assert.equal(r.kind, "refused", argv.join(" "));
assert.match(r.message, /--org/, argv.join(" "));
}
});
test("stop and launches list resolve the selected organization", async (t) => {
const { f } = await orgFixture(t, "acme");
const run = async (argv) => {
const term = io(f.env);
try { await main(argv, term, { system: { dataRoot: f.dataRoot } }); return { kind: "done", out: term.out.text }; }
catch (e) {
if (e instanceof OrgError) return { kind: "org", code: e.code };
if (e instanceof CliError) return { kind: "refused", exitCode: e.exitCode, message: e.message.split("\n")[0] };
throw e;
}
};
for (const [name, selector, expected] of CASES) {
const list = await run(["launches", "list", ...selector]);
if (expected === "acme") assert.deepEqual(list, { kind: "done", out: "no sessions\n" }, name);
else if (expected === "invalid") assert.deepEqual(list, { kind: "org", code: "org-invalid" }, name);
else assert.equal(list.exitCode, 4, name);
}
const reference = await run(["stop", "r1", "--business", "acme"]);
assert.notEqual(reference.kind, "org");
for (const selector of [["--org", "acme"], ["--org", "acme", "--business", "acme"]]) assert.deepEqual(await run(["stop", "r1", ...selector]), reference);
assert.deepEqual(await run(["stop", "r1", "--org", "beta"]), { kind: "org", code: "org-unknown" });
assert.equal((await run(["stop", "r1", "--org", "acme", "--business", "beta"])).exitCode, 4);
});