// Row 148 (#1642): the Console's spec and docs editor routes. Two reads and one save, all through // the human transport; the save has the Settings boundary with a 1 MiB body on this route only. 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'; const preview = { host: 'console.example', header: 'x-user', user: 'jason' }; const C1 = 'a'.repeat(40), C2 = 'b'.repeat(40); const payload = { project: 'stack', path: 'spec/north-star.md', base: C1, content: '# Edited\n' }; async function setup(t, { enabled = true, save, lines = [] } = {}) { const calls = { files: [], file: [], save: [] }; 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, projectFiles: async args => { calls.files.push(args); return { head: C1, files: [{ path: 'docs/README.md' }, { path: 'spec/north-star.md' }] }; }, projectFile: async args => { calls.file.push(args); return { path: args.path, head: C1, commit: C1, content: '# Spec\n', author: 'jason', at: '2026-10-16T00:00:00Z' }; }, settingsWrite: async args => args, projectSave: enabled ? async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: C2 }; } : 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) }; } function call(s, { method = 'POST', path = '/api/project-file', 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, 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, hs = s.headers) => call(s, { method: 'GET', path, hs, data: null }); test('a signed-in same-origin save reaches exactly one human operation and returns the new commit', async t => { const s = await setup(t), r = await call(s); assert.equal(r.status, 200, r.text); assert.deepEqual(s.calls.save, [payload]); assert.equal(r.body.commit, C2); assert.equal(r.body.path, payload.path); assert.equal(s.lines.some(l => l.includes('# Edited')), false, 'the content is never logged'); assert.ok(s.lines.some(l => l.includes(`commit=${C2}`) && l.includes('path="spec/north-star.md"'))); }); test('a save needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata', async t => { const off = await setup(t, { enabled: false }); const refused = await call(off); assert.equal(refused.status, 403); assert.equal(refused.body.sent, false); assert.equal(off.calls.save.length, 0); 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); assert.notEqual(r.body?.sent, true); } assert.equal(s.calls.save.length, 0); }); test('a save validates JSON, strict keys, text values and the 1 MiB body before the bus is asked', async t => { const s = await setup(t); for (const data of [{ ...payload, extra: 1 }, { project: 'stack', path: payload.path, base: C1 }, { ...payload, content: 5 }, { ...payload, base: null }, [], 'null', 'bad', '{'] ) assert.equal((await call(s, { data })).status, 400, JSON.stringify(data)); assert.equal((await call(s, { hs: { ...s.headers, 'content-type': 'text/plain' } })).status, 400); assert.equal((await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024) } })).status, 400, 'over 1 MiB with the envelope'); assert.equal(s.calls.save.length, 0); const near = await call(s, { data: { ...payload, content: 'x'.repeat(1024 * 1024 - 400) } }); assert.equal(near.status, 200, near.text); }); // body() refuses a JSON value that is not an object before the route reads its keys, so null, // an array and a bare scalar each answer 400 with that reason and none reaches the bus. test('a body that is JSON but not an object is refused 400, not thrown on', async t => { const s = await setup(t); for (const data of ['null', '[]', '[{}]', '5', '"text"', 'true']) { const r = await call(s, { data }); assert.equal(r.status, 400, data); assert.equal(r.body.error, 'invalid-request', data); assert.match(r.body.message, /JSON object/, data); } assert.equal(s.calls.save.length, 0); }); test('the 1 MiB limit is this route only; Settings and messages keep 16 KiB', async t => { const s = await setup(t), big = { revision: 'a'.repeat(64), limits: { total: 0 }, pad: 'x'.repeat(20000) }; assert.equal((await call(s, { path: '/api/settings', data: big })).status, 400); assert.equal((await call(s, { path: '/api/bus/message', data: { to: 'coder', body: 'x'.repeat(20000) } })).status, 400); }); test('a bus refusal is reported as not saved with its own status; stale-base tells the user to reload', async t => { for (const [code, status] of [['stale-base', 409], ['project-busy', 409], ['project-dirty', 409], ['human-required', 403], ['project-record-path', 400], ['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 saved: the file changed since you opened it. Reload it, then make your edit again.'); else assert.equal(r.body.message, `not saved: the bus refused it (${code}).`); } }); test('a save whose answer is lost or unusable is reported unknown and is never retried', async t => { for (const save of [() => { throw new BusReadError('outcome-unknown'); }, () => ({ path: payload.path }), () => ({ path: 'docs/x.md', commit: C2 }), () => ({ path: payload.path, commit: 'nope' })]) { const s = await setup(t, { save }), r = await call(s); assert.equal(r.status, 503); assert.equal(r.body.sent, 'unknown'); assert.equal(s.calls.save.length, 1); } }); test('the file list and one file read take strict queries and arrive in the expected shape', async t => { const s = await setup(t); const list = await get(s, '/api/project-files?project=stack'); assert.equal(list.status, 200, list.text); assert.deepEqual(list.body.files.map(f => f.path), ['docs/README.md', 'spec/north-star.md']); const one = await get(s, '/api/project-file?project=stack&path=spec%2Fnorth-star.md'); assert.equal(one.status, 200, one.text); assert.equal(one.body.commit, C1); assert.equal(one.body.content, '# Spec\n'); assert.deepEqual(s.calls.file, [{ project: 'stack', path: 'spec/north-star.md' }]); for (const path of ['/api/project-files', '/api/project-files?project=a&project=b', '/api/project-files?project=stack&path=x', '/api/project-file?project=stack', '/api/project-file?project=stack&path=..%2Fx', '/api/project-file?project=stack&path=a&extra=1', '/api/project-files?project=%20x']) assert.equal((await get(s, path)).status, 400, path); assert.equal((await call(s, { method: 'PUT', path: '/api/project-files', data: '{}' })).status, 403); assert.equal((await get(s, '/api/project-files?project=stack', { ...s.headers, cookie: '__Host-mosaic_session=invalid' })).status, 401); }); test('reads work without --send; an unusable bus answer is a bad response, a refusal keeps its status', async t => { const s = await setup(t, { enabled: false }); assert.equal((await get(s, '/api/project-files?project=stack')).status, 200); const bad = await startServer({ port: 0, preview, log() {}, projectFile: async () => ({ content: 5 }), projectFiles: async () => { throw new BusReadError('project-not-declared'); } }); t.after(() => new Promise(r => bad.close(r))); const previewHeaders = { host: preview.host, 'x-user': preview.user }; const b = { port: bad.address().port, headers: previewHeaders }; assert.equal((await get(b, '/api/project-file?project=stack&path=docs%2Fa.md')).status, 502); assert.equal((await get(b, '/api/project-files?project=nope')).status, 400); const none = await startServer({ port: 0, preview, log() {} }); t.after(() => new Promise(r => none.close(r))); assert.equal((await get({ port: none.address().port, headers: previewHeaders }, '/api/project-files?project=stack')).status, 503); }); test('/api/config says edit only where a save can pass', async t => { const on = await setup(t), off = await setup(t, { enabled: false }); assert.equal((await get(on, '/api/config')).body.edit, true); assert.equal((await get(off, '/api/config')).body.edit, false); assert.equal((await get(on, '/api/config', { ...on.headers, host: `127.0.0.1:${on.port}` })).status, 403); });