Web UI: install/reconfigure addons, default-on install, visual redesign (v2.17.0)
Web UI now installs by default during first-time provisioning (fixed port 8090, no prompt) instead of being opt-in, and can install/ reconfigure CUPS Printing, LMS Server, Squeezelite Player, and Asterisk Intercom, and check for updates - the addons and Update action asked for by name. Privilege model: the web service itself still runs as $KIOSK_USER with zero ambient sudo. A new narrow, allow-listed root helper (menus/addon_webui.sh's webui_write_helper_script) is the only way it ever gains privilege, reachable only via a single-path passwordless sudo rule generated and validated with `visudo -c -f` before being installed, and it re-checks its own fixed action allow-list before dispatching anything. Each allow-listed action is the exact same interactive action_* function the terminal menu already uses, driven by piping the right answers on stdin - the same technique this project's own bash tests already use, so no prompt/mutation refactor of any addon file was needed. webui/lib/actions.js's stdin sequences were cross-validated against the real bash functions (not just read), which caught two real bugs (Squeezelite and Asterisk Intercom both silently lost their "decline reconfigure" path). Long-running installs stream live output via Server-Sent Events (webui/lib/jobs.js), one action at a time. Full visual redesign: a sidebar shell (Sites/Display/Lockout/Addons/ Update) replacing the single scrolling page, light+dark themes via prefers-color-scheme, no external font/CDN dependency. Actually driving the redesigned UI in a headless browser (not just reading the code) caught a real bug: refreshing an addon's pill/button after a successful install used to rebuild the whole card, racing (and usually losing to) the success status/log that job had just written. Fixed to update pill/buttons in place. Uninstall-via-web is deliberately still not offered, for any addon. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
'use strict';
|
||||
|
||||
// webui/test/jobs.test.js - integration test for the addon-install job
|
||||
// system (/api/actions/*, /api/addons/status) against a fake helper
|
||||
// script (test/fixtures/fake-helper.sh) instead of the real, root-owned
|
||||
// kiosk-webui-helper - no real root, apt, or system mutation involved,
|
||||
// matching this project's rule of never touching real system state in
|
||||
// tests. Run with: node test/jobs.test.js
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webui-jobs-test-'));
|
||||
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
|
||||
process.env.HELPER_PATH = path.join(__dirname, 'fixtures', 'fake-helper.sh');
|
||||
process.env.SUDO_CMD = ''; // run the fake helper directly, no real sudo
|
||||
|
||||
const app = require('../server');
|
||||
|
||||
let failures = 0;
|
||||
async function check(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS: ${label}`);
|
||||
} catch (e) {
|
||||
failures++;
|
||||
console.log(`FAIL: ${label} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForDone(base, jobId, timeoutMs = 3000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const res = await fetch(`${base}/api/actions/jobs/${jobId}`);
|
||||
const body = await res.json();
|
||||
if (body.status !== 'running') return body;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('timed out waiting for job to finish');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
const port = server.address().port;
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
await check('GET /api/actions lists the allow-listed actions with their fields', async () => {
|
||||
const res = await fetch(`${base}/api/actions`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
const names = body.map((a) => a.name);
|
||||
assert.ok(names.includes('install_cups'));
|
||||
assert.ok(names.includes('configure_asterisk_intercom'));
|
||||
const asterisk = body.find((a) => a.name === 'configure_asterisk_intercom');
|
||||
assert.ok(asterisk.fields.includes('serverIp'));
|
||||
});
|
||||
|
||||
await check('GET /api/addons/status returns the fake helper\'s status_all JSON', async () => {
|
||||
// fake-helper.sh doesn't implement status_all specially - it just
|
||||
// echoes/exits 0 with non-JSON text, so this exercises the
|
||||
// malformed-response error path rather than a real status shape.
|
||||
const res = await fetch(`${base}/api/addons/status`);
|
||||
assert.strictEqual(res.status, 500);
|
||||
});
|
||||
|
||||
let jobId;
|
||||
await check('POST /api/actions/install_cups/run starts a job and returns its id', async () => {
|
||||
const res = await fetch(`${base}/api/actions/install_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(body.jobId);
|
||||
assert.strictEqual(body.status, 'running');
|
||||
jobId = body.jobId;
|
||||
});
|
||||
|
||||
await check('a second action while one is running is rejected with 409', async () => {
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 409);
|
||||
});
|
||||
|
||||
await check('the job completes successfully and the log shows what the helper received', async () => {
|
||||
const body = await waitForDone(base, jobId);
|
||||
assert.strictEqual(body.status, 'success');
|
||||
assert.strictEqual(body.exitCode, 0);
|
||||
assert.ok(body.log.includes('fake-helper: action=action_install_cups'), body.log);
|
||||
assert.ok(body.log.includes('fake-helper: stdin-bytes='), body.log);
|
||||
assert.ok(body.log.includes('fake-helper: done'), body.log);
|
||||
});
|
||||
|
||||
await check('after completion, a new action is accepted again (not stuck busy)', async () => {
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
await waitForDone(base, body.jobId);
|
||||
});
|
||||
|
||||
await check('unknown action name is rejected with 400, no job created', async () => {
|
||||
const res = await fetch(`${base}/api/actions/definitely_not_real/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
});
|
||||
|
||||
await check('missing required field is rejected with 400 before spawning anything', async () => {
|
||||
const res = await fetch(`${base}/api/actions/configure_asterisk_intercom/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ alreadyInstalled: false, extension: '201', password: 'x' }),
|
||||
});
|
||||
assert.strictEqual(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(/serverIp/.test(body.error), body.error);
|
||||
});
|
||||
|
||||
await check('a failing helper is reflected as status failed with the real exit code', async () => {
|
||||
process.env.FAKE_HELPER_EXIT_CODE = '1';
|
||||
// jobs.js reads process.env.HELPER_PATH/SUDO_CMD once at module
|
||||
// load, but FAKE_HELPER_EXIT_CODE is read fresh by the spawned
|
||||
// shell script every time, so no re-require needed here.
|
||||
const res = await fetch(`${base}/api/actions/reconfigure_cups/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const { jobId: failId } = await res.json();
|
||||
const body = await waitForDone(base, failId);
|
||||
assert.strictEqual(body.status, 'failed');
|
||||
assert.strictEqual(body.exitCode, 1);
|
||||
delete process.env.FAKE_HELPER_EXIT_CODE;
|
||||
});
|
||||
|
||||
await check('GET /api/actions/jobs/:id/stream (SSE) replays the log and sends a final done event', async () => {
|
||||
const res = await fetch(`${base}/api/actions/install_lms/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ alreadyInstalled: false }),
|
||||
});
|
||||
const { jobId: streamJobId } = await res.json();
|
||||
|
||||
const streamRes = await fetch(`${base}/api/actions/jobs/${streamJobId}/stream`);
|
||||
assert.strictEqual(streamRes.status, 200);
|
||||
assert.strictEqual(streamRes.headers.get('content-type'), 'text/event-stream');
|
||||
|
||||
const reader = streamRes.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let raw = '';
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes('event: done') && Date.now() < deadline) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
raw += decoder.decode(value, { stream: true });
|
||||
}
|
||||
assert.ok(raw.includes('event: log'), raw);
|
||||
assert.ok(raw.includes('fake-helper: action=action_install_lms'), raw);
|
||||
assert.ok(raw.includes('event: done'), raw);
|
||||
assert.ok(/data: \{"status":"success","exitCode":0\}/.test(raw), raw);
|
||||
});
|
||||
|
||||
server.close();
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} FAILURE(S)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('ALL DONE');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user