Files
stack/packages/webui/tests/project-file.test.mjs

144 lines
9.4 KiB
JavaScript

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