Off by default. Once a PIN is set in Settings -> System & backup, every non-GET /api/* request (plus GET /api/ammc/config) needs a signed 30-day HttpOnly cookie, which also closes the unauthenticated /api/update/run restart trigger. A 401 opens a PIN dialog and the pending save is retried after login. Reads stay open because the public overlay hydrates through GET /api/state. Optional RC_ADMIN_ALLOWED_CIDRS restricts the same routes by client address. The PIN hash and signing secret live in data/, outside the app_state blob, so they never leak through GET /api/state, exports or backups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
575 lines
26 KiB
JavaScript
575 lines
26 KiB
JavaScript
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);
|
|
|
|
// 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 });
|
|
}
|
|
});
|