fix: clear disconnected peer state; abort toggle on failed status poll
- parseStatusResult returns empty peers/IP/exit node when BackendState is not Running so the UI cannot show a stale node list (#55) - commandForPendingAction requires statusOk; never invents up/down from a failed truth poll - Relax hostname validation for multi-label MagicDNS names (max 253) - Drop trivial shouldShowClearExitNode / isActiveExitNode exports - Expand unit tests for the above and export surface Written by AI agent working for @jtmorris. Model: Grok 4.5.
This commit is contained in:
parent
00a40a9aa2
commit
61626dff51
2 changed files with 327 additions and 167 deletions
|
|
@ -1,7 +1,8 @@
|
||||||
function parsePeers(peerMap) {
|
function parsePeers(peerMap) {
|
||||||
if (!peerMap) { return []; }
|
if (!peerMap) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Object.keys(peerMap).map(function (key) {
|
return Object.keys(peerMap).map(function (key) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; }
|
|
||||||
var p = peerMap[key];
|
var p = peerMap[key];
|
||||||
return {
|
return {
|
||||||
hostname: p.HostName || key,
|
hostname: p.HostName || key,
|
||||||
|
|
@ -9,7 +10,7 @@ function parsePeers(peerMap) {
|
||||||
online: p.Online || false,
|
online: p.Online || false,
|
||||||
exitNode: p.ExitNodeOption || false
|
exitNode: p.ExitNodeOption || false
|
||||||
};
|
};
|
||||||
}).filter(function (peer) { return peer !== null; });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeExitNodeCommand(hostname) {
|
function makeExitNodeCommand(hostname) {
|
||||||
|
|
@ -23,7 +24,9 @@ function makeExitNodeCommand(hostname) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function findActiveExitNode(peerMap) {
|
function findActiveExitNode(peerMap) {
|
||||||
if (!peerMap) { return ""; }
|
if (!peerMap) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
for (const key of Object.keys(peerMap)) {
|
for (const key of Object.keys(peerMap)) {
|
||||||
const p = peerMap[key];
|
const p = peerMap[key];
|
||||||
if (p.ExitNode) {
|
if (p.ExitNode) {
|
||||||
|
|
@ -52,32 +55,43 @@ function getStrings() {
|
||||||
exitNodePrefix: "Exit node: ",
|
exitNodePrefix: "Exit node: ",
|
||||||
none: "None",
|
none: "None",
|
||||||
copied: "Copied %1 to clipboard",
|
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.
|
// 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) {
|
function isValidExitNodeHostname(hostname) {
|
||||||
if (typeof hostname !== "string") { return false; }
|
if (typeof hostname !== "string") {
|
||||||
if (hostname === "") { return true; }
|
return false;
|
||||||
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
|
}
|
||||||
|
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) {
|
function parseStatusResult(jsonText) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonText);
|
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 {
|
return {
|
||||||
isConnected: data.BackendState === "Running",
|
isConnected: false,
|
||||||
|
tailscaleIP: "",
|
||||||
|
currentExitNode: "",
|
||||||
|
peers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isConnected: true,
|
||||||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||||
peers: parsePeers(data.Peer || {})
|
peers: parsePeers(data.Peer || {})
|
||||||
|
|
@ -109,8 +123,7 @@ function errorMessage(cmd) {
|
||||||
return messages[cmd] || "Tailscale command failed";
|
return messages[cmd] || "Tailscale command failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central error formatting for the widget. Used by both success and error paths.
|
// Central error formatting for the widget. detail is optional truncated stderr or extra context.
|
||||||
// detail is optional truncated stderr or extra context.
|
|
||||||
function formatError(action, detail) {
|
function formatError(action, detail) {
|
||||||
var base = errorMessage(action);
|
var base = errorMessage(action);
|
||||||
if (detail && detail.length > 0) {
|
if (detail && detail.length > 0) {
|
||||||
|
|
@ -124,7 +137,12 @@ const PendingAction = Object.freeze({
|
||||||
TOGGLE: "toggle"
|
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) {
|
if (pending === PendingAction.TOGGLE) {
|
||||||
return buildToggleCommand(freshIsConnected);
|
return buildToggleCommand(freshIsConnected);
|
||||||
}
|
}
|
||||||
|
|
@ -132,5 +150,19 @@ function commandForPendingAction(pending, freshIsConnected) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== "undefined" && module.exports) {
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
416
test/lib.test.js
416
test/lib.test.js
|
|
@ -1,18 +1,33 @@
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import lib from "../tailscalectl/lib.js";
|
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. Proc.runCommand
|
||||||
*
|
* coordination, busy-mutex behavior, and widget UI must be verified
|
||||||
* TailscaleWidget.qml has no automated test coverage. The Proc.runCommand
|
* manually in a running DMS instance.
|
||||||
* calls, callback-based coordination (exact poll-act-poll preserved), and all
|
|
||||||
* widget UI behavior must be verified manually in a running DMS instance.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// --- parsePeers ---
|
||||||
|
|
||||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
"peer-1": {
|
"peer-1": {
|
||||||
|
|
@ -31,10 +46,44 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||||
|
|
||||||
const peers = parsePeers(peerMap);
|
const peers = parsePeers(peerMap);
|
||||||
|
|
||||||
|
assert.strictEqual(peers.length, 2);
|
||||||
assert.strictEqual(peers[0].exitNode, true);
|
assert.strictEqual(peers[0].exitNode, true);
|
||||||
assert.strictEqual(peers[1].exitNode, false);
|
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", () => {
|
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
||||||
const cmd = makeExitNodeCommand("router");
|
const cmd = makeExitNodeCommand("router");
|
||||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=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="]);
|
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", () => {
|
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
|
||||||
const peerMap = {
|
const peerMap = {
|
||||||
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
|
"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), "");
|
assert.strictEqual(findActiveExitNode(peerMap), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale up failure", () => {
|
test("findActiveExitNode returns empty string for null peerMap", () => {
|
||||||
const msg = errorMessage("up", 1);
|
assert.strictEqual(findActiveExitNode(null), "");
|
||||||
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", () => {
|
test("findActiveExitNode falls back to map key when HostName missing", () => {
|
||||||
const msg = errorMessage("set", 1);
|
const peerMap = {
|
||||||
assert.strictEqual(msg, "Failed to set exit node");
|
"key-only": { ExitNode: true }
|
||||||
|
};
|
||||||
|
assert.strictEqual(findActiveExitNode(peerMap), "key-only");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("errorMessage returns user-friendly message for tailscale status failure", () => {
|
// --- errorMessage / formatError ---
|
||||||
const msg = errorMessage("status", 1);
|
|
||||||
assert.strictEqual(msg, "Failed to read Tailscale status");
|
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", () => {
|
test("errorMessage returns generic message for unknown command", () => {
|
||||||
const msg = errorMessage("unknown", 1);
|
assert.strictEqual(errorMessage("unknown"), "Tailscale command failed");
|
||||||
assert.strictEqual(msg, "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", () => {
|
test("getClipboardCommands returns ordered argv arrays with text appended", () => {
|
||||||
const cmds = getClipboardCommands("1.2.3.4");
|
const cmds = getClipboardCommands("1.2.3.4");
|
||||||
assert.ok(Array.isArray(cmds));
|
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"));
|
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getClipboardCommands is deterministic and open for future tools", () => {
|
// --- strings ---
|
||||||
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", () => {
|
test("getStrings returns canonical UI strings for the widget", () => {
|
||||||
const s = getStrings();
|
const s = getStrings();
|
||||||
assert.ok(s.header)
|
assert.ok(s.header);
|
||||||
assert.ok(s.connected)
|
assert.ok(s.connected);
|
||||||
assert.ok(s.disconnected)
|
assert.ok(s.disconnected);
|
||||||
assert.ok(s.exitNodePrefix)
|
assert.ok(s.exitNodePrefix);
|
||||||
assert.ok(s.copied)
|
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();
|
const s = getStrings();
|
||||||
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
||||||
})
|
});
|
||||||
|
|
||||||
test("shouldShowClearExitNode returns true only when there is a current exit node", () => {
|
// --- toggle helpers ---
|
||||||
assert.strictEqual(shouldShowClearExitNode("router"), true)
|
|
||||||
assert.strictEqual(shouldShowClearExitNode(""), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("isActiveExitNode correctly identifies the active exit node button", () => {
|
test("buildToggleCommand returns down when connected", () => {
|
||||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-sjc"), true)
|
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]);
|
||||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-den"), false)
|
});
|
||||||
assert.strictEqual(isActiveExitNode("", "router"), false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- buildToggleCommand ---
|
test("buildToggleCommand returns up when disconnected", () => {
|
||||||
|
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]);
|
||||||
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", () => {
|
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
||||||
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
|
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]);
|
||||||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
|
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]);
|
||||||
})
|
});
|
||||||
|
|
||||||
test("commandForPendingAction returns toggle command when pending is TOGGLE and passes through buildToggleCommand logic", () => {
|
test("commandForPendingAction returns toggle command when pending is TOGGLE and statusOk", () => {
|
||||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true), ["tailscale", "down"]);
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]);
|
||||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false), ["tailscale", "up"]);
|
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]);
|
||||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null), ["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", () => {
|
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
|
||||||
assert.strictEqual(commandForPendingAction(null, true), null);
|
assert.strictEqual(commandForPendingAction(null, true, true), null);
|
||||||
assert.strictEqual(commandForPendingAction(undefined, false), null);
|
assert.strictEqual(commandForPendingAction(undefined, false, true), null);
|
||||||
assert.strictEqual(commandForPendingAction("something-else", 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({
|
const json = JSON.stringify({
|
||||||
BackendState: "Running",
|
BackendState: "Running",
|
||||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
Peer: {
|
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)
|
const state = parseStatusResult(json);
|
||||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5")
|
assert.strictEqual(state.isConnected, true);
|
||||||
assert.strictEqual(state.currentExitNode, "router")
|
assert.strictEqual(state.tailscaleIP, "100.64.0.5");
|
||||||
assert.strictEqual(state.peers.length, 1)
|
assert.strictEqual(state.currentExitNode, "router");
|
||||||
})
|
assert.strictEqual(state.peers.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
||||||
const state = parseStatusResult("not json at all")
|
const state = parseStatusResult("not json at all");
|
||||||
assert.strictEqual(state.isConnected, false)
|
assert.strictEqual(state.isConnected, false);
|
||||||
assert.strictEqual(state.tailscaleIP, "")
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
assert.strictEqual(state.currentExitNode, "")
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
assert.strictEqual(state.peers.length, 0)
|
assert.deepStrictEqual(state.peers, []);
|
||||||
})
|
});
|
||||||
|
|
||||||
test("parseStatusResult handles missing Self gracefully", () => {
|
test("parseStatusResult handles missing Self gracefully when Running", () => {
|
||||||
const json = JSON.stringify({ BackendState: "Running", Peer: {} })
|
const json = JSON.stringify({ BackendState: "Running", Peer: {} });
|
||||||
const state = parseStatusResult(json)
|
const state = parseStatusResult(json);
|
||||||
assert.strictEqual(state.isConnected, true)
|
assert.strictEqual(state.isConnected, true);
|
||||||
assert.strictEqual(state.tailscaleIP, "")
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
})
|
});
|
||||||
|
|
||||||
test("parseStatusResult handles missing and empty Peer gracefully", () => {
|
test("parseStatusResult handles missing and empty Peer gracefully when Running", () => {
|
||||||
const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
|
const json = JSON.stringify({
|
||||||
const state = parseStatusResult(json)
|
BackendState: "Running",
|
||||||
assert.strictEqual(state.peers.length, 0)
|
Self: { TailscaleIPs: ["100.64.0.5"] }
|
||||||
assert.strictEqual(state.currentExitNode, "")
|
});
|
||||||
})
|
const state = parseStatusResult(json);
|
||||||
|
assert.strictEqual(state.peers.length, 0);
|
||||||
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
|
});
|
||||||
|
|
||||||
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
||||||
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
|
const json = JSON.stringify({
|
||||||
const state = parseStatusResult(json)
|
BackendState: "NeedsLogin",
|
||||||
assert.strictEqual(state.isConnected, false)
|
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", () => {
|
test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => {
|
||||||
assert.strictEqual(formatError("status"), "Failed to read Tailscale status")
|
const json = JSON.stringify({
|
||||||
assert.strictEqual(formatError("set"), "Failed to set exit node")
|
BackendState: "NeedsLogin",
|
||||||
})
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||||
|
Peer: {
|
||||||
test("formatError appends and truncates detail", () => {
|
"k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false }
|
||||||
const longDetail = "x".repeat(200)
|
}
|
||||||
const msg = formatError("up", longDetail)
|
});
|
||||||
assert.ok(msg.includes("Failed to connect to Tailscale"))
|
const state = parseStatusResult(json);
|
||||||
assert.ok(msg.endsWith("x".repeat(120)))
|
assert.strictEqual(state.isConnected, false);
|
||||||
assert.ok(msg.length < 200)
|
assert.deepStrictEqual(state.peers, []);
|
||||||
})
|
assert.strictEqual(state.currentExitNode, "");
|
||||||
|
assert.strictEqual(state.tailscaleIP, "");
|
||||||
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 ---
|
// --- getStatusCommand ---
|
||||||
|
|
||||||
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
||||||
const cmd = getStatusCommand()
|
const cmd = getStatusCommand();
|
||||||
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"])
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue