2026-05-18 18:27:41 +00:00
|
|
|
import { test } from "node:test"
|
|
|
|
|
import assert from "node:assert"
|
2026-05-18 18:39:39 +00:00
|
|
|
import lib from "../tailscalectl/lib.js"
|
2026-05-21 06:37:14 +00:00
|
|
|
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult } = lib
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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="])
|
|
|
|
|
})
|
2026-05-18 20:53:32 +00:00
|
|
|
|
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 }
|
|
|
|
|
}
|
|
|
|
|
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), "")
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-18 20:53:32 +00:00
|
|
|
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")
|
|
|
|
|
})
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
// --- validateClipboardTool ---
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("validateClipboardTool accepts whitelisted tool names", () => {
|
|
|
|
|
assert.strictEqual(validateClipboardTool("dms"), true)
|
|
|
|
|
assert.strictEqual(validateClipboardTool("wl-copy"), true)
|
|
|
|
|
assert.strictEqual(validateClipboardTool("clipmanctl"), true)
|
2026-05-20 10:08:59 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("validateClipboardTool rejects malicious inputs", () => {
|
|
|
|
|
assert.strictEqual(validateClipboardTool("rm -rf /"), false)
|
|
|
|
|
assert.strictEqual(validateClipboardTool("echo hi; malicious"), false)
|
|
|
|
|
assert.strictEqual(validateClipboardTool("$(whoami)"), false)
|
|
|
|
|
assert.strictEqual(validateClipboardTool("wl-copy || rm -rf /"), false)
|
2026-05-20 10:08:59 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("validateClipboardTool rejects empty and falsy inputs", () => {
|
|
|
|
|
assert.strictEqual(validateClipboardTool(""), false)
|
|
|
|
|
assert.strictEqual(validateClipboardTool(null), false)
|
|
|
|
|
assert.strictEqual(validateClipboardTool(undefined), false)
|
2026-05-20 10:08:59 +00:00
|
|
|
})
|
2026-05-20 10:11:57 +00:00
|
|
|
|
|
|
|
|
// --- buildCopyCommand ---
|
|
|
|
|
|
|
|
|
|
test("buildCopyCommand returns safe command for whitelisted clipboard tool", () => {
|
|
|
|
|
const cmd = buildCopyCommand("hello", "wl-copy")
|
|
|
|
|
assert.ok(Array.isArray(cmd))
|
|
|
|
|
assert.strictEqual(cmd[0], "sh")
|
|
|
|
|
assert.strictEqual(cmd[1], "-c")
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("buildCopyCommand returns null for invalid clipboard tool", () => {
|
2026-05-20 10:11:57 +00:00
|
|
|
assert.strictEqual(buildCopyCommand("hello", "rm -rf /"), null)
|
|
|
|
|
assert.strictEqual(buildCopyCommand("hello", ""), null)
|
|
|
|
|
assert.strictEqual(buildCopyCommand("hello", null), null)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("buildCopyCommand safely handles text with special characters", () => {
|
|
|
|
|
const cmd = buildCopyCommand("it's a 'test' with \"quotes\" and\nnewlines", "wl-copy")
|
|
|
|
|
assert.ok(Array.isArray(cmd))
|
|
|
|
|
})
|
2026-05-20 10:16:02 +00:00
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
// --- nextClipboardTool ---
|
2026-05-20 10:16:02 +00:00
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("nextClipboardTool cycles through tools", () => {
|
|
|
|
|
assert.strictEqual(nextClipboardTool("dms"), "wl-copy")
|
|
|
|
|
assert.strictEqual(nextClipboardTool("wl-copy"), "clipmanctl")
|
|
|
|
|
assert.strictEqual(nextClipboardTool("clipmanctl"), "dms")
|
2026-05-20 10:16:02 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("nextClipboardTool falls back to first tool for unknown input", () => {
|
|
|
|
|
assert.strictEqual(nextClipboardTool(""), "dms")
|
|
|
|
|
assert.strictEqual(nextClipboardTool("unknown"), "dms")
|
2026-05-20 10:16:02 +00:00
|
|
|
})
|
|
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
// --- allClipboardTools ---
|
2026-05-20 10:16:02 +00:00
|
|
|
|
2026-05-21 06:37:14 +00:00
|
|
|
test("allClipboardTools returns the list of safe tools", () => {
|
|
|
|
|
const tools = allClipboardTools()
|
|
|
|
|
assert.ok(Array.isArray(tools))
|
|
|
|
|
assert.strictEqual(tools.length, 3)
|
|
|
|
|
assert.ok(tools.includes("dms"))
|
|
|
|
|
assert.ok(tools.includes("wl-copy"))
|
|
|
|
|
assert.ok(tools.includes("clipmanctl"))
|
2026-05-20 10:16:02 +00:00
|
|
|
})
|
2026-05-20 19:21:17 +00:00
|
|
|
|
|
|
|
|
// --- 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"])
|
|
|
|
|
})
|
2026-05-20 19:29:02 +00:00
|
|
|
|
|
|
|
|
// --- parseStatusResult ---
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|