Files
stack/packages/webui/tests/onboarding-http.test.mjs
jason.woltje 7fe301a172 feat(webui): onboarding interview after Quick Setup and on the project page (F2)
- Quick Setup's done state offers Start onboarding interview next to Open
  organization. It opens a conversation with the assistant and sends the
  template's first message; Open organization skips it.
- The project overview offers the interview while the north star is the
  init template, then shows the assistant's draft with Approve as north
  star and the equivalent CLI command.
- GET /api/onboarding/interview serves the template and the assistant
  instance. POST /api/north-star/approve runs approveNorthStarDraft with
  the save's boundary (header user, exact Origin, same-origin Fetch
  Metadata, strict JSON). In an organization it is the one project write
  the fence lets through; the tester and granted users never reach it.
  /api/config says northStarApprove where the route can pass.
- GET /api/project-file adds `committed`, the file's last commit time; `at`
  stays the read time.
2026-11-09 12:58:32 -06:00

259 lines
17 KiB
JavaScript

// F2 (PRD 1.20 draft REQ-SETUP-2): the onboarding interview read and the north-star approval route.
// The approval has the project save's boundary and writes only through the human save verb; in an
// organization it is the one project write past the org fence. Every transport here is a fake.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { startServer, userRoute } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
import { testerFixture } from './tester-fixture.mjs';
import { TESTER_ORIGIN } from '../src/tester-socket.mjs';
import { interviewMessage, loadInterviewTemplate, northStarTemplateText, SPEC_PATH } from '../../project/src/onboarding.mjs';
const TEMPLATE = loadInterviewTemplate();
const DRAFT = TEMPLATE.drafts.northStar;
const S1 = 'a'.repeat(40), D1 = 'b'.repeat(40), S2 = 'c'.repeat(40);
const TEXT = '# North star\n\nOnboard new organizations in one sitting.\n';
const payload = { project: 'stack', draft: D1, base: S1 };
const INSTANCES = { revision: 'f'.repeat(64), definitions: {}, instances: {
coder: { definition: 'coder' }, helper: { definition: 'assistant' }, assistant: { definition: 'assistant', vars: { 'persona.name': 'Ada' } },
} };
function transports({ read, save, instances } = {}) {
const calls = { file: [], save: [], instances: 0 };
return {
calls,
projectFile: async args => {
calls.file.push(args);
if (read) return read(args);
return args.path === DRAFT ? { path: DRAFT, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z' }
: { path: args.path, head: D1, commit: S1, content: northStarTemplateText(), author: 'jason', at: '2026-11-08T00:00:00Z' };
},
projectSave: async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: S2 }; },
bus: { instances: async () => { calls.instances++; return instances ? instances() : structuredClone(INSTANCES); } },
};
}
async function setup(t, { enabled = true, file = true, bus = true, ...fakes } = {}) {
const lines = [], fake = transports(fakes);
const auth = await createSsoSession(t, { host: 'console.example' });
const server = await startServer({
port: 0, sso: auth.sso, log: l => lines.push(l),
send: enabled ? async () => ({}) : null,
projectFile: file ? fake.projectFile : null,
projectSave: enabled ? fake.projectSave : null,
bus: bus ? fake.bus : null,
});
t.after(() => close(server));
const session = await auth.signIn(server.address().port);
return { calls: fake.calls, lines, port: server.address().port, headers: auth.writeHeaders(session), read: auth.readHeaders(session) };
}
function call(s, { method = 'POST', path = '/api/north-star/approve', hs = s.headers, data = payload } = {}) {
return 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, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null }));
});
req.on('error', reject);
req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
}
const get = (s, path) => call(s, { method: 'GET', path, hs: s.read, data: null });
test('the interview read gives the first message, the questions, the drafts and the first assistant instance', async t => {
const s = await setup(t);
const r = await get(s, '/api/onboarding/interview?project=stack');
assert.equal(r.status, 200, r.text);
assert.equal(r.body.message, interviewMessage(TEMPLATE, { project: 'stack' }));
assert.equal(r.body.threadName, 'Onboarding interview');
assert.deepEqual(r.body.questions, TEMPLATE.questions);
assert.deepEqual(r.body.drafts, TEMPLATE.drafts);
assert.equal(r.body.northStarTemplate, northStarTemplateText());
// The first assistant by instance key, named by its persona when it has one.
assert.deepEqual(r.body.assistant, { instance: 'assistant', name: 'Ada' });
assert.equal(s.calls.instances, 1);
assert.deepEqual(s.calls.file, [], 'the read asks the bus for nothing but the instances');
});
test('the interview read takes one project id, answers 405 to a write and reports a missing or failing bus', async t => {
const s = await setup(t);
for (const path of ['/api/onboarding/interview', '/api/onboarding/interview?project=a&project=b', '/api/onboarding/interview?project=stack&x=1',
'/api/onboarding/interview?project=..%2Fx', '/api/onboarding/interview?project=%20x', '/api/onboarding/interview?project='])
assert.equal((await get(s, path)).status, 400, path);
assert.equal(s.calls.instances, 0);
assert.equal((await call(s, { path: '/api/onboarding/interview?project=stack', data: {} })).status, 405);
const none = await setup(t, { bus: false });
const missing = await get(none, '/api/onboarding/interview?project=stack');
assert.equal(missing.status, 503); assert.equal(missing.body.error, 'not-configured');
const only = await setup(t, { instances: () => ({ ...INSTANCES, instances: { coder: { definition: 'coder' } } }) });
const noAssistant = await get(only, '/api/onboarding/interview?project=stack');
assert.equal(noAssistant.status, 200); assert.equal(noAssistant.body.assistant, null);
const plain = await setup(t, { instances: () => ({ ...INSTANCES, instances: { b: { definition: 'assistant' } } }) });
assert.deepEqual((await get(plain, '/api/onboarding/interview?project=stack')).body.assistant, { instance: 'b', name: 'b' });
for (const [instances, status, error] of [
[() => { throw new BusReadError('human-required'); }, 403, 'human-required'],
[() => { throw new BusReadError('outcome-unknown'); }, 503, 'outcome-unknown'],
[() => ({ instances: [] }), 502, 'invalid-response'],
[() => ({ instances: { a: { definition: 'assistant', vars: { 'persona.name': 5 } } } }), 502, 'invalid-response'],
]) {
const bad = await setup(t, { instances });
const r = await get(bad, '/api/onboarding/interview?project=stack');
assert.equal(r.status, status, error); assert.equal(r.body.error, error);
}
});
test('an approval copies the exact reviewed draft into the spec through one human save', async t => {
const s = await setup(t), r = await call(s);
assert.equal(r.status, 200, r.text);
assert.equal(r.body.path, SPEC_PATH); assert.equal(r.body.commit, S2);
assert.deepEqual(r.body.draft, { path: DRAFT, commit: D1 });
assert.deepEqual(s.calls.file, [{ project: 'stack', path: DRAFT }]);
assert.deepEqual(s.calls.save, [{ project: 'stack', path: SPEC_PATH, base: S1, content: TEXT }]);
assert.ok(s.lines.some(l => l.includes('north-star-approve') && l.includes(`commit=${S2}`) && l.includes('project="stack"') && l.includes(`draft="${D1}"`)), s.lines.join('\n'));
assert.equal(s.lines.some(l => l.includes('Onboard new organizations')), false, 'the content is never logged');
});
test('an approval needs send, the save and read verbs, the named Host, a session, the exact Origin and same-origin Fetch Metadata', async t => {
for (const options of [{ enabled: false }, { file: false }]) {
const off = await setup(t, options), refused = await call(off);
assert.equal(refused.status, 403, JSON.stringify(options)); assert.equal(refused.body.sent, false);
assert.deepEqual(off.calls.file, []); assert.deepEqual(off.calls.save, []);
}
const s = await setup(t);
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403],
['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['cookie', undefined, 401]]) {
const hs = { ...s.headers };
if (value === undefined) delete hs[key]; else hs[key] = value;
const r = await call(s, { hs });
assert.equal(r.status, status, `${key}=${value}`);
assert.notEqual(r.body?.sent, true);
}
assert.equal((await call(s, { method: 'GET', hs: s.read, data: null })).status, 405);
assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []);
});
test('an approval validates JSON, strict keys, text values, the project id and both commits before any read', async t => {
const s = await setup(t);
for (const data of [{ ...payload, extra: 1 }, { project: 'stack', draft: D1 }, { ...payload, base: 5 }, { ...payload, draft: null }, [], 'null', '5', 'bad', '{',
{ ...payload, project: '../x' }, { ...payload, draft: 'abc' }, { ...payload, base: S1.toUpperCase() }]) {
const r = await call(s, { data });
assert.equal(r.status, 400, JSON.stringify(data)); assert.equal(r.body.sent, false);
}
assert.equal((await call(s, { hs: { ...s.headers, 'content-type': 'text/plain' } })).status, 400);
assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []);
// A crafted value is quoted in the log, so it can't start a line of its own.
await call(s, { data: { ...payload, project: 'x\nforged' } });
assert.ok(s.lines.length > 0);
assert.equal(s.lines.some(l => l.includes('\n')), false, s.lines.join('|'));
});
test('a draft that changed, is empty or cannot be read is not approved and nothing is saved', async t => {
const draftFile = over => args => ({ path: args.path, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z', ...over });
for (const [read, status, error] of [
[draftFile({ commit: S2 }), 409, 'draft-stale'],
[draftFile({ content: ' \n' }), 409, 'draft-empty'],
[() => { throw new BusReadError('project-read-missing'); }, 404, 'project-read-missing'],
[() => { throw new BusReadError('human-required'); }, 403, 'human-required'],
[draftFile({ commit: undefined }), 502, 'invalid-response'],
[() => { throw new Error('socket closed'); }, 502, 'invalid-response'],
]) {
const s = await setup(t, { read }), r = await call(s);
assert.equal(r.status, status, error); assert.equal(r.body.error, error); assert.equal(r.body.sent, false);
assert.match(r.body.message, /^not approved: /);
assert.deepEqual(s.calls.save, [], error);
}
});
test('a save refusal keeps its status; a lost or unusable answer is reported unknown and never retried', async t => {
for (const [code, status] of [['stale-base', 409], ['human-required', 403], ['project-busy', 409], ['project-not-configured', 503]]) {
const s = await setup(t, { save: () => { throw new BusReadError(code); } }), r = await call(s);
assert.equal(r.status, status, code); assert.equal(r.body.error, code); assert.equal(r.body.sent, false);
if (code === 'stale-base') assert.equal(r.body.message, 'not approved: the north star changed since you opened it. Reload, then review again.');
else assert.equal(r.body.message, `not approved: the bus refused it (${code}).`);
assert.equal(s.calls.save.length, 1);
}
for (const save of [() => { throw new BusReadError('outcome-unknown'); }, () => { throw new Error('socket closed'); }, () => ({ path: SPEC_PATH }),
() => ({ path: 'docs/x.md', commit: S2 }), () => ({ path: SPEC_PATH, commit: 'nope' })]) {
const s = await setup(t, { save }), r = await call(s);
assert.equal(r.status, 503); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.sent, 'unknown');
assert.match(r.body.message, /may have been saved; nothing was retried/);
assert.equal(s.calls.save.length, 1);
}
});
test('config offers the approval only where the route can pass; granted users and the tester never reach it', async t => {
const config = async options => (await get(await setup(t, options), '/api/config')).body;
assert.equal((await config({})).northStarApprove, true);
assert.equal((await config({ enabled: false })).northStarApprove, false);
assert.equal((await config({ file: false })).northStarApprove, false);
// A granted user stops at the owner gate before either route.
assert.equal(userRoute('/api/north-star/approve', 'POST'), false);
assert.equal(userRoute('/api/onboarding/interview', 'GET'), false);
// The tester listener refuses projectSave at start, so its approval has no save to reach.
const tester = await testerFixture(t);
const status = await new Promise((resolve, reject) => {
const req = request({ socketPath: tester.web.address(), path: '/api/north-star/approve', method: 'POST', headers: {
host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => { r.resume(); r.on('end', () => resolve(r.statusCode)); });
req.on('error', reject); req.end(JSON.stringify(payload));
});
assert.equal(status, 403); assert.deepEqual(tester.calls, []);
});
test('in an organization the approval and the interview read reach only the selected organization; the plain save stays fenced', async t => {
const fakes = { alpha: transports(), beta: transports() };
const organizations = {
list: async () => [],
resolve: async id => {
if (!(id in fakes)) return null;
const { projectFile, projectSave, bus } = fakes[id];
return { busHealthy: id === 'alpha', send: async () => ({}), projectFile, projectSave, bus };
},
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} });
t.after(() => close(server));
const base = `http://127.0.0.1:${server.address().port}`;
const headers = { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const post = async (path, body, extra = {}) => { const r = await fetch(base + path, { method: 'POST', headers: { ...headers, ...extra }, body: JSON.stringify(body) }); return [r.status, await r.json()]; };
const get = async path => { const r = await fetch(base + path); return [r.status, await r.json()]; };
const [status, body] = await post('/api/orgs/alpha/north-star/approve', payload);
assert.equal(status, 200, JSON.stringify(body)); assert.equal(body.commit, S2);
assert.equal(fakes.alpha.calls.save.length, 1); assert.equal(fakes.beta.calls.save.length, 0);
assert.equal((await post('/api/orgs/alpha/north-star/approve', payload, { origin: 'http://evil.example' }))[0], 403);
assert.deepEqual(await post('/api/north-star/approve', payload), [404, { error: 'org-unavailable' }]);
assert.deepEqual(await post('/api/orgs/missing/north-star/approve', payload), [404, { error: 'org-unavailable' }]);
assert.deepEqual(await post('/api/orgs/alpha/project-file', { project: 'stack', path: SPEC_PATH, base: S1, content: '# x\n' }), [403, { error: 'unavailable', sent: false }]);
assert.equal(fakes.alpha.calls.save.length, 1);
const alpha = (await get('/api/orgs/alpha/config'))[1];
assert.equal(alpha.northStarApprove, true); assert.equal(alpha.edit, false);
assert.equal((await get('/api/orgs/beta/config'))[1].northStarApprove, false, 'an offline organization does not offer the approval');
const [read, interview] = await get('/api/orgs/alpha/onboarding/interview?project=stack');
assert.equal(read, 200); assert.equal(interview.assistant.instance, 'assistant');
assert.equal(fakes.alpha.calls.instances, 1); assert.equal(fakes.beta.calls.instances, 0);
});
test('a file read keeps the read time in at and adds the last commit time as committed; a list read does not', async t => {
const s = await setup(t);
const before = Date.now();
const spec = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`);
const draft = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(DRAFT)}`);
assert.equal(spec.status, 200, spec.text); assert.equal(draft.status, 200, draft.text);
assert.equal(spec.body.committed, '2026-11-08T00:00:00.000Z'); assert.equal(draft.body.committed, '2026-11-09T00:00:00.000Z');
for (const r of [spec, draft]) assert.ok(Date.parse(r.body.at) >= before - 1000, 'at stays the read time');
const odd = await setup(t, { read: args => ({ path: args.path, head: D1, commit: S1, content: 'x', author: 'jason', at: 'not a time' }) });
const plain = await get(odd, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`);
assert.equal(plain.status, 200); assert.equal('committed' in plain.body, false, 'an unreadable commit time is left out');
});
test('the page loads the onboarding script and stylesheet', async t => {
const s = await setup(t);
const js = await get(s, '/onboarding.js'), css = await get(s, '/onboarding.css'), page = await get(s, '/');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); assert.match(js.text, /MosaicOnboarding/);
assert.equal(css.status, 200); assert.match(css.type, /^text\/css/); assert.match(css.text, /\.onboarding-panel/);
assert.equal(page.status, 200);
assert.match(page.text, /<script src="\/onboarding\.js"/); assert.match(page.text, /<link [^>]*href="\/onboarding\.css"/);
});