Files
stack/packages/webui/tests/dev-local-owner.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
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.
2026-11-09 11:47:48 -06:00

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');
});