Files
ubuntu-based-kiosk/webui/test/api.test.js
T
Claude b6ed4aad9c Add Web UI addon: browser-based config editor (v2.16.0)
New Addons -> Web UI: a small Node/Express app (webui/) installed as a
systemd service running as $KIOSK_USER, giving a browser-based editor
for Sites & Page Timing, Display & Interaction, and Password Protection
& Lockout - the three Core Settings menus that are pure config.json
read/write with no privileged system mutation involved. Runs with no
sudo at all, since config.json is already owned by $KIOSK_USER.

webui/lib/config.js re-implements lib/config.sh's exact schema and
merge-on-save contract in JS (kiosk-app/main.js already reads the same
config.json directly in JS, so this isn't a new pattern), so it can
never silently clobber fields it doesn't track - the same bug
previously fixed in lib/config.sh's own history.

No login of its own by design: Authelia runs elsewhere, and the
expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth
in front of it, the same way other self-hosted apps get protected -
Authelia integration is explicitly out of scope for this repo.

Deliberately narrow scope for this first pass: WiFi, Timezone,
Power/Display/Quiet Hours, Complete Uninstall, every other addon, and
everything in Advanced remain terminal-only, since a network-facing
process shouldn't be handed sudo-level system mutation without a lot
more thought than this pass gives it. Wired into Complete Uninstall
(webui_do_uninstall) and Clone Settings (addon-presence detection) the
same way every other addon is.

This is the single-kiosk piece of the web-based GUI this repo's
"Modular Management" notes have mentioned for a while - a central
multi-kiosk fleet dashboard is an intentional follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
2026-08-19 16:01:20 +00:00

171 lines
6.7 KiB
JavaScript

'use strict';
// webui/test/api.test.js - integration test: starts the real server.js
// app on a random port against a scratch config.json and hits GET/PUT
// /api/config with real HTTP requests (Node's built-in fetch). Run with:
// node test/api.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-api-test-'));
process.env.CONFIG_PATH = path.join(scratchDir, 'config.json');
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}`);
}
}
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/config returns defaults on a fresh install', async () => {
const res = await fetch(`${base}/api/config`);
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.deepStrictEqual(body.tabs, []);
assert.strictEqual(body.swipeMode, 'dual');
});
await check('PUT /api/config saves and round-trips display settings', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ swipeMode: 'standard', allowNavigation: 'restricted', enableNavButton: false }),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.swipeMode, 'standard');
assert.strictEqual(body.allowNavigation, 'restricted');
assert.strictEqual(body.enableNavButton, false);
});
await check('PUT rejects an invalid allowNavigation value', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowNavigation: 'wide-open' }),
});
assert.strictEqual(res.status, 400);
});
await check('PUT rejects a duration out of range', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tabs: [{ url: 'example.com', duration: 999999 }] }),
});
assert.strictEqual(res.status, 400);
});
await check('PUT normalizes bare hostnames/IPs the same way sites_parse_url does', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tabs: [
{ url: 'example.com', duration: 30, name: 'bare host' },
{ url: '192.168.1.50', duration: 30, name: 'bare ip' },
{ url: 'https://already.example.com', duration: 30, name: 'already a url' },
],
}),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.tabs[0].url, 'https://example.com');
assert.strictEqual(body.tabs[1].url, 'http://192.168.1.50');
assert.strictEqual(body.tabs[2].url, 'https://already.example.com');
});
await check('PUT rejects homeTabIndex out of range', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homeTabIndex: 99 }),
});
assert.strictEqual(res.status, 400);
});
await check('PUT accepts a valid homeTabIndex and converts inactivity minutes to stored seconds', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ homeTabIndex: 1, inactivityTimeoutMinutes: 5 }),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.homeTabIndex, 1);
assert.strictEqual(body.inactivityTimeout, 300);
const onDisk = JSON.parse(fs.readFileSync(process.env.CONFIG_PATH, 'utf8'));
assert.strictEqual(onDisk.inactivityTimeout, 300);
});
await check('PUT rejects enabling password protection with no password set', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enablePasswordProtection: true }),
});
assert.strictEqual(res.status, 400);
});
await check('PUT enables password protection when a new password is supplied, and never echoes it back', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enablePasswordProtection: true, newLockoutPassword: 'hunter2', lockoutTimeoutMinutes: 15 }),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.enablePasswordProtection, true);
assert.strictEqual(body.hasLockoutPassword, true);
assert.strictEqual(body.lockoutPassword, undefined);
assert.strictEqual(JSON.stringify(body).includes('hunter2'), false);
});
await check('PUT rejects a malformed lockoutAtTime', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lockoutAtTime: '25:99' }),
});
assert.strictEqual(res.status, 400);
});
await check('PUT re-enabling protection without a new password succeeds once one is already set', async () => {
const res = await fetch(`${base}/api/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enablePasswordProtection: true, lockoutTimeoutMinutes: 20 }),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.lockoutTimeout, 20);
});
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();