import test from 'node:test'; import assert from 'node:assert/strict'; import { sharingFixture } from './thread-sharing-fixture.mjs'; import { browser } from './browser.mjs'; import { startServer } from '../src/serve.mjs'; import { consoleBus } from '../src/console-bus.mjs'; async function page(t, f) { const b = await browser(); t.after(() => b.close()); await b.navigate(f.origin); const render = async path => { await b.evaluate(`(async()=>{location.hash=${JSON.stringify('#' + path)};await MosaicChat.render({path:${JSON.stringify(path)},container:document.getElementById('view'),canSend:true,user:${!f.owner},navigated:true});return true;})()`); }; const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}return false;})()`); return { b, render, wait }; } const counts = f => Object.fromEntries(['threads', 'messages', 'thread_opens', 'thread_shares'].map(table => [table, f.store.get(`SELECT count(*) n FROM ${table}`).n])); test('193d HTTP shares use the caller, plain/shared lists, exact args and write fences', async t => { const f = await sharingFixture(t); let r = await f.http('/api/chat/threads?shared=true'); assert.equal(r.status, 200); assert.deepEqual(r.body.rows.map(x => x.id), [f.shared]); assert.equal(r.body.rows[0].owner, 'user:bob'); r = await f.http('/api/chat/threads'); assert.deepEqual(r.body.rows.map(x => x.id), [f.own, f.writable]); assert.ok(r.body.rows.every(x => !Object.hasOwn(x, 'owner')), 'plain list shape unchanged'); assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).status, 200); const before = counts(f); for (const [path, body] of [ ['/api/chat/threads', { project: f.project, instance: 'assistant', name: 'forbidden' }], [`/api/chat/threads/${f.own}/messages`, { body: 'forbidden' }], [`/api/chat/threads/${f.own}/open`, {}], [`/api/chat/threads/${f.own}/share`, { to: 'user:bob' }], [`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' }], ]) { r = await f.http(path, body); assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false); } assert.equal((await f.http(`/api/chat/threads/${f.own}/shares`)).body.error, 'access-refused'); assert.deepEqual(counts(f), before); f.access.grants[0].level = 'collaborator'; r = await f.http(`/api/chat/threads/${f.own}/share`, { to: 'user:bob' }); assert.equal(r.status, 200); assert.deepEqual(r.body, { thread: f.own, to: 'user:bob', op: 'share' }); assert.deepEqual((await f.http(`/api/chat/threads/${f.own}/shares`)).body.rows.map(x => x.to), ['user:bob']); assert.equal((await f.http(`/api/chat/threads/${f.own}/unshare`, { to: 'user:bob' })).status, 200); for (const [path, body] of [ ['/api/chat/threads?shared=false', undefined], ['/api/chat/threads?shared=true&shared=true', undefined], [`/api/chat/threads/${f.own}/share`, { to: 'user:bob', owner: 'jason' }], [`/api/chat/threads/${f.own}/share`, { to: 'user:bob', verb: 'thread.send' }], [`/api/chat/threads/${f.own}/shares?to=user:bob`, undefined], ]) assert.equal((await f.http(path, body)).status, 400, path); assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { origin: 'http://other.test' })).status, 403); assert.equal((await f.http(`/api/chat/threads/${f.own}/share`, { to: '*' }, { 'sec-fetch-site': 'cross-site' })).status, 403); f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' }); assert.equal((await f.http(`/api/chat/threads/${f.shared}/messages`)).body.error, 'thread-not-owned'); }); test('193d config supplies only the signed-in user live collaborator scopes and updates on downgrade', async t => { const f = await sharingFixture(t); f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] }); const session = { user: 'alice', name: 'alice', provider: 'fake', sub: 'alice-sub' }; let web; const sso = { get publicHost() { return `127.0.0.1:${web.address().port}`; }, get publicOrigin() { return `http://${this.publicHost}`; }, formTargets: () => [], session: () => session, chatGrants: s => f.hints.chatGrants(s) }; web = await startServer({ port: 0, sso, log: () => {}, userOptions: () => consoleBus(async () => [], { send: true }) }); t.after(() => new Promise(r => web.close(r))); const read = async () => (await fetch(`http://127.0.0.1:${web.address().port}/api/config`)).json(); let config = await read(); assert.equal(config.user, true); assert.deepEqual(config.chatGrants, [{ projects: [f.other], instances: ['assistant'] }]); assert.equal(config.send, true, 'send mode is separate from individual grant levels'); f.access.grants[2].level = 'viewer'; assert.deepEqual((await read()).chatGrants, []); f.access.grants[2].level = 'collaborator'; f.access.grants[2].expires = new Date(Date.now() - 1000).toISOString(); assert.deepEqual((await read()).chatGrants, []); assert.deepEqual(f.hints.chatGrants({ ...session, sub: 'bob-sub' }), []); }); test('193d viewer UI has no composer, resume, share, session mutations or create controls', async t => { const f = await sharingFixture(t); f.owner = false; const { b, render } = await page(t, f); await render('/chat'); assert.match(await b.evaluate('document.getElementById("view").textContent'), /Shared with me/); assert.equal(await b.evaluate('!!document.querySelector("#chat-project-form")'), false); await render('/chat/projects/' + f.project); assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form")'), false); for (const thread of [f.own, f.shared]) { await render('/chat/threads/' + thread); assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /history/); for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '#chat-session-form', '#chat-clear', '#chat-cancel']) { assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, `${thread}: ${selector}`); } } assert.equal(await b.evaluate('!!document.querySelector("img")'), false, 'shared name remains text'); const sharedSessions = f.calls.filter(([verb, args]) => verb === 'session.list' && args.thread === f.shared); assert.equal(sharedSessions.length, 0, 'shared read never requests an owner-only catalog'); }); test('193d mixed grants keep viewer project read-only and collaborator own thread writable; downgrade hides controls', async t => { const f = await sharingFixture(t); f.owner = false; f.access.grants.push({ ...f.access.grants[0], id: 'mixed', level: 'collaborator', projects: [f.other] }); const { b, render, wait } = await page(t, f); await render('/chat/threads/' + f.own); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false); await render('/chat/threads/' + f.writable); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), true); assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true); await b.evaluate('(()=>{const x=document.getElementById("chat-share-target");x.value="user:bob";x.dispatchEvent(new Event("input",{bubbles:true}));document.querySelector("#chat-share-form button").click();return true;})()'); assert.equal(await wait('!!document.querySelector("[data-unshare]")'), true); assert.equal(f.store.get("SELECT count(*) n FROM thread_shares WHERE thread=? AND op='share'", f.writable).n, 1); await b.evaluate('document.querySelector("[data-unshare]").click(),true'); assert.equal(await wait('!document.querySelector("[data-unshare]")'), true); f.access.grants[2].level = 'viewer'; await render('/chat/threads/' + f.writable); assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form,#chat-share-form,#chat-resume")'), false); // Loss of a share clears visible history on the next render. await render('/chat/threads/' + f.shared); f.invoke(f.bob(), 'thread.unshare', { thread: f.shared, to: 'user:alice' }); await render('/chat/threads/' + f.shared); assert.equal(await b.evaluate('!!document.querySelector(".chat-log")'), false); }); test('193d owner has share controls on own threads and shared threads remain read-only', async t => { const f = await sharingFixture(t, { owner: true }); f.owner = true; const own = f.invoke(f.human, 'thread.create', { project: f.project, instance: 'assistant', name: 'Owner thread' }).id; f.invoke(f.bob(), 'thread.share', { thread: f.shared, to: 'jason' }); const { b, render } = await page(t, f); await render('/chat/threads/' + own); assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form")'), true); await render('/chat/threads/' + f.shared); assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /Shared history/); assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume")'), false); }); // Run under each served-source mutant after the normal absence test kills it. // Every browser POST bypasses all local UI checks and still uses the viewer transport. test('193d bypassed UI checks still reach a refusing bus for every viewer write', async t => { const f = await sharingFixture(t); f.owner = false; const { b, render } = await page(t, f); await render('/chat/threads/' + f.own); const before = counts(f); for (const [path, value] of [ ['threads', { project: f.project, instance: 'assistant', name: 'mutant create' }], [`threads/${f.own}/messages`, { body: 'mutant send' }], [`threads/${f.own}/open`, {}], [`threads/${f.own}/share`, { to: 'user:bob' }], ]) { const r = JSON.parse(await b.evaluate(`(async()=>{const r=await fetch('/api/chat/'+${JSON.stringify(path)},{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(${JSON.stringify(value)})});return JSON.stringify({status:r.status,body:await r.json()});})()`)); assert.equal(r.status, 403, path); assert.equal(r.body.error, 'access-refused'); assert.equal(r.body.sent, false); } assert.deepEqual(counts(f), before); for (const verb of ['thread.create', 'thread.send', 'thread.open', 'thread.share']) assert.ok(f.calls.some(([v]) => v === verb), `${verb} reached the bus`); }); test('193d collaborator shares stay read-only and never expose reshare or wake controls', async t => { const f = await sharingFixture(t); f.owner = false; f.access.grants[0].level = 'collaborator'; const { b, render } = await page(t, f); await render('/chat/threads/' + f.shared); assert.equal(await b.evaluate('!!document.querySelector("#chat-share-form,#chat-send-form,#chat-resume,#chat-session-form")'), false); for (const [action, body] of [['messages', { body: 'shared write' }], ['open', {}], ['share', { to: '*' }]]) { const r = await f.http(`/api/chat/threads/${f.shared}/${action}`, body); assert.equal(r.status, 403); assert.equal(r.body.error, 'thread-not-owned'); } });