import test from "node:test"; import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; import fs, { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import QRCode from "qrcode"; const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); function reservePort() { return new Promise((resolve, reject) => { const server = createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; server.close((error) => error ? reject(error) : resolve(port)); }); }); } function waitForServer(child, timeoutMs = 10000) { return new Promise((resolve, reject) => { let output = ""; const timeout = setTimeout(() => { reject(new Error(`Server startup timed out. Output: ${output}`)); }, timeoutMs); const onData = (chunk) => { output += chunk.toString(); if (output.includes("RC Timing server listening")) { clearTimeout(timeout); child.stdout.off("data", onData); resolve(); } }; child.stdout.on("data", onData); child.stderr.on("data", (chunk) => { output += chunk.toString(); }); child.once("exit", (code, signal) => { clearTimeout(timeout); reject(new Error(`Server exited before startup (code=${code}, signal=${signal}). Output: ${output}`)); }); }); } function stopServer(child) { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve(); } return new Promise((resolve) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); }, 3000); child.once("exit", () => { clearTimeout(timeout); resolve(); }); child.kill("SIGTERM"); }); } test("SQLite API persists state, passings, and overlay session results", { timeout: 20000 }, async () => { const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-")); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_ALLOWED_ORIGINS: "https://trusted.example", }, stdio: ["ignore", "pipe", "pipe"], }); try { await waitForServer(child); const healthResponse = await fetch(`${baseUrl}/api/health`); const health = await healthResponse.json(); assert.equal(healthResponse.status, 200); assert.equal(health.ok, true); assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite")); const versionResponse = await fetch(`${baseUrl}/api/app-version`); assert.equal(versionResponse.status, 200); const appVersion = await versionResponse.json(); assert.equal(appVersion.version, JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version); assert.equal(typeof appVersion.revision, "number"); assert.ok(appVersion.updatedAt); const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } }); assert.equal(sameOriginResponse.status, 200); assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl); const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } }); assert.equal(allowedOriginResponse.status, 200); assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example"); const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, { method: "POST", headers: { Origin: "https://untrusted.example" }, }); assert.equal(rejectedOriginResponse.status, 403); const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`); const soundManifest = await soundManifestResponse.json(); assert.equal(soundManifestResponse.status, 200); assert.equal(soundManifest.events.start_tone.effect, "effects/start_beep.wav"); assert.equal(soundManifest.voice_events.sv.track_call, "Stopp på banan. Stopp på banan."); const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ managedEnabled: true, decoderHost: "192.168.1.10", wsPort: 9000, splitEnabled: true, splitDecoderHost: "192.168.1.11", splitWsPort: 9001, pitEnabled: true, pitEntryDecoderHost: "192.168.1.12", pitEntryWsPort: 9002, pitExitDecoderHost: "192.168.1.13", pitExitWsPort: 9003, }), }); const ammcConfigPayload = await ammcConfigResponse.json(); assert.equal(ammcConfigResponse.status, 200); assert.equal(ammcConfigPayload.config.splitWsPort, 9001); assert.equal(ammcConfigPayload.config.pitExitDecoderHost, "192.168.1.13"); assert.equal(ammcConfigPayload.status.role, "main"); assert.equal(ammcConfigPayload.status.split.role, "split"); assert.equal(ammcConfigPayload.status.pitEntry.wsPort, 9002); const duplicatePortResponse = await fetch(`${baseUrl}/api/ammc/config`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ splitEnabled: true, splitWsPort: 9000 }), }); const duplicatePortPayload = await duplicatePortResponse.json(); assert.equal(duplicatePortResponse.status, 400); assert.match(duplicatePortPayload.error, /unique WebSocket port/); const emptyResponse = await fetch(`${baseUrl}/api/state`); assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null }); const initialState = { events: [{ id: "event_1", name: "Club race" }], classes: [{ id: "class_1", name: "Buggy" }], drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }], sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }], resultsBySession: {}, settings: { publicBaseUrl: "https://racecore.example.org" }, training: { shares: [{ driverId: "driver_1", token: "test-share-token-1234567890", enabled: true }], }, }; const saveResponse = await fetch(`${baseUrl}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initialState), }); const savePayload = await saveResponse.json(); assert.equal(saveResponse.status, 200); assert.equal(savePayload.ok, true); assert.equal(typeof savePayload.updatedAt, "string"); const shareToken = initialState.training.shares[0].token; const publicQrResponse = await fetch( `${baseUrl}/public-training/qr/${shareToken}?lang=en&baseUrl=${encodeURIComponent("https://public.example.test")}`, ); const publicQrSvg = await publicQrResponse.text(); const expectedPublicQrSvg = await QRCode.toString( `https://public.example.test/public-training/${shareToken}?lang=en`, { type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 }, ); assert.equal(publicQrResponse.status, 200); assert.match(publicQrResponse.headers.get("content-type"), /^image\/svg\+xml/); assert.equal(publicQrSvg, expectedPublicQrSvg); const localQrResponse = await fetch(`${baseUrl}/public-training/qr/${shareToken}?lang=sv&baseUrl=local`); const localQrSvg = await localQrResponse.text(); assert.equal(localQrResponse.status, 200); assert.match(localQrResponse.headers.get("content-type"), /^image\/svg\+xml/); assert.notEqual(localQrSvg, publicQrSvg); const missingQrResponse = await fetch(`${baseUrl}/public-training/qr/unknown-share-token-123456?baseUrl=local`); assert.equal(missingQrResponse.status, 404); const backupResponse = await fetch(`${baseUrl}/api/backups`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }), }); const backupPayload = await backupResponse.json(); assert.equal(backupResponse.status, 201); assert.equal(backupPayload.ok, true); assert.equal(backupPayload.backup.reason, "pre_start"); assert.equal(backupPayload.backup.sessionId, "session_1"); assert.ok(backupPayload.backup.sizeBytes > 0); const backupsResponse = await fetch(`${baseUrl}/api/backups`); const backupsPayload = await backupsResponse.json(); assert.equal(backupsResponse.status, 200); assert.equal(backupsPayload.backups.length, 1); assert.equal(backupsPayload.backups[0].fileName, backupPayload.backup.fileName); const storedResponse = await fetch(`${baseUrl}/api/state`); const storedPayload = await storedResponse.json(); assert.deepEqual(storedPayload.state, initialState); const markerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: "class_1", type: "car", note: "Ride height change" }), }); const markerPayload = await markerResponse.json(); assert.equal(markerResponse.status, 201); assert.equal(markerPayload.marker.type, "car"); const markerStateResponse = await fetch(baseUrl + "/api/state"); const markerState = await markerStateResponse.json(); assert.equal(markerState.state.training.markers.length, 1); assert.equal(markerState.state.training.markers[0].driverId, "driver_1"); assert.equal(markerState.state.training.markers[0].classId, "class_1"); assert.equal(markerState.state.training.markers[0].note, "Ride height change"); const invalidMarkerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: "unknown_class", type: "car" }), }); assert.equal(invalidMarkerResponse.status, 404); const sessionResult = { competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16500 }, }, passings: [{ competitorKey: "driver_1", timestamp: 17500, lapMs: 16500, validLap: true }], adjustments: [], }; const passing = { timestamp: 17500, transponder: "1234567", driverId: "driver_1", driverName: "Driver One", carId: "car_1", carName: "Buggy", strength: -52.5, loopId: "loop_1", resend: false, }; const passingResponse = await fetch(`${baseUrl}/api/passings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1", passing, sessionResult }), }); assert.deepEqual(await passingResponse.json(), { ok: true }); const hotStateResponse = await fetch(`${baseUrl}/api/state`); const hotState = await hotStateResponse.json(); assert.deepEqual(hotState.state.resultsBySession.session_1, sessionResult); assert.deepEqual(hotState.state.events, initialState.events); const rowsResponse = await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`); const rowsPayload = await rowsResponse.json(); assert.equal(rowsPayload.rows.length, 1); assert.equal(rowsPayload.rows[0].session_id, "session_1"); assert.equal(rowsPayload.rows[0].timestamp_ms, 17500); assert.equal(rowsPayload.rows[0].transponder, "1234567"); assert.equal(rowsPayload.rows[0].driver_id, "driver_1"); assert.equal(rowsPayload.rows[0].car_id, "car_1"); assert.equal(rowsPayload.rows[0].strength, -52.5); assert.deepEqual(JSON.parse(rowsPayload.rows[0].raw_json), passing); // A second, unrelated session's passing must survive purging session_1's rows. await fetch(`${baseUrl}/api/passings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_other", passing: { ...passing, timestamp: 18000 } }), }); const purgeResponse = await fetch(`${baseUrl}/api/passings/purge`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionIds: ["session_1"] }), }); const purgePayload = await purgeResponse.json(); assert.equal(purgeResponse.status, 200); assert.equal(purgePayload.ok, true); assert.equal(purgePayload.deleted, 1); const afterPurgeSession1 = await (await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`)).json(); assert.equal(afterPurgeSession1.rows.length, 0, "the deleted session's rows are gone"); const afterPurgeOtherSession = await (await fetch(`${baseUrl}/api/passings?sessionId=session_other&limit=10`)).json(); assert.equal(afterPurgeOtherSession.rows.length, 1, "an unrelated session's rows must be untouched"); const emptyPurgeResponse = await fetch(`${baseUrl}/api/passings/purge`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionIds: [] }), }); assert.deepEqual(await emptyPurgeResponse.json(), { ok: true, deleted: 0 }); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); } }); test("a stale full-state save from a second browser cannot revert a session's just-arrived live results", { timeout: 20000 }, async () => { const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-concurrent-")); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_RESULT_PROTECTION_WINDOW_MS: "300", }, stdio: ["ignore", "pipe", "pipe"], }); try { await waitForServer(child); const staleSessionResult = { competitors: {}, passings: [], adjustments: [] }; const baseState = { events: [{ id: "event_1", name: "Club race" }], classes: [{ id: "class_1", name: "Buggy" }], drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }], sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }], resultsBySession: { session_1: staleSessionResult }, settings: {}, training: {}, }; await fetch(`${baseUrl}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(baseState), }); // Browser A is actively timing: a live passing arrives and updates session_1's result. const liveSessionResult = { competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16000 } }, passings: [{ competitorKey: "driver_1", timestamp: 5000, lapMs: 16000, validLap: true }], adjustments: [], }; await fetch(`${baseUrl}/api/passings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1", passing: { timestamp: 5000, transponder: "1234567", driverId: "driver_1" }, sessionResult: liveSessionResult, }), }); // Browser B has been idle since before the race and still has driver_1's old name and the // stale (empty) session_1 result in memory. It now saves an unrelated edit (renaming the // driver) and pushes its entire stale snapshot along with it. const staleBrowserState = { ...baseState, drivers: [{ id: "driver_1", name: "Driver One (renamed)", classId: "class_1" }], }; const staleSaveResponse = await fetch(`${baseUrl}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(staleBrowserState), }); assert.equal(staleSaveResponse.status, 200); const afterStaleSave = await (await fetch(`${baseUrl}/api/state`)).json(); // The unrelated edit went through... assert.equal(afterStaleSave.state.drivers[0].name, "Driver One (renamed)"); // ...but the live race result survived the stale overwrite instead of reverting to empty. assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult); // A judge removing that lap moments later must not be held back by the same protection: // the corrected result goes through /api/session-results and wins straight away. const correctedResult = { ...liveSessionResult, competitors: { driver_1: { ...liveSessionResult.competitors.driver_1, laps: 0, bestLapMs: null } }, passings: [{ ...liveSessionResult.passings[0], validLap: false, invalidReason: "manual_invalid" }], }; assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1", sessionResult: correctedResult }) })).status, 200); assert.deepEqual((await (await fetch(`${baseUrl}/api/state`)).json()).state.resultsBySession.session_1, correctedResult); assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1" }) })).status, 400); // The protection is time-bounded, not permanent: once the window passes, a save is trusted // again, so a legitimate later change to that same session's results (e.g. an admin // deleting the session) still takes effect. await new Promise((resolve) => setTimeout(resolve, 400)); const afterWindowState = { ...staleBrowserState, resultsBySession: {} }; await fetch(`${baseUrl}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(afterWindowState), }); const afterWindow = await (await fetch(`${baseUrl}/api/state`)).json(); assert.deepEqual(afterWindow.state.resultsBySession, {}); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); } }); function runGit(args, cwd) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); return result.stdout.trim(); } test("update check compares against a real (throwaway, local) git remote and extracts its package.json version", { timeout: 20000 }, async () => { const workDir = mkdtempSync(join(tmpdir(), "live-rc-test-update-")); const bareRemote = join(workDir, "origin.git"); const installDir = join(workDir, "install"); const otherClone = join(workDir, "other-clone"); fs.mkdirSync(bareRemote, { recursive: true }); fs.mkdirSync(installDir, { recursive: true }); runGit(["init", "--bare"], bareRemote); runGit(["init", "-b", "main"], installDir); runGit(["config", "user.email", "test@example.com"], installDir); runGit(["config", "user.name", "Test"], installDir); fs.writeFileSync(join(installDir, "package.json"), JSON.stringify({ name: "fake", version: "1.0.0" })); runGit(["add", "."], installDir); runGit(["commit", "-m", "initial"], installDir); runGit(["remote", "add", "origin", bareRemote], installDir); runGit(["push", "-u", "origin", "main"], installDir); const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-update-data-")); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_UPDATE_GIT_DIR: installDir }, stdio: ["ignore", "pipe", "pipe"], }); try { await waitForServer(child); const upToDate = await (await fetch(`${baseUrl}/api/update-status?force=1`)).json(); assert.equal(upToDate.checked, true); assert.equal(upToDate.available, false, "no commit has been pushed beyond what the install already has"); assert.equal(upToDate.currentVersion, JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version, "currentVersion reflects the real running server, not the fake install dir"); // Simulate someone else pushing a new release to the remote. fs.mkdirSync(otherClone, { recursive: true }); runGit(["clone", bareRemote, otherClone], workDir); // The bare remote's own HEAD symref still points at whatever git's default branch name was // at `git init --bare` time (e.g. "master"), even though "main" is the only branch that // actually has commits - check it out explicitly rather than relying on the clone's HEAD. runGit(["checkout", "main"], otherClone); runGit(["config", "user.email", "test@example.com"], otherClone); runGit(["config", "user.name", "Test"], otherClone); fs.writeFileSync(join(otherClone, "package.json"), JSON.stringify({ name: "fake", version: "1.1.0" })); runGit(["commit", "-am", "bump version"], otherClone); runGit(["push", "origin", "main"], otherClone); const outdated = await (await fetch(`${baseUrl}/api/update-status?force=1`)).json(); assert.equal(outdated.checked, true); assert.equal(outdated.available, true, "the remote now has a commit the install doesn't"); assert.equal(outdated.remoteVersion, "1.1.0", "the new version must be read from the remote's package.json, not guessed"); assert.notEqual(outdated.remoteCommit, outdated.localCommit); // Without force, the endpoint must serve the cached result instantly rather than doing // another real git fetch - confirmed by pushing yet another change and checking it is // *not* reflected without force, only the previously cached "available: true" is. fs.writeFileSync(join(otherClone, "package.json"), JSON.stringify({ name: "fake", version: "1.2.0" })); runGit(["commit", "-am", "bump version again"], otherClone); runGit(["push", "origin", "main"], otherClone); const cached = await (await fetch(`${baseUrl}/api/update-status`)).json(); assert.equal(cached.available, true, "reuses the cached result from the previous forced check"); assert.equal(cached.remoteVersion, "1.1.0", "must still be the cached 1.1.0, not the just-pushed 1.2.0 - proving it did not re-check"); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); rmSync(workDir, { recursive: true, force: true }); } }); test("PDF export renders Swedish åäö/ÅÄÖ correctly instead of mojibake", { timeout: 20000 }, async () => { const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-pdf-")); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "" }, stdio: ["ignore", "pipe", "pipe"], }); try { await waitForServer(child); const response = await fetch(`${baseUrl}/api/export/pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "Test åäö ÅÄÖ", filename: "swedish_test.pdf", sections: [{ title: "Förare", headers: ["Namn", "Klass"], rows: [["Örjan Åström", "2WD"]] }], }), }); assert.equal(response.status, 200); assert.equal(response.headers.get("content-type"), "application/pdf"); const pdfBuffer = Buffer.from(await response.arrayBuffer()); // The font must declare WinAnsiEncoding - without it a PDF viewer falls back to a standard // font's built-in encoding, which has no å/ä/ö glyphs at all regardless of the bytes sent. assert.ok(pdfBuffer.toString("latin1").includes("/Encoding /WinAnsiEncoding"), "the font must declare WinAnsiEncoding"); // "åäö ÅÄÖ" must appear as single WinAnsi/Latin-1 bytes (0xE5 0xE4 0xF6 0x20 0xC5 0xC4 0xD6), // not as UTF-8 multi-byte sequences (which a WinAnsi-encoded viewer would render as garbage // like "åäö"). const winAnsiBytes = Buffer.from([0xe5, 0xe4, 0xf6, 0x20, 0xc5, 0xc4, 0xd6]); assert.ok(pdfBuffer.includes(winAnsiBytes), "åäö ÅÄÖ must be present as single WinAnsi bytes in the content stream"); const utf8Bytes = Buffer.from("åäö ÅÄÖ", "utf8"); assert.ok(!pdfBuffer.includes(utf8Bytes), "the raw UTF-8 byte sequence must not appear - that is the mojibake bug"); // A name with å from a table row must also be encoded correctly, not just the title. const orjanWinAnsi = Buffer.from([0xd6, 0x72, 0x6a, 0x61, 0x6e]); // "Örjan" - Ö = 0xD6 assert.ok(pdfBuffer.includes(orjanWinAnsi), "a Swedish name inside a table row must also use WinAnsi bytes"); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); } }); test("an admin PIN blocks unauthenticated writes, and the login cookie lets them through", { timeout: 20000 }, async () => { const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-auth-")); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "" }, stdio: ["ignore", "pipe", "pipe"], }); const post = (path, body, cookie = "") => fetch(`${baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) }); try { await waitForServer(child); assert.equal((await post("/api/state", { classes: [] })).status, 200, "auth is off by default, so writes work as before"); const setPin = await post("/api/auth/pin", { newPin: "4711" }); assert.equal(setPin.status, 200); const cookie = setPin.headers.get("set-cookie").split(";")[0]; assert.equal((await post("/api/state", { classes: [] })).status, 401, "without the cookie a write is rejected"); assert.equal((await post("/api/update/run", {})).status, 401, "the update/restart trigger is protected too"); assert.equal((await fetch(`${baseUrl}/api/state`)).status, 200, "reads stay open for the public overlay"); assert.equal((await post("/api/auth/pin", { newPin: "9999" })).status, 401, "the PIN cannot be changed without logging in"); assert.equal((await post("/api/state", { classes: [] }, cookie)).status, 200, "the cookie from setting the PIN authorizes writes"); assert.equal((await post("/api/auth/login", { pin: "0000" })).status, 401); const login = await post("/api/auth/login", { pin: "4711" }); assert.equal(login.status, 200); assert.ok(login.headers.get("set-cookie").includes("HttpOnly")); const status = await (await fetch(`${baseUrl}/api/auth/status`, { headers: { Cookie: cookie } })).json(); assert.deepEqual(status, { enabled: true, authenticated: true, ipAllowed: true }); const stateBody = await (await fetch(`${baseUrl}/api/state`)).text(); assert.ok(!stateBody.includes("pinHash"), "the PIN hash never appears in the state payload"); assert.equal((await post("/api/auth/disable", {}, cookie)).status, 200); assert.equal((await post("/api/state", { classes: [] })).status, 200, "disabling restores open writes"); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); } }); test("local Piper speech is served as WAV, cached, and falls under the admin PIN", { timeout: 20000, skip: process.platform === "win32" }, async () => { const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-tts-")); const voicesDir = join(dataDir, "voices"); fs.mkdirSync(voicesDir); for (const name of ["sv_SE-test-medium", "sv_SE-other-medium", "en_US-test-medium"]) { fs.writeFileSync(join(voicesDir, `${name}.onnx`), ""); fs.writeFileSync(join(voicesDir, `${name}.onnx.json`), "{}"); } // HOME points at the temp dir so voices installed on the developer's own machine // (~/.local/share/piper-voices) can't leak into the test. // Fake Piper: counts invocations and writes the text it was given into the output file. const fakePiper = join(dataDir, "fake-piper"); const counter = join(dataDir, "piper-calls"); fs.writeFileSync(fakePiper, `#!/usr/bin/env node const fs = require("fs"); const out = process.argv[process.argv.indexOf("-f") + 1]; const model = require("path").basename(process.argv[process.argv.indexOf("-m") + 1], ".onnx"); let text = ""; process.stdin.on("data", (chunk) => { text += chunk; }); process.stdin.on("end", () => { fs.appendFileSync(${JSON.stringify(counter)}, "x"); fs.writeFileSync(out, "RIFF:" + model + ":" + text); }); `); fs.chmodSync(fakePiper, 0o755); const port = await reservePort(); const baseUrl = `http://127.0.0.1:${port}`; const child = spawn(process.execPath, [join(projectRoot, "server.js")], { cwd: projectRoot, env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "", RC_PIPER_BIN: fakePiper, RC_PIPER_VOICES_DIR: voicesDir, HOME: dataDir, USERPROFILE: dataDir }, stdio: ["ignore", "pipe", "pipe"], }); const speech = (body, cookie = "") => fetch(`${baseUrl}/api/tts/speech`, { method: "POST", headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) }, body: JSON.stringify(body) }); try { await waitForServer(child); const status = await (await fetch(`${baseUrl}/api/tts/status`)).json(); assert.equal(status.available, true); assert.equal(status.binary, fakePiper); assert.ok(["sv", "en"].every((language) => status.voices.some((voice) => voice.language === language)), "both Swedish and English voices are found"); const first = await speech({ text: "Heat ett startar.", language: "sv", rate: 1 }); assert.equal(first.status, 200); assert.match(first.headers.get("content-type"), /audio\/wav/); assert.equal(await first.text(), "RIFF:sv_SE-other-medium:Heat ett startar.\n", "with no choice and no catalog default installed, the first medium Swedish voice is used"); assert.equal((await speech({ text: "Heat ett startar.", language: "sv", rate: 1 })).status, 200); assert.equal(fs.readFileSync(counter, "utf8").length, 1, "the second request is served from the cache"); const chosen = await speech({ text: "Heat ett startar.", language: "sv", rate: 1, voice: "sv_SE-test-medium" }); assert.equal(await chosen.text(), "RIFF:sv_SE-test-medium:Heat ett startar.\n", "the voice chosen in Settings reaches Piper"); assert.equal((await speech({ text: "", language: "sv" })).status, 400); assert.equal((await speech({ text: "Moi", language: "fi" })).status, 503, "no voice for the language -> the browser falls back"); const setPin = await fetch(`${baseUrl}/api/auth/pin`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ newPin: "4711" }) }); const cookie = setPin.headers.get("set-cookie").split(";")[0]; assert.equal((await speech({ text: "Hej", language: "sv" })).status, 401, "generating audio needs the PIN once protection is on"); assert.equal((await speech({ text: "Hej", language: "sv" }, cookie)).status, 200); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true }); } });