Files
stack/packages/webui/tests/thread-sharing.test.mjs

165 lines
11 KiB
JavaScript

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