git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
198 lines
15 KiB
JavaScript
198 lines
15 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { request } from 'node:http';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { createDevOwner } from '../src/dev-owner.mjs';
|
|
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
|
import { close } from './fixture.mjs';
|
|
|
|
// CLI paths below are repository-relative, including under npm --prefix packages/webui test.
|
|
process.chdir(fileURLToPath(new URL('../../..', import.meta.url)));
|
|
|
|
const cli = 'packages/webui/src/cli.mjs';
|
|
const run = (args, env = {}) => spawnSync(process.execPath, [cli, 'serve', ...args], { encoding: 'utf8', env: { ...process.env, ...env } });
|
|
test('dev owner refuses incompatible flags before loading configuration', () => {
|
|
for (const args of [['--sso'], ['--public-host','example.test'], ['--auth-header','x-auth'], ['--allow-user','x'], ['--proof-header','x-proof'], ['--proof-file','/unused'], ['--tester-socket']]) {
|
|
const r = run(['--dev-local-owner', ...args]);
|
|
assert.equal(r.status, 2); assert.match(r.stderr, /--dev-local-owner cannot be combined/);
|
|
}
|
|
});
|
|
test('dev owner refuses the default instance before reading configuration', () => {
|
|
for (const config of ['', `${process.env.HOME}/.config/mosaic-dev/config.json`, '/invalid/config.json']) {
|
|
const r = run(['--dev-local-owner'], { MOSAIC_CONFIG: config });
|
|
assert.equal(r.status, 2); assert.match(r.stderr, /--dev-local-owner requires a named instance/);
|
|
}
|
|
});
|
|
test('dev owner refuses a named config symlink to the default config', async t => {
|
|
const { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os'); const { join } = await import('node:path');
|
|
const home=mkdtempSync(join(tmpdir(),'dev-owner-home-')); t.after(()=>rmSync(home,{recursive:true,force:true}));
|
|
const dev=join(home,'.config','mosaic-dev'), named=join(home,'.config','mosaic-test');
|
|
mkdirSync(dev,{recursive:true}); mkdirSync(named,{recursive:true}); writeFileSync(join(dev,'config.json'),'{}');
|
|
symlinkSync(join(dev,'config.json'),join(named,'config.json'));
|
|
const r=run(['--dev-local-owner'],{HOME:home,MOSAIC_CONFIG:join(named,'config.json')});
|
|
assert.equal(r.status,2); assert.match(r.stderr,/--dev-local-owner requires a named instance/);
|
|
});
|
|
|
|
for (const scenario of [
|
|
{ name: 'missing marker', owner: null, reason: /instance-root-unclaimed.*holds no instance\.json naming test/ },
|
|
{ name: 'default dev marker', owner: 'dev', reason: /instance-root-mismatch.*claimed by instance dev, not test/ },
|
|
{ name: 'different instance marker', owner: 'other', reason: /instance-root-mismatch.*claimed by instance other, not test/ },
|
|
{ name: 'named hard link to default config', owner: 'dev', hardLink: true, reason: /instance-root-mismatch.*claimed by instance dev, not test/ },
|
|
]) {
|
|
test(`dev owner entry points refuse ${scenario.name} before startup`, async t => {
|
|
const { existsSync, linkSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const { fixture } = await import('../../cli/tests/helpers.mjs');
|
|
const root = mkdtempSync(join(tmpdir(), 'dev-owner-claim-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const f = fixture(root), home = join(root, 'home'), base = join(home, '.config');
|
|
mkdirSync(base, { recursive: true });
|
|
if (scenario.hardLink) {
|
|
renameSync(join(root, 'config'), join(base, 'mosaic-dev'));
|
|
mkdirSync(join(base, 'mosaic-test'));
|
|
linkSync(join(base, 'mosaic-dev', 'config.json'), join(base, 'mosaic-test', 'config.json'));
|
|
} else renameSync(join(root, 'config'), join(base, 'mosaic-test'));
|
|
if (scenario.owner) writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: scenario.owner }));
|
|
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(base, 'mosaic-test', 'config.json') };
|
|
for (const args of [
|
|
['packages/webui/src/cli.mjs', 'serve', '--port', '0', '--dev-local-owner'],
|
|
['packages/cli/src/cli.mjs', 'bus', 'start', 'acme', '--dev-local-owner'],
|
|
]) {
|
|
const r = spawnSync(process.execPath, args, { env, encoding: 'utf8', timeout: 3000 });
|
|
assert.notEqual(r.status, 0, args.join(' '));
|
|
assert.match(r.stderr, scenario.reason, args.join(' '));
|
|
assert.doesNotMatch(r.stdout, /DEV LOCAL OWNER MODE|Mosaic Console:|bus host up:/);
|
|
assert.equal(existsSync(join(f.dataRoot, 'bus')), false, 'no bus startup state');
|
|
}
|
|
});
|
|
}
|
|
test('dev identity is fixed and cannot be configured as a real SSO issuer', () => {
|
|
const provider = createDevOwner(), a = provider.session(), b = provider.session();
|
|
assert.equal(a,b); assert.deepEqual({provider:a.provider,iss:a.iss,sub:a.sub}, DEV_OWNER);
|
|
assert.equal(a.name,'Local operator'); assert.ok(Object.isFrozen(a));
|
|
});
|
|
test('dev owner binds only 127.0.0.1', async () => {
|
|
await assert.rejects(startServer({ host: '127.0.0.2', port: 0, sso: createDevOwner() }), /127\.0\.0\.1/);
|
|
});
|
|
function call(port, { host = `127.0.0.1:${port}`, method = 'GET', origin, path = '/api/config' } = {}) {
|
|
return new Promise((resolve, reject) => { const req = request({ host:'127.0.0.1', port, path, method, headers: { host, ...(origin === undefined ? {} : { origin }) } }, res => { let body=''; res.on('data', c => body += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) })); }); req.on('error', reject); req.end(); });
|
|
}
|
|
test('dev owner enforces exact Host and Origin and exposes mode', async t => {
|
|
const server = await startServer({ port: 0, board:'http://127.0.0.1:9', sso:createDevOwner(), send: async () => ({}) });
|
|
t.after(() => close(server)); const port=server.address().port;
|
|
for (const host of [`127.0.0.1:${port}`, `localhost:${port}`]) {
|
|
const r=await call(port,{host}); assert.equal(r.status,200); assert.equal(r.body.devOwner,true); assert.equal(r.body.send,true);
|
|
assert.notEqual((await call(port,{host,method:'POST',origin:`http://${host}`})).body.error,'cross-origin request refused');
|
|
}
|
|
for (const host of ['evil.example', `127.0.0.2:${port}`, `localhost:${port+1}`]) assert.equal((await call(port,{host})).status,403);
|
|
for (const origin of [undefined,'http://evil.example',`http://localhost:${port+1}`]) assert.equal((await call(port,{method:'POST',origin})).status,403);
|
|
});
|
|
|
|
test('dev owner resolves a decision through the real Console wrapper and bus', {timeout:90000}, async t => {
|
|
const { mkdtempSync, mkdirSync, renameSync, chmodSync, openSync, closeSync, readFileSync, rmSync, writeFileSync } = await import('node:fs');
|
|
const { tmpdir } = await import('node:os');
|
|
const { join } = await import('node:path');
|
|
const { spawn } = await import('node:child_process');
|
|
const { fixture, notifyConfig } = await import('../../cli/tests/helpers.mjs');
|
|
const { loadSystem, bootConfig } = await import('../../cli/src/config.mjs');
|
|
const { createOrg } = await import('../../organization/src/org-service.mjs');
|
|
const { Store } = await import('../../bus/src/store.mjs');
|
|
const { Broker } = await import('../../bus/src/broker.mjs');
|
|
const root=mkdtempSync(join(tmpdir(),'o-'));
|
|
// P19 #875: t.after hooks run in registration order, so removing root came first, both
|
|
// `bus stop` calls then failed, and SIGTERM to the outer `bus start` reached neither the
|
|
// --internal-host process nor its broker; they outlived the file. One hook now stops the
|
|
// Console and both buses while root exists, fails if any recorded process survives, then
|
|
// removes root. Each process is recorded with its start time so a reused pid is not it.
|
|
const startOf=pid=>{ try { const raw=readFileSync(`/proc/${pid}/stat`,'utf8'), fields=raw.slice(raw.lastIndexOf(')')+2).split(' '); return fields[0]==='Z'||fields[0]==='X'?null:fields[19]; } catch { return null; } };
|
|
const tracked=new Map();
|
|
const track=pid=>{ const start=startOf(pid); if(!start||tracked.has(pid))return; tracked.set(pid,start); let kids=''; try { kids=readFileSync(`/proc/${pid}/task/${pid}/children`,'utf8'); } catch {} for(const kid of kids.split(' ').filter(Boolean))track(Number(kid)); };
|
|
const survivors=()=>[...tracked].filter(([pid,start])=>startOf(pid)===start).map(([pid])=>pid);
|
|
const hostOf=(out,org)=>JSON.parse(out.split('\n').find(line=>line.startsWith('{') && line.includes(`"org":"${org}"`))).pid;
|
|
let env, pid;
|
|
t.after(async()=>{
|
|
try {
|
|
if(pid){ try { process.kill(pid,'SIGTERM'); } catch {} }
|
|
if(env) for(const org of ['acme','beta']) spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop',org],{env,encoding:'utf8'});
|
|
for(let i=0;i<100 && survivors().length;i++) await new Promise(r=>setTimeout(r,100));
|
|
const left=survivors();
|
|
// Stop what survived so the failure is this assertion, not a leak into the next file.
|
|
for(const p of left){ try { process.kill(p,'SIGKILL'); } catch {} }
|
|
assert.deepEqual(left,[],`processes still running after teardown: ${left.join(' ')}`);
|
|
} finally { rmSync(root,{recursive:true,force:true}); }
|
|
});
|
|
const f=fixture(root); const home=join(root,'home'), configDir=join(home,'.config','mosaic-test');
|
|
mkdirSync(join(home,'.config'),{recursive:true}); renameSync(join(root,'config'),configDir); chmodSync(configDir, 0o700);
|
|
writeFileSync(join(f.dataRoot,'instance.json'), JSON.stringify({instanceVersion:1,name:'test'}));
|
|
env={...f.env,HOME:home,MOSAIC_CONFIG:join(configDir,'config.json')};
|
|
const ctx={configDir,dataRoot:f.dataRoot,instance:'test',principal:DEV_OWNER};
|
|
const doc=structuredClone(f.doc); doc.human=DEV_OWNER.sub; doc.projects.stack.root='/__mosaic_org__/acme/projects/stack';
|
|
rmSync(join(configDir,'businesses','acme.json')); chmodSync(join(configDir,'businesses'),0o700);
|
|
const org=await createOrg(ctx,'acme',doc);
|
|
const system=loadSystem({env,instance:'test'}), boot=bootConfig({system,businessId:'acme',orgRoot:org.root,env});
|
|
const store=new Store(org.root), broker=new Broker({store,businesses:boot.businesses});
|
|
const cap=broker.bindLaunch({business:'acme',role:'chief',run:'chief-run',harness:'pi',address:'chief'});
|
|
broker.request(cap,{verb:'role.claim'});
|
|
const decision=broker.request(cap,{verb:'decision.raise',args:{action:'prd.approve',question:'Choose?',blocking:false,options:[{key:'yes',text:'Yes'},{key:'no',text:'No'}],recommendation:'yes',approvalChoice:'yes'}});
|
|
store.close();
|
|
const betaDoc=structuredClone(doc); betaDoc.id='beta'; betaDoc.projects.stack.root='/__mosaic_org__/beta/projects/stack';
|
|
const betaOrg=await createOrg(ctx,'beta',betaDoc);
|
|
const betaBoot=bootConfig({system,businessId:'beta',orgRoot:betaOrg.root,env});
|
|
const betaStore=new Store(betaOrg.root), betaBroker=new Broker({store:betaStore,businesses:betaBoot.businesses});
|
|
const betaCap=betaBroker.bindLaunch({business:'beta',role:'chief',run:'beta-chief',harness:'pi',address:'chief'});
|
|
betaBroker.request(betaCap,{verb:'role.claim'});
|
|
const betaDecision=betaBroker.request(betaCap,{verb:'decision.raise',args:{action:'prd.approve',question:'Beta only?',blocking:false,options:[{key:'yes',text:'Yes'},{key:'no',text:'No'}],recommendation:'yes',approvalChoice:'yes'}});
|
|
betaStore.close();
|
|
let betaOut=''; const betaBus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','beta','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
|
betaBus.stdout.on('data',c=>betaOut+=c); betaBus.stderr.on('data',c=>betaOut+=c);
|
|
track(betaBus.pid);
|
|
let bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
|
let busOut=''; bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
|
|
track(bus.pid);
|
|
const wait=async (fn) => { for(let i=0;i<300;i++){ const v=fn(); if(v)return v; await new Promise(r=>setTimeout(r,100)); } throw Error(`startup timeout: ${busOut}`); };
|
|
await wait(()=>busOut.includes('\"org\":\"acme\"'));
|
|
await wait(()=>betaOut.includes('\"org\":\"beta\"'));
|
|
track(hostOf(busOut,'acme')); track(hostOf(betaOut,'beta'));
|
|
const hostLog=()=>readFileSync(join(org.root,'bus-host','host.log'),'utf8');
|
|
assert.match(hostLog(),/DEV LOCAL OWNER MODE: console owner local-dev accepted \(named instance test\)/);
|
|
assert.match(hostLog(),/launches disabled/);
|
|
// Reparent only the disposable Console to init: the bus deliberately rejects agent ancestry.
|
|
const log=join(root,'console.log'), fd=openSync(log,'w');
|
|
const launch=spawnSync(process.execPath,['-e',`const {spawn}=require('node:child_process');const p=spawn(process.execPath,[require('node:path').resolve('packages/webui/src/cli.mjs'),'serve','--port','0','--board','http://127.0.0.1:9','--business','acme','--send','--dev-local-owner'],{detached:true,stdio:['ignore',1,2]});console.log('PID '+p.pid);p.unref();`],{env,stdio:['ignore',fd,fd]});
|
|
closeSync(fd); assert.equal(launch.status,0);
|
|
const output=()=>readFileSync(log,'utf8');
|
|
pid=Number((await wait(()=>/PID (\d+)/.exec(output()))) [1]);
|
|
track(pid);
|
|
const port=Number((await wait(()=>/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(output())))[1]);
|
|
assert.match(output(),/DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner \(named instance test\)/);
|
|
const post=async (path,body,extra={})=>{
|
|
const host=`127.0.0.1:${port}`, data=JSON.stringify(body);
|
|
return new Promise((resolve,reject)=>{const req=request({host:'127.0.0.1',port,path,method:'POST',headers:{host,origin:`http://${host}`,'sec-fetch-site':'same-origin','content-type':'application/json','content-length':Buffer.byteLength(data),...extra}},res=>{let raw='';res.on('data',c=>raw+=c);res.on('end',()=>resolve({status:res.statusCode,body:JSON.parse(raw)}));});req.on('error',reject);req.end(data);});
|
|
};
|
|
const wrongOrg=await post(`/api/orgs/acme/decisions/${betaDecision.id}/confirmation`,{choice:'yes'});
|
|
assert.equal(wrongOrg.status,404,JSON.stringify(wrongOrg));
|
|
const betaStillOpen=await post(`/api/orgs/beta/decisions/${betaDecision.id}/confirmation`,{choice:'yes'});
|
|
assert.equal(betaStillOpen.status,200,JSON.stringify(betaStillOpen));
|
|
assert.equal(betaStillOpen.body.id,betaDecision.id);
|
|
const crossSite=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'},{'sec-fetch-site':'cross-site'});
|
|
assert.equal(crossSite.status,403);
|
|
const confirmation=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'});
|
|
assert.equal(confirmation.status,200,JSON.stringify(confirmation));
|
|
const result=await post(`/api/orgs/acme/decisions/${decision.id}/resolve`,{choice:'yes',confirmation:confirmation.body.confirmation});
|
|
assert.equal(result.status,200,JSON.stringify(result));
|
|
assert.equal(result.body.choice,'yes');
|
|
const stopped = new Promise(resolve => bus.once('exit', resolve));
|
|
const stop = spawnSync(process.execPath,['packages/cli/src/cli.mjs','bus','stop','acme'],{env,encoding:'utf8'}); assert.equal(stop.status,0,stop.stderr); await stopped;
|
|
busOut=''; bus=spawn(process.execPath,['packages/cli/src/cli.mjs','bus','start','acme','--no-launch'],{env,stdio:['ignore','pipe','pipe']});
|
|
bus.stdout.on('data',c=>busOut+=c); bus.stderr.on('data',c=>busOut+=c);
|
|
track(bus.pid);
|
|
await wait(()=>busOut.includes('\"org\":\"acme\"'));
|
|
track(hostOf(busOut,'acme'));
|
|
const refused=await post(`/api/orgs/acme/decisions/${decision.id}/confirmation`,{choice:'yes'});
|
|
assert.equal(refused.status,403,JSON.stringify(refused)); assert.equal(refused.body.error,'human-required');
|
|
});
|