Security review: - Release verification cache keys on ctime as well as mtime, in nanoseconds. A same-size rewrite with mtime set back now re-hashes and reports proxy-install-invalid. Build review: - The setup page hides the account panels on any failed accounts read, not only proxy-down, and brings them back once the proxy answers. - The organizations table shows a dash, not a made-up 0, when a bus does not answer or sends no model list (orgCatalogue in proxy-admin.mjs). - A fence clear the bus did not answer keeps the confirmed restart for a retry. - A fence with no readable time names `mosaic proxy fence clear --proxy-restarted` on the page, in the refusal table and in the README. - CLI `init --generate-key` goes through the setup controller and writes the same init receipt as the page. - Status carries the last ten receipts, so the page shows them after a reload. An initialized proxy keeps the init form folded. - README: proxy-admin-busy is per process; the cross-process locks that serialize the writes are named.
131 lines
9.6 KiB
JavaScript
131 lines
9.6 KiB
JavaScript
// #1873 CLI parity for the Console's Proxy page: status, unit, init-lock, init --generate-key and
|
|
// fence clear --restart. Every verb runs only from a human shell; nothing prints a key or a path.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { main } from '../src/cli.mjs';
|
|
import { proxyCommand } from '../src/model-proxy.mjs';
|
|
import { io } from './helpers.mjs';
|
|
|
|
const code = c => e => e.code === c;
|
|
const fakeSystem = () => ({ dataRoot: join(mkdtempSync(join(tmpdir(), 'ps-cli-')), 'data') });
|
|
const STATUS = {
|
|
release: { fetched: true, version: '8.0.42', panelVersion: '1.26.7', verified: true },
|
|
init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
|
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 77, port: 18432, listening: true },
|
|
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
|
|
ownProxy: { available: false, state: 'not built' }, next: ['accounts'],
|
|
};
|
|
function fakeAdmin({ restart = { confirmed: true } } = {}) {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
status: async () => { calls.push(['status']); return STATUS; },
|
|
render: async () => { calls.push(['render']); return { text: '[Unit]\nDescription=fixture\n' }; },
|
|
unitAction: async op => { calls.push(['unit', op]); return op === 'restart' ? { receipt: { op: 'unit.restart' }, evidence: restart } : { op, ok: true }; },
|
|
initLockClear: async () => { calls.push(['init-lock.clear']); return { cleared: true, receipt: { op: 'init-lock.clear' } }; },
|
|
fenceClear: async call => { calls.push(['fence.clear']); return call('proxy.fence.clear', { evidence: restart }); },
|
|
};
|
|
}
|
|
const human = () => io();
|
|
|
|
test('every new setup verb refuses inside an agent shell and with --tester, before the controller runs', async () => {
|
|
const admin = fakeAdmin();
|
|
for (const args of [['proxy', 'status'], ['proxy', 'status', '--json'], ['proxy', 'unit', 'render'], ['proxy', 'unit', 'start'], ['proxy', 'unit', 'restart'],
|
|
['proxy', 'init-lock', 'status'], ['proxy', 'init-lock', 'clear'], ['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], ['proxy', 'init', '--generate-key', '--business', 'demo']]) {
|
|
await assert.rejects(main(args, io({ CODEX_THREAD_ID: 'fixture' }), { system: fakeSystem(), proxyAdmin: admin, transport: () => () => { throw Error('must not call'); } }), code('human-required'), args.join(' '));
|
|
await assert.rejects(main([...args, '--tester'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('human-required'), `${args.join(' ')} --tester`);
|
|
}
|
|
assert.deepEqual(admin.calls, []);
|
|
});
|
|
|
|
test('proxy status prints the same facts the page shows, and --json the status object', async () => {
|
|
const admin = fakeAdmin();
|
|
const x = human();
|
|
await main(['proxy', 'status'], x, { system: fakeSystem(), proxyAdmin: admin });
|
|
assert.match(x.out.text, /^release: 8\.0\.42 \(panel 1\.26\.7\) hashes verified$/m);
|
|
assert.match(x.out.text, /^unit: mosaic-proxy\.service installed, active pid 77, port 18432 answering$/m);
|
|
assert.match(x.out.text, /^own proxy: not built$/m);
|
|
assert.match(x.out.text, /^next: accounts$/m);
|
|
const y = human();
|
|
await main(['proxy', 'status', '--json'], y, { system: fakeSystem(), proxyAdmin: admin });
|
|
assert.deepEqual(JSON.parse(y.out.text), STATUS);
|
|
await assert.rejects(main(['proxy', 'status', '--verbose'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
|
|
});
|
|
|
|
test('proxy unit and init-lock verbs map one to one onto the controller; bad usage refuses', async () => {
|
|
const admin = fakeAdmin();
|
|
const x = human();
|
|
await main(['proxy', 'unit', 'render'], x, { system: fakeSystem(), proxyAdmin: admin });
|
|
assert.equal(x.out.text, '[Unit]\nDescription=fixture\n');
|
|
for (const op of ['install', 'start', 'stop']) await main(['proxy', 'unit', op], human(), { system: fakeSystem(), proxyAdmin: admin });
|
|
await main(['proxy', 'unit', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
|
|
await main(['proxy', 'init-lock', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
|
|
const y = human();
|
|
await main(['proxy', 'init-lock', 'clear'], y, { system: fakeSystem(), proxyAdmin: admin });
|
|
assert.equal(JSON.parse(y.out.text).cleared, true);
|
|
assert.deepEqual(admin.calls, [['render'], ['unit', 'install'], ['unit', 'start'], ['unit', 'stop'], ['status'], ['status'], ['init-lock.clear']]);
|
|
for (const args of [['proxy', 'unit'], ['proxy', 'unit', 'reload'], ['proxy', 'unit', 'start', 'now'], ['proxy', 'init-lock'], ['proxy', 'init-lock', 'clear', '--force']])
|
|
await assert.rejects(main(args, human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'), args.join(' '));
|
|
});
|
|
|
|
test('a controller refusal reaches the shell as its proxy code only, with no path or detail', async () => {
|
|
const admin = fakeAdmin();
|
|
admin.unitAction = async () => { throw Object.assign(Error('/home/owner/.config/mosaic-dev/proxy.json pa-cli-sentinel'), { code: 'proxy-unit-missing' }); };
|
|
admin.status = async () => { throw Error('pa-cli-sentinel'); };
|
|
await assert.rejects(main(['proxy', 'unit', 'start'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-missing' && !e.message.includes('pa-cli-sentinel') && !e.message.includes('/home/'));
|
|
await assert.rejects(main(['proxy', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-failed' && !e.message.includes('pa-cli-sentinel'));
|
|
});
|
|
|
|
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
|
|
const x = human();
|
|
assert.notEqual(x.stdin.isTTY, true);
|
|
const calls = [], system = fakeSystem();
|
|
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
|
|
system, promptProxyKey: () => { throw Error('must not prompt'); },
|
|
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
|
|
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
|
|
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
|
|
// The same init receipt the page writes, settings only.
|
|
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
|
|
assert.match(x.err.text, /^receipt: \{"time":/m);
|
|
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
|
|
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
|
|
});
|
|
|
|
test('proxy fence clear --restart restarts first and clears only on a confirmed restart', async () => {
|
|
const admin = fakeAdmin();
|
|
const calls = [];
|
|
const x = human();
|
|
await main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], x, { system: fakeSystem(), proxyAdmin: admin,
|
|
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
|
|
assert.deepEqual(admin.calls, [['unit', 'restart'], ['fence.clear']]);
|
|
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.fence.clear', args: { evidence: { confirmed: true } } }]);
|
|
assert.deepEqual(JSON.parse(x.out.text), { restart: { op: 'unit.restart' }, cleared: true, evidence: { after: { pid: 2 } } });
|
|
const unconfirmed = fakeAdmin({ restart: { confirmed: false } });
|
|
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], human(), { system: fakeSystem(), proxyAdmin: unconfirmed,
|
|
transport: () => () => { throw Error('must not call'); } }), code('proxy-restart-unconfirmed'));
|
|
assert.deepEqual(unconfirmed.calls, [['unit', 'restart']]);
|
|
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--restart'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
|
|
});
|
|
|
|
test('proxy fetch shows the page\'s progress steps and writes the same fetch receipt; stdout keeps its line', async () => {
|
|
const system = fakeSystem();
|
|
const x = human();
|
|
await proxyCommand(['fetch'], x, { system, fetch: async ({ progress }) => { for (const step of ['download-archive', 'verify-archive', 'install']) progress(step); return { binary: 'fixture-binary', panel: 'fixture-panel' }; } });
|
|
assert.deepEqual(JSON.parse(x.out.text), { binary: 'fixture-binary', panel: 'fixture-panel' });
|
|
assert.match(x.err.text, /^fetch: download-archive\nfetch: verify-archive\nfetch: install\nreceipt: /);
|
|
const receipts = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
assert.equal(receipts.length, 1);
|
|
assert.equal(receipts[0].op, 'fetch');
|
|
assert.equal(receipts[0].verified, true);
|
|
assert.match(receipts[0].archiveSha256, /^[0-9a-f]{64}$/);
|
|
// A failed hash check writes no receipt and keeps its code.
|
|
const failed = fakeSystem();
|
|
await assert.rejects(proxyCommand(['fetch'], human(), { system: failed, fetch: async () => { throw Object.assign(Error('mismatch'), { code: 'proxy-fetch-hash' }); } }), code('proxy-fetch-hash'));
|
|
assert.throws(() => readFileSync(join(failed.dataRoot, 'state', 'proxy-admin.jsonl')), { code: 'ENOENT' });
|
|
});
|