2026-05-23 04:57:46 +00:00
|
|
|
import { test } from "node:test";
|
|
|
|
|
import assert from "node:assert";
|
|
|
|
|
import lib from "../tailscalectl/lib.js";
|
2026-07-15 01:11:59 -07:00
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
parsePeers,
|
|
|
|
|
makeExitNodeCommand,
|
|
|
|
|
findActiveExitNode,
|
|
|
|
|
errorMessage,
|
|
|
|
|
formatError,
|
|
|
|
|
getStatusCommand,
|
|
|
|
|
isValidExitNodeHostname,
|
|
|
|
|
getClipboardCommands,
|
|
|
|
|
buildToggleCommand,
|
|
|
|
|
parseStatusResult,
|
|
|
|
|
getStrings,
|
|
|
|
|
PendingAction,
|
|
|
|
|
commandForPendingAction
|
|
|
|
|
} = lib;
|
2026-05-18 18:27:41 +00:00
|
|
|
|
2026-05-22 06:33:40 +00:00
|
|
|
/*
|
2026-07-15 01:11:59 -07:00
|
|
|
* Unit tests for pure functions in lib.js.
|
2026-05-22 21:45:13 +00:00
|
|
|
*
|
2026-07-15 01:11:59 -07:00
|
|
|
* 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.
|
2026-05-22 06:33:40 +00:00
|
|
|
*/
|
|
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- parsePeers ---
|
|
|
|
|
|
2026-05-18 18:27:41 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
2026-05-18 18:27:41 +00:00
|
|
|
|
2026-05-23 04:57:46 +00:00
|
|
|
const peers = parsePeers(peerMap);
|
2026-05-18 18:27:41 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
assert.strictEqual(peers.length, 2);
|
2026-05-23 04:57:46 +00:00
|
|
|
assert.strictEqual(peers[0].exitNode, true);
|
|
|
|
|
assert.strictEqual(peers[1].exitNode, false);
|
2026-07-15 01:11:59 -07:00
|
|
|
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);
|
2026-05-23 04:57:46 +00:00
|
|
|
});
|
2026-05-18 18:27:41 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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 ---
|
|
|
|
|
|
2026-05-18 18:27:41 +00:00
|
|
|
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
2026-05-23 04:57:46 +00:00
|
|
|
const cmd = makeExitNodeCommand("router");
|
|
|
|
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]);
|
|
|
|
|
});
|
2026-05-18 18:27:41 +00:00
|
|
|
|
|
|
|
|
test("makeExitNodeCommand with empty string clears exit node", () => {
|
2026-05-23 04:57:46 +00:00
|
|
|
const cmd = makeExitNodeCommand("");
|
|
|
|
|
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]);
|
|
|
|
|
});
|
2026-05-18 20:53:32 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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 ---
|
|
|
|
|
|
2026-05-18 22:10:37 +00:00
|
|
|
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 }
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
|
|
|
|
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc");
|
|
|
|
|
});
|
2026-05-18 22:10:37 +00:00
|
|
|
|
|
|
|
|
test("findActiveExitNode returns empty string when no exit node", () => {
|
|
|
|
|
const peerMap = {
|
|
|
|
|
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
|
|
|
|
assert.strictEqual(findActiveExitNode(peerMap), "");
|
|
|
|
|
});
|
2026-05-18 22:10:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("findActiveExitNode returns empty string for null peerMap", () => {
|
|
|
|
|
assert.strictEqual(findActiveExitNode(null), "");
|
2026-05-23 04:57:46 +00:00
|
|
|
});
|
2026-05-18 20:53:32 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("findActiveExitNode falls back to map key when HostName missing", () => {
|
|
|
|
|
const peerMap = {
|
|
|
|
|
"key-only": { ExitNode: true }
|
|
|
|
|
};
|
|
|
|
|
assert.strictEqual(findActiveExitNode(peerMap), "key-only");
|
2026-05-23 04:57:46 +00:00
|
|
|
});
|
2026-05-18 20:53:32 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- 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");
|
2026-05-23 04:57:46 +00:00
|
|
|
});
|
2026-05-18 20:53:32 +00:00
|
|
|
|
|
|
|
|
test("errorMessage returns generic message for unknown command", () => {
|
2026-07-15 01:11:59 -07:00
|
|
|
assert.strictEqual(errorMessage("unknown"), "Tailscale command failed");
|
2026-05-23 04:57:46 +00:00
|
|
|
});
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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 ---
|
|
|
|
|
|
2026-05-22 21:35:07 +00:00
|
|
|
test("getClipboardCommands returns ordered argv arrays with text appended", () => {
|
2026-05-23 04:57:46 +00:00
|
|
|
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"]);
|
|
|
|
|
});
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-05-22 21:35:07 +00:00
|
|
|
test("getClipboardCommands handles text with special characters safely (direct argv)", () => {
|
2026-05-23 04:57:46 +00:00
|
|
|
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"));
|
|
|
|
|
});
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- strings ---
|
2026-05-22 06:33:08 +00:00
|
|
|
|
|
|
|
|
test("getStrings returns canonical UI strings for the widget", () => {
|
2026-05-23 04:57:46 +00:00
|
|
|
const s = getStrings();
|
2026-07-15 01:11:59 -07:00
|
|
|
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 via .arg at call site)", () => {
|
2026-05-24 20:57:14 +00:00
|
|
|
const s = getStrings();
|
|
|
|
|
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
2026-07-15 01:11:59 -07:00
|
|
|
});
|
2026-05-22 06:33:33 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- toggle helpers ---
|
2026-05-20 19:21:17 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("buildToggleCommand returns down when connected", () => {
|
|
|
|
|
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]);
|
|
|
|
|
});
|
2026-05-20 19:21:17 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("buildToggleCommand returns up when disconnected", () => {
|
|
|
|
|
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]);
|
|
|
|
|
});
|
2026-05-20 19:21:17 +00:00
|
|
|
|
|
|
|
|
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
2026-07-15 01:11:59 -07:00
|
|
|
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]);
|
|
|
|
|
assert.deepStrictEqual(buildToggleCommand(undefined), ["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"]);
|
|
|
|
|
});
|
2026-05-20 19:29:02 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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);
|
2026-05-23 04:41:40 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
|
2026-07-15 01:11:59 -07:00
|
|
|
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);
|
2026-05-23 04:41:40 +00:00
|
|
|
});
|
2026-05-20 19:29:02 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- parseStatusResult (#55 and ground-truth parsing) ---
|
|
|
|
|
|
|
|
|
|
test("parseStatusResult produces correct state from valid Running JSON", () => {
|
2026-05-20 19:29:02 +00:00
|
|
|
const json = JSON.stringify({
|
|
|
|
|
BackendState: "Running",
|
|
|
|
|
Self: { TailscaleIPs: ["100.64.0.5"] },
|
|
|
|
|
Peer: {
|
2026-07-15 01:11:59 -07:00
|
|
|
"key-1": {
|
|
|
|
|
HostName: "router",
|
|
|
|
|
TailscaleIPs: ["100.64.0.1"],
|
|
|
|
|
Online: true,
|
|
|
|
|
ExitNode: true,
|
|
|
|
|
ExitNodeOption: true
|
|
|
|
|
}
|
2026-05-20 19:29:02 +00:00
|
|
|
}
|
2026-07-15 01:11:59 -07:00
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-05-20 19:29:02 +00:00
|
|
|
|
|
|
|
|
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
2026-07-15 01:11:59 -07:00
|
|
|
const state = parseStatusResult("not json at all");
|
|
|
|
|
assert.strictEqual(state.isConnected, false);
|
|
|
|
|
assert.strictEqual(state.tailscaleIP, "");
|
|
|
|
|
assert.strictEqual(state.currentExitNode, "");
|
|
|
|
|
assert.deepStrictEqual(state.peers, []);
|
|
|
|
|
});
|
2026-05-22 10:19:25 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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, "");
|
|
|
|
|
});
|
2026-05-22 10:19:25 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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, "");
|
|
|
|
|
});
|
2026-05-22 10:19:25 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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);
|
|
|
|
|
});
|
2026-05-22 10:19:39 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// #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, "");
|
|
|
|
|
});
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
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, "");
|
|
|
|
|
});
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- getStatusCommand ---
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
|
|
|
|
const cmd = getStatusCommand();
|
|
|
|
|
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]);
|
|
|
|
|
});
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- PendingAction constant ---
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("PendingAction.TOGGLE is the stable string used by the widget", () => {
|
|
|
|
|
assert.strictEqual(PendingAction.TOGGLE, "toggle");
|
|
|
|
|
});
|
2026-05-22 20:46:37 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// --- export surface: no over-abstracted UI predicates ---
|
2026-05-22 10:19:39 +00:00
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => {
|
|
|
|
|
assert.strictEqual(lib.shouldShowClearExitNode, undefined);
|
|
|
|
|
assert.strictEqual(lib.isActiveExitNode, undefined);
|
|
|
|
|
});
|
2026-07-15 01:31:46 -07:00
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
// --- on-demand public egress check (#54) ---
|
2026-07-15 01:31:46 -07:00
|
|
|
|
|
|
|
|
test("hostFromEndpoint strips port from IPv4 endpoint", () => {
|
|
|
|
|
assert.strictEqual(lib.hostFromEndpoint("76.87.221.174:41641"), "76.87.221.174");
|
|
|
|
|
assert.strictEqual(lib.hostFromEndpoint("10.0.3.103:41641"), "10.0.3.103");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("hostFromEndpoint handles bare IP and bracketed IPv6", () => {
|
|
|
|
|
assert.strictEqual(lib.hostFromEndpoint("8.8.8.8"), "8.8.8.8");
|
|
|
|
|
assert.strictEqual(lib.hostFromEndpoint("[2001:db8::1]:41641"), "2001:db8::1");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("isPublicIPv4 accepts global unicast and rejects private/CGNAT/loopback", () => {
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("76.87.221.174"), true);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("8.8.8.8"), true);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("10.0.3.103"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("192.168.1.1"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("172.17.0.1"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("127.0.0.1"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("100.64.0.5"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("100.120.126.20"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4("not-an-ip"), false);
|
|
|
|
|
assert.strictEqual(lib.isPublicIPv4(""), false);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("getEgressCheckCommands returns direct curl argv lists (no shell)", () => {
|
|
|
|
|
const cmds = lib.getEgressCheckCommands();
|
|
|
|
|
assert.ok(Array.isArray(cmds));
|
|
|
|
|
assert.ok(cmds.length >= 2);
|
|
|
|
|
cmds.forEach((cmd) => {
|
|
|
|
|
assert.strictEqual(cmd[0], "curl");
|
|
|
|
|
assert.ok(cmd.includes("-4"));
|
|
|
|
|
assert.ok(cmd.includes("-sS") || cmd.includes("-s"));
|
|
|
|
|
assert.ok(cmd.some((a) => String(a).startsWith("https://")));
|
|
|
|
|
});
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("parseEgressCheckResponse accepts trimmed public IPv4", () => {
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse("76.87.221.174\n"), "76.87.221.174");
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse(" 8.8.8.8 "), "8.8.8.8");
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("parseEgressCheckResponse rejects garbage private and empty", () => {
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse(""), "");
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse("not an ip"), "");
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse("10.0.0.1"), "");
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse("100.64.0.1"), "");
|
|
|
|
|
assert.strictEqual(lib.parseEgressCheckResponse(null), "");
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("parseStatusResult does not auto-fill publicIP (lazy egress is separate)", () => {
|
2026-07-15 01:31:46 -07:00
|
|
|
const json = JSON.stringify({
|
|
|
|
|
BackendState: "Running",
|
|
|
|
|
Self: {
|
|
|
|
|
TailscaleIPs: ["100.64.0.5"],
|
2026-07-15 01:35:53 -07:00
|
|
|
Addrs: ["203.0.113.10:41641"]
|
2026-07-15 01:31:46 -07:00
|
|
|
},
|
2026-07-15 01:35:53 -07:00
|
|
|
Peer: {}
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
const state = lib.parseStatusResult(json);
|
2026-07-15 01:35:53 -07:00
|
|
|
assert.strictEqual(state.isConnected, true);
|
|
|
|
|
assert.strictEqual(state.tailscaleIP, "100.64.0.5");
|
|
|
|
|
assert.strictEqual(state.publicIP, undefined);
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("getStrings includes public IP lazy-load strings", () => {
|
|
|
|
|
const s = lib.getStrings();
|
|
|
|
|
assert.ok(s.publicIPPrefix);
|
|
|
|
|
assert.ok(s.publicIPTapHint);
|
|
|
|
|
assert.ok(s.publicIPLoading);
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:35:53 -07:00
|
|
|
test("errorMessage includes egress failure", () => {
|
|
|
|
|
assert.strictEqual(lib.errorMessage("egress"), "Failed to look up public IP");
|
2026-07-15 01:31:46 -07:00
|
|
|
});
|