import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { startServer } from '../src/serve.mjs'; import { close } from './fixture.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; const GEN = '12345678-1234-4234-8234-123456789abc'; function http(server, path, { method = 'GET', headers = {}, body = '' } = {}) { return new Promise((resolve, reject) => { const address = server.address(); const req = request({ ...(typeof address === 'string' ? { socketPath: address } : { host: '127.0.0.1', port: address.port }), path, method, headers: Object.fromEntries(Object.entries({ ...(server.authHeaders ?? { host: 'console.example' }), ...headers }).filter(([, v]) => v !== undefined)) }, res => { let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) })); }); req.on('error', reject); req.end(body); }); } const POST = { method: 'POST', headers: { origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{}' }; async function fixture(t, { send = true, tester = false } = {}) { const calls = []; const state = { version: 1, generation: GEN, halted: false, quiescent: true, remaining: 0, stopped: 0, stopping: false }; const processing = { read: async () => { calls.push('read'); return { ...state }; }, halt: async args => { calls.push(['halt', args]); state.halted = true; return { ...state }; }, resume: async args => { calls.push(['resume', args]); state.halted = false; return { ...state }; }, }; const root = tester ? mkdtempSync(join(tmpdir(), 'processing-web-')) : null; const options = { processing, log: () => {} }; const auth = tester ? null : await createSsoSession(t, { host: 'console.example' }); const server = tester ? await startTesterSocket({ dataRoot: root, ...options }) : await startServer({ port: 0, sso: auth.sso, send: send ? async () => {} : null, ...options }); if (auth) { const session = await auth.signIn(server.address().port); server.authHeaders = auth.writeHeaders(session); } t.after(async () => { await close(server); if (root) rmSync(root, { recursive: true, force: true }); }); return { server, calls, state, processing }; } test('read-only Console can read processing state but neither human control writes without --send', async t => { const f = await fixture(t, { send: false }); const read = await http(f.server, '/api/processing'); assert.equal(read.status, 200); assert.equal(read.value.halted, false); for (const action of ['halt', 'resume']) assert.equal((await http(f.server, '/api/processing/' + action, POST)).status, 403); assert.deepEqual(f.calls, ['read']); }); test('Console halt and generation-bound resume use only the management writer and keep partial stops honest', async t => { const f = await fixture(t); const halted = await http(f.server, '/api/processing/halt', POST); assert.equal(halted.status, 200); assert.equal(halted.value.halted, true); const resumed = await http(f.server, '/api/processing/resume', { ...POST, body: JSON.stringify({ generation: GEN }) }); assert.equal(resumed.status, 200); assert.equal(resumed.value.halted, false); assert.deepEqual(f.calls, [['halt', {}], ['resume', { generation: GEN }]]); f.processing.halt = async () => ({ ...f.state, halted: true, quiescent: false, remaining: 1 }); const partial = await http(f.server, '/api/processing/halt', POST); assert.equal(partial.status, 202); assert.equal(partial.value.quiescent, false); }); test('processing write fences refuse cross-site, wrong origin, unnamed host, duplicate identity and bad payload before writer', async t => { const f = await fixture(t); const cases = [ ['wrong origin', { headers: { ...POST.headers, origin: 'https://elsewhere' } }, 403, 'cross-origin request refused'], ['cross-site', { headers: { ...POST.headers, 'sec-fetch-site': 'cross-site' } }, 403, 'cross-site'], ['unnamed host', { headers: { ...POST.headers, host: `127.0.0.1:${f.server.address().port}`, origin: `http://127.0.0.1:${f.server.address().port}` } }, 403, 'public Host required'], ['invalid session', { headers: { ...POST.headers, cookie: '__Host-mosaic_session=invalid' } }, 401, 'sign-in required'], ['content type', { headers: { ...POST.headers, 'content-type': 'text/plain' } }, 415, 'invalid-request'], ['extra key', { body: '{"human":"jason"}' }, 400, 'invalid-request'], ['array', { body: '[]' }, 400, 'invalid-request'], ['null', { body: 'null' }, 400, 'invalid-request'], ['body bound', { body: ' '.repeat(1025) + '{}' }, 400, 'invalid-request'], ]; for (const [name, changed, status, error] of cases) { const response = await http(f.server, '/api/processing/halt', { ...POST, ...changed }); assert.equal(response.status, status, name); assert.equal(response.value.error, error, name); assert.deepEqual(f.calls, [], name + ' must refuse before the writer'); } const missingGeneration = await http(f.server, '/api/processing/resume', POST); assert.equal(missingGeneration.status, 400); assert.equal(missingGeneration.value.error, 'invalid-request'); assert.deepEqual(f.calls, []); }); test('tester cannot invoke either control writer and failures never echo backend text', async t => { const f = await fixture(t, { tester: true }); const headers = { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'x-user': undefined }; for (const action of ['halt', 'resume']) assert.equal((await http(f.server, '/api/processing/' + action, { ...POST, headers: { ...POST.headers, ...headers } })).status, 403); assert.deepEqual(f.calls, []); const human = await fixture(t); human.processing.halt = async () => { throw Object.assign(Error('NEVER ECHO fixture path'), { code: 'outcome-unknown' }); }; const r = await http(human.server, '/api/processing/halt', POST); assert.equal(r.status, 503); assert.equal(r.value.sent, 'unknown'); assert.doesNotMatch(r.text, /NEVER ECHO/); }); // Rebase on row 192: the server verifies the cookie before passing its signer. test('processing writes use a real OIDC session and still require --send and same-origin', async t => { for (const enabled of [false, true]) { const f = await fixture(t, { send: enabled }), calls = f.calls, server = f.server; const headers = { ...POST.headers }; assert.equal((await http(server, '/api/processing/halt', { ...POST, headers })).status, enabled ? 200 : 403); assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, cookie: '__Host-mosaic_session=invalid' } })).status, 401); assert.equal((await http(server, '/api/processing/halt', { ...POST, headers: { ...headers, origin: 'https://elsewhere' } })).status, 403); assert.deepEqual(calls, enabled ? [['halt', {}]] : []); } }); // Exercise the handler's own origin/site guards independently of serve's outer fence. test('processing handler independently refuses wrong origin and cross-site before its writer', async () => { const { Readable } = await import('node:stream'); const { processingHTTP } = await import('../src/processing-http.mjs'); for (const changed of [{ origin: ['https://elsewhere'] }, { 'sec-fetch-site': ['cross-site'] }]) { const req = Readable.from([Buffer.from('{}')]); req.method = 'POST'; req.headersDistinct = { 'x-user': ['jason'], origin: ['https://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...changed }; let status, body, called = false; const res = { writeHead: value => { status = value; }, end: value => { body = JSON.parse(value); } }; await processingHTTP(req, res, { path: '/api/processing/halt', search: '', named: true, send: () => {}, preview: { host: 'console.example', header: 'x-user', user: 'jason' }, processing: { halt: async () => { called = true; return {}; } } }); assert.equal(status, 403); assert.equal(called, false); assert.equal(body.sent, false); } });