254 lines
8.5 KiB
JavaScript
254 lines
8.5 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
|
|
function loadInternals() {
|
|
const scriptPath = path.join(__dirname, "nordvpn-client.js");
|
|
const source = fs.readFileSync(scriptPath, "utf8").replace(/\nmain\(\);\s*$/, "\n");
|
|
const wrapped = `${source}
|
|
module.exports = {
|
|
buildMacTailscaleState:
|
|
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
|
buildWireguardConfig:
|
|
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
|
buildLookupResult:
|
|
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
|
cleanupMacWireguardState:
|
|
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
|
getMacTailscalePath:
|
|
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
|
isMacTailscaleActive:
|
|
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
|
normalizeSuccessfulConnectState:
|
|
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
|
shouldAttemptMacWireguardDisconnect:
|
|
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
|
detectMacWireguardActiveFromIfconfig:
|
|
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
|
resolveHostnameWithFallback:
|
|
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
|
verifyConnectionWithRetry:
|
|
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
|
|
};`;
|
|
|
|
const sandbox = {
|
|
require,
|
|
module: { exports: {} },
|
|
exports: {},
|
|
__dirname,
|
|
__filename: scriptPath,
|
|
process: { ...process, exit() {} },
|
|
console,
|
|
setTimeout,
|
|
clearTimeout,
|
|
Buffer,
|
|
};
|
|
|
|
vm.runInNewContext(wrapped, sandbox, { filename: scriptPath });
|
|
return sandbox.module.exports;
|
|
}
|
|
|
|
test("detectMacWireguardActiveFromIfconfig detects nordvpn utun client address", () => {
|
|
const { detectMacWireguardActiveFromIfconfig } = loadInternals();
|
|
assert.equal(typeof detectMacWireguardActiveFromIfconfig, "function");
|
|
|
|
const ifconfig = `
|
|
utun8: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1380
|
|
utun9: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1420
|
|
\tinet 10.5.0.2 --> 10.5.0.2 netmask 0xff000000
|
|
`;
|
|
|
|
assert.equal(detectMacWireguardActiveFromIfconfig(ifconfig), true);
|
|
assert.equal(detectMacWireguardActiveFromIfconfig("utun7: flags=8051\n\tinet 100.64.0.4"), false);
|
|
});
|
|
|
|
test("buildLookupResult supports lookup all=true mode", () => {
|
|
const { buildLookupResult } = loadInternals();
|
|
assert.equal(typeof buildLookupResult, "function");
|
|
assert.equal(
|
|
JSON.stringify(buildLookupResult("104.26.9.44", { all: true })),
|
|
JSON.stringify([{ address: "104.26.9.44", family: 4 }])
|
|
);
|
|
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
|
|
});
|
|
|
|
test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => {
|
|
const { buildWireguardConfig } = loadInternals();
|
|
assert.equal(typeof buildWireguardConfig, "function");
|
|
|
|
const config = buildWireguardConfig(
|
|
{
|
|
hostname: "tr73.nordvpn.com",
|
|
ips: [{ ip: { version: 4, ip: "45.89.52.1" } }],
|
|
technologies: [{ identifier: "wireguard_udp", metadata: [{ name: "public_key", value: "PUBKEY" }] }],
|
|
},
|
|
"PRIVATEKEY"
|
|
);
|
|
|
|
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true);
|
|
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
|
});
|
|
|
|
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
|
const { getMacTailscalePath } = loadInternals();
|
|
assert.equal(typeof getMacTailscalePath, "function");
|
|
assert.equal(
|
|
getMacTailscalePath({
|
|
commandExists: () => "",
|
|
fileExists: (target) => target === "/opt/homebrew/bin/tailscale",
|
|
}),
|
|
"/opt/homebrew/bin/tailscale"
|
|
);
|
|
});
|
|
|
|
test("buildMacTailscaleState records whether tailscale was active", () => {
|
|
const { buildMacTailscaleState } = loadInternals();
|
|
assert.equal(typeof buildMacTailscaleState, "function");
|
|
assert.equal(
|
|
JSON.stringify(buildMacTailscaleState(true)),
|
|
JSON.stringify({
|
|
tailscaleWasActive: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
|
const { cleanupMacWireguardState } = loadInternals();
|
|
assert.equal(typeof cleanupMacWireguardState, "function");
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
|
|
const configPath = path.join(tmpDir, "nordvpnctl.conf");
|
|
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
|
|
fs.writeFileSync(configPath, "wireguard-config");
|
|
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
|
|
|
|
const result = cleanupMacWireguardState({
|
|
configPath,
|
|
lastConnectionPath,
|
|
});
|
|
|
|
assert.equal(result.cleaned, true);
|
|
assert.equal(fs.existsSync(configPath), false);
|
|
assert.equal(fs.existsSync(lastConnectionPath), false);
|
|
});
|
|
|
|
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
|
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
|
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
|
|
|
assert.equal(
|
|
shouldAttemptMacWireguardDisconnect({
|
|
active: false,
|
|
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
|
|
endpoint: null,
|
|
lastConnection: null,
|
|
}),
|
|
true
|
|
);
|
|
|
|
assert.equal(
|
|
shouldAttemptMacWireguardDisconnect({
|
|
active: false,
|
|
configPath: null,
|
|
endpoint: null,
|
|
lastConnection: { country: "Italy" },
|
|
}),
|
|
true
|
|
);
|
|
|
|
assert.equal(
|
|
shouldAttemptMacWireguardDisconnect({
|
|
active: false,
|
|
configPath: null,
|
|
endpoint: null,
|
|
lastConnection: null,
|
|
}),
|
|
false
|
|
);
|
|
});
|
|
|
|
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
|
const { normalizeSuccessfulConnectState } = loadInternals();
|
|
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
|
|
|
const state = normalizeSuccessfulConnectState(
|
|
{
|
|
platform: "darwin",
|
|
controlMode: "wireguard",
|
|
connected: false,
|
|
wireguard: {
|
|
active: false,
|
|
endpoint: null,
|
|
},
|
|
},
|
|
{
|
|
backend: "wireguard",
|
|
server: {
|
|
hostname: "de1227.nordvpn.com",
|
|
},
|
|
},
|
|
{
|
|
ok: true,
|
|
ipInfo: {
|
|
country: "Germany",
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(state.connected, true);
|
|
assert.equal(state.wireguard.active, true);
|
|
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
|
});
|
|
|
|
test("isMacTailscaleActive treats Running backend as active", () => {
|
|
const { isMacTailscaleActive } = loadInternals();
|
|
assert.equal(typeof isMacTailscaleActive, "function");
|
|
assert.equal(isMacTailscaleActive({ BackendState: "Running" }), true);
|
|
assert.equal(isMacTailscaleActive({ BackendState: "Stopped" }), false);
|
|
});
|
|
|
|
test("verifyConnectionWithRetry retries transient reachability failures", async () => {
|
|
const { verifyConnectionWithRetry } = loadInternals();
|
|
assert.equal(typeof verifyConnectionWithRetry, "function");
|
|
|
|
let attempts = 0;
|
|
const result = await verifyConnectionWithRetry(
|
|
{ country: "Italy", city: "Milan" },
|
|
{
|
|
attempts: 3,
|
|
delayMs: 1,
|
|
getPublicIpInfo: async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) {
|
|
return { ok: false, error: "read EHOSTUNREACH" };
|
|
}
|
|
return { ok: true, country: "Italy", city: "Milan" };
|
|
},
|
|
}
|
|
);
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.ipInfo.country, "Italy");
|
|
assert.equal(attempts, 2);
|
|
});
|
|
|
|
test("resolveHostnameWithFallback uses fallback resolvers when system lookup fails", async () => {
|
|
const { resolveHostnameWithFallback } = loadInternals();
|
|
assert.equal(typeof resolveHostnameWithFallback, "function");
|
|
|
|
const calls = [];
|
|
const address = await resolveHostnameWithFallback("ipapi.co", {
|
|
resolvers: ["1.1.1.1", "8.8.8.8"],
|
|
resolveWithResolver: async (hostname, resolver) => {
|
|
calls.push(`${resolver}:${hostname}`);
|
|
if (resolver === "1.1.1.1") return [];
|
|
return ["104.26.9.44"];
|
|
},
|
|
});
|
|
|
|
assert.equal(address, "104.26.9.44");
|
|
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
|
|
});
|