diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index 0f93d3d..2c0dda3 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -1,7 +1,8 @@ function parsePeers(peerMap) { - if (!peerMap) { return []; } + if (!peerMap) { + return []; + } return Object.keys(peerMap).map(function (key) { - if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; } var p = peerMap[key]; return { hostname: p.HostName || key, @@ -9,7 +10,7 @@ function parsePeers(peerMap) { online: p.Online || false, exitNode: p.ExitNodeOption || false }; - }).filter(function (peer) { return peer !== null; }); + }); } function makeExitNodeCommand(hostname) { @@ -23,7 +24,9 @@ function makeExitNodeCommand(hostname) { } function findActiveExitNode(peerMap) { - if (!peerMap) { return ""; } + if (!peerMap) { + return ""; + } for (const key of Object.keys(peerMap)) { const p = peerMap[key]; if (p.ExitNode) { @@ -52,32 +55,43 @@ function getStrings() { exitNodePrefix: "Exit node: ", none: "None", copied: "Copied %1 to clipboard", - invalidExitNodeHostname: "Invalid exit node hostname" + invalidExitNodeHostname: "Invalid exit node hostname", + notConnectedHint: "Not connected" }; } -// Light UI predicates — keep the view thin. -function shouldShowClearExitNode(currentExitNode) { - return currentExitNode !== ""; -} - -function isActiveExitNode(currentExitNode, hostname) { - return currentExitNode === hostname; -} - // Security: validate hostnames coming from tailscale status JSON. -// Fail closed on obviously malicious input. +// Fail closed on obviously malicious input. Allow multi-label MagicDNS names +// up to DNS FQDN length (253). function isValidExitNodeHostname(hostname) { - if (typeof hostname !== "string") { return false; } - if (hostname === "") { return true; } - return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname); + if (typeof hostname !== "string") { + return false; + } + if (hostname === "") { + return true; + } + if (hostname.length > 253) { + return false; + } + // Each label: alnum start/end, alnum/hyphen/underscore inside; dots separate labels. + return /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?)(\.([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?))*$/.test(hostname); } function parseStatusResult(jsonText) { try { const data = JSON.parse(jsonText); + const isConnected = data.BackendState === "Running"; + if (!isConnected) { + // #55: when not Running, do not surface stale peer list / exit node / IP. + return { + isConnected: false, + tailscaleIP: "", + currentExitNode: "", + peers: [] + }; + } return { - isConnected: data.BackendState === "Running", + isConnected: true, tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "", currentExitNode: findActiveExitNode(data.Peer || {}), peers: parsePeers(data.Peer || {}) @@ -109,8 +123,7 @@ function errorMessage(cmd) { return messages[cmd] || "Tailscale command failed"; } -// Central error formatting for the widget. Used by both success and error paths. -// detail is optional truncated stderr or extra context. +// Central error formatting for the widget. detail is optional truncated stderr or extra context. function formatError(action, detail) { var base = errorMessage(action); if (detail && detail.length > 0) { @@ -124,7 +137,12 @@ const PendingAction = Object.freeze({ TOGGLE: "toggle" }); -function commandForPendingAction(pending, freshIsConnected) { +// statusOk must be true (successful status poll) before acting on pending toggle. +// Never invent up/down from a failed poll (would force "up" after clearing isConnected). +function commandForPendingAction(pending, freshIsConnected, statusOk) { + if (!statusOk) { + return null; + } if (pending === PendingAction.TOGGLE) { return buildToggleCommand(freshIsConnected); } @@ -132,5 +150,19 @@ function commandForPendingAction(pending, freshIsConnected) { } if (typeof module !== "undefined" && module.exports) { - module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction }; + module.exports = { + parsePeers, + makeExitNodeCommand, + findActiveExitNode, + errorMessage, + formatError, + getStatusCommand, + isValidExitNodeHostname, + getClipboardCommands, + buildToggleCommand, + parseStatusResult, + getStrings, + PendingAction, + commandForPendingAction + }; } diff --git a/test/lib.test.js b/test/lib.test.js index ee06864..9e8be51 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -1,18 +1,33 @@ 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; + +const { + parsePeers, + makeExitNodeCommand, + findActiveExitNode, + errorMessage, + formatError, + getStatusCommand, + isValidExitNodeHostname, + getClipboardCommands, + buildToggleCommand, + parseStatusResult, + getStrings, + PendingAction, + commandForPendingAction +} = lib; /* - * Unit tests for the pure functions exported from lib.js. + * Unit tests for pure functions in lib.js. * - * All functions in lib.js are exercised via Node's built-in test runner. - * - * TailscaleWidget.qml has no automated test coverage. The Proc.runCommand - * calls, callback-based coordination (exact poll-act-poll preserved), and all - * widget UI behavior must be verified manually in a running DMS instance. + * TailscaleWidget.qml has no automated test coverage. Proc.runCommand + * coordination, busy-mutex behavior, and widget UI must be verified + * manually in a running DMS instance. */ +// --- parsePeers --- + test("parsePeers extracts exitNode from ExitNodeOption", () => { const peerMap = { "peer-1": { @@ -31,10 +46,44 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => { const peers = parsePeers(peerMap); + assert.strictEqual(peers.length, 2); assert.strictEqual(peers[0].exitNode, true); assert.strictEqual(peers[1].exitNode, false); + assert.strictEqual(peers[0].hostname, "router"); + assert.strictEqual(peers[0].ip, "100.64.0.1"); + assert.strictEqual(peers[0].online, true); }); +test("parsePeers returns empty array for null/undefined peerMap", () => { + assert.deepStrictEqual(parsePeers(null), []); + assert.deepStrictEqual(parsePeers(undefined), []); +}); + +test("parsePeers returns empty array for empty peerMap", () => { + assert.deepStrictEqual(parsePeers({}), []); +}); + +test("parsePeers falls back to key when HostName missing", () => { + const peers = parsePeers({ + "node-key-abc": { TailscaleIPs: ["100.64.0.9"], Online: false } + }); + assert.strictEqual(peers[0].hostname, "node-key-abc"); + assert.strictEqual(peers[0].ip, "100.64.0.9"); + assert.strictEqual(peers[0].online, false); + assert.strictEqual(peers[0].exitNode, false); +}); + +test("parsePeers uses empty ip when TailscaleIPs missing or empty", () => { + const peers = parsePeers({ + a: { HostName: "a" }, + b: { HostName: "b", TailscaleIPs: [] } + }); + assert.strictEqual(peers[0].ip, ""); + assert.strictEqual(peers[1].ip, ""); +}); + +// --- makeExitNodeCommand / hostname validation --- + test("makeExitNodeCommand returns tailscale set command for hostname", () => { const cmd = makeExitNodeCommand("router"); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]); @@ -45,6 +94,49 @@ test("makeExitNodeCommand with empty string clears exit node", () => { assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]); }); +test("makeExitNodeCommand returns null for invalid hostname", () => { + assert.strictEqual(makeExitNodeCommand("; rm"), null); + assert.strictEqual(makeExitNodeCommand("$(whoami)"), null); + assert.strictEqual(makeExitNodeCommand(null), null); + assert.strictEqual(makeExitNodeCommand(42), 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"]); +}); + +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 accepts MagicDNS-style FQDNs under 253 chars", () => { + assert.strictEqual(isValidExitNodeHostname("my-node.tail1234.ts.net"), true); + const longButValid = "a".repeat(60) + "." + "b".repeat(60) + "." + "c".repeat(60); + assert.ok(longButValid.length < 253); + assert.strictEqual(isValidExitNodeHostname(longButValid), true); +}); + +test("isValidExitNodeHostname rejects injection attempts and garbage", () => { + ["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)", " spacy ", "-leading", "trailing-"].forEach((h) => { + assert.strictEqual(isValidExitNodeHostname(h), false, h); + }); +}); + +test("isValidExitNodeHostname rejects non-strings and oversized names", () => { + assert.strictEqual(isValidExitNodeHostname(undefined), false); + assert.strictEqual(isValidExitNodeHostname({}), false); + assert.strictEqual(isValidExitNodeHostname("x".repeat(254)), false); +}); + +// --- findActiveExitNode --- + test("findActiveExitNode returns hostname of peer with ExitNode=true", () => { const peerMap = { "peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true }, @@ -60,31 +152,53 @@ test("findActiveExitNode returns empty string when no exit node", () => { 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("findActiveExitNode returns empty string for null peerMap", () => { + assert.strictEqual(findActiveExitNode(null), ""); }); -test("errorMessage returns user-friendly message for tailscale set failure", () => { - const msg = errorMessage("set", 1); - assert.strictEqual(msg, "Failed to set exit node"); +test("findActiveExitNode falls back to map key when HostName missing", () => { + const peerMap = { + "key-only": { ExitNode: true } + }; + assert.strictEqual(findActiveExitNode(peerMap), "key-only"); }); -test("errorMessage returns user-friendly message for tailscale status failure", () => { - const msg = errorMessage("status", 1); - assert.strictEqual(msg, "Failed to read Tailscale status"); +// --- errorMessage / formatError --- + +test("errorMessage returns user-friendly messages for known actions", () => { + assert.strictEqual(errorMessage("up"), "Failed to connect to Tailscale"); + assert.strictEqual(errorMessage("connect"), "Failed to connect to Tailscale"); + assert.strictEqual(errorMessage("down"), "Failed to disconnect from Tailscale"); + assert.strictEqual(errorMessage("disconnect"), "Failed to disconnect from Tailscale"); + assert.strictEqual(errorMessage("set"), "Failed to set exit node"); + assert.strictEqual(errorMessage("status"), "Failed to read Tailscale status"); + assert.strictEqual(errorMessage("clipboard"), "Error copying to clipboard"); }); test("errorMessage returns generic message for unknown command", () => { - const msg = errorMessage("unknown", 1); - assert.strictEqual(msg, "Tailscale command failed"); + assert.strictEqual(errorMessage("unknown"), "Tailscale command failed"); }); +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 to 120 chars", () => { + 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"); +}); + +// --- clipboard --- + test("getClipboardCommands returns ordered argv arrays with text appended", () => { const cmds = getClipboardCommands("1.2.3.4"); assert.ok(Array.isArray(cmds)); @@ -100,158 +214,172 @@ test("getClipboardCommands handles text with special characters safely (direct a 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])); -}); +// --- strings --- 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) -}) + assert.ok(s.header); + assert.ok(s.connected); + assert.ok(s.disconnected); + assert.ok(s.exitNodePrefix); + assert.ok(s.none); + assert.ok(s.copied); + assert.ok(s.invalidExitNodeHostname); + assert.ok(s.notConnectedHint); +}); -test("getStrings.copied is the I18n template key (interpolation happens at call site via .arg)", () => { +test("getStrings.copied is the I18n template key (interpolation via .arg at call site)", () => { 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) -}) +// --- toggle helpers --- -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) -}) +test("buildToggleCommand returns down when connected", () => { + assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]); +}); -// --- 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 returns up 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"]) -}) + 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 toggle command when pending is TOGGLE and statusOk", () => { + assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]); + assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]); + assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null, true), ["tailscale", "up"]); +}); + +test("commandForPendingAction returns null when status poll failed (do not invent up/down)", () => { + assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, false), null); + assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, true, false), null); + assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, undefined), null); }); 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); + assert.strictEqual(commandForPendingAction(null, true, true), null); + assert.strictEqual(commandForPendingAction(undefined, false, true), null); + assert.strictEqual(commandForPendingAction("something-else", true, true), null); + assert.strictEqual(commandForPendingAction("", true, true), null); }); -test("parseStatusResult produces correct state from valid JSON", () => { +// --- parseStatusResult (#55 and ground-truth parsing) --- + +test("parseStatusResult produces correct state from valid Running 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 } + "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) -}) + }); + 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) -}) + const state = parseStatusResult("not json at all"); + assert.strictEqual(state.isConnected, false); + assert.strictEqual(state.tailscaleIP, ""); + assert.strictEqual(state.currentExitNode, ""); + assert.deepStrictEqual(state.peers, []); +}); -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 Self gracefully when Running", () => { + 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 handles missing and empty Peer gracefully when Running", () => { + 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) -}) + 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) --- +// #55: disconnected must not surface peer list / exit node from leftover JSON +test("parseStatusResult clears peers and exit node when BackendState is not Running (#55)", () => { + const json = JSON.stringify({ + BackendState: "Stopped", + Self: { TailscaleIPs: ["100.64.0.5"] }, + Peer: { + "k1": { + HostName: "router", + TailscaleIPs: ["100.64.0.1"], + Online: false, + ExitNode: true, + ExitNodeOption: true + } + } + }); + const state = parseStatusResult(json); + assert.strictEqual(state.isConnected, false); + assert.deepStrictEqual(state.peers, []); + assert.strictEqual(state.currentExitNode, ""); + // Self IP may still appear in raw JSON; we clear display IP when disconnected + // so the popout does not look "half connected". + assert.strictEqual(state.tailscaleIP, ""); +}); -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"]) -}) +test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => { + const json = JSON.stringify({ + BackendState: "NeedsLogin", + Self: { TailscaleIPs: ["100.64.0.5"] }, + Peer: { + "k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false } + } + }); + const state = parseStatusResult(json); + assert.strictEqual(state.isConnected, false); + assert.deepStrictEqual(state.peers, []); + assert.strictEqual(state.currentExitNode, ""); + assert.strictEqual(state.tailscaleIP, ""); +}); // --- getStatusCommand --- test("getStatusCommand returns the canonical tailscale status --json argv", () => { - const cmd = getStatusCommand() - assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]) -}) + const cmd = getStatusCommand(); + assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]); +}); + +// --- PendingAction constant --- + +test("PendingAction.TOGGLE is the stable string used by the widget", () => { + assert.strictEqual(PendingAction.TOGGLE, "toggle"); +}); + +// --- export surface: no over-abstracted UI predicates --- + +test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => { + assert.strictEqual(lib.shouldShowClearExitNode, undefined); + assert.strictEqual(lib.isActiveExitNode, undefined); +});