tests/web.test.js
Rama main · réplica de lectura del repositorio original.
// 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();
}
});