import { test } from "node:test"; import assert from "node:assert"; import lib from "../tailscalectl/lib.js"; const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction } = lib; /* * Unit tests for the pure functions exported from lib.js. * * All functions in lib.js are exercised via Node's built-in test runner. * * TailscaleWidget.qml has no automated test coverage. The four Process * objects, their onExited handlers, and all widget UI behavior must be * verified manually. */ test("parsePeers extracts exitNode from ExitNodeOption", () => { const peerMap = { "peer-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNodeOption: true }, "peer-2": { HostName: "laptop", TailscaleIPs: ["100.64.0.2"], Online: true, ExitNodeOption: false } }; const peers = parsePeers(peerMap); assert.strictEqual(peers[0].exitNode, true); assert.strictEqual(peers[1].exitNode, false); }); test("makeExitNodeCommand returns tailscale set command for hostname", () => { const cmd = makeExitNodeCommand("router"); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]); }); test("makeExitNodeCommand with empty string clears exit node", () => { const cmd = makeExitNodeCommand(""); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]); }); test("findActiveExitNode returns hostname of peer with ExitNode=true", () => { const peerMap = { "peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true }, "peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true } }; assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc"); }); test("findActiveExitNode returns empty string when no exit node", () => { const peerMap = { "peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false } }; assert.strictEqual(findActiveExitNode(peerMap), ""); }); test("errorMessage returns user-friendly message for tailscale up failure", () => { const msg = errorMessage("up", 1); assert.strictEqual(msg, "Failed to connect to Tailscale"); }) test("errorMessage returns user-friendly message for tailscale down failure", () => { const msg = errorMessage("down", 1); assert.strictEqual(msg, "Failed to disconnect from Tailscale"); }); test("errorMessage returns user-friendly message for tailscale set failure", () => { const msg = errorMessage("set", 1); assert.strictEqual(msg, "Failed to set exit node"); }); test("errorMessage returns user-friendly message for tailscale status failure", () => { const msg = errorMessage("status", 1); assert.strictEqual(msg, "Failed to read Tailscale status"); }); test("errorMessage returns generic message for unknown command", () => { const msg = errorMessage("unknown", 1); assert.strictEqual(msg, "Tailscale command failed"); }); test("getClipboardCommands returns ordered argv arrays with text appended", () => { const cmds = getClipboardCommands("1.2.3.4"); assert.ok(Array.isArray(cmds)); assert.strictEqual(cmds.length, 2); assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"]); assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"]); }); test("getClipboardCommands handles text with special characters safely (direct argv)", () => { const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines"); assert.ok(Array.isArray(cmds)); assert.strictEqual(cmds.length, 2); assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines")); }); test("getClipboardCommands is deterministic and open for future tools", () => { const cmds = getClipboardCommands("foo"); assert.ok(Array.isArray(cmds[0])); assert.ok(Array.isArray(cmds[1])); }); test("getStrings returns canonical UI strings for the widget", () => { const s = getStrings(); assert.ok(s.header) assert.ok(s.connected) assert.ok(s.disconnected) assert.ok(s.exitNodePrefix) assert.ok(s.copied) }) test("getStrings.copied is the I18n template key (interpolation happens at call site via .arg)", () => { const s = getStrings(); assert.strictEqual(s.copied, "Copied %1 to clipboard"); }) test("shouldShowClearExitNode returns true only when there is a current exit node", () => { assert.strictEqual(shouldShowClearExitNode("router"), true) assert.strictEqual(shouldShowClearExitNode(""), false) }) test("isActiveExitNode correctly identifies the active exit node button", () => { assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-sjc"), true) assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-den"), false) assert.strictEqual(isActiveExitNode("", "router"), false) }) // --- buildToggleCommand --- test("buildToggleCommand returns down command when connected", () => { assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]) }) test("buildToggleCommand returns up command when disconnected", () => { assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]) }) test("buildToggleCommand treats null and undefined as disconnected", () => { assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]) assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]) }) test("commandForPendingAction returns toggle command when pending is TOGGLE and passes through buildToggleCommand logic", () => { assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true), ["tailscale", "down"]); assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false), ["tailscale", "up"]); assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null), ["tailscale", "up"]); }); test("commandForPendingAction returns null for no pending action or unknown pending value", () => { assert.strictEqual(commandForPendingAction(null, true), null); assert.strictEqual(commandForPendingAction(undefined, false), null); assert.strictEqual(commandForPendingAction("something-else", true), null); }); test("parseStatusResult produces correct state from valid JSON", () => { const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: { "key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true } } }) const state = parseStatusResult(json) assert.strictEqual(state.isConnected, true) assert.strictEqual(state.tailscaleIP, "100.64.0.5") assert.strictEqual(state.currentExitNode, "router") assert.strictEqual(state.peers.length, 1) }) test("parseStatusResult returns safe defaults for invalid JSON", () => { const state = parseStatusResult("not json at all") assert.strictEqual(state.isConnected, false) assert.strictEqual(state.tailscaleIP, "") assert.strictEqual(state.currentExitNode, "") assert.strictEqual(state.peers.length, 0) }) test("parseStatusResult handles missing Self gracefully", () => { const json = JSON.stringify({ BackendState: "Running", Peer: {} }) const state = parseStatusResult(json) assert.strictEqual(state.isConnected, true) assert.strictEqual(state.tailscaleIP, "") }) test("parseStatusResult handles missing and empty Peer gracefully", () => { const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } }) const state = parseStatusResult(json) assert.strictEqual(state.peers.length, 0) assert.strictEqual(state.currentExitNode, "") }) test("parseStatusResult sets isConnected false for non-Running BackendState", () => { const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} }) const state = parseStatusResult(json) assert.strictEqual(state.isConnected, false) }) // --- formatError (central error + detail formatting) --- test("formatError returns base message without detail", () => { assert.strictEqual(formatError("status"), "Failed to read Tailscale status") assert.strictEqual(formatError("set"), "Failed to set exit node") }) test("formatError appends and truncates detail", () => { const longDetail = "x".repeat(200) const msg = formatError("up", longDetail) assert.ok(msg.includes("Failed to connect to Tailscale")) assert.ok(msg.endsWith("x".repeat(120))) assert.ok(msg.length < 200) }) test("formatError handles empty or falsy detail gracefully", () => { assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale") assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale") }) // --- isValidExitNodeHostname + makeExitNodeCommand safety --- test("isValidExitNodeHostname accepts empty string (clear)", () => { assert.strictEqual(isValidExitNodeHostname(""), true) }) test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => { ["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach(h => assert.strictEqual(isValidExitNodeHostname(h), true, h) ) }) test("isValidExitNodeHostname rejects obvious injection attempts", () => { ["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)"].forEach(h => assert.strictEqual(isValidExitNodeHostname(h), false, h) ) }) test("makeExitNodeCommand returns null for invalid hostname", () => { assert.strictEqual(makeExitNodeCommand("; rm"), null) assert.strictEqual(makeExitNodeCommand("$(whoami)"), null) }) test("makeExitNodeCommand still produces correct argv for valid input", () => { assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="]) assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"]) }) // --- getStatusCommand --- test("getStatusCommand returns the canonical tailscale status --json argv", () => { const cmd = getStatusCommand() assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]) })