Files
stack/packages/webui/tests/assistant.test.mjs

110 lines
6.7 KiB
JavaScript

// Row 155 (#1649, Q169): the Console's seat name and assistant stop routes. Each has the Settings
// boundary and makes one human-transport call; the broker's own checks are in the bus tests.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
async function setup(t, { on = true, name = async args => ({ ...args }), stop = async () => ({ stopped: [{ thread: 't-1', run: 'a-run' }] }), bus = null } = {}) {
const calls = [], lines = [];
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({
port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
nameWrite: on ? async args => { calls.push(['name.set', args]); return name(args); } : null,
assistantStop: on ? async args => { calls.push(['assistant.stop', args]); return stop(args); } : null,
});
t.after(() => new Promise(r => server.close(r)));
const session = await auth.signIn(server.address().port);
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
}
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
});
req.on('error', reject);
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
test('Q169 the signed-in human names a seat and stops the assistant, one bus call each, logged with the user', async t => {
const s = await setup(t);
const named = await send(s, '/api/name', { instance: 'assistant', name: 'piper' });
assert.equal(named.status, 200);
assert.equal(named.body.name, 'piper');
const stopped = await send(s, '/api/assistant-stop', { instance: 'assistant' });
assert.equal(stopped.status, 200);
assert.deepEqual(stopped.body.stopped, [{ thread: 't-1', run: 'a-run' }]);
assert.deepEqual(s.calls, [['name.set', { instance: 'assistant', name: 'piper' }], ['assistant.stop', { instance: 'assistant' }]]);
assert.match(s.lines[0], / name user="jason" instance="assistant" done$/);
assert.match(s.lines[1], / assistant-stop user="jason" instance="assistant" done$/);
});
test('Q169 the name and stop routes need send on, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
const off = await setup(t, { on: false });
for (const path of ['/api/name', '/api/assistant-stop']) assert.equal((await send(off, path, { instance: 'assistant' })).status, 403);
const s = await setup(t);
for (const path of ['/api/name', '/api/assistant-stop']) {
const data = path === '/api/name' ? { instance: 'assistant', name: 'piper' } : { instance: 'assistant' };
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value;
assert.equal((await send(s, path, data, hs)).status, status, `${path} ${key}`);
}
assert.equal((await send(s, path, undefined, s.headers, 'GET')).status, 405);
}
assert.equal(s.calls.length, 0);
});
test('Q169 the name and stop routes take JSON within 4 KiB and only their own keys, all text', async t => {
const s = await setup(t);
for (const value of [{ instance: 'assistant' }, { instance: 'assistant', name: 'piper', by: 'jason' }, { instance: 'assistant', name: 7 }, [], 'bad', { instance: 'assistant', name: 'x'.repeat(5000) }])
assert.equal((await send(s, '/api/name', value)).status, 400, JSON.stringify(value).slice(0, 40));
for (const value of [{}, { instance: 'assistant', thread: 't-1' }, { instance: null }])
assert.equal((await send(s, '/api/assistant-stop', value)).status, 400, JSON.stringify(value));
assert.equal((await send(s, '/api/name', { instance: 'assistant', name: 'piper' }, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
assert.equal(s.calls.length, 0);
});
// Row 167 (#1664, Dewey's F1): a JSON null body is refused by body() before the key check runs,
// so neither route throws on it, calls the bus or stops answering.
test('Row 167 a null body on the name and stop routes is 400 invalid-request with no write, and the next request is answered', async t => {
const s = await setup(t);
for (const [path, data, call] of [['/api/name', { instance: 'assistant', name: 'piper' }, 'name.set'], ['/api/assistant-stop', { instance: 'assistant' }, 'assistant.stop']]) {
const before = s.calls.length;
const r = await send(s, path, 'null');
assert.deepEqual([r.status, r.body.error, r.body.sent], [400, 'invalid-request', false], path);
assert.equal(s.calls.length, before, path);
assert.equal((await send(s, path, data)).status, 200, path);
assert.deepEqual(s.calls.at(-1), [call, data]);
}
assert.equal(s.calls.length, 2);
});
test('Q169 a bus refusal says nothing changed; a lost or odd answer says the change may have been made', async t => {
const fail = code => async () => { throw new BusReadError(code); };
for (const [stop, status, error, sent] of [
[fail('not-held'), 409, 'not-held', false], [fail('human-required'), 403, 'human-required', false],
[fail('not-assistant'), 400, 'not-assistant', false], [fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
[async () => ({ stopped: 'all' }), 503, 'outcome-unknown', 'unknown'],
]) {
const s = await setup(t, { stop });
const r = await send(s, '/api/assistant-stop', { instance: 'assistant' });
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent]);
assert.equal(s.calls.length, 1);
assert.match(s.lines[0], new RegExp(` error=${error}$`));
}
const s = await setup(t, { name: fail('name-taken') });
const r = await send(s, '/api/name', { instance: 'assistant', name: 'piper' });
assert.deepEqual([r.status, r.body.error, r.body.sent], [409, 'name-taken', false]);
});
test('Q169 the names read is a bus read like the others', async t => {
const rows = [{ instance: 'assistant', name: 'piper', at: '2026-10-18T00:00:00.000Z' }];
const s = await setup(t, { bus: { names: async () => rows } });
const r = await send(s, '/api/bus/names', undefined, s.headers, 'GET');
assert.equal(r.status, 200);
assert.deepEqual(r.body.rows, rows);
});