Files
stack/packages/cli/tests/access-boot.test.mjs
jason.woltje f3f33a6171 fix(organization,cli,discord,seat,control-board): refusals name the field or file by label, never a host path (#1873)
redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.

- organization: checkCredentialRef names the field and each forbidden root by
  label; OrgError keeps .path only for a document field ($...); the registry,
  fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
  their code and names, everything else reads as a field and its constraint
  against a labelled config. Project-key, access warning, halt gate, sessions
  file, launch socket, harness relays and the workspace rename name labels.
  Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
  binding, registration and seen-marks refusals name labels.

Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
2026-11-11 08:53:31 -06:00

100 lines
5.5 KiB
JavaScript

// FED R6 (#1800, sage-3142, sage-3153): the bus host's boot config carries the runtime's `access`
// ({ file, context }) when <configDir>/access.json exists, with the cli's accessContextFor as the
// context. The broker then binds a granted user; with no file, or a file R1's loader refuses, no
// user binds and the owner's bus still boots. A refused file gives one boot warning naming the path
// and a code, never the file's contents. Scratch roots only. The boot config goes through a JSON
// round trip first, as it does over the host's IPC.
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { checkAccess } from "../../organization/src/index.mjs";
import { accessContextFor } from "../src/access.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { fixture, tmp } from "./helpers.mjs";
const SSO = { sso: { providers: [{ id: "authentik", issuer: "https://idp.test", subjects: [{ sub: "jason-sub" }] }] } };
const FEDERATION = { self: "hub", peers: [{ id: "factory", origin: "https://factory.example.test", label: "Software factory" }] };
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "factory", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [{ id: "g-1", user: "alice", business: "acme", projects: ["*"], instances: ["*"], level: "collaborator" }],
};
// The shared helpers.mjs business sets tracker.baseUrl without tracker.project, so every boot warns
// this first: trackerFor runs before accessFor in bootConfig.
const TRACKER = "business acme sets tracker.baseUrl, but no project file sets tracker.project; the broker gets no task verbs";
// F8: the context's peers are federation.peers' ids, so a user whose home is a configured peer
// validates and one whose home is any other id, the hub's own included, refuses.
test("R6 a configured peer id validates as a user's home and an unconfigured one refuses", (t) => {
const root = tmp(t, "mosaic-access-peers-");
fixture(root, "acme");
const ctx = accessContextFor({ console: SSO, federation: FEDERATION }, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.peers, ["factory"]);
const doc = (home) => ({ ...DOC, users: [{ ...DOC.users[0], home }] });
assert.equal(checkAccess(doc("factory"), ctx).users[0].home, "factory");
assert.throws(() => checkAccess(doc("elsewhere"), ctx), /home must be a configured peer/);
assert.throws(() => checkAccess(doc("hub"), ctx), /home must be a configured peer/);
});
// A scratch instance whose system config names the sign-in provider and one peer, and the boot
// config bootConfig makes for it. `file` is the access file's text, null for none, or a function
// that makes the file at the path.
async function boot(t, file) {
const f = fixture(tmp(t, "mosaic-access-boot-"), "acme");
const system = { ...loadSystem({ env: f.env }), console: SSO, federation: FEDERATION };
const path = join(dirname(f.config), "access.json");
if (typeof file === "function") file(path);
else if (file !== null) {
writeFileSync(path, file);
chmodSync(path, 0o600);
}
const warnings = [];
const { trackers, ...config } = JSON.parse(JSON.stringify(bootConfig({ system, businessId: "acme", env: f.env, warn: (w) => warnings.push(w) })));
const runtime = await startBroker(config);
t.after(() => runtime.close());
const bind = (user) => {
try {
return runtime.broker.bindUser({ business: "acme", user }) ? "bound" : "none";
} catch (e) {
return e.code;
}
};
return { config, path, bind, warnings };
}
test("R6 B1 a boot with access.json binds a granted user, whose home is a configured peer", async (t) => {
const s = await boot(t, JSON.stringify(DOC));
assert.equal(s.config.access.file, s.path);
assert.deepEqual(s.config.access.context.peers, ["factory"]);
assert.deepEqual(s.config.access.context.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.equal(s.bind("alice"), "bound");
assert.equal(s.bind("bob"), "access-revoked");
assert.deepEqual(s.warnings, [TRACKER]);
});
test("R6 B2 a boot without access.json has no access and binds no user", async (t) => {
const s = await boot(t, null);
assert.equal(Object.hasOwn(s.config, "access"), false);
assert.equal(s.bind("alice"), "access-revoked");
assert.deepEqual(s.warnings, [TRACKER]);
});
test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user and says so once", async (t) => {
for (const [why, file, code] of [
["not JSON", '{"secret-marker"', "access-invalid"],
["a home that is not a configured peer", JSON.stringify({ ...DOC, users: [{ ...DOC.users[0], home: "secret-marker" }] }), "access-invalid"],
["a symbolic link", (path) => { writeFileSync(`${path}.real`, JSON.stringify(DOC), { mode: 0o600 }); symlinkSync(`${path}.real`, path); }, "access-unreadable"],
]) {
const s = await boot(t, file);
assert.equal(s.config.access.file, s.path, why);
assert.equal(s.bind("alice"), "access-revoked", why);
// #1873: the warning names access.json by its place, never by host path.
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`], why);
assert.ok(!s.warnings.join("\n").includes(s.path), why);
assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why);
}
});