// SPDX-License-Identifier: GPL-3.0-or-later import { test } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:net"; import { startWeb } from "../src/web.js"; // /api/ignore and /api/history/clear write the config and history files. Point // both at throwaway paths so a test never touches the developer's real ones. process.env.LINUX_DOCTOR_CONFIG = join(tmpdir(), `ld-web-test-config-${process.pid}.json`); process.env.LINUX_DOCTOR_HISTORY = join(tmpdir(), `ld-web-test-history-${process.pid}.json`); // Raw request helper — fetch() treats Host and Origin as forbidden header // names, and these tests must control both headers exactly. function rawRequest(port, path, { method = "GET", headers = {}, body = null } = {}) { return new Promise((resolve, reject) => { const req = http.request({ host: "127.0.0.1", port, path, method, headers }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => resolve({ status: res.statusCode, body: data })); }); req.on("error", reject); if (body !== null) req.write(body); req.end(); }); } // Is 43901 (the dashboard's default port) currently free? Used to pin the // default in tests without flaking when a live dashboard is running. const portFree = (p) => new Promise((resolve) => { const s = createServer(); s.once("error", () => resolve(false)); s.once("listening", () => s.close(() => resolve(true))); s.listen(p, "127.0.0.1"); }); test("web dashboard serves the page and a fresh report via /api/report", async () => { const collect = async () => ({ generatedAt: new Date().toISOString(), system: { distro: "Bazzite", kernel: "6.x", cores: "4", uptime: "2h 0m" }, findings: [ { id: 1, severity: "high", title: "3 services failed to start", detail: "Some detail.", evidence: "a\tb", fix: "systemctl status x" }, { id: 2, severity: "info", title: "System is up to date", detail: "No pending updates.", evidence: null, fix: null }, ], }); const server = await startWeb({ collect, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const page = await (await fetch(base + "/")).text(); assert.match(page, /Linux Doctor/); assert.match(page, /api\/report/); assert.match(page, /Re-run checks/); const data = await (await fetch(base + "/api/report")).json(); assert.equal(data.findings.length, 2); assert.equal(data.findings[0].severity, "high"); assert.equal(data.system.distro, "Bazzite"); } finally { server.close(); } }); test("web /api/report can serve the versioned --json envelope via render", async () => { const collect = async () => ({ generatedAt: new Date().toISOString(), system: { distro: "Bazzite", kind: "desktop" }, findings: [{ id: 1, severity: "info", title: "All good", check: "memory", code: "memory/all-good" }], }); // Like cli.js does with renderJson: the render function returns a JSON // string, and the server must pass it through without double-encoding. const render = (d) => JSON.stringify({ schemaVersion: 1, tool: "linux-doctor", version: "0.3.0", ...d }); const server = await startWeb({ collect, render, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const data = await (await fetch(base + "/api/report")).json(); assert.equal(data.schemaVersion, 1); assert.equal(data.tool, "linux-doctor"); assert.equal(data.findings[0].check, "memory"); } finally { server.close(); } }); test("web dashboard serves run history via /api/history", async () => { const history = () => [{ at: "2026-08-15T00:00:00Z", score: 85 }, { at: "2026-08-16T00:00:00Z", score: 69 }]; const server = await startWeb({ collect: async () => ({ findings: [] }), history, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const body = await (await fetch(base + "/api/history")).json(); assert.equal(body.runs.length, 2); assert.equal(body.runs[1].score, 69); } finally { server.close(); } }); test("web dashboard defaults to port 43901 and falls back when it is taken", async () => { const collect = async () => ({ findings: [] }); const first = await startWeb({ collect, open: false, quiet: true }); const second = await startWeb({ collect, open: false, quiet: true }); try { if (await portFree(43901)) { assert.equal(first.address().port, 43901, "default port is 43901 when free"); } assert.notEqual(second.address().port, first.address().port, "second instance falls back to another port"); const ok = await (await fetch(`http://127.0.0.1:${second.address().port}/api/report`)).ok; assert.ok(ok, "fallback server still serves the report"); } finally { first.close(); second.close(); } }); test("POST /api/ignore writes the pattern to the config file", async () => { const dir = mkdtempSync(join(tmpdir(), "ld-web-")); const config = join(dir, "config.json"); const prev = process.env.LINUX_DOCTOR_CONFIG; process.env.LINUX_DOCTOR_CONFIG = config; try { const server = await startWeb({ collect: async () => ({ findings: [] }), open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const res = await fetch(base + "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pattern: "Suspend hooks are failing" }), }); assert.equal(res.status, 200); const saved = JSON.parse(readFileSync(config, "utf8")); assert.deepEqual(saved.ignore, ["Suspend hooks are failing"]); } finally { server.close(); } } finally { if (prev === undefined) delete process.env.LINUX_DOCTOR_CONFIG; else process.env.LINUX_DOCTOR_CONFIG = prev; rmSync(dir, { recursive: true, force: true }); } }); test("web rejects non-loopback Host headers (DNS rebinding)", async () => { const server = await startWeb({ collect: async () => ({ findings: [] }), open: false, port: 0, quiet: true }); try { // An attacker page whose domain rebinds to 127.0.0.1 arrives with an // attacker-chosen Host header — it must not be served. const rebound = await rawRequest(server.address().port, "/", { headers: { Host: "evil.example" } }); assert.equal(rebound.status, 403); const report = await rawRequest(server.address().port, "/api/report", { headers: { Host: "evil.example" } }); assert.equal(report.status, 403); // A normal loopback visit keeps working. const local = await rawRequest(server.address().port, "/"); assert.equal(local.status, 200); } finally { server.close(); } }); test("web rejects cross-origin POSTs to config-writing endpoints (CSRF)", async () => { const dir = mkdtempSync(join(tmpdir(), "ld-web-csrf-")); const prev = process.env.LINUX_DOCTOR_CONFIG; process.env.LINUX_DOCTOR_CONFIG = join(dir, "config.json"); try { const server = await startWeb({ collect: async () => ({ findings: [] }), open: false, port: 0, quiet: true }); const port = server.address().port; try { // A forged cross-site POST always carries the attacker's Origin. const body = JSON.stringify({ pattern: "Suspend hooks are failing" }); const forged = await rawRequest(port, "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json", Origin: "https://evil.example" }, body, }); assert.equal(forged.status, 403); // A "null" origin (sandboxed iframe) is equally untrusted. const nulled = await rawRequest(port, "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json", Origin: "null" }, body, }); assert.equal(nulled.status, 403); // The dashboard itself posts from a loopback origin — still allowed. const local = await rawRequest(port, "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${port}` }, body, }); assert.equal(local.status, 200); // Non-browser clients (curl, scripts) send no Origin at all — allowed. const noOrigin = await rawRequest(port, "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json" }, body, }); assert.equal(noOrigin.status, 200); } finally { server.close(); } } finally { if (prev === undefined) delete process.env.LINUX_DOCTOR_CONFIG; else process.env.LINUX_DOCTOR_CONFIG = prev; rmSync(dir, { recursive: true, force: true }); } }); test("web serves read-only schedule state via /api/schedule", async () => { const sched = { installed: true, enabled: true, active: true, systemd: true }; const server = await startWeb({ collect: async () => ({ findings: [] }), schedule: () => sched, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const body = await (await fetch(base + "/api/schedule")).json(); assert.deepEqual(body.schedule, sched); } finally { server.close(); } }); test("web /api/schedule defaults to the real timer probe with a stable shape", async () => { const server = await startWeb({ collect: async () => ({ findings: [] }), open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const body = await (await fetch(base + "/api/schedule")).json(); for (const k of ["installed", "enabled", "active", "systemd"]) { assert.equal(typeof body.schedule[k], "boolean", `schedule.${k} is a boolean`); } } finally { server.close(); } }); test("web: /api/ignore lists the patterns and edits them", async () => { const { addIgnore } = await import("../src/ignore.js"); addIgnore("Volatile title"); const server = await startWeb({ collect: async () => ({ generatedAt: "", system: {}, findings: [] }), open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const listed = await (await fetch(base + "/api/ignore")).json(); assert.ok(listed.patterns.includes("Volatile title"), `expected the pattern: ${JSON.stringify(listed)}`); assert.ok(String(listed.path).endsWith(".json"), "the config path is reported"); const del = await (await fetch(base + "/api/ignore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pattern: "Volatile title", remove: true }) })).json(); assert.equal(del.ok, true); const after = await (await fetch(base + "/api/ignore")).json(); assert.ok(!after.patterns.includes("Volatile title"), "the pattern is gone"); } finally { server.close(); } }); test("web: /api/history/clear empties the history", async () => { const { saveRun, loadHistory } = await import("../src/history.js"); saveRun({ at: new Date().toISOString(), score: 50, counts: {}, findings: [] }); assert.ok(loadHistory().length > 0, "precondition: a run is stored"); const server = await startWeb({ collect: async () => ({ generatedAt: "", system: {}, findings: [] }), history: loadHistory, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const res = await (await fetch(base + "/api/history/clear", { method: "POST" })).json(); assert.equal(res.ok, true); assert.equal(loadHistory().length, 0, "history is cleared"); } finally { server.close(); } }); test("web: /api/report is cached; ?refresh=1 bypasses the TTL", async () => { let calls = 0; const collect = async () => { calls += 1; return { generatedAt: new Date().toISOString(), system: {}, findings: [{ severity: "info", title: `run ${calls}` }] }; }; const server = await startWeb({ collect, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { const a = await (await fetch(base + "/api/report")).json(); const b = await (await fetch(base + "/api/report")).json(); assert.equal(calls, 1, "a second request within the TTL must hit the cache"); assert.equal(a.findings[0].title, b.findings[0].title); const c = await (await fetch(base + "/api/report?refresh=1")).json(); assert.equal(calls, 2, "?refresh=1 must force a fresh scan"); assert.equal(c.findings[0].title, "run 2"); } finally { server.close(); } }); test("web: a stale report is served instantly and revalidated in the background", async () => { let calls = 0; const collect = async () => { calls += 1; return { generatedAt: "t" + calls, system: {}, findings: [{ severity: "info", title: `run ${calls}` }] }; }; const server = await startWeb({ collect, open: false, port: 0, quiet: true, reportTtlMs: 40 }); const base = `http://127.0.0.1:${server.address().port}`; try { await (await fetch(base + "/api/report")).json(); // first paint blocks on the scan await new Promise((r) => setTimeout(r, 70)); // let the TTL expire const stale = await (await fetch(base + "/api/report")).json(); assert.equal(stale.findings[0].title, "run 1", "the stale body is served instantly, not a blocking scan"); await new Promise((r) => setTimeout(r, 70)); // let the background scan finish const fresh = await (await fetch(base + "/api/report")).json(); assert.equal(fresh.findings[0].title, "run 2", "the next poll sees the revalidated report"); } finally { server.close(); } }); test("web: a Re-run is never swallowed by an in-flight background poll", async () => { // The SWR background scan calls collect(false). Sharing its single-flight // with the blocking path meant a Re-run arriving during a background poll // awaited that non-saving scan and never recorded history. const saves = []; let releaseBg; const collect = async (save) => { saves.push(save); if (saves.length === 2) await new Promise((r) => { releaseBg = r; }); // hold the background scan return { generatedAt: "t" + saves.length, system: {}, findings: [] }; }; const server = await startWeb({ collect, open: false, port: 0, quiet: true, reportTtlMs: 10 }); const base = `http://127.0.0.1:${server.address().port}`; try { await fetch(base + "/api/report"); // first paint: collect(false) await new Promise((r) => setTimeout(r, 30)); // let the TTL expire const bg = fetch(base + "/api/report"); // stale poll -> background scan (held) await new Promise((r) => setTimeout(r, 20)); // let the background scan start const rerun = fetch(base + "/api/report?refresh=1&save=1"); // must save on its own await new Promise((r) => setTimeout(r, 20)); if (releaseBg) releaseBg(); await Promise.all([bg, rerun]); assert.deepEqual(saves, [false, false, true], `a Re-run must save even during a background poll: ${JSON.stringify(saves)}`); } finally { server.close(); } }); test("web: a refresh during a background scan gets a body, not an empty response", async () => { // The background scan resolved to undefined. Sharing its single-flight with // the blocking path served that undefined as an empty body ("Unexpected end // of JSON input" in the dashboard) and poisoned the cache with it. let releaseBg; let calls = 0; const collect = async () => { calls += 1; if (calls === 2) await new Promise((r) => { releaseBg = r; }); // hold the background scan return { generatedAt: "t" + calls, system: {}, findings: [{ severity: "info", title: `run ${calls}` }] }; }; const server = await startWeb({ collect, open: false, port: 0, quiet: true, reportTtlMs: 10 }); const base = `http://127.0.0.1:${server.address().port}`; try { await (await fetch(base + "/api/report")).json(); // first paint: calls=1 await new Promise((r) => setTimeout(r, 30)); // TTL expires fetch(base + "/api/report"); // stale poll -> background scan (held), calls=2 await new Promise((r) => setTimeout(r, 20)); // let it start const refresh = fetch(base + "/api/report?refresh=1"); // must get the held scan's body await new Promise((r) => setTimeout(r, 20)); if (releaseBg) releaseBg(); const text = await (await refresh).text(); assert.ok(text.length > 0, "the refresh must not be served an empty body"); assert.ok(Array.isArray(JSON.parse(text).findings), `expected a report, got: ${text.slice(0, 80)}`); } finally { server.close(); } }); test("web: an explicit Re-run (?save=1) records history; polls never do", async () => { const saves = []; const collect = async (save) => { saves.push(save); return { generatedAt: new Date().toISOString(), system: {}, findings: [] }; }; const server = await startWeb({ collect, open: false, port: 0, quiet: true }); const base = `http://127.0.0.1:${server.address().port}`; try { await fetch(base + "/api/report"); // background poll await fetch(base + "/api/report?refresh=1"); // another poll/first paint await fetch(base + "/api/report?refresh=1&save=1"); // explicit Re-run assert.deepEqual(saves, [false, false, true], "only the Re-run must ask to save"); } finally { server.close(); } });