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