`mosaic business instances BUSINESS` reads the role instances, and `mosaic business instance add|set` writes one add or change through the broker's new human-only `instances` and `instances.set` verbs. The writer is packages/business/src/instance-settings.mjs. It takes the caller's revision, validates the next document, refuses a definition that declares credentials (organization-credentials-out-of-scope), keeps protected instances as they are, logs the human as the actor, and says when the running bus needs a restart. Neither command restarts the bus. The writer refuses the instance ids human and tester with invalid-request, because the broker refuses a business whose roles use either name when the bus starts. The Console's Settings view shows the instances and, under --send only, posts to POST /api/instances with the exact Origin and same-origin Fetch Metadata. A refusal keeps the draft. An answer without the view's shape is outcome-unknown (503). The page's public/ diff carries the decision 123 go in mos-0832. Neither verb is in ORGANIZATION_KEYS, so no agent tool reaches them. Owner Darkwing, brief in row 165 (#1662). Round 3 rebases round 2 onto4f45cadc0(row 156). The public/ +/- digest is 939ecd28...9444, unchanged at landing. Reviewers on candidate manifest e30b54da...f580: Dewey approve 29052, Filbert approve 29053. Seat run on4f45cadc0: 1436/0 singles, 37/37 mutants killed. Sage gate one8b09735a: GATE_PASSED 25/25, packages 2282/0, queue node 156/0, script selftests 422/0, 23:59:13Z to 00:05:35Z. Rebased onto50a57e454over two SESSIONS-only commits. Nothing live.
98 lines
9.1 KiB
JavaScript
98 lines
9.1 KiB
JavaScript
// Row 165 (#1662, Q171, REQ-ROLE-3): Settings shows the role instances, offers only the
|
|
// definitions add and set accept, names the ones that declare credentials as hand edits, and posts
|
|
// one add or change with the read's revision. The receipt names a needed bus restart and offers no
|
|
// way to run it. A refusal keeps the draft.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {browser} from './browser.mjs';
|
|
import {startServer} from '../src/serve.mjs';
|
|
import {close} from './fixture.mjs';
|
|
|
|
const hostile='<img src=x onerror="window.injected=true">';
|
|
const REV='a'.repeat(64);
|
|
const view={business:'acme',revision:REV,launch:true,
|
|
instances:{chief:{definition:'chief-of-staff',vars:{},launched:false,launcher:true,protected:false},coder:{definition:'coder',vars:{note:hostile},launched:true,launcher:false,protected:false},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true}},
|
|
definitions:{assistant:{services:[],accepted:false},coder:{services:['gitea'],accepted:false},'merge-gate':{services:[],accepted:true},reviewer:{services:['gitea','vikunja'],accepted:false},'security-reviewer':{services:[],accepted:true}}};
|
|
|
|
test('row 165 Settings: role instances read, add with a restart named, change without one, a refusal keeps the draft',{timeout:90000},async()=>{
|
|
// The server's read follows each write, so a poll between steps shows what the stub answered.
|
|
let current=view;
|
|
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>current};
|
|
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
|
try{
|
|
// The stub answers each POST with the next queued reply and records the body.
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(url==='/api/instances'&&options.method==='POST'){posts.push(JSON.parse(options.body));const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
|
|
const port=web.address().port;
|
|
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
|
const receipt=()=>b.evaluate('document.querySelector("#instances-receipt").textContent');
|
|
const set=(form,field,value)=>b.evaluate(`(()=>{const e=document.querySelector('#${form} [data-instance-field="${field}"]');if(e.type==='checkbox')e.checked=${JSON.stringify(value)};else e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
|
|
// Read-only: the table, the accepted definitions only, the hand-edit line, every control disabled.
|
|
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
|
|
await wait('!!document.querySelector("#instances-table")');
|
|
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instances-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].map(c=>c.textContent))"),[
|
|
['chief','chief-of-staff','launcher','none'],['coder','coder','yes',JSON.stringify({note:hostile})],['assistant','assistant','no','none'],
|
|
]);
|
|
assert.equal(await b.evaluate('window.injected===true'),false);
|
|
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-add-form [data-instance-field=definition] option')].map(o=>o.value)"),['merge-gate','security-reviewer']);
|
|
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#instance-change-form [data-instance-field=instance] option')].map(o=>o.value)"),['chief','coder'],'an assistant changes in the Assistant section');
|
|
assert.match(await b.evaluate('document.querySelector("#instances-title").parentElement.textContent'),/Definitions that declare credentials, a hand edit: coder \(gitea\), reviewer \(gitea, vikunja\)\./);
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll(".instance-form button,.instance-form input,.instance-form select,.instance-form textarea")].every(e=>e.disabled)'),true);
|
|
|
|
// Send mode: an add posts the read's revision and the fields, and the receipt names the restart.
|
|
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);
|
|
await wait('document.querySelector("#instance-add-form button")?.disabled===false');
|
|
await set('instance-add-form','instance','sec-1');
|
|
await set('instance-add-form','definition','security-reviewer');
|
|
await set('instance-add-form','launch',true);
|
|
current={...view,revision:'b'.repeat(64)};
|
|
await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-1',restart:true})}]);true`);
|
|
await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true');
|
|
await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Added")');
|
|
assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:REV,instance:'sec-1',op:'add',definition:'security-reviewer',launch:true});
|
|
assert.equal(await receipt(),"Added sec-1. Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.");
|
|
assert.equal(await b.evaluate('[...document.querySelectorAll("button")].some(e=>/restart/i.test(e.textContent))'),false,'the page offers no restart');
|
|
|
|
// A change sends the new revision and only what changed; a launch-only change needs no restart.
|
|
await set('instance-change-form','instance','coder');
|
|
await set('instance-change-form','launch','no');
|
|
current={...view,revision:'c'.repeat(64)};
|
|
await b.evaluate(`replies.push([200,${JSON.stringify({...current,transaction:'t-2',restart:false})}]);true`);
|
|
await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true');
|
|
await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Changed")');
|
|
assert.deepEqual(await b.evaluate('posts.at(-1)'),{revision:'b'.repeat(64),instance:'coder',op:'change',launch:false});
|
|
assert.equal(await receipt(),'Changed coder. No restart: the launcher reads the change at its next launch check.');
|
|
|
|
// Nothing chosen is caught on the page; a refusal shows the server's reason and keeps the draft.
|
|
await b.evaluate('document.querySelector("#instance-change-form").requestSubmit();true');
|
|
await wait('document.querySelector("#instances-receipt")?.textContent==="Nothing changed."');
|
|
assert.equal(await b.evaluate('posts.length'),2);
|
|
await set('instance-add-form','instance','gate-1');
|
|
await set('instance-add-form','vars','{"model":"x"}');
|
|
await b.evaluate(`replies.push([400,{error:'organization-credentials-out-of-scope',message:'organization-credentials-out-of-scope: that definition declares credentials. Nothing changed.',sent:false}]);true`);
|
|
await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true');
|
|
await wait('document.querySelector("#instances-receipt")?.textContent.endsWith("Draft kept.")');
|
|
assert.match(await receipt(),/^organization-credentials-out-of-scope: that definition declares credentials\. Nothing changed\. Draft kept\.$/);
|
|
assert.deepEqual(await b.evaluate('posts.at(-1).vars'),{model:'x'});
|
|
assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1');
|
|
assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=vars]").value'),'{"model":"x"}');
|
|
|
|
// mos-0825 F1: a 200 without the view's definitions leaves the view as it was, keeps the draft and
|
|
// says to read again, and Settings still draws.
|
|
await b.evaluate(`replies.push([200,${JSON.stringify({revision:'d'.repeat(64),restart:true,instances:{'gate-1':{definition:'merge-gate',vars:{}}}})}]);true`);
|
|
await b.evaluate('document.querySelector("#instance-add-form").requestSubmit();true');
|
|
await wait('document.querySelector("#instances-receipt")?.textContent.startsWith("Outcome unknown")');
|
|
assert.equal(await receipt(),'Outcome unknown. Read the role instances again before trying. Draft kept.');
|
|
assert.equal(await b.evaluate('posts.length'),4);
|
|
assert.equal(await b.evaluate('document.querySelector("#instance-add-form [data-instance-field=instance]").value'),'gate-1');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#instances-table tbody tr").length'),3);
|
|
|
|
// An unread view is one section's warning, not the page's empty state, and offers no form.
|
|
// The same URL with a hash would be a same-document navigation, so reload the page.
|
|
current=null;
|
|
await b.evaluate('location.reload();true');
|
|
await wait('!!document.querySelector("#instances-title")?.parentElement.querySelector(".warn")');
|
|
assert.equal(await b.evaluate('document.querySelector("#instances-title").parentElement.querySelector(".warn").textContent'),'The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.');
|
|
assert.equal(await b.evaluate('document.querySelectorAll("#instances-title ~ .empty, .instance-form").length'),0);
|
|
}finally{await b.close();await close(web);}
|
|
});
|