Files
stack/packages/webui/tests/proxy-setup-browser.test.mjs
jason.woltje 75f6d5533a fix(proxy): review fixes for the Console proxy setup page (#1873)
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.
2026-11-11 16:32:31 -06:00

147 lines
14 KiB
JavaScript

// #1873 the Proxy setup panel in a real browser: the next step is one button, init sends a
// generated-key request unless the owner pastes a key on loopback, and pasted fields are cleared.
import test from 'node:test';
import assert from 'node:assert/strict';
import { w15Fixture } from './w15-fixture.mjs';
const T = '2026-11-11T12:00:00.000Z';
const base = {
release: { fetched: false, verified: false, version: '8.0.42', panelVersion: '1.26.7' },
init: { initialized: false, partial: false, files: [{ label: 'proxy settings', present: false }, { label: 'management key', present: false }] },
unit: { unit: 'mosaic-proxy.service', installed: false, active: 'inactive' },
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
fetch: { state: 'idle' }, restart: null, busy: null,
organizations: [{ id: 'acme', proxy: 'shared', catalogue: null }],
ownProxy: { available: false, state: 'not built', reason: 'The configuration has one proxy per instance.' },
next: ['fetch'],
};
const fetched = { ...base, release: { ...base.release, fetched: true, verified: true }, next: ['init'] };
const fenced = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true },
fence: { set: true, op: 'apikey.add', org: 'acme', time: T }, restart: { confirmed: true, pid: 4242, observedAt: T }, next: ['fence-clear'] };
const unconfigured = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'not-configured' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'not-configured' } } };
const scenario = (status, config = {}) => ({ '/api/config': { send: true, devOwner: true, proxyAdmin: true, proxyAdminSend: true, ...config }, '/api/proxy/admin/status': status, ...unconfigured });
const writes = b => b.evaluate('window.__writes.filter(w=>w.path.startsWith("/api/proxy/admin/")).map(w=>({path:w.path,body:JSON.parse(w.body)}))');
test('a fresh instance shows the fetch step as one button, the not-built own proxy, and no terminal note', { timeout: 90000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(base), 'POST /api/proxy/admin/fetch': { status: 202, body: { state: 'running' } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
assert.equal(await b.evaluate('document.querySelector("#proxy-setup").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").textContent'), 'Fetch the pinned release');
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /CLIProxyAPI 8\.0\.42/);
assert.match(await b.evaluate('document.querySelector("#proxy-setup-own").textContent'), /not built/);
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-orgs td").textContent'), 'acme');
await wait('document.querySelector("#proxy-message")?.textContent.includes("Follow the next step under Proxy setup")');
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'), false, 'the page replaces the terminal note');
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-setup-init [data-setup-write]:not([disabled])").length'), 0, 'init waits for a verified release');
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/fetch")');
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fetch', body: {} }]);
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
});
test('without send the next-step button is disabled and nothing is posted', { timeout: 90000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(base, { send: false, proxyAdminSend: false }) });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").disabled'), true);
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
assert.deepEqual(await writes(b), []);
});
test('init sends generate by default; a pasted key on loopback is sent with its confirmation and the fields are cleared', { timeout: 90000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fetched), 'POST /api/proxy/admin/init': { body: { settings: { port: 18432 }, receipt: { op: 'init', time: T, port: 18432, generated: true } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="init"');
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('document.querySelector("#proxy-setup-init-box").open===true');
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste-choice").hidden'), false, 'loopback page offers the paste choice');
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste").hidden'), true);
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.port.value="18432";f.requestSubmit();})()');
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/init")');
await wait('document.querySelector("#proxy-setup-message").textContent.includes("with a generated key")');
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/init', body: { port: 18432, generate: true } }]);
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /init/);
await wait('!document.querySelector("#proxy-setup-init [data-setup-write]").disabled');
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.keySource.value="paste";f.querySelector("[value=paste]").dispatchEvent(new Event("change"));f.elements.port.value="";f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0002";f.requestSubmit();})()');
await wait('document.querySelector("#proxy-setup-message").textContent.includes("proxy-key-mismatch")');
assert.equal((await writes(b)).length, 1, 'a mismatch is refused in the page');
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-init").elements.key.value'), '', 'the key field is cleared on submit');
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0001";f.requestSubmit();})()');
await wait('window.__writes.filter(w=>w.path==="/api/proxy/admin/init").length===2');
assert.deepEqual((await writes(b))[1], { path: '/api/proxy/admin/init', body: { key: 'pa-browser-sentinel-0001', confirm: 'pa-browser-sentinel-0001' } });
assert.equal(await b.evaluate('[...document.querySelectorAll("#proxy-setup-init input[type=password]")].every(i=>i.value==="")'), true);
assert.equal(await b.evaluate('document.body.innerText.includes("pa-browser-sentinel")'), false);
});
test('after a confirmed restart the next step clears the fence and shows the receipt', { timeout: 90000 }, async t => {
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fenced), 'POST /api/proxy/admin/fence/clear': { body: { cleared: true, receipt: { op: 'fence.clear', time: T, pid: 4242 } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fence-clear"');
assert.match(await b.evaluate('document.querySelector("#proxy-setup-state").textContent'), /new pid 4242/);
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('document.querySelector("#proxy-setup-message").textContent.includes("Fence cleared")');
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fence/clear', body: {} }]);
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /fence\.clear.*pid: 4242/);
});
test('a stopped proxy hides the account panels with a setup note, and starting the service brings them back', { timeout: 90000 }, async t => {
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
const down = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'proxy-down' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'proxy-down' } } };
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...down, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
await wait('document.querySelector("#proxy-message").textContent.includes("not running yet")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), true);
await b.evaluate(`Object.assign(window.__scenario, {
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/start")');
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), false);
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
});
test('an initialized proxy whose accounts read fails with another code also hides the panels until it answers', { timeout: 90000 }, async t => {
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
const failing = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'outcome-unknown' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'outcome-unknown' } } };
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...failing, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
await wait('document.querySelector("#proxy-message").textContent.includes("the account panels return once the proxy answers")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), true);
await b.evaluate(`Object.assign(window.__scenario, {
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
});
test('a fence with no readable time names the --proxy-restarted path instead of a page restart', { timeout: 90000 }, async t => {
const timeless = { ...fenced, fence: { set: true, invalid: true }, restart: null, next: ['unit-restart'] };
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(timeless) });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-restart"');
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /no readable time.*mosaic proxy fence clear --proxy-restarted/);
});
test('after a reload the receipts come from status, and an initialized proxy folds the init form', { timeout: 90000 }, async t => {
const initialized = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
receipts: [{ op: 'fetch', time: T, version: '8.0.42' }, { op: 'init', time: T, generated: true, port: 18432 }], next: ['unit-install'] };
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(initialized), 'POST /api/proxy/admin/unit/install': { body: { receipt: { op: 'unit.install', time: T } } } } });
await b.navigate(f.base + '/#/settings/model-proxy');
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-install"');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#proxy-setup-receipts li strong")].map(s=>s.textContent)'), ['init', 'fetch'], 'newest first, from proxy-admin.jsonl');
await b.evaluate('document.querySelector("#proxy-setup-init-box").open = true');
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/install")');
await wait('document.querySelector("#proxy-setup-init-box").open===false');
});