Files
stack/packages/webui/tests/archive-unknown-browser.test.mjs

146 lines
13 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { request } from 'node:http';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { createChat } from '../src/chat.mjs';
import { BusReadError } from '../src/bus.mjs';
import { startServer } from '../src/serve.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
import { setup, state, REQUEST } from './helpers/w15-requests.mjs';
for (const failure of ['403', 'network', 'malformed']) {
test(`archive lookup ${failure} leaves a readable thread and request panel with an unknown-state notice`, async t => {
const { b, navigate, consoleErrors } = await setup(t, { states: { [REQUEST]: state({}) }, archiveFailure: failure });
await navigate(1000);
assert.match(await b.evaluate('document.querySelector(".chat-conversation").textContent'), /Build a countdown timer/);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status.*unavailable/i);
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /this project is archived/i);
assert.ok(await b.evaluate('!!document.querySelector(".request-panel")'));
assert.deepEqual(consoleErrors, []);
});
}
test('a failed archive lookup cannot hide the real broker refusal of an archived thread send', async t => {
const root = mkdtempSync(join(tmpdir(), 'archive-unknown-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const roles = { assistant: { definition:'assistant', authority:{withinRole:['message.send'],crossRole:[]} } };
const broker = new Broker({ store, businesses:{ demo:{ id:'demo', human:'jason', arbiters:{technical:'chief',delivery:'chief'}, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, ...roles }, definitions:{assistant:{}}, projects:{} } } });
const cap = broker.bindHuman({ business:'demo', human:'jason', via:'cli', outsideAgent:true });
const call = (verb, args = {}) => broker.request(cap, { verb, args });
const project = call('project.create', { name:'archive test' }).id;
const thread = call('thread.create', { project, name:'assistant', instance:'assistant' }).id;
call('thread.send', { thread, body:'before archive' });
call('project.archive', { project });
const before = store.get('SELECT count(*) n FROM messages').n;
const auth = await createSsoSession(t, { host:'chat.example.com' });
const chat = createChat({ call: async (verb, args) => {
if (verb === 'plugins') return { plugins:{channel:['webui']} };
try { return structuredClone(call(verb, args)); } catch (e) { throw new BusReadError(e.code); }
} });
const server = await startServer({ port:0, chat, sso:auth.sso, send:async()=>({}), log:()=>{} });
t.after(() => close(server));
const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session);
let response;
const forwardSend = req => new Promise((resolve, reject) => {
const body = req.postData;
const out = request({ host:'127.0.0.1', port:server.address().port, path:new URL(req.url).pathname, method:'POST', headers }, res => {
let data=''; res.on('data', chunk => data += chunk); res.on('end', () => { response={status:res.statusCode,body:JSON.parse(data)}; resolve(response); });
});
out.on('error', reject); out.end(body);
});
const probe = await forwardSend({ url:`http://127.0.0.1/api/chat/threads/${thread}/messages`, postData:JSON.stringify({body:'refusal probe'}) });
assert.equal(probe.status, 409);
assert.equal(probe.body.error, 'project-archived');
assert.equal(probe.body.sent, false);
const { b, navigate } = await setup(t, { states:{[REQUEST]:state({})}, thread, project, archiveFailure:'403', forwardSend:true }, { forwardSend });
await navigate(1000);
assert.ok(await b.evaluate('!!document.querySelector(".request-panel")'));
await b.evaluate(`(()=>{const e=document.querySelector('#chat-body');e.value='must be refused';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit();return true})()`);
await b.evaluate(`(async()=>{for(let i=0;i<150;i++){if(document.querySelector('#chat-receipt')?.textContent.includes('Not sent'))return true;await new Promise(r=>setTimeout(r,30))}throw Error('no refusal')})()`);
assert.equal(response.status, 409);
assert.equal(response.body.error, 'project-archived');
assert.equal(response.body.sent, false);
assert.match(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Not sent \(project-archived\)/);
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'must be refused');
assert.equal(store.get('SELECT count(*) n FROM messages').n, before);
});
test('unknown status suppresses unguarded controls and retains guarded controls', async t => {
const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs');
const active = {...draft, snapshot:{id:'snapshot-1',revision:2}};
const current = state({ question, wait:wait('question','question','q-1'), candidate, snapshot:'snapshot-1' });
const {b,navigate} = await setup(t,{states:{[REQUEST]:current}, draft:active, archiveFailure:'403'});
await navigate(1000);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable.*may be refused/i);
for (const selector of ['#chat-session-form button', '#chat-share-form button', '[data-unshare]', '.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-revert]']) {
assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}) || document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
}
assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), true, 'cancel clear');
for (const selector of ['#chat-send-form button', '#chat-resume', '#chat-clear']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector);
assert.match(await b.evaluate('document.querySelector(".request-panel").textContent'), /Which platform/);
assert.deepEqual(await b.evaluate('window.__writes'), []);
});
test('unknown status suppresses decision writes but keeps the guarded picker', async t => {
const { wait, DECISION } = await import('./helpers/w15-requests.mjs');
const choice = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)});
const inbox = [{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}];
const {b,navigate} = await setup(t,{states:{[REQUEST]:choice},inbox,projects:[{project:'old',name:'Old'}],archiveFailure:'403'});
await navigate(1000);
assert.equal(await b.evaluate("!!document.querySelector('[data-request-choice]')"), false);
await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(state({project:null,phase:'selection',route:'existing',wait:wait('setup','request-existing-project',REQUEST)}))};document.querySelector('[data-request-reload]').click();true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-project]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`);
assert.equal(await b.evaluate("document.querySelector('[data-request-project]').disabled"),false);
});
test('a later successful lookup restores live controls or shows the archived state', async t => {
const { question, wait, draft, candidate, DECISION } = await import('./helpers/w15-requests.mjs');
const current = state({question, candidate, snapshot:'snapshot-1', wait:wait('question','question','q-1')});
const {b,navigate} = await setup(t,{states:{[REQUEST]:current},draft:{...draft,snapshot:{id:'snapshot-1',revision:2}},shares:[{to:'user:reader'}],archiveFailure:'403',inbox:[{id:DECISION,question:'Choose',console:{kind:'decision',options:[{key:'existing',text:'Existing project'}]}}]});
await navigate(1000);
assert.equal(await b.evaluate("!!document.querySelector('.request-answer')"), false);
await b.evaluate(`window.__archiveFailure=null;window.__liveProjects=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Live',repo:null}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-answer') && document.querySelector('#chat-session-form') && document.querySelector('#chat-share-form'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('live controls missing')})()`);
for (const selector of ['.request-answer', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-unshare]']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), true, selector);
assert.equal(await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return document.querySelector('#chat-cancel').disabled})()`), false);
const selection = state({project:null,phase:'selection',selection:{decision:DECISION},wait:wait('decision','request-mode',DECISION)});
await b.evaluate(`window.__states['${REQUEST}']=${JSON.stringify(selection)};document.querySelector('[data-request-reload]').click();true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('[data-request-choice]'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('decision not restored')})()`);
await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[{id:'${'20000000-0000-4000-8000-000000000002'}',name:'Archived',repo:null,archived:true}];window.dispatchEvent(new HashChangeEvent('hashchange'));true`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(!document.querySelector('#chat-send-form') && document.querySelector('.chat-conversation'))return true;await new Promise(r=>setTimeout(r,25))}throw Error('archived controls still present')})()`);
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /this project is archived/i);
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /archive status unavailable/i);
});
test('real authenticated attach on an archived origin shows a known refusal', async t => {
const { atPicker } = await import('./helpers/archive-origin.mjs');
const x = await atPicker(t);
const project = x.store.get('SELECT project FROM threads WHERE id=?',x.state.thread).project;
await x.call('human','project.archive',{project});
const auth = await createSsoSession(t,{host:'chat.example.com'});
const bus = async (verb,args) => { try { return await x.call('console',verb,args); } catch(e) { throw new BusReadError(e.code); } };
const server = await startServer({port:0,sso:auth.sso,send:async()=>({}),requestRead:bus,requestWrite:bus,log:()=>{}});
t.after(()=>close(server));
const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session);
let response;
const forwardSend = req => new Promise((resolve,reject)=>{
const out=request({host:'127.0.0.1',port:server.address().port,path:new URL(req.url).pathname,method:'POST',headers},res=>{
let data='';res.on('data',chunk=>data+=chunk);res.on('end',()=>{response={status:res.statusCode,body:JSON.parse(data)};resolve(response);});
});out.on('error',reject);out.end(req.postData);
});
const {b,navigate} = await setup(t,{request:x.source.request,thread:x.state.thread,project,states:{[x.source.request]:x.state},projects:[{project:'old',name:'Old'}],archiveFailure:'403',forwardSend:true},{forwardSend});
await navigate(1000);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){const e=document.querySelector('[data-request-project=old]');if(e){e.click();return true}await new Promise(r=>setTimeout(r,25))}throw Error('picker missing')})()`);
await b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.request-receipt')?.textContent.match(/Not picked|Outcome unknown/))return true;await new Promise(r=>setTimeout(r,25))}throw Error('receipt missing')})()`);
assert.equal(response.status,409);
assert.equal(response.body.error,'project-archived');
assert.match(await b.evaluate('document.querySelector(".request-receipt").textContent'), /Not picked.*project-archived/);
assert.equal(x.journal.readRequest('demo',x.source.request).revision,x.state.revision);
});