stack-release refuses a user-unit activate, rollback or pm-on when the instance shows any accounts sign (stamped target, accounts.json, console.sso, .deployed), before anything stops. Root renders the system units with case2-unit.sh, and the op runs with --units external. case2-unit renders a stamped copy's units only with --sso, console.sso, an accounts.json that loads and a valid .deployed, and names each missing step in run-sheet order before any write. Per Jason's ruling of 2026-11-10, a stamped library unit takes --sso and refuses proxy-preview admission and its proof. The unstamped and dev paths are unchanged. rehearse runs a stamped copy against rehearsal.invalid with scratch slot placeholders, a copy of the live accounts.json and the copy's own .deployed. It refuses sso-not-prepared and accounts-not-initialized before any rehearsal directory exists, and sso-isolated before the host starts. The tests that turned SSO on through the user unit now pin the refusal and reach the same end state through the system path. The copy tests start the Console on real scratch prerequisites and pin each refusal. The run sheet, TOOLS.md and the accounts design record the ruling and the migration order.
482 lines
44 KiB
JavaScript
482 lines
44 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {execFileSync,spawnSync,spawn} from 'node:child_process';
|
|
import {symlinkSync,unlinkSync,mkdtempSync,mkdirSync,writeFileSync,readFileSync,cpSync,rmSync,renameSync,chmodSync,existsSync,statSync,readdirSync,lstatSync,readlinkSync} from 'node:fs';
|
|
import {createHash} from 'node:crypto';
|
|
import {tmpdir} from 'node:os';
|
|
import {join,dirname} from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {once} from 'node:events';
|
|
import {createServer} from 'node:net';
|
|
import {createOwnedRunner} from './fixtures/owned-child.mjs';
|
|
import {pathToFileURL} from 'node:url';
|
|
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
|
|
import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs';
|
|
import {writeDeployment} from '../src/deployment.mjs';
|
|
import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs';
|
|
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
|
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
|
|
export function setup(t, realPin=false){
|
|
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
|
|
const runner=createOwnedRunner(t,()=>env);
|
|
t.after(async()=>{
|
|
await runner.close();
|
|
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
|
|
});
|
|
mkdirSync(repo);mkdirSync(bin);
|
|
// A linked node_modules (a deploy tree) is not a directory to .gitignore's node_modules/; npm ci makes the copy's own.
|
|
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','tools/unslop-hook','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation',':(exclude,glob)**/node_modules'],{encoding:'utf8'}).trim().split('\n');
|
|
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
|
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
|
|
const pkg={name:'copy-fixture',version:'1.0.0'};
|
|
if(!realPin){writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
|
|
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));}
|
|
else {for(const f of ['package.json','package-lock.json'])cpSync(join(ROOT,f),join(repo,f));}
|
|
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
|
|
const id=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',id);
|
|
const env={PATH:`${bin}:${dirname(process.execPath)}:/usr/bin:/bin`,HOME:root,XDG_DATA_HOME:join(root,'data'),XDG_CONFIG_HOME:join(root,'config')};
|
|
const search=join(root,'unit-search');mkdirSync(search);
|
|
const analyze=body=>{writeFileSync(join(bin,'systemd-analyze'),'#!/bin/sh\n'+body);chmodSync(join(bin,'systemd-analyze'),0o755);};
|
|
analyze(`printf '%s\\n' '${search}'\n`);
|
|
const runChild=runner.run;
|
|
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
|
|
const run=(script,args=[])=>runChild(join(repo,'scripts',script),args);
|
|
return {root,repo,copies,bin,id,env,run,runChild,fake,search,analyze};
|
|
}
|
|
// Row 66: a session launched from a pinned copy reads each harness module's files from the copy,
|
|
// so every path a module names under its REPO (adapter, mounts, bundle sources) is in COPY_PATHS.
|
|
test('a copy carries every file the harness modules read from it',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const copy=join(s.copies,s.id),paths=[];
|
|
for(const kind of ['pi','claude-code']){
|
|
const m=await import(pathToFileURL(join(copy,'packages','plugins','harness',kind,'index.mjs')).href);
|
|
// pi's mounts() resolves node_modules, which this fixture copy lacks; its unslop mounts are its
|
|
// bundle sources, so the sources stand in for them.
|
|
paths.push(m.adapter(),...(kind==='pi'?Object.values(m.bundle().sources):m.mounts({env:{}}).map(x=>x.src)));
|
|
}
|
|
assert.equal(paths.filter(p=>p.includes('/tools/unslop-hook/')).length,6,paths.join('\n'));
|
|
for(const p of paths){assert.ok(p.startsWith(copy+'/'),p);assert.ok(existsSync(p),p);}
|
|
});
|
|
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{
|
|
const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr);
|
|
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
|
|
assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0);
|
|
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
|
|
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
|
|
const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
|
|
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
|
|
const denied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.notEqual(denied.status,0);assert.match(denied.stderr,/origin\/refactor/);
|
|
});
|
|
test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check uses effective roots',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const wrapper=join(s.bin,'mosaic'),marker='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
|
|
const lease=`[ "\${1:-}" = __lease-capability ] && exec "${s.bin}/mosaic-old" "$@"`;
|
|
writeFileSync(wrapper,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec "/old/scripts/mosaic" "$@"\n`);
|
|
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin])).status,0);
|
|
const lines=readFileSync(wrapper,'utf8').trimEnd().split('\n');
|
|
assert.equal(lines[1],marker);
|
|
assert.equal(lines[2],`# CLI runs from pinned copy: ${join(s.copies,s.id)}`);
|
|
assert.equal(lines[3],'# The Q81 lease probe still answers at mosaic-old.');
|
|
assert.equal(lines.at(-1),`exec "${join(s.copies,s.id)}/scripts/mosaic" "$@"`);
|
|
assert.match(readFileSync(wrapper,'utf8'),new RegExp(s.id));assert.ok(readFileSync(wrapper,'utf8').includes(lease));
|
|
const old=join(s.bin,'mosaic-old');writeFileSync(old,'#!/bin/sh\nprintf \"legacy:%s\\n\" \"$1\"\n');chmodSync(old,0o755);
|
|
assert.equal(spawnSync(wrapper,['__lease-capability'],{env:s.env,encoding:'utf8'}).stdout,'legacy:__lease-capability\n');
|
|
const copy=join(s.copies,s.id);s.fake(`case "$*" in *mosaic-bus*) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start mosaic-stack --pm ; }';; *) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
|
|
assert.equal((await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin])).status,0);
|
|
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}';case \"$*\" in *mosaic-bus*)echo 'ExecStart={ path=/other/scripts/mosaic ; argv[]=/other/scripts/mosaic bus start demo ; }';; *)echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';;esac\n`);
|
|
assert.equal((await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin])).status,1,'effective ExecStart mismatch');
|
|
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=/other';\n`);assert.equal((await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin])).status,1);
|
|
const foreign=`#!/usr/bin/env bash\n# foreign wrapper\n${lease}\nexec \"${copy}/scripts/mosaic\" \"$@\"\n`;writeFileSync(wrapper,foreign);
|
|
// Restore matching effective roots: only the foreign marker refuses check.
|
|
s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *)echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';;esac\n`);
|
|
assert.equal((await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin])).status,1);
|
|
assert.notEqual((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin])).status,0);assert.equal(readFileSync(wrapper,'utf8'),foreign);
|
|
const backing=join(s.bin,'backing');writeFileSync(backing,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`);unlinkSync(wrapper);symlinkSync(backing,wrapper);assert.notEqual((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin])).status,0);
|
|
});
|
|
test('row 190 N6: check --instance passes only when the wrapper exports the instance\'s MOSAIC_CONFIG',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const boot=(await s.run('bootstrap.sh',['--instance','alpha']));assert.equal(boot.status,0,boot.stderr);
|
|
const copy=join(s.copies,s.id),wrapper=join(s.bin,'mosaic-alpha'),cfg=join(s.root,'.config','mosaic-alpha','config.json');
|
|
const w=(await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]));assert.equal(w.status,0,w.stderr);
|
|
s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
|
|
const check=async()=>(await s.run('stack-copy.sh',['check','--instance','alpha','--copies',s.copies,'--bin',s.bin,'--business','demo']));
|
|
const good=readFileSync(wrapper,'utf8');assert.equal(good.split('\n')[3],`export MOSAIC_CONFIG='${cfg}'`);
|
|
let r=await check();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`);
|
|
for(const [label,text] of [
|
|
['another config',good.replace(`export MOSAIC_CONFIG='${cfg}'`,`export MOSAIC_CONFIG='${join(s.root,'.config','mosaic-beta','config.json')}'`)],
|
|
['no config line',good.replace(`export MOSAIC_CONFIG='${cfg}'\n`,'')],
|
|
]){
|
|
writeFileSync(wrapper,text);r=await check();
|
|
assert.equal(r.status,1,label);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy} (MOSAIC_CONFIG mismatch)\n`,label);
|
|
}
|
|
});
|
|
test('bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const args=['--copy',s.id,'--copies',s.copies,'--pm'];
|
|
const render=(await s.run('bus-service.sh',['render',...args]));assert.equal(render.status,0,render.stderr);assert.match(render.stdout,new RegExp(`WorkingDirectory=${s.copies}/${s.id}`));assert.match(render.stdout,/bus start %i --pm/);
|
|
const units=join(s.root,'units');
|
|
for(const state of ['active','activating','deactivating','reloading']){s.fake(`echo '[email protected] loaded ${state} running fixture';\n`);const r=(await s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']));assert.notEqual(r.status,0,state);assert.ok(!existsSync(units));}
|
|
s.fake('exit 1\n');assert.notEqual((await s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload'])).status,0);assert.ok(!existsSync(units));
|
|
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'pm.conf');writeFileSync(drop,'[Service]\nExecStart=elsewhere\n');
|
|
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *)exit 99;; esac\n`);
|
|
const refused=(await s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']));assert.notEqual(refused.status,0);assert.ok(refused.stderr.includes(drop));assert.ok(!existsSync(units));
|
|
writeFileSync(drop,'[Service]\nEnvironment=SAFE=1\n');assert.equal((await s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload'])).status,0);
|
|
});
|
|
|
|
|
|
test('missing package manifest refuses before npm; npm failure removes the unpublished temp copy', async t=>{
|
|
const s=setup(t);git(s.repo,'rm','package.json');git(s.repo,'commit','-qm','missing package');const id=git(s.repo,'rev-parse','HEAD');git(s.repo,'update-ref','refs/remotes/origin/refactor',id);
|
|
const npm=join(s.bin,'npm'),called=join(s.root,'npm-called');writeFileSync(npm,`#!/bin/bash\necho called > '${called}'\nexit 1\n`);chmodSync(npm,0o755);
|
|
const r=(await s.run('stack-copy.sh',['copy',id,'--copies',s.copies]));assert.notEqual(r.status,0);assert.match(r.stderr,/package.json/);assert.ok(!existsSync(called));assert.ok(!existsSync(join(s.copies,id)));
|
|
git(s.repo,'reset','--hard',s.id);git(s.repo,'update-ref','refs/remotes/origin/refactor',s.id);
|
|
const tar=join(s.bin,'tar');writeFileSync(tar,'#!/bin/sh\n/usr/bin/tar \"$@\" || exit $?\nfor a; do dest=\"$a\"; done\nrm \"$dest/package.json\"\n');chmodSync(tar,0o755);
|
|
const absent=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.notEqual(absent.status,0);assert.match(absent.stderr,/package.json.*missing/);assert.ok(!existsSync(called));unlinkSync(tar);
|
|
const fail=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.notEqual(fail.status,0);assert.match(fail.stderr,/npm ci failed/);assert.ok(existsSync(called));assert.deepEqual(execFileSync('ls',['-A',s.copies],{encoding:'utf8'}),'');
|
|
});
|
|
|
|
|
|
async function until(fn,ms=10000){const end=Date.now()+ms;while(Date.now()<end){if(fn())return;await new Promise(r=>setTimeout(r,25));}throw Error('fixture startup timed out');}
|
|
async function childResult(command,args,env,input){
|
|
const p=spawn(command,args,{env,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';
|
|
p.stdout.on('data',c=>stdout+=c);p.stderr.on('data',c=>stderr+=c);p.stdin.end(input);
|
|
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
|
|
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
|
|
}
|
|
test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
|
|
const s=setup(t,true);
|
|
const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr);
|
|
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
|
|
const {checkEnginePin}=await import(url('packages/conversation/src/pi-pin.mjs'));assert.equal(checkEnginePin(root).version,'0.85.1');
|
|
const home=join(s.root,'home'),f=fixture(home),configDir=join(home,'.config','mosaic-pin');
|
|
mkdirSync(join(home,'.config'),{recursive:true});renameSync(join(home,'config'),configDir);chmodSync(configDir,0o700);
|
|
writeFileSync(join(f.dataRoot,'instance.json'),JSON.stringify({instanceVersion:1,name:'pin'}));
|
|
const ctx={configDir,dataRoot:f.dataRoot,instance:'pin'};
|
|
const acme=await reserveOrg(ctx,'acme');await activateOrg(ctx,'acme');notifyConfig(acme.root,'acme',null);
|
|
const demo=await reserveOrg(ctx,'demo');await activateOrg(ctx,'demo');
|
|
// Only synthetic configuration, no inherited credentials/agent environment.
|
|
const env={...s.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json'),MOSAIC_ROLES_DIR:f.roles};
|
|
const host=spawn(join(root,'scripts/mosaic'),['bus','start','acme'],{env,stdio:['ignore','pipe','pipe']});let hostOut='',hostErr='';
|
|
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
|
|
try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);}
|
|
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}}
|
|
// The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json.
|
|
const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])];
|
|
const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a');
|
|
const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca};
|
|
const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);};
|
|
await startRefused(false,/stamped build serve requires --sso/);
|
|
await startRefused(true,/\.deployed is missing or invalid/);
|
|
writeDeployment({dataRoot:f.dataRoot},configDir);
|
|
await startRefused(true,/accounts file not found/);
|
|
writeFileSync(join(configDir,'accounts.json'),JSON.stringify({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:[]}),{mode:0o600});
|
|
await startRefused(true,/must be a configured provider/);
|
|
const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8'));
|
|
config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
|
writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config));
|
|
await startRefused(true,/client secret slot is missing/);
|
|
mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700});
|
|
const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600);
|
|
const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
|
|
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
|
|
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
|
|
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
|
|
// The production runtime derives cliPath. Only agent ancestry is mocked;
|
|
// readProcess passes through for tester.mjs (row 110).
|
|
// real pid/start/argv/nonce and the runtime's entry-path binding remain intact.
|
|
const proofScript=join(s.root,'proof-runtime.mjs');
|
|
writeFileSync(proofScript,`
|
|
import {mock} from 'node:test';
|
|
const humanURL=${JSON.stringify(url('packages/bus/src/human.mjs'))};
|
|
const real=await import(humanURL);
|
|
mock.module(humanURL,{namedExports:{readProcess:real.readProcess,verifyHuman:(proof,options)=>real.verifyHuman(proof,{...options,readProcess:pid=>({...real.readProcess(pid),ppid:1})})}});
|
|
const {startBroker}=await import(${JSON.stringify(url('packages/bus/src/runtime.mjs'))});
|
|
const runtime=await startBroker({dataRoot:${JSON.stringify(demo.root)},businesses:${JSON.stringify(BUSINESSES)}});
|
|
const broker=runtime.broker;
|
|
const pm=broker.bindLaunch({business:'demo',role:'pm',run:'pm-fixture',harness:'pi'});broker.request(pm,{verb:'role.claim'});
|
|
const request=broker.request.bind(broker);broker.request=(cap,r)=>{const result=request(cap,r);if(r.verb==='message.send'&&r.args.to==='pm')request(pm,{verb:'message.send',args:{to:'human',body:'same-copy reply',in_reply_to:result.id}});return result;};
|
|
process.on('SIGTERM',async()=>{await runtime.close();process.exit(0)});
|
|
console.log('proof runtime ready');
|
|
`);
|
|
const proof=spawn(process.execPath,['--experimental-test-module-mocks',proofScript],{env,stdio:['ignore','pipe','pipe']});let proofOut='',proofErr='';
|
|
proof.stdout.on('data',c=>proofOut+=c);proof.stderr.on('data',c=>proofErr+=c);
|
|
const socket=join(demo.root,'bus','broker.sock');
|
|
try{
|
|
await until(()=>proofOut.includes('proof runtime ready')||proof.exitCode!==null);assert.match(proofOut,/proof runtime ready/,proofErr);
|
|
const result=await childResult(join(root,'scripts/mosaic'),['talk','pm','hello','--business','demo','--wait','1'],env);
|
|
assert.equal(result.status,0,result.stderr);assert.match(result.stdout,/sent .* to pm/);assert.match(result.stdout,/same-copy reply/);
|
|
for(const other of [ROOT,join(s.root,'other-copy')]){
|
|
if(other!==ROOT)cpSync(join(root,'packages'),join(other,'packages'),{recursive:true});
|
|
const result=await childResult(process.execPath,[join(other,'packages/bus/src/human-cli.mjs'),socket],env,JSON.stringify({business:'demo',verb:'inbox'})+'\n');
|
|
assert.notEqual(result.status,0);assert.equal(result.stderr.trim(),'human-required');
|
|
}
|
|
}finally{if(proof.exitCode===null&&proof.signalCode===null){const done=once(proof,'close');proof.kill('SIGTERM');await done;}}
|
|
});
|
|
|
|
|
|
test('bus status reads effective copy/pm/drop-ins without writes; check refuses an overridden ExecStart',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const copy=join(s.copies,s.id),drop=join(s.root,'pm.conf');
|
|
const rows=`LoadState=loaded\nWorkingDirectory=${copy}\nExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo --pm ; }\nDropInPaths=${drop}\n`;
|
|
s.fake(`case "$*" in *show*) cat <<'ROWS'\n${rows}ROWS\n;; *is-active*) echo inactive;; *is-enabled*) echo disabled;; *)exit 99;;esac\n`);
|
|
const status=(await s.run('bus-service.sh',['status','demo']));assert.equal(status.status,0,status.stderr);assert.match(status.stdout,new RegExp(`copy: ${s.id}`));assert.match(status.stdout,/pm: on/);assert.ok(status.stdout.includes(drop));
|
|
});
|
|
|
|
|
|
test('drop-ins in either override field and malformed metadata refuse; uninstall also checks --no-reload',async t=>{
|
|
const s=setup(t),units=join(s.root,'units'),dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'override.conf');
|
|
for(const field of ['ExecStart','WorkingDirectory']){
|
|
writeFileSync(drop,`[Service]\n${field}=override\n`);
|
|
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *)exit 99;;esac\n`);
|
|
const r=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.notEqual(r.status,0);assert.ok(r.stderr.includes(drop));assert.ok(!existsSync(units));
|
|
}
|
|
s.fake('exit 0\n');s.analyze('exit 0\n');const unknown=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.notEqual(unknown.status,0);assert.match(unknown.stderr,/unknown/);
|
|
mkdirSync(units);const file=join(units,'[email protected]');writeFileSync(file,'unchanged');
|
|
s.fake("echo '[email protected] loaded active running fixture'\n");assert.notEqual((await s.run('bus-service.sh',['uninstall','--dir',units,'--no-reload'])).status,0);assert.equal(readFileSync(file,'utf8'),'unchanged');
|
|
});
|
|
|
|
|
|
test('install never shows a template name on systemd 261; template drop-ins still refuse',async t=>{
|
|
const s=setup(t),units=join(s.root,'units'),calls=join(s.root,'systemctl-calls');
|
|
s.fake(`echo "$*" >> '${calls}'\ncase "$*" in *show*[email protected]*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo '[email protected] loaded inactive dead fixture';; *)exit 99;; esac\n`);
|
|
const result=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.equal(result.status,0,result.stderr);
|
|
assert.doesNotMatch(readFileSync(calls,'utf8'),/show mosaic-bus@\.service/);
|
|
// This rejection arm is deliberately exercised; it is not a dead show reply.
|
|
assert.equal(spawnSync(join(s.bin,'systemctl'),['--user','show','[email protected]'],{env:s.env}).status,1);
|
|
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'pm.conf');writeFileSync(file,'[Service]\nExecStart=override\n');
|
|
const before=readFileSync(join(units,'[email protected]'),'utf8');
|
|
const denied=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.equal(readFileSync(join(units,'[email protected]'),'utf8'),before);
|
|
});
|
|
test('unlisted instance drop-in is found in another unit lookup directory; path discovery errors refuse',async t=>{
|
|
const s=setup(t),units=join(s.root,'units'),second=join(s.root,'second-search');mkdirSync(second);
|
|
const dropdir=join(second,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'cwd.conf');writeFileSync(file,'[Service]\nWorkingDirectory=/other\n');
|
|
s.analyze(`printf '%s\\n' '${s.search}' '${second}'\n`);
|
|
s.fake("case \"$*\" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *)exit 99;; esac\n");
|
|
const denied=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.ok(!existsSync(units));
|
|
s.analyze('exit 1\n');const failed=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.notEqual(failed.status,0);assert.match(failed.stderr,/unit-paths failed/);assert.ok(!existsSync(units));
|
|
});
|
|
|
|
// Row 108: inherited drop-ins apply even without a loaded bus instance.
|
|
for(const name of ['mosaic-.service.d','service.d'])test(`inherited ${name} override refuses without changing the unit`,async t=>{
|
|
const s=setup(t),units=join(s.root,'units');mkdirSync(units);
|
|
const target=join(units,'[email protected]');writeFileSync(target,'previous unit bytes');
|
|
s.fake('exit 0\n');
|
|
const dropdir=join(s.search,name);mkdirSync(dropdir);const file=join(dropdir,'override.conf');
|
|
for(const field of ['WorkingDirectory','ExecStart']){
|
|
writeFileSync(file,`[Service]\n${field}=/other\n`);
|
|
const denied=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));
|
|
assert.equal(denied.status,1,denied.stderr);assert.ok(denied.stderr.includes(file));assert.equal(readFileSync(target,'utf8'),'previous unit bytes');
|
|
}
|
|
writeFileSync(file,'[Service]\nEnvironment=SAFE=1\n');
|
|
const allowed=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.equal(allowed.status,0,allowed.stderr);
|
|
});
|
|
test('inherited drop-in unreadable directory or file refuses before writing',async t=>{
|
|
const s=setup(t),units=join(s.root,'units');mkdirSync(units);const target=join(units,'[email protected]');writeFileSync(target,'previous unit bytes');s.fake('exit 0\n');
|
|
const dropdir=join(s.search,'service.d');symlinkSync(join(s.root,'absent-dir'),dropdir);
|
|
const dir=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.equal(dir.status,1);assert.ok(dir.stderr.includes(dropdir));assert.equal(readFileSync(target,'utf8'),'previous unit bytes');
|
|
unlinkSync(dropdir);mkdirSync(dropdir);const file=join(dropdir,'broken.conf');symlinkSync(join(s.root,'absent-file'),file);
|
|
const broken=(await s.run('bus-service.sh',['install','--dir',units,'--no-reload']));assert.equal(broken.status,1);assert.ok(broken.stderr.includes(file));assert.equal(readFileSync(target,'utf8'),'previous unit bytes');
|
|
});
|
|
// Row 232: every file of a copy with its mode and bytes, for the byte-identical comparison.
|
|
function tree(dir){
|
|
const out=[];
|
|
const walk=rel=>{for(const e of readdirSync(join(dir,rel),{withFileTypes:true}).sort((a,b)=>a.name<b.name?-1:1)){
|
|
const r=rel?`${rel}/${e.name}`:e.name,st=lstatSync(join(dir,r));
|
|
if(e.isDirectory()){out.push(`d ${(st.mode&0o7777).toString(8)} ${r}`);walk(r);}
|
|
else if(e.isSymbolicLink())out.push(`l ${r} ${readlinkSync(join(dir,r))}`);
|
|
else out.push(`f ${(st.mode&0o7777).toString(8)} ${r} ${createHash('sha256').update(readFileSync(join(dir,r))).digest('hex')}`);
|
|
}};
|
|
walk('');return out;
|
|
}
|
|
test('row 232: copy --bundle copies a SHA the bundle\'s refactor reaches, byte-identical to copy SHA, and refuses the rest',async t=>{
|
|
const s=setup(t),tmp=join(s.root,'t');mkdirSync(tmp);s.env.TMPDIR=tmp;
|
|
const good=join(s.root,'good.bundle');git(s.repo,'bundle','create','-q',good,'refactor');
|
|
const a=join(s.root,'copies-a'),b=join(s.root,'copies-b');
|
|
let r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',a]));assert.equal(r.status,0,r.stderr);
|
|
r=(await s.run('stack-copy.sh',['copy','--bundle',good,s.id,'--copies',b]));assert.equal(r.status,0,r.stderr);
|
|
const sum=createHash('sha256').update(readFileSync(good)).digest('hex'),release=git(s.repo,'show',`${s.id}:RELEASE`);
|
|
assert.equal(r.stdout,`copied: ${join(b,s.id)}\nrelease: ${release}\nbundle sha256: ${sum}\n`);
|
|
assert.deepEqual(tree(join(b,s.id)),tree(join(a,s.id)));
|
|
assert.equal(statSync(join(b,s.id)).mode&0o777,0o555);
|
|
const left=()=>readdirSync(tmp).filter(f=>f.startsWith('tmp.'));
|
|
assert.deepEqual(left(),[],'the bundle\'s temporary repository is removed');
|
|
// A second run finds the copy and still names the release.
|
|
r=(await s.run('stack-copy.sh',['copy','--bundle',good,s.id,'--copies',b]));assert.equal(r.status,0,r.stderr);
|
|
assert.equal(r.stdout,`exists: ${join(b,s.id)}\nrelease: ${release}\nbundle sha256: ${sum}\n`);
|
|
// A relative bundle path works from the caller's directory.
|
|
const c=join(s.root,'copies-c');
|
|
r=await s.runChild(join(s.repo,'scripts','stack-copy.sh'),['copy','--bundle','good.bundle',s.id,'--copies',c],{cwd:s.root});
|
|
assert.equal(r.status,0,r.stderr);assert.deepEqual(tree(join(c,s.id)),tree(join(a,s.id)));
|
|
// A commit on another branch of the bundle: the bundle carries it, refactor does not reach it.
|
|
const side=git(s.repo,'commit-tree','-p',s.id,'-m','side',`${s.id}^{tree}`);git(s.repo,'update-ref','refs/heads/side',side);
|
|
const both=join(s.root,'both.bundle');git(s.repo,'bundle','create','-q',both,'refactor','side');
|
|
const only=join(s.root,'side.bundle');git(s.repo,'bundle','create','-q',only,'side');
|
|
const corrupt=join(s.root,'corrupt.bundle'),bytes=readFileSync(good);bytes[bytes.length-30]^=0xff;writeFileSync(corrupt,bytes);
|
|
const cut=join(s.root,'cut.bundle');writeFileSync(cut,readFileSync(good).subarray(0,200));
|
|
const junk=join(s.root,'junk.bundle');writeFileSync(junk,'# v2 git bundle\nnot a ref line\n\n');
|
|
const d=join(s.root,'copies-d');
|
|
for(const [label,bundle,id,re] of [
|
|
['not reachable',both,side,/not reachable from the bundle's refs\/heads\/refactor/],
|
|
['no refactor',only,side,/no refs\/heads\/refactor/],
|
|
// verify reads the header and prerequisites; a damaged pack fails the fetch's own check.
|
|
['not a bundle',junk,s.id,/git bundle verify failed/],
|
|
['corrupt pack',corrupt,s.id,/cannot fetch/],
|
|
['truncated pack',cut,s.id,/cannot fetch/],
|
|
['absent',join(s.root,'none.bundle'),s.id,/bundle is absent/],
|
|
['short id',good,s.id.slice(0,12),/full 40-character commit id/],
|
|
]){
|
|
r=(await s.run('stack-copy.sh',['copy','--bundle',bundle,id,'--copies',d]));
|
|
assert.equal(r.status,1,label);assert.match(r.stderr,re,label);assert.equal(r.stdout,'',label);
|
|
assert.ok(!existsSync(join(d,id)),label);assert.deepEqual(left(),[],label);
|
|
if(existsSync(d))assert.deepEqual(readdirSync(d),[],label);
|
|
}
|
|
const link=join(s.root,'link.bundle');symlinkSync(good,link);
|
|
r=(await s.run('stack-copy.sh',['copy','--bundle',link,s.id,'--copies',d]));assert.equal(r.status,1);assert.match(r.stderr,/symlink/);
|
|
for(const args of [['copy','--bundle',good,s.id,'--allow-worktree'],['copy','--bundle',good],['copy','--bundle'],['wrapper','--bundle',good,s.id],['check','--units','system'],['copy',s.id,'--units','external'],['copy',s.id,'--create']])
|
|
assert.equal((await s.run('stack-copy.sh',args)).status,4,args.join(' '));
|
|
});
|
|
test('row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses over any file',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
const wrapper=join(s.bin,'mosaic'),root=join(s.copies,s.id);
|
|
let r=(await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]));
|
|
assert.equal(r.status,1);assert.match(r.stderr,/wrapper is absent/,'no --create keeps the missing-wrapper refusal');assert.ok(!existsSync(wrapper));
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create']));assert.equal(r.status,0,r.stderr);
|
|
assert.equal(r.stdout,`wrapper: ${root}\n`);
|
|
const created=readFileSync(wrapper,'utf8');assert.equal(statSync(wrapper).mode&0o777,0o755);
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]));assert.equal(r.status,0,r.stderr);
|
|
assert.equal(r.stdout,`wrapper: ${root}\n`);assert.equal(readFileSync(wrapper,'utf8'),created,'wrapper ID writes the same bytes');
|
|
const foreign='#!/bin/sh\n# not ours\n';
|
|
for(const [label,make] of [['own wrapper',()=>{}],['foreign file',()=>writeFileSync(wrapper,foreign)],['symlink',()=>{rmSync(wrapper);symlinkSync(join(s.root,'elsewhere'),wrapper);}]]){
|
|
make();const before=existsSync(wrapper)||label==='symlink'?(label==='symlink'?readlinkSync(wrapper):readFileSync(wrapper,'utf8')):null;
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create']));
|
|
assert.equal(r.status,1,label);assert.match(r.stderr,/--create and a wrapper exists/,label);
|
|
assert.equal(label==='symlink'?readlinkSync(wrapper):readFileSync(wrapper,'utf8'),before,label);
|
|
assert.deepEqual(readdirSync(s.bin).filter(f=>f.startsWith('.')),[],`${label}: no temporary file left`);
|
|
}
|
|
// A named instance: --create writes mosaic-NAME as wrapper --instance does, once.
|
|
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
|
|
const named=join(s.bin,'mosaic-alpha');
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin,'--create']));assert.equal(r.status,0,r.stderr);
|
|
const first=readFileSync(named,'utf8');
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]));assert.equal(r.status,0,r.stderr);
|
|
assert.equal(readFileSync(named,'utf8'),first);
|
|
r=(await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin,'--create']));
|
|
assert.equal(r.status,1);assert.match(r.stderr,/--create and a wrapper exists/);assert.equal(readFileSync(named,'utf8'),first);
|
|
});
|
|
test('row 232: check --units external reads the system units, the default reads the user units',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
|
|
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log');
|
|
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start mosaic-stack --pm ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
|
|
const want=`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`;
|
|
let r=(await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin,'--units','external']));assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,want);
|
|
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),[
|
|
'show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',
|
|
'show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart']);
|
|
rmSync(argv);
|
|
for(const args of [[],['--units','user']]){
|
|
r=(await s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin,...args]));assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,want);
|
|
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),[
|
|
'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',
|
|
'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart']);
|
|
rmSync(argv);
|
|
}
|
|
});
|
|
|
|
// Rename r2 "Selector scopes and parser matrix": check takes --org as --business, each spelling once, both only
|
|
// with one value; a refused selector reads no unit, and --org is refused outside check as --business is.
|
|
test('rename r2: check takes --org as --business, once per spelling, and refuses before any unit read',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create'])).status,0);
|
|
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--copies',s.copies,'--bin',s.bin];
|
|
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
|
|
const shows=b=>[`--user show mosaic-bus@${b}.service -p LoadState -p WorkingDirectory -p ExecStart`,'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart'];
|
|
for(const sel of [['--business','demo'],['--org','demo'],['--business','demo','--org','demo'],['--org','demo','--business','demo'],['--org','demo','--units','user']]){
|
|
const r=await s.run('stack-copy.sh',[...base,...sel]);
|
|
assert.equal(r.status,0,sel.join(' ')+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
|
|
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'),sel.join(' '));rmSync(argv);
|
|
}
|
|
// The selector may come before the other options too.
|
|
let r=await s.run('stack-copy.sh',['check','--org','demo','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
|
|
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),shows('demo'));rmSync(argv);
|
|
for(const [sel,status,message] of [
|
|
[['--business','demo','--org','beta'],4,'--business demo and --org beta conflict'],
|
|
[['--org','beta','--business','demo'],4,'--org beta and --business demo conflict'],
|
|
[['--business','demo','--business','demo'],4,'--business given twice'],
|
|
[['--org','demo','--org','demo'],4,'--org given twice'],
|
|
[['--org','demo','--business','demo','--org','demo'],4,'--org given twice'],
|
|
[['--org'],4,'--org needs one value'],
|
|
[['--business'],4,'--business needs one value'],
|
|
[['--org',''],4,'--org needs one value'],
|
|
[['--org','--units','user'],4,'--org needs one value, not --units'],
|
|
[['--business','-x'],4,'--business needs one value, not -x'],
|
|
[['--org','Demo'],1,'--org Demo is not valid'],
|
|
[['--business','demo_x'],1,'--business demo_x is not valid'],
|
|
[['--org','-demo'],4,'--org needs one value, not -demo'],
|
|
]){
|
|
r=await s.run('stack-copy.sh',[...base,...sel]);
|
|
assert.equal(r.status,status,sel.join(' '));assert.equal(r.stderr,`stack-copy: ${message}\n`,sel.join(' '));assert.equal(r.stdout,'',sel.join(' '));
|
|
assert.ok(!existsSync(argv),`${sel.join(' ')} reads no unit`);
|
|
}
|
|
// Positional text after a selector value stays a usage error; --org outside check is one too.
|
|
for(const args of [[...base,'--org','demo','beta'],['copy',s.id,'--copies',s.copies,'--org','demo'],['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--org','demo'],['copy',s.id,'--business','demo']]){
|
|
r=await s.run('stack-copy.sh',args);assert.equal(r.status,4,args.join(' '));assert.match(r.stderr,/^usage: stack-copy\.sh /,args.join(' '));
|
|
assert.ok(!existsSync(argv),args.join(' '));
|
|
}
|
|
});
|
|
test('rename r2: check --instance takes --org for its business and still needs one selector',async t=>{
|
|
const s=setup(t);assert.equal((await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies])).status,0);
|
|
assert.equal((await s.run('bootstrap.sh',['--instance','alpha'])).status,0);
|
|
assert.equal((await s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin])).status,0);
|
|
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log'),base=['check','--instance','alpha','--copies',s.copies,'--bin',s.bin];
|
|
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
|
|
for(const sel of [['--org','demo'],['--business','demo','--org','demo']]){
|
|
const r=await s.run('stack-copy.sh',[...base,...sel]);assert.equal(r.status,0,sel.join(' ')+r.stderr);
|
|
assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`,sel.join(' '));
|
|
assert.equal(readFileSync(argv,'utf8').split('\n')[0],'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',sel.join(' '));rmSync(argv);
|
|
}
|
|
const r=await s.run('stack-copy.sh',base);assert.equal(r.status,4);assert.match(r.stderr,/^usage: stack-copy\.sh /);assert.ok(!existsSync(argv));
|
|
});
|
|
|
|
test('Vikunja 614: bundle and checkout copy leave an unreadable caller directory before find',async t=>{
|
|
const s=setup(t),cwd=join(s.root,'unreadable');mkdirSync(cwd);
|
|
const bundle=join(s.root,'release.bundle');git(s.repo,'bundle','create','-q',bundle,'refactor');
|
|
// Enter while searchable, then reproduce a cwd inherited across a uid drop:
|
|
// the caller can neither read nor search it. Absolute bundle lookup must work.
|
|
const enter='cd "$1" && chmod 000 . && [ ! -r . ] && [ ! -x . ] && shift && exec "$@"';
|
|
for(const [name,args] of [['bundle',['copy','--bundle',bundle,s.id]],['checkout',['copy',s.id]]]){
|
|
const copies=join(s.root,`copies-${name}`);
|
|
try{
|
|
const r=await s.runChild('/bin/sh',['-c',enter,'stack-copy-cwd',cwd,join(s.repo,'scripts','stack-copy.sh'),...args,'--copies',copies]);
|
|
assert.equal(statSync(cwd).mode&0o555,0,'the inherited cwd is neither readable nor searchable');
|
|
assert.equal(r.status,0,r.stdout+r.stderr);
|
|
assert.doesNotMatch(r.stderr,/Failed to (restore initial working|change) directory|Permission denied/);
|
|
assert.ok(existsSync(join(copies,s.id,'scripts','mosaic')));
|
|
assert.equal(statSync(join(copies,s.id)).mode&0o777,0o555);
|
|
}finally{
|
|
// Restore even after a failed assertion/mutant, before setup's recursive cleanup.
|
|
chmodSync(cwd,0o700);
|
|
}
|
|
}
|
|
});
|
|
|
|
//643: the owned npm child announces readiness and waits for this parent. A
|
|
// synchronous runner cannot service that signal; no elapsed-time guess releases it.
|
|
test('copy waits for owned child completion after its readiness signal', {timeout:30000}, async t=>{
|
|
const s=setup(t),path=join(s.root,'ready.sock'),sockets=new Set();
|
|
let ready;
|
|
const announced=new Promise(resolve=>{ready=resolve});
|
|
const server=createServer(socket=>{sockets.add(socket);socket.on('error',()=>{});socket.once('close',()=>sockets.delete(socket));ready(socket)});
|
|
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(path,resolve)});
|
|
t.after(async()=>{for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve))});
|
|
const npm=join(s.bin,'npm');
|
|
const program=`const s=require('node:net').connect(${JSON.stringify(path)});s.on('error',()=>process.exit(1));s.once('data',()=>s.end());`;
|
|
writeFileSync(npm,`#!/bin/sh\nexec '${process.execPath}' -e '${program.replaceAll("'", "'\\''")}'\n`);chmodSync(npm,0o755);
|
|
const copying=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);
|
|
const socket=await Promise.race([announced,copying.then(r=>{throw Error(`copy exited before readiness: ${r.status}`)})]);
|
|
assert.ok(!existsSync(join(s.copies,s.id)),'no copy is published while npm is held');
|
|
socket.write('continue');
|
|
const result=await copying;
|
|
assert.equal(result.status,0,result.stderr);
|
|
assert.equal(statSync(join(s.copies,s.id)).mode&0o777,0o555);
|
|
});
|