193 lines
11 KiB
JavaScript
193 lines
11 KiB
JavaScript
// Row 225 (C2-10b, design 7.4): the chat's library status read, a write notice's Revert and the
|
|
// shared reply renderer served to the page. The revert is C2-02's `library.revert`; until that verb
|
|
// exists the bus refuses it and the route reports the code.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { startServer, userRoute } from '../src/serve.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { consoleAccess } from '../src/access.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: 'books', commit: C1 };
|
|
const STATUS = { project: 'books', version: 1, branch: 'main', served: C2, lastFetch: '2026-10-23T18:00:00.000Z', lastError: null, diverged: null, problems: [] };
|
|
async function setup(t, { enabled = true, revert, status, lines = [] } = {}) {
|
|
const calls = { revert: [], status: [] };
|
|
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,
|
|
libraryStatus: status === null ? null : async args => { calls.status.push(args); return status ? status(args) : STATUS; },
|
|
libraryRevert: enabled ? async args => { calls.revert.push(args); return revert ? revert(args) : { project: args.project, path: 'Notes/Eyes.md', reverted: args.commit, 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/library/revert', 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, hs = s.headers) => call(s, { method: 'GET', path, hs, data: null });
|
|
const refusal = code => { throw new BusReadError(code); };
|
|
|
|
test('a signed-in same-origin revert reaches exactly one library.revert 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.revert, [payload]);
|
|
assert.deepEqual({ ...r.body, at: undefined }, { project: 'books', path: 'Notes/Eyes.md', reverted: C1, commit: C2, at: undefined });
|
|
assert.ok(s.lines.some(l => l.includes('library-revert') && l.includes(`commit=${C2}`) && l.includes('project="books"')));
|
|
});
|
|
|
|
test('a revert needs send, the named Host, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
|
const off = await setup(t, { enabled: false });
|
|
assert.deepEqual((await call(off)).body, { error: 'read-only', sent: false });
|
|
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((await call(s, { method: 'GET', data: null })).status, 405);
|
|
assert.deepEqual(s.calls.revert, []);
|
|
});
|
|
|
|
test('a revert takes a project and a 40-hex commit, nothing else, before the bus is asked', async t => {
|
|
const s = await setup(t);
|
|
for (const data of ['not json', 'null', '[]', '5', { project: 'books' }, { commit: C1 }, { ...payload, path: 'a.md' },
|
|
{ project: 'books', commit: 'abc' }, { project: 'books', commit: C1.toUpperCase() }, { project: '../x', commit: C1 }, { project: 7, commit: C1 }]) {
|
|
const r = await call(s, { data });
|
|
assert.equal(r.status, 400, JSON.stringify(data));
|
|
assert.equal(r.body.error, 'invalid-request');
|
|
assert.equal(r.body.sent, false);
|
|
}
|
|
assert.deepEqual(s.calls.revert, []);
|
|
});
|
|
|
|
test('a bus refusal is reported as not reverted with its code; before C2-02 that is the verb refusal', async t => {
|
|
const codes = { 'unknown-verb': 502, 'library-revert-not-agent': 409, 'stale-base': 409, 'human-required': 403, 'project-not-configured': 503, 'invalid-request': 400 };
|
|
for (const [code, status] of Object.entries(codes)) {
|
|
const s = await setup(t, { revert: () => refusal(code) }), r = await call(s);
|
|
assert.equal(r.status, status, code);
|
|
assert.equal(r.body.error, code);
|
|
assert.equal(r.body.sent, false);
|
|
assert.match(r.body.message, new RegExp(`not reverted: the bus refused it \\(${code}\\)`));
|
|
assert.equal(s.calls.revert.length, 1, 'never retried');
|
|
}
|
|
});
|
|
|
|
test('a revert whose answer is lost or unusable is reported unknown and is never retried', async t => {
|
|
const answers = [() => refusal('outcome-unknown'), () => { throw new Error('socket closed'); }, () => ({ project: 'books', path: 'a.md', reverted: C1 }),
|
|
() => ({ project: 'books', path: 'a.md', reverted: C2, commit: C2 }), () => ({ project: 'books', reverted: C1, commit: C2 }), () => null];
|
|
for (const revert of answers) {
|
|
const s = await setup(t, { revert }), r = await call(s);
|
|
assert.equal(r.status, 503, revert.toString());
|
|
assert.equal(r.body.error, 'outcome-unknown');
|
|
assert.equal(r.body.sent, 'unknown');
|
|
assert.equal(s.calls.revert.length, 1);
|
|
}
|
|
});
|
|
|
|
test('the status read takes one project query and answers the bus result for that project', async t => {
|
|
const s = await setup(t), r = await get(s, '/api/library/status?project=books');
|
|
assert.equal(r.status, 200, r.text);
|
|
assert.deepEqual(s.calls.status, [{ project: 'books' }]);
|
|
assert.deepEqual({ ...r.body, at: undefined }, { ...STATUS, at: undefined });
|
|
for (const path of ['/api/library/status', '/api/library/status?project=books&x=1', '/api/library/status?project=books&project=other', '/api/library/status?project=../x'])
|
|
assert.equal((await get(s, path)).status, 400, path);
|
|
assert.equal((await call(s, { path: '/api/library/status?project=books' })).status, 403, 'the preview write fence precedes GET-only routes');
|
|
assert.equal(s.calls.status.length, 1);
|
|
});
|
|
|
|
test('a status answer for another project or of the wrong shape is refused; a refusal keeps its status', async t => {
|
|
for (const answer of [{ ...STATUS, project: 'other' }, null, [], 'books']) {
|
|
const s = await setup(t, { status: () => answer }), r = await get(s, '/api/library/status?project=books');
|
|
assert.equal(r.status, 502, JSON.stringify(answer));
|
|
assert.equal(r.body.error, 'invalid-response');
|
|
}
|
|
for (const [code, status] of Object.entries({ 'human-required': 403, 'project-not-configured': 503, 'project-not-declared': 400, 'library-mapping-invalid': 502 })) {
|
|
const s = await setup(t, { status: () => refusal(code) }), r = await get(s, '/api/library/status?project=books');
|
|
assert.equal(r.status, status, code);
|
|
assert.equal(r.body.error, code);
|
|
}
|
|
const none = await setup(t, { status: null });
|
|
assert.equal((await get(none, '/api/library/status?project=books')).body.error, 'not-configured');
|
|
});
|
|
|
|
test('the status read needs no send; a read-only Console still has no revert', async t => {
|
|
const s = await setup(t, { enabled: false });
|
|
assert.equal((await get(s, '/api/library/status?project=books')).status, 200);
|
|
assert.equal((await call(s)).status, 403);
|
|
});
|
|
|
|
test('library routes are the owner\'s: a granted user is refused them, and the tester never reverts', async t => {
|
|
for (const [path, method] of [['/api/library/status', 'GET'], ['/api/library/revert', 'POST']]) assert.equal(userRoute(path, method), false, path);
|
|
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: '/nonexistent', libraryRevert: async () => ({}) }), /tester listener permits only/);
|
|
});
|
|
|
|
test('the page gets the one library renderer, byte for byte, as a JavaScript module', async t => {
|
|
const s = await setup(t), r = await get(s, '/render.mjs');
|
|
assert.equal(r.status, 200);
|
|
assert.match(r.type, /^text\/javascript/);
|
|
assert.equal(r.text, readFileSync(new URL('../../library/src/render.mjs', import.meta.url), 'utf8'));
|
|
});
|
|
|
|
// A valid commit from an unrelated project is an unusable receipt, never evidence of this revert.
|
|
test('a revert receipt for another project is unknown and is never retried', async t => {
|
|
const s = await setup(t, { revert: args => ({ project: 'other', path: 'Notes/Eyes.md', reverted: args.commit, commit: C2 }) });
|
|
const r = await call(s);
|
|
assert.equal(r.status, 503);
|
|
assert.equal(r.body.error, 'outcome-unknown');
|
|
assert.equal(r.body.sent, 'unknown');
|
|
assert.deepEqual(s.calls.revert, [payload]);
|
|
});
|
|
|
|
// Catch a route being added ahead of the granted-user owner fence: neither transport may be used.
|
|
test('a granted-user HTTP session cannot reach owner library callbacks', async t => {
|
|
let ownerCalls = 0, userCalls = 0;
|
|
const access = { accessVersion: 1, revision: 1,
|
|
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
|
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
|
|
const users = consoleAccess({ load: () => access, business: () => 'demo' });
|
|
const auth = await createSsoSession(t, { host: 'console.example', users });
|
|
const server = await startServer({ port: 0, sso: auth.sso, send: async () => ({}),
|
|
libraryStatus: async () => { ownerCalls++; return STATUS; },
|
|
libraryRevert: async () => { ownerCalls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; },
|
|
userOptions: () => { userCalls++; return {}; }, log: () => {} });
|
|
t.after(() => new Promise(r => server.close(r)));
|
|
const session = await auth.signIn(server.address().port, { sub: 'alice-sub' });
|
|
const s = { port: server.address().port, headers: auth.writeHeaders(session) };
|
|
for (const options of [{ method: 'GET', path: '/api/library/status?project=books', data: null }, {}]) {
|
|
const r = await call(s, options);
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error, 'owner-only');
|
|
}
|
|
assert.equal(ownerCalls, 0);
|
|
assert.equal(userCalls, 0);
|
|
});
|
|
|
|
// A supplied callback is insufficient authority: --send must independently be enabled.
|
|
test('a configured revert callback remains unreachable when send is off', async t => {
|
|
let calls = 0;
|
|
const server = await startServer({ port: 0, preview,
|
|
libraryRevert: async () => { calls++; return { project: 'books', path: 'a.md', reverted: C1, commit: C2 }; } });
|
|
t.after(() => new Promise(r => server.close(r)));
|
|
const r = await call({ port: server.address().port, headers: { host: preview.host, 'x-user': preview.user, origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
|
|
assert.equal(r.status, 403);
|
|
assert.equal(r.body.error, 'read-only');
|
|
assert.equal(r.body.sent, false);
|
|
assert.equal(calls, 0);
|
|
});
|