1609 lines
96 KiB
JavaScript
1609 lines
96 KiB
JavaScript
// Case 2 row C2-13a (#1732): scripts/case2-unit.sh renders and installs the hub's two system units,
|
|
// and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md
|
|
// "C2-13a". Every case runs against a scratch unit directory, a scratch home and fake systemctl,
|
|
// getent, sudo, id, node, bwrap and claude. Nothing here reads or writes /etc/systemd,
|
|
// ~/.mosaic-dev or ~/.config/mosaic-dev, and no fixture value is a real credential.
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { createServer } from "node:net";
|
|
import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
|
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
|
import { tmp } from "./helpers.mjs";
|
|
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
|
|
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
|
|
|
|
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const UNIT = join(REPO, "scripts", "case2-unit.sh");
|
|
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
|
const BUS_TEMPLATE = join(REPO, "packages", "cli", "systemd", "mosaic-bus.service.in");
|
|
const WEBUI_TEMPLATE = join(REPO, "packages", "webui", "systemd", "mosaic-webui.service.in");
|
|
const COPY_A = "a".repeat(40);
|
|
const COPY_B = "b".repeat(40);
|
|
const UID = process.getuid();
|
|
const GID = process.getgid();
|
|
// A made-up key value; no output may hold it.
|
|
const FIXTURE_KEY = "fixture-zai-key-3c9e51";
|
|
|
|
// A root-owned executable outside /usr/local/bin, /usr/bin and /bin, for the PATH case.
|
|
function outsideNode() {
|
|
const dir = "/usr/lib/systemd";
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const p = join(dir, name);
|
|
const s = statSync(p, { throwIfNoEntry: false });
|
|
if (s?.isFile() && s.uid === 0 && (s.mode & 0o111) && (s.mode & 0o022) === 0 && realpathSync(p) === p) return p;
|
|
}
|
|
throw new Error(`no root-owned executable in ${dir}`);
|
|
}
|
|
const USR_NODE = realpathSync("/usr/bin/node");
|
|
const TRUE_BIN = realpathSync("/usr/bin/true");
|
|
|
|
function script(file, text) {
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
|
|
chmodSync(file, 0o755);
|
|
return file;
|
|
}
|
|
|
|
// A scratch hub: a home with two copies and a proof file, a unit directory, and fakes.
|
|
function hub(t, { uid = UID } = {}) {
|
|
const root = tmp(t, "case2-unit-");
|
|
const home = join(root, "home", "mosaic");
|
|
for (const id of [COPY_A, COPY_B]) {
|
|
mkdirSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts"), { recursive: true });
|
|
writeFileSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts", "mosaic"), "");
|
|
}
|
|
const secrets = join(home, ".config", "mosaic-dev", "secrets");
|
|
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
|
const proof = join(secrets, "proxy-proof");
|
|
writeFileSync(proof, "made-up-proof\n");
|
|
chmodSync(proof, 0o600);
|
|
const units = join(root, "units");
|
|
mkdirSync(units);
|
|
const bin = join(root, "bin");
|
|
const log = join(root, "systemctl.log");
|
|
script(join(bin, "getent"), `case "$1" in
|
|
passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;;
|
|
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
|
*) exit 2 ;;
|
|
esac`);
|
|
// is-active answers yes for each unit named in CASE2_TEST_ACTIVE.
|
|
script(join(bin, "systemctl"), `echo "$*" >>'${log}'
|
|
if [ "$1" = is-active ]; then case " \${CASE2_TEST_ACTIVE:-} " in *" $3 "*) exit 0 ;; *) exit 3 ;; esac; fi
|
|
[ "$*" != "\${CASE2_TEST_FAIL:-}" ]`);
|
|
const dataRoot = join(home, '.mosaic-dev'), query = join(root, 'query.json'), sudoLog = join(root, 'sudo.log');
|
|
mkdirSync(dataRoot, { recursive: true });
|
|
writeJson(query, { dataRoot });
|
|
for (const copy of [COPY_A, COPY_B]) {
|
|
const src = join(home, '.local/share/mosaic-stack/copies', copy, 'packages');
|
|
mkdirSync(join(src, 'cli/src'), { recursive: true }); mkdirSync(join(src, 'webui/src'), { recursive: true });
|
|
writeFileSync(join(src, 'cli/src/config.mjs'), `import fs from 'node:fs';export const loadSystem=()=>JSON.parse(fs.readFileSync(${JSON.stringify(query)},'utf8'));`);
|
|
writeFileSync(join(src, 'webui/src/cli.mjs'), '// MOSAIC_TESTER_SOCKET_V1\n');
|
|
}
|
|
script(join(bin, 'sudo'), `${CASE_SUDO_SESSION}
|
|
echo "$*" >>'${sudoLog}'
|
|
[ "$1" = -u ] && [ "$2" = mosaic ] && [ "$3" = -H ] || exit 97
|
|
shift 3
|
|
[ "$1" = env ] && [ "$2" = -i ] && [ "$3" = 'HOME=${home}' ] || exit 98
|
|
if [ -f '${root}/query-hang' ]; then
|
|
trap '' TERM
|
|
(trap '' TERM; echo "$BASHPID" >>'${root}/query-pids'; exec sleep 60) &
|
|
echo "$BASHPID" >>'${root}/query-pids'; wait; exit
|
|
fi
|
|
if [ -f '${root}/query-orphan' ]; then
|
|
(trap '' TERM; here="$BASHPID"; read -r sid < <(/bin/ps -o sid= -p "$here"); printf '%s %s\\n' "$here" "$sid" >'${root}/query-owned'; exec sleep 60) </dev/null >/dev/null 2>&1 &
|
|
echo "$!" >'${root}/query-pids'
|
|
cat '${root}/query-output'; exit 0
|
|
fi
|
|
if [ -f '${root}/query-output' ]; then cat '${root}/query-output'; exit "$(cat '${root}/query-status')"; fi
|
|
case_sudo_run '${root}' "$@"`);
|
|
return { root, home, secrets, proof, units, bin, log, dataRoot, query, sudoLog };
|
|
}
|
|
|
|
const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-host", "books.example", "--auth-header", "X-Auth-User",
|
|
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
|
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
|
|
|
|
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
|
|
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
|
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
|
const r = spawnSync(unitScript, args, {
|
|
encoding: "utf8",
|
|
timeout: 30000,
|
|
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
|
});
|
|
return { code: r.status, out: r.stdout, err: r.stderr };
|
|
}
|
|
|
|
const busFile = (h) => join(h.units, "[email protected]");
|
|
const webuiFile = (h) => join(h.units, "mosaic-webui.service");
|
|
const read = (f) => readFileSync(f, "utf8");
|
|
const lines = (text, key) => text.split("\n").filter((l) => l.startsWith(`${key}=`)).map((l) => l.slice(key.length + 1));
|
|
const copyDir = (h, id = COPY_A) => join(h.home, ".local", "share", "mosaic-stack", "copies", id);
|
|
const HARDENING = (h) => ["ProtectSystem=strict", `ReadWritePaths=${h.home}`, "ProtectHome=yes", "PrivateTmp=yes"];
|
|
|
|
test('row253 factory socket uses actual config and target-uid query without widening hardening', t=>{
|
|
const h=hub(t),custom=join(h.home,'custom');mkdirSync(custom);writeJson(h.query,{dataRoot:custom});
|
|
const r=unit(h,{profile:'factory'});assert.equal(r.code,0,r.out+r.err);
|
|
assert.deepEqual(lines(read(webuiFile(h)),'ExecStart'),[`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(' ')}`]);
|
|
assert.deepEqual(read(webuiFile(h)).split('\n').filter(l=>/^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)),HARDENING(h));
|
|
assert.match(read(h.sudoLog),new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, 'm'));
|
|
assert.equal(existsSync(join(custom,'console')),false,'installer never creates the socket parent');
|
|
});
|
|
|
|
test('row253 installer refuses unsafe location and query facts before unit writes',t=>{
|
|
const cases=[
|
|
['sibling prefix',h=>writeJson(h.query,{dataRoot:h.home+'2/data'}),/outside.*home/],
|
|
['dot',h=>writeJson(h.query,{dataRoot:h.home+'/./data'}),/normalized/],
|
|
['dotdot',h=>writeJson(h.query,{dataRoot:h.home+'/x/../data'}),/normalized/],
|
|
['double slash',h=>writeJson(h.query,{dataRoot:h.home+'//data'}),/normalized/],
|
|
['trailing slash',h=>writeJson(h.query,{dataRoot:h.home+'/data/'}),/normalized/],
|
|
['newline',h=>writeJson(h.query,{dataRoot:h.home+'/x\nP10 forged closed'}),/query/],
|
|
['non ASCII',h=>writeJson(h.query,{dataRoot:h.home+'/é'}),/query/],
|
|
['middle symlink',h=>{symlinkSync(h.dataRoot,join(h.home,'link'));writeJson(h.query,{dataRoot:join(h.home,'link','nested')});},/symlink/],
|
|
['long path',h=>writeJson(h.query,{dataRoot:join(h.home,'a'.repeat(108))}),/too.long/],
|
|
['parent file',h=>writeFileSync(join(h.dataRoot,'console'),'x',{mode:0o600}),/parent-type/],
|
|
['parent symlink',h=>symlinkSync(h.home,join(h.dataRoot,'console')),/symlink/],
|
|
['parent mode',h=>{mkdirSync(join(h.dataRoot,'console'));chmodSync(join(h.dataRoot,'console'),0o755);},/parent/],
|
|
['foreign parent',h=>{mkdirSync(join(h.dataRoot,'console'),{mode:0o700});},/parent/],
|
|
['unsupported',h=>writeFileSync(join(copyDir(h),'packages/webui/src/cli.mjs'),'// old copy'),/support/],
|
|
['schema',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true,extra:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
|
['wrong type',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:'true'}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
|
['noisy query',h=>{writeFileSync(join(h.root,'query-output'),'noise\n'+JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'0');},/query/],
|
|
['failed valid query',h=>{writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));writeFileSync(join(h.root,'query-status'),'1');},/query/],
|
|
];
|
|
for(const [name,change,why] of cases){
|
|
const h=hub(t,{uid:name==='foreign parent'?UID+4242:UID});change(h);
|
|
const r=unit(h,{profile:'factory'});refuses(r,why);
|
|
assert.equal(existsSync(busFile(h)),false,name);assert.equal(existsSync(webuiFile(h)),false,name);assert.equal(existsSync(h.log),false,name);
|
|
assert.equal((r.out+r.err).includes('P10 forged closed'),false,name);
|
|
}
|
|
});
|
|
|
|
test('row253 query timeout kills its process group and leaves no running child',async t=>{
|
|
const h=hub(t);writeFileSync(join(h.root,'query-hang'),'');
|
|
const r=unit(h,{profile:'factory'});refuses(r,/query/);
|
|
const pids=read(join(h.root,'query-pids')).trim().split('\n').map(Number);assert.ok(pids.length>=2);
|
|
for(const pid of pids){
|
|
let state='gone';
|
|
for(let n=0;n<50;n++){
|
|
try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';}
|
|
if(state==='gone'||state==='Z')break;
|
|
await new Promise(resolve=>setTimeout(resolve,10));
|
|
}
|
|
assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`);
|
|
}
|
|
assert.equal(existsSync(h.log),false);assert.equal(existsSync(busFile(h)),false);
|
|
});
|
|
|
|
test('row253 protected homes refuse before any filesystem access',()=>{
|
|
const source=read(UNIT), body=source.slice(source.indexOf('tester_location() {'),source.indexOf('# tester-location-end'));
|
|
for(const home of ['/home/mosaic','/root/mosaic','/run/user/997','/tmp/mosaic','/var/tmp/mosaic']){
|
|
const r=spawnSync('/bin/bash',['-c',body+'\nif tester_location "$1/data" "$1" 997 ""; then exit 99; fi; printf "%s" "$TS_REASON"','--',home],{encoding:'utf8'});
|
|
assert.equal(r.status,0);assert.equal(r.stdout,'protected-private-home');
|
|
}
|
|
});
|
|
|
|
// C4 cases run through both sudo process models. Exec shares the runner's
|
|
// session, so its session-wide check would be vacuous; exact recorded PIDs
|
|
// are checked in both modes. Hop additionally isolates the fake session.
|
|
for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query has exact inner reason and cleans all members`,async t=>{
|
|
for(const mode of ['exec','hop']){
|
|
const h=hub(t),sessions=join(h.root,'session-pids'),queries=join(h.root,'query-pids'),files=[sessions,queries];
|
|
writeFileSync(join(h.root,'session-mode'),mode);
|
|
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
|
|
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
|
|
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
|
|
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
|
|
mkdirSync(dirname(observed),{recursive:true});
|
|
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
|
|
writeFileSync(clock,'',{mode:0o600});
|
|
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
|
|
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
|
|
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
|
|
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
|
|
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
|
|
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
|
|
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
|
|
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
|
|
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
|
|
}}),wall=(performance.now()-start)/1000;capture();
|
|
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
|
const seconds=readProbeSeconds(read(clock));
|
|
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
|
|
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
|
|
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
|
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
|
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
|
assert.deepEqual(await pollFixtureGone(files),[],mode);
|
|
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
|
|
assert.equal(existsSync(h.log),false,mode);await stopGuard();
|
|
}
|
|
});
|
|
|
|
test('row253 C4 overflow is output-limit in both sudo modes',async t=>{
|
|
for(const mode of ['exec','hop']){
|
|
const h=hub(t),files=[join(h.root,'session-pids')];writeFileSync(join(h.root,'session-mode'),mode);const capture=cleanupFixture(t,files);
|
|
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),`process.stdout.write('X'.repeat(8192));export const loadSystem=()=>({dataRoot:${JSON.stringify(h.dataRoot)}});`);
|
|
const r=unit(h,{profile:'factory'});capture();refuses(r,/^case2-unit: tester config query output-limit\n$/);
|
|
assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false);
|
|
assert.deepEqual(await pollFixtureGone(files),[]);
|
|
}
|
|
});
|
|
|
|
test('row253 C4 stalled supervisor uses the outer reason and explicit residual cleanup',async t=>{
|
|
for(const mode of ['exec','hop']){
|
|
const h=hub(t),file=join(h.root,'session-pids'),files=[file];writeFileSync(join(h.root,'session-mode'),mode);
|
|
writeFileSync(join(h.root,'stall-config'),'');writeFileSync(join(h.root,'stall-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));
|
|
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
|
|
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
|
|
refuses(r,/^case2-unit: tester config query outer-timeout\n$/);
|
|
assert.ok(seconds>=7&&seconds<9,`${mode}: ${seconds}s; 2s slack includes root preflight`);
|
|
assert.equal(r.out,'');assert.equal(existsSync(busFile(h)),false);assert.equal(existsSync(h.log),false);
|
|
if(mode==='hop')assert.ok(recordedLivePids(file).length>0,'R3 residual exists before fixture cleanup');
|
|
killRecordedPids(file);assert.deepEqual(await pollFixtureGone(files),[], 'explicit fixture cleanup of R3 residual');await stopGuard();
|
|
}
|
|
});
|
|
|
|
test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
|
const extract=s=>s.split('# row253-supervisor-begin\n')[1]?.split('# row253-supervisor-end')[0];
|
|
const a=extract(read(UNIT)),b=extract(read(join(REPO,'scripts/case1-accept.sh')));
|
|
assert.ok(a?.length>100);assert.equal(a,b);
|
|
});
|
|
|
|
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
|
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
|
|
// kernel operation is replaced before execution; no real PID is signalled.
|
|
const source=read(UNIT);
|
|
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
|
assert.ok(program,'fixed cleanup program exists');
|
|
const anchor='\ntry:\n';
|
|
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
|
|
const standins=`
|
|
from types import SimpleNamespace
|
|
alive = True
|
|
kills = 0
|
|
clock = time.monotonic()
|
|
|
|
def inventory(path):
|
|
global clock
|
|
clock += 1000
|
|
return ["999"]
|
|
|
|
def fake_fields(pid):
|
|
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
|
|
|
|
def send(fd, sig):
|
|
global alive, kills
|
|
if sig == 9:
|
|
alive = False
|
|
kills += 1
|
|
|
|
fields = fake_fields
|
|
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
|
|
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
|
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
|
|
`;
|
|
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
|
|
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
|
});
|
|
|
|
test('row253 query reaps its group even when a child closes stdout before the query returns',async t=>{
|
|
const h=hub(t);writeFileSync(join(h.root,'query-orphan'),'');
|
|
writeFileSync(join(h.root,'query-output'),JSON.stringify({dataRoot:h.dataRoot,socket:true}));
|
|
let pid;
|
|
const capture=cleanupFixture(t,[join(h.root,'query-owned')]);
|
|
const r=unit(h,{profile:'factory'});capture();
|
|
pid=Number(read(join(h.root,'query-pids')).trim());
|
|
assert.equal(r.code,0,r.out+r.err);
|
|
let state='gone';
|
|
for(let n=0;n<50;n++){
|
|
try{state=read(`/proc/${pid}/stat`).split(') ')[1].split(' ')[0];}catch{state='gone';}
|
|
if(state==='gone'||state==='Z')break;
|
|
await new Promise(resolve=>setTimeout(resolve,10));
|
|
}
|
|
assert.ok(state==='gone'||state==='Z',`query descendant ${pid} still ${state}`);
|
|
});
|
|
|
|
test('row253 config parser is strict',()=>{
|
|
const source=read(UNIT), body=source.slice(source.indexOf('tester_facts() {'),source.indexOf('tester_location() {'));
|
|
const valid=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1" && printf "%s %s" "$TS_DATA" "$TS_MARKED"','--','{"dataRoot":"/srv/mosaic/data","socket":true}'],{encoding:'utf8'});
|
|
assert.equal(valid.status,0);assert.equal(valid.stdout,'/srv/mosaic/data true');
|
|
const facts=['{"dataRoot":"/srv/mosaic/data","socket":true,"extra":1}','{"dataRoot":4,"socket":true}','{"dataRoot":"/srv/mosaic/data","socket":"true"}','{"dataRoot":"/srv/mosaic/data","socket":true}\nP10 forged closed'];
|
|
for(const fact of facts){
|
|
const r=spawnSync('/bin/bash',['-c',body+'\ntester_facts "$1"','--',fact]);
|
|
assert.notEqual(r.status,0,fact);
|
|
}
|
|
});
|
|
|
|
function refuses(r, pattern) {
|
|
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
|
assert.match(r.err, pattern);
|
|
}
|
|
|
|
// A template copy with one edit, for the template cases.
|
|
function template(h, from, name, edit) {
|
|
const file = join(h.root, name);
|
|
writeFileSync(file, edit(read(from)));
|
|
return file;
|
|
}
|
|
|
|
test("a clean read-only library render writes both units 0644 with the fixed lines, and enables both then starts the bus then the Console", (t) => {
|
|
const h = hub(t);
|
|
const r = unit(h);
|
|
assert.equal(r.code, 0, r.err);
|
|
for (const f of [busFile(h), webuiFile(h)]) assert.equal(statSync(f).mode & 0o777, 0o644);
|
|
const bus = read(busFile(h));
|
|
const webui = read(webuiFile(h));
|
|
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin"]);
|
|
assert.deepEqual(lines(bus, "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
|
assert.deepEqual(lines(bus, "EnvironmentFile"), [`-${h.home}/.config/mosaic-dev/secrets/claude-session.env`]);
|
|
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${libraryFlags(h).join(" ")}`]);
|
|
assert.doesNotMatch(lines(webui, "ExecStart")[0], / --(?:send|sso)(?: |$)/);
|
|
assert.deepEqual(lines(webui, "EnvironmentFile"), []);
|
|
for (const text of [bus, webui]) {
|
|
assert.deepEqual(lines(text, "WantedBy"), ["multi-user.target"]);
|
|
assert.deepEqual(lines(text, "User"), ["mosaic"]);
|
|
assert.deepEqual(lines(text, "Group"), ["mosaic"]);
|
|
assert.deepEqual(lines(text, "NoNewPrivileges"), ["yes"]);
|
|
const hardening = text.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l));
|
|
assert.deepEqual(hardening, HARDENING(h));
|
|
assert.doesNotMatch(text, /@[A-Z_]+@|%h|MOSAIC_CONFIG=|MOSAIC_CLAUDE_BIN|After=|Wants=/);
|
|
}
|
|
assert.deepEqual(lines(bus, "AmbientCapabilities"), []);
|
|
assert.deepEqual(lines(bus, "CapabilityBoundingSet"), []);
|
|
assert.deepEqual(lines(webui, "AmbientCapabilities"), ["CAP_NET_BIND_SERVICE"]);
|
|
assert.deepEqual(lines(webui, "CapabilityBoundingSet"), ["CAP_NET_BIND_SERVICE"]);
|
|
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
|
assert.deepEqual(read(h.log).trim().split("\n"), [
|
|
"is-active --quiet [email protected]",
|
|
"is-active --quiet mosaic-webui.service",
|
|
"daemon-reload",
|
|
"enable [email protected] mosaic-webui.service",
|
|
"start [email protected]",
|
|
"start mosaic-webui.service",
|
|
]);
|
|
});
|
|
|
|
// The selector (rename design r2): --org and --business are one option in each of two scopes, the
|
|
// script's own flags before -- and the serve flags after it. Nothing is counted across --; the serve
|
|
// selector is required, must be the unit's business, and renders as --business.
|
|
const SELECTORS = [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]];
|
|
|
|
test("the unit takes --org or --business on either side of --, renders --business once, and re-installs as the same units", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme"] }).code, 0);
|
|
const before = [read(busFile(h)), read(webuiFile(h))];
|
|
assert.deepEqual(lines(before[1], "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme`]);
|
|
for (const outer of SELECTORS) {
|
|
for (const child of SELECTORS) {
|
|
const r = unit(h, { profile: "factory", outer, flags: ["--port", "443", ...child] });
|
|
assert.equal(r.code, 0, `${outer.join(" ")} -- ${child.join(" ")}: ${r.err}`);
|
|
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before, `${outer.join(" ")} -- ${child.join(" ")}`);
|
|
}
|
|
}
|
|
// The selector keeps its place among the serve flags.
|
|
const g = hub(t);
|
|
assert.equal(unit(g, { profile: "factory", outer: ["--org", "acme"], flags: ["--org", "acme", "--port", "443", "--business", "acme"] }).code, 0);
|
|
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --business acme --port 443`]);
|
|
});
|
|
|
|
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
|
|
const h = hub(t);
|
|
const serve = ["--port", "443", "--business", "acme"];
|
|
const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
|
|
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
|
|
for (const [label, argv, code, err] of [
|
|
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
|
|
["outer unequal, new first", [...head, "--org", "acme", "--business", "beta", "--", ...serve], 4, "case2-unit: --org acme and --business beta conflict\n"],
|
|
["outer old repeated", [...head, "--business", "acme", "--business", "acme", "--", ...serve], 4, "case2-unit: --business given twice\n"],
|
|
["outer new repeated", [...head, "--org", "acme", "--org", "acme", "--", ...serve], 4, "case2-unit: --org given twice\n"],
|
|
["outer missing", [...head, "--org"], 4, usage],
|
|
["outer empty", [...head, "--org", "", "--", ...serve], 4, usage],
|
|
["outer none", [...head, "--", ...serve], 4, usage],
|
|
["outer invalid", [...head, "--org", "Acme", "--", ...serve], 1, "case2-unit: --business must be a business id: Acme\n"],
|
|
["outer flag-shaped", [...head, "--business", "-x", "--", ...serve], 1, "case2-unit: --business must be a business id: -x\n"],
|
|
["outer takes no separator", [...head, "--org", "--", ...serve], 1, "case2-unit: --business must be a business id: --\n"],
|
|
["serve unequal, old first", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--org", "beta"], 1, "case2-unit: serve --business acme and --org beta conflict\n"],
|
|
["serve unequal, new first", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--business", "beta"], 1, "case2-unit: serve --org acme and --business beta conflict\n"],
|
|
["serve old repeated", [...head, "--business", "acme", "--", "--port", "443", "--business", "acme", "--business", "acme"], 1, "case2-unit: serve flag --business given twice\n"],
|
|
["serve new repeated", [...head, "--business", "acme", "--", "--port", "443", "--org", "acme", "--org", "acme"], 1, "case2-unit: serve flag --org given twice\n"],
|
|
["serve missing", [...head, "--business", "acme", "--", "--port", "443", "--org"], 1, "case2-unit: serve flag --org needs a value\n"],
|
|
["serve empty", [...head, "--business", "acme", "--", "--port", "443", "--org", ""], 1, "case2-unit: serve flag --org needs a value\n"],
|
|
["serve none", [...head, "--org", "acme", "--", "--port", "443"], 1, "case2-unit: serve flag --business is required\n"],
|
|
["serve invalid", [...head, "--business", "acme", "--", "--port", "443", "--org", "Acme"], 1, "case2-unit: serve --org Acme is not the unit's business acme\n"],
|
|
["serve flag-shaped", [...head, "--business", "acme", "--", "--port", "443", "--org", "--tester-socket"], 1, "case2-unit: serve --org --tester-socket is not the unit's business acme\n"],
|
|
["outer and serve differ", [...head, "--org", "acme", "--", "--port", "443", "--org", "beta"], 1, "case2-unit: serve --org beta is not the unit's business acme\n"],
|
|
["serve and outer differ", [...head, "--org", "beta", "--", "--port", "443", "--business", "acme"], 1, "case2-unit: serve --business acme is not the unit's business beta\n"],
|
|
["a selector as --copy's value", ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", "--org", "--node", USR_NODE, "--profile", "factory", "--", ...serve], 1, "case2-unit: --copy must be a full 40-character lower-case commit id: --org\n"],
|
|
]) {
|
|
const r = unit(h, { argv });
|
|
assert.equal(r.code, code, `${label}: ${r.err}`);
|
|
assert.equal(r.err, err, label);
|
|
assert.equal(r.out, "", label);
|
|
}
|
|
// A selector spelling taken as a library serve flag's value is that flag's text.
|
|
const library = libraryFlags(h).map((f) => (f === "books.example" ? "--org" : f));
|
|
refuses(unit(h, { flags: library }), /^case2-unit: public host must be a hostname, no port or scheme: --org\n$/);
|
|
assert.equal(existsSync(busFile(h)), false);
|
|
assert.equal(existsSync(webuiFile(h)), false);
|
|
assert.equal(existsSync(h.log), false);
|
|
});
|
|
|
|
test("the read-only library profile explicitly refuses --send before writing a unit", (t) => {
|
|
const h = hub(t);
|
|
const r = unit(h, { flags: [...libraryFlags(h), "--send"] });
|
|
refuses(r, /the library profile is read-only; refusing --send until Case 2 supports --sso \(#412\)/);
|
|
assert.equal(existsSync(busFile(h)), false);
|
|
assert.equal(existsSync(webuiFile(h)), false);
|
|
assert.equal(existsSync(h.log), false);
|
|
});
|
|
|
|
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
|
|
const h = hub(t);
|
|
const node = outsideNode();
|
|
// This metadata-only test uses a root-owned executable as a path stand-in.
|
|
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
|
assert.equal(unit(h, { node }).code, 0);
|
|
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
|
|
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
|
|
});
|
|
|
|
test("a failed bus start refuses with the Console not started", (t) => {
|
|
const h = hub(t);
|
|
const r = unit(h, { env: { CASE2_TEST_FAIL: "start [email protected]" } });
|
|
refuses(r, /start mosaic-bus@acme\.service failed; the Console is not started/);
|
|
assert.deepEqual(read(h.log).trim().split("\n").slice(2), ["daemon-reload", "enable [email protected] mosaic-webui.service", "start [email protected]"]);
|
|
});
|
|
|
|
test("the script never links a unit file", () => {
|
|
assert.doesNotMatch(read(UNIT), /systemctl link/);
|
|
});
|
|
|
|
test("factory renders --pm once and one MOSAIC_CLAUDE_BIN line, and without them both tokens render empty", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
|
const bus = read(busFile(h));
|
|
const exec = lines(bus, "ExecStart");
|
|
assert.equal(exec.length, 1);
|
|
assert.match(exec[0], / bus start %i --pm$/);
|
|
assert.equal(exec[0].split("--pm").length, 2);
|
|
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin", `MOSAIC_CLAUDE_BIN=${TRUE_BIN}`]);
|
|
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(" ")}`]);
|
|
|
|
const plain = hub(t);
|
|
assert.equal(unit(plain, { profile: "factory" }).code, 0);
|
|
const text = read(busFile(plain));
|
|
assert.deepEqual(lines(text, "ExecStart"), [`${copyDir(plain)}/scripts/mosaic bus start %i`]);
|
|
assert.doesNotMatch(text, /MOSAIC_CLAUDE_BIN|@PM_FLAGS@|@CLAUDE_ENV@/);
|
|
});
|
|
|
|
test("a re-install over running units says they are still on the old copy and must restart together, never that it started them", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h).code, 0);
|
|
const both = unit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: "[email protected] mosaic-webui.service" } });
|
|
assert.equal(both.code, 0, both.err);
|
|
assert.doesNotMatch(both.out, /started/);
|
|
assert.match(both.out, new RegExp(`mosaic-bus@acme\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
|
assert.match(both.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
|
assert.match(both.out, new RegExp(`restart mosaic-bus@acme\\.service and mosaic-webui\\.service together to put both on copy ${COPY_B}`));
|
|
assert.deepEqual(read(h.log).trim().split("\n").slice(-2), ["start [email protected]", "start mosaic-webui.service"]);
|
|
|
|
// Only the Console ran (the bus had stopped): the bus starts on the new copy, the Console stays.
|
|
const one = unit(h, { copy: COPY_A, env: { CASE2_TEST_ACTIVE: "mosaic-webui.service" } });
|
|
assert.equal(one.code, 0, one.err);
|
|
assert.match(one.out, new RegExp(`started mosaic-bus@acme\\.service on copy ${COPY_A}`));
|
|
assert.match(one.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_B}, not copy ${COPY_A}`));
|
|
assert.doesNotMatch(one.out, /started mosaic-webui/);
|
|
assert.match(one.out, /restart mosaic-bus@acme\.service and mosaic-webui\.service together/);
|
|
});
|
|
|
|
test("a re-install to another copy re-renders both units, and an edited installed unit refuses", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h).code, 0);
|
|
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
|
assert.match(read(busFile(h)), new RegExp(`copies/${COPY_B}/scripts/mosaic`));
|
|
assert.doesNotMatch(read(busFile(h)) + read(webuiFile(h)), new RegExp(COPY_A));
|
|
|
|
const edits = [
|
|
[busFile, (s) => s.replace("User=mosaic", "User=root"), /User= must be exactly once in \[Service\], as User=mosaic/],
|
|
[webuiFile, (s) => s.replace(/^ExecStart=(.*)$/m, "ExecStart=$1 --board http://127.0.0.1:1"), /ExecStart= must be exactly once/],
|
|
[busFile, (s) => s.replace("ProtectHome=yes\n", ""), /ProtectHome= must be exactly once/],
|
|
[webuiFile, (s) => s.replace("PrivateTmp=yes\n", ""), /PrivateTmp= must be exactly once/],
|
|
[busFile, (s) => s.replace("Type=simple", "Type=simple\nX-Note=%h"), /%h is left/],
|
|
];
|
|
for (const [file, edit, pattern] of edits) {
|
|
const before = read(file(h));
|
|
writeFileSync(file(h), edit(before));
|
|
refuses(unit(h, { copy: COPY_B }), pattern);
|
|
writeFileSync(file(h), before);
|
|
}
|
|
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
|
});
|
|
|
|
test("a template with a namespace, user or system call restriction refuses", (t) => {
|
|
const h = hub(t);
|
|
for (const line of ["RestrictNamespaces=yes", "PrivateUsers=yes", "SystemCallFilter=@system-service"]) {
|
|
const key = line.split("=")[0];
|
|
for (const [from, env] of [[BUS_TEMPLATE, "CASE2_BUS_TEMPLATE"], [WEBUI_TEMPLATE, "CASE2_WEBUI_TEMPLATE"]]) {
|
|
const file = template(h, from, `t-${key}.in`, (s) => s.replace("NoNewPrivileges=yes", `NoNewPrivileges=yes\n${line}`));
|
|
refuses(unit(h, { env: { [env]: file } }), new RegExp(`refusing ${key}=`));
|
|
}
|
|
}
|
|
assert.equal(existsSync(busFile(h)), false);
|
|
});
|
|
|
|
test("the bus template's EnvironmentFile line must be there and point at the session env, and the Console template must have none", (t) => {
|
|
const h = hub(t);
|
|
const gone = template(h, BUS_TEMPLATE, "no-env.in", (s) => s.replace(/^EnvironmentFile=.*\n/m, ""));
|
|
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: gone } }), /EnvironmentFile= must be exactly once/);
|
|
const elsewhere = template(h, BUS_TEMPLATE, "env-elsewhere.in", (s) => s.replace("EnvironmentFile=-@SESSION_ENV@", "EnvironmentFile=-/etc/other.env"));
|
|
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: elsewhere } }), /EnvironmentFile= must be exactly once/);
|
|
const added = template(h, WEBUI_TEMPLATE, "webui-env.in", (s) => s.replace("NoNewPrivileges=yes", "NoNewPrivileges=yes\nEnvironmentFile=-/etc/other.env"));
|
|
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: added } }), /refusing EnvironmentFile=/);
|
|
});
|
|
|
|
test("a template holding an unknown token, PermissionsStartOnly or a privileged Exec prefix refuses", (t) => {
|
|
const h = hub(t);
|
|
const token = template(h, BUS_TEMPLATE, "token.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
|
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: token } }), /a token is left after the render: @SECRET@/);
|
|
const webuiToken = template(h, WEBUI_TEMPLATE, "wtoken.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
|
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: webuiToken } }), /a token other than one @FLAGS@/);
|
|
const pso = template(h, BUS_TEMPLATE, "pso.in", (s) => s.replace("Type=simple", "Type=simple\nPermissionsStartOnly=yes"));
|
|
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: pso } }), /refusing PermissionsStartOnly=/);
|
|
for (const pre of ["+/srv/mosaic/x", "-+/srv/mosaic/x"]) {
|
|
const file = template(h, BUS_TEMPLATE, "pre.in", (s) => s.replace("Type=simple", `Type=simple\nExecStartPre=${pre}`));
|
|
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: file } }), /an Exec line that runs with full privileges: ExecStartPre=/);
|
|
}
|
|
});
|
|
|
|
test("a symlink at either unit path refuses", (t) => {
|
|
for (const name of ["[email protected]", "mosaic-webui.service"]) {
|
|
const h = hub(t);
|
|
const target = join(h.root, "elsewhere");
|
|
writeFileSync(target, "");
|
|
symlinkSync(target, join(h.units, name));
|
|
refuses(unit(h), new RegExp(`${name.replace(".", "\\.")} is a symlink`));
|
|
}
|
|
});
|
|
|
|
test("a --node that is relative, not root-owned or under <home> refuses", (t) => {
|
|
const h = hub(t);
|
|
refuses(unit(h, { node: "bin/node" }), /--node must be an absolute path/);
|
|
const mine = script(join(h.root, "opt", "node"), "exit 0");
|
|
refuses(unit(h, { node: mine }), /--node: .* is not owned by root/);
|
|
const inside = script(join(h.home, "bin", "node"), "exit 0");
|
|
refuses(unit(h, { node: inside }), /--node must be outside/);
|
|
});
|
|
|
|
test("a re-install with a different --node or different serve flags refuses", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h).code, 0);
|
|
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
|
|
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
|
|
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
|
|
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
|
refuses(unit(h, { flags }), /installed .*: ExecStart= must be exactly once/);
|
|
// A line check_unit doesn't fix still has to equal the render.
|
|
writeFileSync(busFile(h), read(busFile(h)).replace(/^Description=.*$/m, "Description=edited"));
|
|
refuses(unit(h), /differs from the render in more than the copy id/);
|
|
});
|
|
|
|
test("an installed pair whose units name different copies refuses", (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h).code, 0);
|
|
writeFileSync(webuiFile(h), read(webuiFile(h)).replaceAll(COPY_A, COPY_B));
|
|
refuses(unit(h), new RegExp(`the installed units name different copies, ${COPY_A} and ${COPY_B}`));
|
|
});
|
|
|
|
test("a copy id that is short, uppercase, holds / or .., or names no directory refuses", (t) => {
|
|
const h = hub(t);
|
|
for (const copy of ["a".repeat(39), "A".repeat(40), `${"a".repeat(19)}/${"a".repeat(20)}`, `..${"a".repeat(38)}`]) {
|
|
refuses(unit(h, { copy }), /--copy must be a full 40-character lower-case commit id/);
|
|
}
|
|
refuses(unit(h, { copy: "c".repeat(40) }), /no copy directory/);
|
|
});
|
|
|
|
test("library refuses a privileged-floor miss, a bad proof pair and every flag outside its set", (t) => {
|
|
const h = hub(t);
|
|
const base = libraryFlags(h);
|
|
const without = (...names) => {
|
|
const out = [];
|
|
for (let i = 0; i < base.length; i++) {
|
|
if (names.includes(base[i])) { i++; continue; }
|
|
out.push(base[i]);
|
|
}
|
|
return out;
|
|
};
|
|
refuses(unit(h, { flags: base.map((f) => (f === "443" ? "7332" : f)) }), /--port must be 1\.\.1023/);
|
|
refuses(unit(h, { flags: without("--proof-header", "--proof-file") }), /serve flag --proof-header is required/);
|
|
refuses(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f)) }), /must be an Authentik uid/);
|
|
assert.equal(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f === "melanie" ? "0".repeat(64) : f)) }).code, 0);
|
|
rmSync(busFile(h));
|
|
rmSync(webuiFile(h));
|
|
for (const [flag, value, pattern] of [
|
|
["--tester-port", "7333", /case2-unit.sh retires tester TCP; use --tester-socket/],
|
|
["--tester-socket", null, /the library profile refuses --tester-socket/],
|
|
["--checkout", "/srv/x", /refusing --checkout/],
|
|
["--board", "http://127.0.0.1:7331", /refusing --board/],
|
|
["--sso", null, /refusing --sso/],
|
|
["--nonsense", "1", /unknown serve flag: --nonsense/],
|
|
]) {
|
|
refuses(unit(h, { flags: [...base, flag, ...(value === null ? [] : [value])] }), pattern);
|
|
}
|
|
refuses(unit(h, { flags: base.map((f) => (f === "acme" ? "other" : f)) }), /serve --business other is not the unit's business acme/);
|
|
refuses(unit(h, { extra: ["--pm"] }), /the library profile refuses --pm/);
|
|
refuses(unit(h, { extra: ["--claude", TRUE_BIN] }), /the library profile refuses --claude/);
|
|
|
|
chmodSync(h.proof, 0o644);
|
|
refuses(unit(h), /mode 0600 or 0400, not 644/);
|
|
chmodSync(h.proof, 0o600);
|
|
const link = join(h.secrets, "proof-link");
|
|
symlinkSync(h.proof, link);
|
|
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? link : f)) }), /must not be a symlink/);
|
|
const dataRoot = join(h.home, ".mosaic-dev");
|
|
mkdirSync(dataRoot, { recursive: true });
|
|
const outside = join(dataRoot, "proof");
|
|
writeFileSync(outside, "made-up-proof\n");
|
|
chmodSync(outside, 0o600);
|
|
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? outside : f)) }), /must be under .*secrets, outside the data root/);
|
|
assert.equal(existsSync(busFile(h)), false);
|
|
});
|
|
|
|
// A non-root test can't make a root-owned file under the scratch home, so the
|
|
// owner case names another uid in the passwd database instead.
|
|
test("library refuses a proof file not owned by <user>'s uid", (t) => {
|
|
const h = hub(t, { uid: UID + 4242 });
|
|
refuses(unit(h), new RegExp(`proxy proof file must be owned by mosaic \\(uid ${UID + 4242}\\)`));
|
|
});
|
|
|
|
test("factory refuses duplicate socket, a missing --port or --business, and each proxy flag", (t) => {
|
|
const h = hub(t);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--tester-socket'] }), /given twice/);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), '--port', '443'] }), /given twice/);
|
|
refuses(unit(h, { profile: "factory", flags: ["--business", "acme"] }), /serve flag --port is required/);
|
|
refuses(unit(h, { profile: "factory", flags: ["--port", "443"] }), /serve flag --business is required/);
|
|
for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof]]) {
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), new RegExp(`the factory profile refuses ${flag}`));
|
|
}
|
|
for (const [flag, value, pattern] of [["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--nonsense", "1", /unknown serve flag/]]) {
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), pattern);
|
|
}
|
|
});
|
|
|
|
// #415/#416: the factory Console signs its owner in itself (console.sso) and takes writes only as the
|
|
// paired --sso --send. The pair never unlocks the proxy preview set or the proof pair.
|
|
const ssoConfig = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { providers: [] } } });
|
|
const noUnits = (h) => {
|
|
assert.equal(existsSync(busFile(h)), false);
|
|
assert.equal(existsSync(webuiFile(h)), false);
|
|
assert.equal(existsSync(h.log), false);
|
|
};
|
|
|
|
test("#415 factory with --sso --send renders and the Console unit carries both flags", (t) => {
|
|
const h = hub(t);
|
|
ssoConfig(h);
|
|
const r = unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send"] });
|
|
assert.equal(r.code, 0, r.out + r.err);
|
|
const webui = read(webuiFile(h));
|
|
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --tester-socket --sso --send`]);
|
|
assert.deepEqual(lines(webui, "EnvironmentFile"), []);
|
|
assert.deepEqual(webui.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l)), HARDENING(h));
|
|
// The console.sso check ran as the stack user, through the same sudo as the tester query.
|
|
const sudo = read(h.sudoLog);
|
|
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
|
|
assert.match(sudo, /s\.console&&s\.console\.sso/);
|
|
// Without the tester marker the pair renders too, in the order given.
|
|
const g = hub(t);
|
|
ssoConfig(g);
|
|
assert.equal(unit(g, { profile: "factory", flags: ["--port", "443", "--send", "--business", "acme", "--sso"] }).code, 0);
|
|
assert.deepEqual(lines(read(webuiFile(g)), "ExecStart"), [`${USR_NODE} ${copyDir(g)}/packages/webui/src/cli.mjs serve --port 443 --send --business acme --sso`]);
|
|
});
|
|
|
|
test("#415 factory refuses --sso alone and --send alone before writing a unit", (t) => {
|
|
const h = hub(t);
|
|
ssoConfig(h);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--send"] }), /the factory profile refuses --send without --sso/);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--sso", "--send"] }), /serve flag --sso given twice/);
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send", "--send"] }), /serve flag --send given twice/);
|
|
noUnits(h);
|
|
});
|
|
|
|
test("#415 factory with --sso --send still refuses each proxy preview flag and the proof flags", (t) => {
|
|
const h = hub(t);
|
|
ssoConfig(h);
|
|
for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof]]) {
|
|
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), "--sso", "--send", flag, value] }), new RegExp(`the factory profile refuses ${flag}: its host has no proxy`));
|
|
}
|
|
noUnits(h);
|
|
});
|
|
|
|
test("#415 factory --sso refuses before any unit write when the stack user's config has no console.sso", (t) => {
|
|
const h = hub(t);
|
|
const pair = ["--port", "443", "--business", "acme", "--sso", "--send"];
|
|
refuses(unit(h, { profile: "factory", flags: pair }), /--sso needs console\.sso in mosaic's system config/);
|
|
writeJson(h.query, { dataRoot: h.dataRoot, console: {} });
|
|
refuses(unit(h, { profile: "factory", flags: pair }), /--sso needs console\.sso in mosaic's system config/);
|
|
noUnits(h);
|
|
// Only the exact one-line answer counts; anything else refuses, and so does a failed query.
|
|
for (const [output, status, pattern] of [['{"sso":true}\nforged', "0", /sso config query has invalid facts/], ['{"sso":"true"}', "0", /sso config query has invalid facts/], ['{"sso":true}', "1", /sso config query inner-command/]]) {
|
|
writeFileSync(join(h.root, "query-output"), output);
|
|
writeFileSync(join(h.root, "query-status"), status);
|
|
refuses(unit(h, { profile: "factory", flags: pair }), pattern);
|
|
}
|
|
noUnits(h);
|
|
});
|
|
|
|
// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The
|
|
// stamped copies below sign in through SSO accounts.
|
|
test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => {
|
|
const h = hub(t);
|
|
ssoConfig(h);
|
|
refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/);
|
|
refuses(unit(h, { flags: [...libraryFlags(h), "--sso", "--send"] }), /refusing --sso: the Console signs people in through the proxy/);
|
|
noUnits(h);
|
|
});
|
|
|
|
// ---- M3 (#1873): stamped copies ----
|
|
// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso,
|
|
// an accounts.json that loads and a .deployed naming the default instance. The render asks the
|
|
// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and
|
|
// organization accounts.mjs, and refuses before any write, naming every missing step in order.
|
|
// No real host, config or credential: the issuer is on .invalid and nothing connects to it.
|
|
const ISSUER = "https://idp.invalid/a";
|
|
const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] };
|
|
const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json");
|
|
const deployedFile = (h) => join(h.dataRoot, ".deployed");
|
|
function stamp(h, id = COPY_A) {
|
|
const dir = copyDir(h, id);
|
|
cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs"));
|
|
for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg));
|
|
writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n");
|
|
chmodSync(join(dir, ".stack-build.json"), 0o444);
|
|
}
|
|
const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } });
|
|
function stampAccounts(h, doc = STAMP_ACCOUNTS) {
|
|
writeFileSync(accountsFile(h), JSON.stringify(doc));
|
|
chmodSync(accountsFile(h), 0o600);
|
|
}
|
|
function stampDeployed(h, instance = "dev") {
|
|
writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n");
|
|
chmodSync(deployedFile(h), 0o444);
|
|
}
|
|
function stamped(t, { sso = true, accounts = true, deployed = true } = {}) {
|
|
const h = hub(t);
|
|
stamp(h);
|
|
if (sso) stampSso(h);
|
|
if (accounts) stampAccounts(h);
|
|
if (deployed) stampDeployed(h);
|
|
return h;
|
|
}
|
|
const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"];
|
|
const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"];
|
|
const STEP = {
|
|
deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`,
|
|
deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`,
|
|
sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`,
|
|
accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`,
|
|
accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`,
|
|
library: () => "add --sso to the serve flags, then re-pin",
|
|
factory: () => "add --sso --send to the serve flags, then re-pin",
|
|
};
|
|
// The exact refusal: one header line, then the steps numbered in the run sheet's order.
|
|
function refusesSteps(h, r, steps) {
|
|
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
|
const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n";
|
|
assert.equal(r.err, want);
|
|
}
|
|
// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites.
|
|
function wroteNothing(h, { accounts = false, deployed = false } = {}) {
|
|
noUnits(h);
|
|
assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json");
|
|
assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed");
|
|
}
|
|
|
|
test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => {
|
|
const h = stamped(t);
|
|
const r = unit(h, { flags: STAMP_LIBRARY });
|
|
assert.equal(r.code, 0, r.out + r.err);
|
|
const webui = read(webuiFile(h));
|
|
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]);
|
|
assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/);
|
|
assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
|
// The prerequisites were asked as the stack user, through the copy's own modules.
|
|
const sudo = read(h.sudoLog);
|
|
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
|
|
assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/);
|
|
assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`));
|
|
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
|
// The render read them and left each as it was.
|
|
assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS);
|
|
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
|
});
|
|
|
|
test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => {
|
|
const h = stamped(t);
|
|
for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) {
|
|
const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] });
|
|
assert.equal(r.code, 1, flag);
|
|
assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`);
|
|
}
|
|
// The whole old preview set refuses at its first flag, with or without --sso.
|
|
refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/);
|
|
refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/);
|
|
wroteNothing(h, { accounts: true, deployed: true });
|
|
assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query");
|
|
});
|
|
|
|
test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => {
|
|
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
|
refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]);
|
|
wroteNothing(none);
|
|
refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
|
wroteNothing(none);
|
|
// Each prerequisite alone.
|
|
for (const [name, opts, steps] of [
|
|
["no .deployed", { deployed: false }, [STEP.deployed]],
|
|
["no console.sso", { sso: false }, [STEP.sso]],
|
|
["no accounts.json", { accounts: false }, [STEP.accounts]],
|
|
]) {
|
|
const h = stamped(t, opts);
|
|
refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps);
|
|
wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false });
|
|
}
|
|
const flagless = stamped(t);
|
|
refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]);
|
|
wroteNothing(flagless, { accounts: true, deployed: true });
|
|
});
|
|
|
|
test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => {
|
|
const cases = [
|
|
[".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]],
|
|
[".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]],
|
|
[".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]],
|
|
["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]],
|
|
["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]],
|
|
["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]],
|
|
["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]],
|
|
];
|
|
for (const [name, change, steps] of cases) {
|
|
for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) {
|
|
const h = stamped(t);
|
|
change(h);
|
|
refusesSteps(h, unit(h, { profile, flags }), steps);
|
|
noUnits(h);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => {
|
|
const h = stamped(t);
|
|
const r = unit(h, { profile: "factory", flags: STAMP_FACTORY });
|
|
assert.equal(r.code, 0, r.out + r.err);
|
|
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]);
|
|
const none = stamped(t, { sso: false, accounts: false, deployed: false });
|
|
refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]);
|
|
wroteNothing(none);
|
|
refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]);
|
|
wroteNothing(none);
|
|
// A stamped copy without the flag pair names it as the last step, with the prerequisites present.
|
|
const flagless = stamped(t);
|
|
refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]);
|
|
refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]);
|
|
wroteNothing(flagless, { accounts: true, deployed: true });
|
|
// The pair stays a pair: --sso alone still refuses, as for an unstamped copy.
|
|
refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
|
|
noUnits(flagless);
|
|
});
|
|
|
|
test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => {
|
|
// The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit.
|
|
for (const [body, pattern] of [
|
|
['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/],
|
|
["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/],
|
|
]) {
|
|
const h = stamped(t);
|
|
const org = join(copyDir(h), "packages/organization");
|
|
rmSync(org);
|
|
mkdirSync(join(org, "src"), { recursive: true });
|
|
writeFileSync(join(org, "src/accounts.mjs"), body);
|
|
refuses(unit(h, { flags: STAMP_LIBRARY }), pattern);
|
|
noUnits(h);
|
|
}
|
|
});
|
|
|
|
// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's
|
|
// checks, the installed units' included, and writes nothing; --check-new skips only the installed
|
|
// units' checks, for --next-args, which moves them aside before its render.
|
|
const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) =>
|
|
[mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--",
|
|
...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))];
|
|
const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null));
|
|
const logNow = (h) => (existsSync(h.log) ? read(h.log) : "");
|
|
|
|
test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => {
|
|
const h = hub(t);
|
|
const empty = unit(h, { argv: checkArgv(h) });
|
|
assert.equal(empty.code, 0, empty.out + empty.err);
|
|
assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`);
|
|
noUnits(h);
|
|
assert.equal(unit(h).code, 0);
|
|
const units = unitsNow(h), log = logNow(h);
|
|
for (const copy of [COPY_A, COPY_B]) {
|
|
const r = unit(h, { argv: checkArgv(h, { copy }) });
|
|
assert.equal(r.code, 0, r.out + r.err);
|
|
assert.deepEqual(unitsNow(h), units, copy);
|
|
assert.equal(logNow(h), log, `${copy}: no systemctl call`);
|
|
}
|
|
// The installed-unit match stays: an edit, or other serve flags, refuses as the render would.
|
|
writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited"));
|
|
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/);
|
|
writeFileSync(busFile(h), units[0]);
|
|
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
|
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
|
assert.deepEqual(unitsNow(h), units);
|
|
assert.equal(logNow(h), log);
|
|
});
|
|
|
|
for (const profile of ["library", "factory"]) {
|
|
test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h, { profile }).code, 0);
|
|
stamp(h, COPY_B);
|
|
stampSso(h);
|
|
stampAccounts(h);
|
|
stampDeployed(h);
|
|
const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY;
|
|
const units = unitsNow(h), log = logNow(h);
|
|
refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
|
|
refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/);
|
|
const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) });
|
|
assert.equal(r.code, 0, r.out + r.err);
|
|
assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`);
|
|
// --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse.
|
|
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/);
|
|
rmSync(accountsFile(h));
|
|
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/);
|
|
assert.deepEqual(unitsNow(h), units);
|
|
assert.equal(logNow(h), log, "no systemctl call");
|
|
assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json");
|
|
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
|
|
});
|
|
}
|
|
|
|
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
|
|
const h = hub(t);
|
|
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
|
|
refuses(unit(h, { profile: 'factory', flags: [...factoryFlags(), '7333'] }), /unknown serve flag: 7333/);
|
|
});
|
|
|
|
test("factory refuses a --claude that is relative, not root-owned or under <home>, and a re-install with another --pm or --claude", (t) => {
|
|
const h = hub(t);
|
|
refuses(unit(h, { profile: "factory", extra: ["--claude", "bin/claude"] }), /--claude must be an absolute path/);
|
|
const mine = script(join(h.root, "opt", "claude"), "exit 0");
|
|
refuses(unit(h, { profile: "factory", extra: ["--claude", mine] }), /--claude: .* is not owned by root/);
|
|
const inside = script(join(h.home, "bin", "claude"), "exit 0");
|
|
refuses(unit(h, { profile: "factory", extra: ["--claude", inside] }), /--claude must be outside/);
|
|
|
|
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
|
refuses(unit(h, { profile: "factory", extra: ["--claude", TRUE_BIN] }), /--pm, --claude and --proxy must equal the installed ones|ExecStart= must be exactly once/);
|
|
refuses(unit(h, { profile: "factory", extra: ["--pm"] }), /Environment= lines must be exactly|differs from the render/);
|
|
refuses(unit(h, { profile: "factory", extra: ["--pm", "--claude", USR_NODE] }), /Environment= lines must be exactly|differs from the render/);
|
|
});
|
|
|
|
// ---- case2-precheck.sh ----
|
|
|
|
// A scratch user for the precheck: a config, a business file, an installed Console unit,
|
|
// fakes on PATH and a fake /proc/sys.
|
|
function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\n`, port = "443", busClaude = null } = {}) {
|
|
const root = tmp(t, "case2-pre-");
|
|
const home = join(root, "home");
|
|
const configDir = join(home, ".config", "mosaic-dev");
|
|
const config = writeJson(join(configDir, "config.json"), {
|
|
configVersion: 1,
|
|
environment: "production",
|
|
dataRoot: join(home, ".mosaic-dev"),
|
|
execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "pi" },
|
|
});
|
|
const doc = businessDoc(root);
|
|
doc.roles.cto.vars = { harness: "pi" };
|
|
for (const [name, v] of Object.entries(roles)) doc.roles[name].vars = v;
|
|
Object.assign(doc.vars, vars);
|
|
writeJson(join(configDir, "businesses", "acme.json"), doc);
|
|
const secrets = join(configDir, "secrets");
|
|
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
|
if (keyFile !== null) {
|
|
writeFileSync(join(secrets, "claude-session.env"), keyFile);
|
|
chmodSync(join(secrets, "claude-session.env"), 0o600);
|
|
}
|
|
const units = join(root, "units");
|
|
mkdirSync(units);
|
|
writeFileSync(join(units, "mosaic-webui.service"), `[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port ${port} --business acme\n`);
|
|
writeFileSync(join(units, "[email protected]"), `[Service]\nEnvironment=PATH=/usr/bin\n${busClaude ? `Environment=MOSAIC_CLAUDE_BIN=${busClaude}\n` : ""}`);
|
|
const sys = join(root, "sys");
|
|
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
|
|
mkdirSync(join(sys, "kernel"), { recursive: true });
|
|
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
|
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
|
const bin = join(root, "bin");
|
|
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
|
|
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
|
|
script(join(bin, "sudo"), "exit 1");
|
|
script(join(bin, "id"), `if [ "$1" = -G ]; then echo "\${CASE2_TEST_GROUPS:-${GID}}"; else exec /usr/bin/id "$@"; fi`);
|
|
script(join(bin, "node"), `if [ "$1" = --version ]; then echo "\${CASE2_TEST_NODE:-v24.3.0}"; exit 0; fi\nexec '${process.execPath}' "$@"`);
|
|
script(join(bin, "bwrap"), `exit "\${CASE2_TEST_BWRAP:-0}"`);
|
|
const notify = join(home, ".mosaic-dev", "notify", "acme");
|
|
mkdirSync(notify, { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(notify, "notify.json"), '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
|
|
return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") };
|
|
}
|
|
|
|
// A fake claude: replace answers with the marker and a small prompt; append answers with a large one;
|
|
// nomarker answers with a small prompt and no marker; text answers with plain text, not JSON.
|
|
function fakeClaude(dir, kind) {
|
|
return script(join(dir, "claude"), `[ "$1" = --version ] && { echo "9.9.9 (Claude Code, ${kind} fake)"; exit 0; }
|
|
file=''
|
|
while [ $# -gt 0 ]; do [ "$1" = --system-prompt-file ] && file="$2"; shift; done
|
|
marker="$(awk '{ print $NF }' "$file")"
|
|
${kind === "nomarker" ? "marker=something-else" : ""}
|
|
${kind === "text" ? 'echo "$marker"; exit 0' : ""}
|
|
tokens=${kind === "append" ? 9000 : 40}
|
|
printf '{"result":"%s","usage":{"input_tokens":%s,"cache_read_input_tokens":0}}\\n' "$marker" "$tokens"`);
|
|
}
|
|
|
|
// This host may have a claude in /usr/bin, so this PATH links every /usr/bin entry but that one.
|
|
function pathWithoutClaude(x) {
|
|
const usr = join(x.root, "usr-bin");
|
|
mkdirSync(usr);
|
|
for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name));
|
|
return `${x.bin}:${usr}`;
|
|
}
|
|
|
|
function precheck(x, { profile = "library", env = {}, path, args = ["--business", "acme", "--profile", profile] } = {}) {
|
|
const r = spawnSync(PRECHECK, args, {
|
|
encoding: "utf8",
|
|
timeout: 60000,
|
|
env: {
|
|
PATH: path ?? `${x.bin}:/usr/bin:/bin`,
|
|
HOME: x.home,
|
|
MOSAIC_CONFIG: x.config,
|
|
CASE2_UNIT_DIR: x.units,
|
|
CASE2_PROC_SYS: x.sys,
|
|
CASE2_GETENT: join(x.bin, "getent"),
|
|
...env,
|
|
},
|
|
});
|
|
const out = r.stdout + r.stderr;
|
|
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the precheck printed the fixture key");
|
|
return { code: r.status, out };
|
|
}
|
|
|
|
function blocked(r, pattern) {
|
|
assert.equal(r.code, 1, r.out);
|
|
assert.match(r.out, pattern);
|
|
}
|
|
|
|
test("the precheck passes on a good host and prints free memory, without the key", (t) => {
|
|
const x = host(t);
|
|
const r = precheck(x);
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.match(r.out, /ok {6}config validates/);
|
|
assert.match(r.out, /ok {6}bus\.tester is off/);
|
|
assert.match(r.out, /ok {6}net\.ipv4\.ip_unprivileged_port_start 1024 is above the Console's port 443/);
|
|
assert.match(r.out, /claude-session\.env sets ZAI_API_KEY/);
|
|
assert.match(r.out, /free memory: \d+ MiB/);
|
|
assert.doesNotMatch(r.out, /claude .*fake|marker/);
|
|
});
|
|
|
|
// The selector (rename design r2): --org and --business are one option in the precheck's scope, and a
|
|
// bad one refuses before any check line, with the script's usage exit 4.
|
|
test("the precheck takes --org or --business, alone or matching in either order, and refuses a bad selector before any check", (t) => {
|
|
const x = host(t);
|
|
for (const sel of [["--business", "acme"], ["--org", "acme"], ["--business", "acme", "--org", "acme"], ["--org", "acme", "--business", "acme"]]) {
|
|
const r = precheck(x, { args: [...sel, "--profile", "library"] });
|
|
assert.equal(r.code, 0, `${sel.join(" ")}: ${r.out}`);
|
|
assert.match(r.out, /ok {6}config validates/, sel.join(" "));
|
|
}
|
|
const usage = "usage: case2-precheck.sh --business ID [--profile factory|library] | --business ID --library-status\n";
|
|
const notId = (v) => `case2-precheck: --business must be a business id: ${v}\n`;
|
|
for (const [args, out] of [
|
|
[["--business", "acme", "--org", "beta"], "case2-precheck: --business acme and --org beta conflict\n"],
|
|
[["--org", "acme", "--business", "beta"], "case2-precheck: --org acme and --business beta conflict\n"],
|
|
[["--business", "acme", "--business", "acme"], "case2-precheck: --business given twice\n"],
|
|
[["--org", "acme", "--org", "acme"], "case2-precheck: --org given twice\n"],
|
|
[["--org"], usage],
|
|
[["--business"], usage],
|
|
[["--org", ""], usage],
|
|
[["--org", "--profile", "library"], notId("--profile")],
|
|
[["--business", "-x"], notId("-x")],
|
|
[["--org", "Acme"], notId("Acme")],
|
|
[["--org", "acme/../x"], notId("acme/../x")],
|
|
]) {
|
|
const r = precheck(x, { args });
|
|
assert.equal(r.code, 4, args.join(" "));
|
|
assert.equal(r.out, out, args.join(" "));
|
|
}
|
|
// A selector spelling taken as --profile's value is that option's text.
|
|
const r = precheck(x, { args: ["--profile", "--org", "--business", "acme"] });
|
|
assert.equal(r.code, 4, r.out);
|
|
assert.equal(r.out, "case2-precheck: --profile must be factory or library: --org\n");
|
|
});
|
|
|
|
test("the precheck refuses when its selector.sh is missing, before any check", (t) => {
|
|
const x = host(t), lone = join(x.root, "lone", "scripts", "case2-precheck.sh");
|
|
mkdirSync(dirname(lone), { recursive: true });
|
|
cpSync(PRECHECK, lone);
|
|
const r = spawnSync(lone, ["--org", "acme"], { encoding: "utf8", env: { PATH: `${x.bin}:/usr/bin:/bin`, HOME: x.home } });
|
|
assert.equal(r.status, 4, r.stdout + r.stderr);
|
|
assert.equal(r.stdout, "");
|
|
assert.ok(r.stderr.endsWith(`case2-precheck: cannot read ${join(x.root, "lone", "scripts", "selector.sh")}\n`), r.stderr);
|
|
});
|
|
|
|
test("the precheck refuses each failed host check", (t) => {
|
|
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
|
|
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
|
|
const sudo = host(t);
|
|
script(join(sudo.bin, "sudo"), "exit 0");
|
|
blocked(precheck(sudo), /REFUSED sudo -n true succeeds/);
|
|
blocked(precheck(host(t), { env: { XDG_DATA_HOME: "/elsewhere" } }), /REFUSED XDG_DATA_HOME is \/elsewhere/);
|
|
const home = host(t);
|
|
assert.equal(precheck(home, { env: { XDG_DATA_HOME: `${home.home}/.local/share` } }).code, 0);
|
|
blocked(precheck(host(t), { env: { HOME: "/tmp" } }), /REFUSED HOME is \/tmp/);
|
|
|
|
const runs = host(t);
|
|
mkdirSync(join(runs.dataRoot, "launches", "acme"), { recursive: true, mode: 0o755 });
|
|
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o755);
|
|
blocked(precheck(runs), /REFUSED run root .* is not a 0700 directory \(mode 755\)/);
|
|
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o700);
|
|
assert.match(precheck(runs).out, /ok {6}run root .* is 0700/);
|
|
|
|
const bad = host(t);
|
|
writeFileSync(bad.config, "{ not json");
|
|
blocked(precheck(bad), /REFUSED config:/);
|
|
});
|
|
|
|
test("the precheck refuses a user missing from passwd, a business file that does not load, and a symlinked run root", (t) => {
|
|
const nouser = host(t);
|
|
script(join(nouser.bin, "getent"), "exit 2");
|
|
blocked(precheck(nouser), /REFUSED user: no .* in the passwd database/);
|
|
|
|
const unparsed = host(t);
|
|
writeFileSync(join(unparsed.configDir, "businesses", "acme.json"), "{ not json");
|
|
blocked(precheck(unparsed), /REFUSED business file: cannot read .*acme\.json/);
|
|
const gone = host(t);
|
|
rmSync(join(gone.configDir, "businesses", "acme.json"));
|
|
blocked(precheck(gone), /REFUSED business file: cannot read .*acme\.json: ENOENT/);
|
|
const invalid = host(t);
|
|
const file = join(invalid.configDir, "businesses", "acme.json");
|
|
const doc = JSON.parse(read(file));
|
|
doc.notAField = true;
|
|
writeJson(file, doc);
|
|
const r = precheck(invalid);
|
|
blocked(r, /REFUSED business file: /);
|
|
assert.doesNotMatch(r.out, /REFUSED business file: cannot read/);
|
|
|
|
// stat follows a link, so a link to a 0700 directory would read as 0700.
|
|
const link = host(t);
|
|
const target = join(link.root, "elsewhere");
|
|
mkdirSync(target, { mode: 0o700 });
|
|
chmodSync(target, 0o700);
|
|
mkdirSync(join(link.dataRoot, "launches"), { recursive: true });
|
|
symlinkSync(target, join(link.dataRoot, "launches", "acme"));
|
|
blocked(precheck(link), /REFUSED run root .* is a symlink/);
|
|
});
|
|
|
|
test("a bwrap that fails refuses and prints the sysctl and where bwrap is", (t) => {
|
|
const x = host(t);
|
|
blocked(precheck(x, { env: { CASE2_TEST_BWRAP: "1" } }), new RegExp(`REFUSED bwrap .* failed; bwrap: ${join(x.bin, "bwrap")}; kernel\\.apparmor_restrict_unprivileged_userns: 1`));
|
|
});
|
|
|
|
test("an unprivileged port floor at or below the Console's port refuses, and so does a missing Console unit", (t) => {
|
|
for (const floor of ["443", "80"]) {
|
|
const x = host(t);
|
|
writeFileSync(join(x.sys, "net", "ipv4", "ip_unprivileged_port_start"), `${floor}\n`);
|
|
blocked(precheck(x), new RegExp(`REFUSED net\\.ipv4\\.ip_unprivileged_port_start is ${floor}, at or below the Console's port 443`));
|
|
}
|
|
const x = host(t);
|
|
rmSync(join(x.units, "mosaic-webui.service"));
|
|
blocked(precheck(x), /REFUSED no installed Console unit/);
|
|
const noPort = host(t);
|
|
writeFileSync(join(noPort.units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --business acme\n");
|
|
blocked(precheck(noPort), /REFUSED .*mosaic-webui\.service has no --port in its ExecStart/);
|
|
const noFloor = host(t);
|
|
rmSync(join(noFloor.sys, "net", "ipv4", "ip_unprivileged_port_start"));
|
|
blocked(precheck(noFloor), /REFUSED cannot read net\.ipv4\.ip_unprivileged_port_start/);
|
|
});
|
|
|
|
test("bus.tester: library refuses it on, factory needs it on, and factory refuses a library business", (t) => {
|
|
blocked(precheck(host(t, { vars: { "bus.tester": true } })), /REFUSED bus\.tester is on; the library profile needs it off/);
|
|
blocked(precheck(host(t), { profile: "factory" }), /REFUSED bus\.tester is off; the factory profile needs it on/);
|
|
const on = precheck(host(t, { vars: { "bus.tester": true } }), { profile: "factory" });
|
|
assert.equal(on.code, 0, on.out);
|
|
const lib = host(t, { vars: { "bus.tester": true } });
|
|
const file = join(lib.configDir, "businesses", "acme.json");
|
|
const doc = JSON.parse(read(file));
|
|
doc.projects.stack.kind = "library";
|
|
writeJson(file, doc);
|
|
blocked(precheck(lib, { profile: "factory" }), /REFUSED profile-library-business: .* projects stack are library projects/);
|
|
});
|
|
|
|
test("the claude check runs only for a claude-code instance, and refuses a claude that appends or answers wrong", (t) => {
|
|
// No claude-code instance and no claude anywhere on PATH: the precheck passes.
|
|
const none = host(t);
|
|
const r = precheck(none, { path: pathWithoutClaude(none) });
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.doesNotMatch(r.out, /--system-prompt-file|claude is installed/);
|
|
|
|
const good = host(t, { roles: { cto: { harness: "claude-code" } } });
|
|
fakeClaude(good.bin, "replace");
|
|
const pass = precheck(good);
|
|
assert.equal(pass.code, 0, pass.out);
|
|
assert.match(pass.out, /claude .*9\.9\.9 \(Claude Code, replace fake\)/);
|
|
assert.match(pass.out, /ok {6}claude replaces its prompt with --system-prompt-file \(40 input tokens\)/);
|
|
|
|
const bad = host(t, { roles: { cto: { harness: "claude-code" } } });
|
|
fakeClaude(bad.bin, "append");
|
|
blocked(precheck(bad), /REFUSED claude adds to its own prompt with --system-prompt-file: 9000 input tokens/);
|
|
|
|
const nomarker = host(t, { roles: { cto: { harness: "claude-code" } } });
|
|
fakeClaude(nomarker.bin, "nomarker");
|
|
blocked(precheck(nomarker), /REFUSED claude's reply to the marker prompt lacks the marker \(40 input tokens\)/);
|
|
|
|
const text = host(t, { roles: { cto: { harness: "claude-code" } } });
|
|
fakeClaude(text.bin, "text");
|
|
blocked(precheck(text), /REFUSED claude gave no JSON reply to the marker prompt/);
|
|
|
|
const missing = host(t, { roles: { cto: { harness: "claude-code" } } });
|
|
blocked(precheck(missing, { path: pathWithoutClaude(missing) }), /REFUSED an instance runs claude-code and no claude is installed/);
|
|
});
|
|
|
|
test("the claude check runs the bus unit's MOSAIC_CLAUDE_BIN over claude on PATH", (t) => {
|
|
const pinned = join(tmp(t, "case2-claude-"), "pinned");
|
|
const replace = fakeClaude(pinned, "replace");
|
|
const x = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: replace });
|
|
fakeClaude(x.bin, "append");
|
|
const r = precheck(x);
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.match(r.out, /replace fake/);
|
|
|
|
const pinnedBad = join(tmp(t, "case2-claude-"), "pinned");
|
|
const append = fakeClaude(pinnedBad, "append");
|
|
const y = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: append });
|
|
fakeClaude(y.bin, "replace");
|
|
blocked(precheck(y), /REFUSED claude adds to its own prompt/);
|
|
});
|
|
|
|
test("the session env file refuses when missing, open to others, a symlink, empty, or emptied by a later line", (t) => {
|
|
blocked(precheck(host(t, { keyFile: null })), /REFUSED an instance's provider is zai and .*claude-session\.env is missing/);
|
|
const open = host(t);
|
|
chmodSync(join(open.secrets, "claude-session.env"), 0o644);
|
|
blocked(precheck(open), /REFUSED .*claude-session\.env must be owned by .* at 0600 \(mode 644\)/);
|
|
const link = host(t, { keyFile: null });
|
|
const real = join(link.root, "real.env");
|
|
writeFileSync(real, `ZAI_API_KEY=${FIXTURE_KEY}\n`);
|
|
chmodSync(real, 0o600);
|
|
symlinkSync(real, join(link.secrets, "claude-session.env"));
|
|
blocked(precheck(link), /REFUSED .*claude-session\.env is a symlink/);
|
|
blocked(precheck(host(t, { keyFile: "ZAI_API_KEY=\n" })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
|
blocked(precheck(host(t, { keyFile: `ZAI_API_KEY=${FIXTURE_KEY}\nZAI_API_KEY=\n` })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
|
const quoted = precheck(host(t, { keyFile: `ZAI_API_KEY=\nZAI_API_KEY="${FIXTURE_KEY}"\n` }));
|
|
assert.equal(quoted.code, 0, quoted.out);
|
|
});
|
|
|
|
// ---- the bind test (mos-1100) ----
|
|
|
|
// A session can't take the Console's port: inside the sandbox a real session gets, a bind below
|
|
// 1024 fails with EACCES, and the same bind on a free port of 1024 or above succeeds.
|
|
const sandboxSkip = sandboxProblem() ?? false;
|
|
const LOW_PORT = 980;
|
|
test("inside a session's sandbox a bind below 1024 fails with EACCES, and a bind on a free high port succeeds", { skip: sandboxSkip, timeout: 30000 }, async (t) => {
|
|
const floor = Number(read("/proc/sys/net/ipv4/ip_unprivileged_port_start").trim());
|
|
assert.ok(floor > LOW_PORT, `this host's net.ipv4.ip_unprivileged_port_start is ${floor}, at or below the test port ${LOW_PORT}, so the test can't show the sandbox refuses it`);
|
|
const root = tmp(t, "c2b-");
|
|
const runDir = join(root, "run");
|
|
const workspace = join(root, "ws");
|
|
mkdirSync(runDir);
|
|
mkdirSync(workspace);
|
|
mkdirSync(join(runDir, "bundle"));
|
|
writeFileSync(join(runDir, "session.json"), "{}");
|
|
const adapter = join(root, "adapter.sh");
|
|
const brokerSocket = join(root, "broker.sock");
|
|
writeFileSync(adapter, "");
|
|
writeFileSync(brokerSocket, "");
|
|
prepareRun(runDir);
|
|
const list = binds({ runDir, workspace, adapter, harnessMounts: [], brokerSocket });
|
|
const high = await new Promise((done) => {
|
|
const s = createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => done(port)); });
|
|
});
|
|
const bindIn = (port) => {
|
|
const code = `require("net").createServer().on("error", (e) => { console.log(e.code); process.exit(0); }).listen(${port}, "127.0.0.1", function () { console.log("bound"); this.close(); });`;
|
|
const argv = sandboxArgv(list, runDir, [process.execPath, "-e", code], { business: "acme", role: "coder" });
|
|
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
return r.stdout.trim();
|
|
};
|
|
assert.equal(bindIn(LOW_PORT), "EACCES");
|
|
assert.equal(bindIn(high), "bound");
|
|
});
|
|
|
|
test("Vikunja 614 factory: scoped @anthropic-ai Claude path renders and passes installed read-back", (t) => {
|
|
const h = hub(t);
|
|
const claude = script(join(h.root, "opt", "claude-pin", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), "exit 0");
|
|
// The suite runs unprivileged. Model root ownership for this regular executable
|
|
// and its ancestors only; file type/executable checks and both unit readers are real.
|
|
const ancestors = [];
|
|
for (let p = claude; p !== "/"; p = dirname(p)) ancestors.push(p);
|
|
script(join(h.bin, "stat"), `if [ "$1" = -c ] && [ "$3" = -- ]; then
|
|
case "$4" in ${ancestors.map((p) => JSON.stringify(p)).join("|")})
|
|
case "$2" in %u) echo 0; exit ;; %a) echo 755; exit ;; esac ;;
|
|
esac
|
|
fi
|
|
exec /usr/bin/stat "$@"`);
|
|
const first = unit(h, { profile: "factory", extra: ["--claude", claude] });
|
|
assert.equal(first.code, 0, first.out + first.err);
|
|
assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`));
|
|
const again = unit(h, { profile: "factory", copy: COPY_B, extra: ["--claude", claude] });
|
|
assert.equal(again.code, 0, again.out + again.err);
|
|
assert.ok(lines(read(busFile(h)), "Environment").includes(`MOSAIC_CLAUDE_BIN=${claude}`));
|
|
assert.deepEqual(lines(read(busFile(h)), "WorkingDirectory"), [copyDir(h, COPY_B)]);
|
|
});
|
|
|
|
test("Vikunja 614 factory: plain paths still refuse every named unsafe character", (t) => {
|
|
const h = hub(t);
|
|
for (const char of [" ", "%", "$", "\"", "'", "\\", "&", "|"]) {
|
|
refuses(unit(h, { profile: "factory", extra: ["--claude", `/opt/claude${char}bin`] }), /--claude contains a character the unit cannot carry/);
|
|
}
|
|
});
|
|
|
|
test("Vikunja 614 precheck: explicit null and named notifier decisions pass", (t) => {
|
|
for (const binding of [null, "host-notify"]) {
|
|
const x = host(t);
|
|
const dir = join(x.dataRoot, "notify", "acme");
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(dir, "notify.json"), JSON.stringify({ notifyVersion: 1, binding }), { mode: 0o600 });
|
|
const r = precheck(x);
|
|
assert.equal(r.code, 0, r.out);
|
|
assert.ok(r.out.includes(`ok notifier config ${dir}/notify.json validates (directory 0700, file 0600)`), r.out);
|
|
}
|
|
});
|
|
|
|
test("Vikunja 614 precheck: notifier config refuses missing, public, malformed and extra-key decisions", (t) => {
|
|
for (const fault of ["missing", "mode", "directory", "extra", "json", "binding", "version", "symlink"]) {
|
|
const x = host(t);
|
|
const dir = join(x.dataRoot, "notify", "acme"), file = join(dir, "notify.json");
|
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
writeFileSync(file, '{"notifyVersion":1,"binding":null}\n', { mode: 0o600 });
|
|
if (fault === "missing") rmSync(file);
|
|
if (fault === "mode") chmodSync(file, 0o644);
|
|
if (fault === "directory") chmodSync(dir, 0o755);
|
|
if (fault === "extra") writeFileSync(file, '{"notifyVersion":1,"binding":null,"extra":true}\n');
|
|
if (fault === "json") writeFileSync(file, '{bad json\n');
|
|
if (fault === "binding") writeFileSync(file, '{"notifyVersion":1,"binding":"bad binding"}\n');
|
|
if (fault === "version") writeFileSync(file, '{"notifyVersion":2,"binding":null}\n');
|
|
if (fault === "symlink") {
|
|
const target = join(dir, "target.json");
|
|
writeFileSync(target, readFileSync(file), { mode: 0o600 });
|
|
rmSync(file); symlinkSync(target, file);
|
|
}
|
|
const r = precheck(x);
|
|
assert.equal(r.code, 1, `${fault}: ${r.out}`);
|
|
assert.match(r.out, /REFUSED notifier config:/, `${fault}: ${r.out}`);
|
|
}
|
|
});
|
|
|
|
|
|
// Row334: render only into the scratch hub; fake systemctl never touches the host.
|
|
const PROXY_TEMPLATE = join(REPO, 'packages/cli/systemd/mosaic-proxy.service.in');
|
|
const proxyFile = h => join(h.units, 'mosaic-proxy.service');
|
|
const proxyUnit = (h, options = {}) => unit(h, { ...options, extra: ['--proxy', ...(options.extra ?? [])] });
|
|
|
|
// Frozen bytes of the renderer just before row 334 that already carries #385 (418f00bce:scripts/case2-unit.sh,
|
|
// git blob d669e8cbd). A fixture, not a git-history read, so a shallow clone or an exported tree still has it.
|
|
// The .txt suffix keeps it out of any *.sh enumeration; the test writes it out as an executable in the scratch hub.
|
|
const PRE_ROW334_RENDERER = join(REPO, 'packages/cli/tests/fixtures/case2-unit-pre-row334-renderer.sh.txt');
|
|
const PRE_ROW334_BLOB = 'd669e8cbde7f16c9f6d16b8e550935f9fdfd9df4';
|
|
|
|
test('row334 no-proxy renders stay byte-identical to the pre-row renderer', t => {
|
|
const h = hub(t);
|
|
// Executed only with the same scratch seams. The blob id check stops a silent edit of the fixture.
|
|
const bytes = readFileSync(PRE_ROW334_RENDERER);
|
|
assert.equal(createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'), PRE_ROW334_BLOB);
|
|
const old = join(h.root, 'baseline/scripts/case2-unit.sh');
|
|
mkdirSync(dirname(old), { recursive: true });
|
|
cpSync(PRE_ROW334_RENDERER, old);
|
|
chmodSync(old, 0o755);
|
|
const args = ['--user', 'mosaic', '--home', h.home, '--business', 'acme', '--copy', COPY_A, '--node', USR_NODE, '--', ...libraryFlags(h)];
|
|
const r = spawnSync(old, args, { encoding: 'utf8', env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units,
|
|
CASE2_SYSTEMCTL: join(h.bin, 'systemctl'), CASE2_GETENT: join(h.bin, 'getent'), CASE2_BUS_TEMPLATE: BUS_TEMPLATE, CASE2_WEBUI_TEMPLATE: WEBUI_TEMPLATE } });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
const before = [read(busFile(h)), read(webuiFile(h))];
|
|
assert.equal(unit(h).code, 0);
|
|
assert.deepEqual([read(busFile(h)), read(webuiFile(h))], before);
|
|
assert.equal(existsSync(proxyFile(h)), false);
|
|
});
|
|
|
|
test('row334 proxy uses P1 runner, bus dependencies and proxy bus Console start order', t => {
|
|
const h = hub(t);
|
|
const r = proxyUnit(h, { profile: 'factory', flags: ['--port', '443', '--business', 'acme'], extra: ['--claude', TRUE_BIN] });
|
|
assert.equal(r.code, 0, r.err);
|
|
const proxy = read(proxyFile(h));
|
|
const expected = read(PROXY_TEMPLATE).replaceAll('@REPO@', copyDir(h)).replaceAll('@PATH@', '/usr/bin:/usr/local/bin:/bin')
|
|
.replaceAll('@NODE@', USR_NODE).replace(/^@CONFIG_ENV@\n/m, '').replace('WantedBy=default.target', 'WantedBy=multi-user.target')
|
|
.replace('TimeoutStopSec=60', `TimeoutStopSec=60\nUser=mosaic\nGroup=mosaic\n${HARDENING(h).join('\n')}`);
|
|
assert.equal(proxy, expected);
|
|
assert.equal(statSync(proxyFile(h)).mode & 0o777, 0o644);
|
|
assert.match(read(busFile(h)), /Description=[^\n]+\nWants=mosaic-proxy.service\nAfter=mosaic-proxy.service\n/);
|
|
assert.deepEqual(lines(proxy, 'ExecStart'), [`${USR_NODE} ${copyDir(h)}/packages/model-proxy/src/run.mjs`]);
|
|
assert.doesNotMatch(proxy, /EnvironmentFile=|Capabilities=|CapabilityBoundingSet=|MOSAIC_CLAUDE_BIN/);
|
|
assert.doesNotMatch(read(webuiFile(h)), /Wants=|After=/);
|
|
assert.deepEqual(read(h.log).trim().split('\n'), [
|
|
'is-active --quiet mosaic-proxy.service', 'is-active --quiet [email protected]', 'is-active --quiet mosaic-webui.service',
|
|
'daemon-reload', 'enable mosaic-proxy.service [email protected] mosaic-webui.service',
|
|
'start mosaic-proxy.service', 'start [email protected]', 'start mosaic-webui.service',
|
|
]);
|
|
});
|
|
|
|
const proxyRefusals = [
|
|
['continuation', s => s.replace('Type=simple', 'Type=simple' + String.fromCharCode(92)), /a line continues onto the next/],
|
|
['not-setting', s => s.replace('Type=simple', 'not a setting'), /neither a section/],
|
|
...['RestrictNamespaces', 'PrivateUsers', 'SystemCallFilter', 'PermissionsStartOnly', 'EnvironmentFile', 'AmbientCapabilities', 'CapabilityBoundingSet'].map(k =>
|
|
[k, s => s.replace('Type=simple', `Type=simple\n${k}=yes`), new RegExp(`refusing ${k}=`)]),
|
|
['privileged', s => s.replace('ExecStart=', 'ExecStart=+'), /Exec line that runs with full privileges/],
|
|
['direct-binary', s => s.replace('packages/model-proxy/src/run.mjs', 'opt/cli-proxy-api'), /ExecStart= must be exactly once/],
|
|
['nnp', s => s.replace('NoNewPrivileges=yes', 'NoNewPrivileges=no'), /NoNewPrivileges= must be exactly once/],
|
|
['cwd', s => s.replace('WorkingDirectory=@REPO@', 'WorkingDirectory=/elsewhere'), /WorkingDirectory= must be exactly once/],
|
|
['environment', s => s.replace('Environment=PATH=@PATH@', 'Environment=PATH=/elsewhere'), /Environment= lines must be exactly/],
|
|
['install', s => s.replace('WantedBy=default.target', 'WantedBy=other.target'), /WantedBy= must be exactly once/],
|
|
['user', s => s.replace('Type=simple', 'Type=simple\nUser=other'), /User= must be exactly once/],
|
|
['group', s => s.replace('Type=simple', 'Type=simple\nGroup=other'), /Group= must be exactly once/],
|
|
...['ProtectSystem', 'ReadWritePaths', 'ProtectHome', 'PrivateTmp'].map(k =>
|
|
[k, s => s.replace('Type=simple', `Type=simple\n${k}=other`), new RegExp(`${k}= must be exactly once`)]),
|
|
['token', s => s.replace('Type=simple', 'Type=simple\nX-Note=@UNKNOWN@'), /a token is left after the render/],
|
|
];
|
|
for (const [name, edit, message] of proxyRefusals) test(`row334 proxy refuses ${name} by message before writing`, t => {
|
|
const h = hub(t);
|
|
const file = template(h, PROXY_TEMPLATE, `${name}.in`, edit);
|
|
refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: file } }), message);
|
|
assert.deepEqual(readdirSync(h.units), []);
|
|
assert.equal(existsSync(h.log), false);
|
|
});
|
|
|
|
test('row334 proxy template missing or symlink refuses', t => {
|
|
const h = hub(t), missing = join(h.root, 'missing.in');
|
|
refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: missing } }), /template missing or not a regular file/);
|
|
symlinkSync(PROXY_TEMPLATE, missing);
|
|
refuses(proxyUnit(h, { env: { CASE2_PROXY_TEMPLATE: missing } }), /template missing or not a regular file/);
|
|
});
|
|
|
|
test('row334 rendered ExecStart refuses wrong PIN and missing proxy with P1 status', t => {
|
|
const h = hub(t);
|
|
assert.equal(proxyUnit(h).code, 0);
|
|
const copy = copyDir(h);
|
|
cpSync(join(REPO, 'packages/model-proxy'), join(copy, 'packages/model-proxy'), { recursive: true });
|
|
cpSync(join(REPO, 'scripts/mosaic-config.mjs'), join(copy, 'scripts/mosaic-config.mjs'));
|
|
const config = systemConfig(join(h.root, 'runner'));
|
|
const doc = JSON.parse(read(config));
|
|
mkdirSync(doc.dataRoot, { recursive: true, mode: 0o700 });
|
|
const pin = JSON.parse(read(join(REPO, 'packages/model-proxy/PIN')));
|
|
const binary = join(doc.dataRoot, 'opt/cliproxyapi', pin.version, 'cli-proxy-api');
|
|
script(binary, `echo unexpected-spawn > '${h.root}/spawned'`);
|
|
writeJson(join(dirname(config), 'proxy.json'), { port: 18541, binary });
|
|
const argv = lines(read(proxyFile(h)), 'ExecStart')[0].split(' ');
|
|
const run = () => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', env: { PATH: '/usr/bin:/bin', HOME: h.home, MOSAIC_CONFIG: config } });
|
|
const bad = run();
|
|
assert.equal(bad.status, 3, bad.stderr);
|
|
assert.equal(bad.stderr, 'proxy-install-invalid\n');
|
|
assert.equal(existsSync(join(h.root, 'spawned')), false);
|
|
rmSync(join(dirname(config), 'proxy.json'));
|
|
const absent = run();
|
|
assert.equal(absent.status, 3, absent.stderr);
|
|
assert.equal(absent.stderr, 'proxy-not-configured\n');
|
|
});
|
|
|
|
test('row334 reinstall repins all three and refuses a proxy copy mismatch', t => {
|
|
const h = hub(t);
|
|
assert.equal(proxyUnit(h).code, 0);
|
|
assert.equal(proxyUnit(h, { copy: COPY_B }).code, 0);
|
|
for (const file of [proxyFile(h), busFile(h), webuiFile(h)]) assert.match(read(file), new RegExp(copyDir(h, COPY_B)));
|
|
writeFileSync(proxyFile(h), read(proxyFile(h)).replaceAll(copyDir(h, COPY_B), copyDir(h)));
|
|
const before = [proxyFile(h), busFile(h), webuiFile(h)].map(read);
|
|
refuses(proxyUnit(h), /installed units name different copies/);
|
|
assert.deepEqual([proxyFile(h), busFile(h), webuiFile(h)].map(read), before);
|
|
});
|
|
|
|
test('row334 installed proxy without --proxy refuses and preserves all bytes', t => {
|
|
const h = hub(t);
|
|
assert.equal(proxyUnit(h).code, 0);
|
|
const files = [proxyFile(h), busFile(h), webuiFile(h)], before = files.map(read);
|
|
writeFileSync(h.log, '');
|
|
refuses(unit(h), /installed mosaic-proxy.service requires --proxy/);
|
|
assert.deepEqual(files.map(read), before);
|
|
assert.equal(read(h.log), '');
|
|
});
|
|
|
|
test('row334 adding --proxy to a no-proxy install refuses by naming --proxy and preserves all bytes', t => {
|
|
const h = hub(t);
|
|
assert.equal(unit(h).code, 0);
|
|
const files = [busFile(h), webuiFile(h)], before = files.map(read);
|
|
writeFileSync(h.log, '');
|
|
refuses(proxyUnit(h), /--pm, --claude and --proxy must equal the installed ones/);
|
|
assert.deepEqual(files.map(read), before);
|
|
assert.equal(existsSync(proxyFile(h)), false);
|
|
assert.equal(read(h.log), '');
|
|
});
|
|
|
|
test('row334 dangling proxy symlink without flag refuses without deleting', t => {
|
|
const h = hub(t);
|
|
symlinkSync(join(h.root, 'absent'), proxyFile(h));
|
|
refuses(unit(h), /installed mosaic-proxy.service requires --proxy/);
|
|
assert.equal(readdirSync(h.units).includes('mosaic-proxy.service'), true);
|
|
});
|
|
|
|
test('row334 active proxy reports old copy and all three must restart together', t => {
|
|
const h = hub(t);
|
|
assert.equal(proxyUnit(h).code, 0);
|
|
const r = proxyUnit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: 'mosaic-proxy.service [email protected] mosaic-webui.service' } });
|
|
assert.equal(r.code, 0, r.err);
|
|
assert.match(r.out, /mosaic-proxy.service was already running/);
|
|
assert.match(r.out, /restart mosaic-proxy.service, [email protected] and mosaic-webui.service together/);
|
|
});
|
|
for (const name of ['mosaic-proxy.service', '[email protected]']) test(`row334 failed ${name} start leaves later units unstarted`, t => {
|
|
const h = hub(t);
|
|
refuses(proxyUnit(h, { env: { CASE2_TEST_FAIL: `start ${name}` } }), /systemctl start .* failed/);
|
|
assert.doesNotMatch(read(h.log), /start mosaic-webui.service/);
|
|
if (name === 'mosaic-proxy.service') assert.doesNotMatch(read(h.log), /start [email protected]/);
|
|
});
|
|
|
|
|
|
test('row334 installed proxy enforces hardening order and remaining common refusals', t => {
|
|
const h = hub(t);
|
|
assert.equal(proxyUnit(h).code, 0);
|
|
const original = read(proxyFile(h));
|
|
for (const [edit, message] of [
|
|
[s => s.replace('ProtectSystem=strict\nReadWritePaths=' + h.home, 'ReadWritePaths=' + h.home + '\nProtectSystem=strict'), /hardening lines must be in the order/],
|
|
[s => s.replace('WorkingDirectory=' + copyDir(h), 'WorkingDirectory=%h'), /names no copy/],
|
|
[s => s.replace('Type=simple', 'Type=simple\nX-Note=%h'), /%h is left; a system unit/],
|
|
[s => s.replace('RestartSec=15', 'RestartSec=16'), /differs from the render in more than the copy id/],
|
|
]) {
|
|
writeFileSync(proxyFile(h), edit(original));
|
|
refuses(proxyUnit(h), message);
|
|
}
|
|
});
|
|
|
|
test('row334 proxy installed path rejects symlink and non-regular file', t => {
|
|
const h = hub(t);
|
|
symlinkSync(join(h.root, 'absent'), proxyFile(h));
|
|
refuses(proxyUnit(h), /is a symlink/);
|
|
rmSync(proxyFile(h));
|
|
mkdirSync(proxyFile(h));
|
|
refuses(proxyUnit(h), /is not a regular file/);
|
|
});
|
|
|
|
// M3: the managed uid is a dedicated non-login service identity. These are
|
|
// database fixtures only: no real passwd/group or instance directories change.
|
|
test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => {
|
|
for (const [kind, shell, extraPasswd, members] of [
|
|
['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''],
|
|
['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''],
|
|
['supplementary primary-group member','/usr/sbin/nologin','','human'],
|
|
]) {
|
|
const h = hub(t);
|
|
script(join(h.bin,'getent'), `case "$1" in
|
|
passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';;
|
|
group) echo 'mosaic:x:${GID}:${members}';;
|
|
esac`);
|
|
const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false);
|
|
}
|
|
const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); }
|
|
});
|
|
test('M3 host config and data directories belong to the selected uid and both units use it', t => {
|
|
const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err);
|
|
for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); }
|
|
const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/);
|
|
});
|