165 lines
11 KiB
JavaScript
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');
|
|
}
|
|
});
|