MeshChatX/tests/frontend/AddInterfaceOptions.test.js

442 lines
16 KiB
JavaScript

import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach } from "vitest";
import AddInterfacePage from "../../meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue";
const mockAxios = {
get: vi.fn(),
post: vi.fn(),
};
window.api = mockAxios;
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
default: {
alert: vi.fn(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mountPage = () =>
mount(AddInterfacePage, {
global: {
mocks: {
$route: { query: {} },
$router: { push: vi.fn() },
$t: (msg) => msg,
},
stubs: ["RouterLink", "MaterialDesignIcon", "Toggle", "ExpandingSection", "FormLabel", "FormSubLabel"],
},
});
describe("AddInterfacePage.vue interface options", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAxios.get.mockImplementation(async (url) => {
if (String(url).includes("/api/v1/config")) {
return { data: { config: { is_transport_enabled: true } } };
}
if (String(url).includes("/api/v1/reticulum/instance")) {
return { data: { instance: { enable_transport: true } } };
}
if (String(url).includes("/api/v1/reticulum/interface-modules")) {
return {
data: {
interfacepath: "/tmp/meshchatx/reticulum/interfaces",
modules: [{ type: "ExampleInterface", filename: "ExampleInterface.py", size: 12 }],
},
};
}
if (String(url).includes("/api/v1/reticulum/interfaces")) {
return { data: { interfaces: {} } };
}
return { data: {} };
});
mockAxios.post.mockResolvedValue({ data: { message: "ok" } });
});
it("loads installed interface modules for custom external type", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceType = "__external__";
await wrapper.vm.$nextTick();
await wrapper.vm.loadInstalledInterfaceModules();
expect(mockAxios.get).toHaveBeenCalledWith("/api/v1/reticulum/interface-modules");
expect(wrapper.vm.interfaceModulesPath).toContain("interfaces");
expect(wrapper.vm.installedInterfaceModules).toHaveLength(1);
expect(wrapper.text()).toContain("interfaces.custom_external_install_button");
});
it("sends AutoInterface group/discovery/data port settings", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "AutoLAN";
wrapper.vm.newInterfaceType = "AutoInterface";
wrapper.vm.newInterfaceGroupID = "homelab";
wrapper.vm.newInterfaceDiscoveryScope = "site";
wrapper.vm.newInterfaceDiscoveryPort = 35000;
wrapper.vm.newInterfaceDataPort = 35001;
wrapper.vm.newInterfaceMulticastAddressType = "permanent";
wrapper.vm.newInterfaceDevices = "eth0,wlan0";
wrapper.vm.newInterfaceIgnoredDevices = "tun0";
wrapper.vm.newInterfaceConfiguredBitrate = 5000000;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
type: "AutoInterface",
group_id: "homelab",
discovery_scope: "site",
discovery_port: 35000,
data_port: 35001,
multicast_address_type: "permanent",
devices: "eth0,wlan0",
ignored_devices: "tun0",
configured_bitrate: 5000000,
})
);
});
it("sends TCP client advanced options (kiss/i2p/timeout/mtu)", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "TCPC";
wrapper.vm.newInterfaceType = "TCPClientInterface";
wrapper.vm.newInterfaceTargetHost = "example.com";
wrapper.vm.newInterfaceTargetPort = 4242;
wrapper.vm.newInterfaceKISSFramingEnabled = true;
wrapper.vm.newInterfaceI2PTunnelingEnabled = true;
wrapper.vm.newInterfaceConnectTimeout = 12;
wrapper.vm.newInterfaceMaxReconnectTries = 7;
wrapper.vm.newInterfaceFixedMTU = 512;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
kiss_framing: true,
i2p_tunneled: true,
connect_timeout: 12,
max_reconnect_tries: 7,
fixed_mtu: 512,
})
);
});
it("blocks TCP client save when fixed_mtu is below Reticulum minimum", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "TCPC";
wrapper.vm.newInterfaceType = "TCPClientInterface";
wrapper.vm.newInterfaceTargetHost = "example.com";
wrapper.vm.newInterfaceTargetPort = 4242;
wrapper.vm.newInterfaceFixedMTU = 485;
await wrapper.vm.saveInterface();
expect(mockAxios.post).not.toHaveBeenCalled();
});
it("sends TCP server device/prefer_ipv6/i2p_tunneled options", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "TCPS";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "0.0.0.0";
wrapper.vm.newInterfaceListenPort = 4242;
wrapper.vm.newInterfaceNetworkDevice = "eth0";
wrapper.vm.newInterfacePreferIPV6 = true;
wrapper.vm.newInterfaceI2PTunnelingEnabled = true;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
device: "eth0",
prefer_ipv6: true,
i2p_tunneled: true,
})
);
});
it("sends BackboneInterface listener payload when listener mode is on", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "Backbone";
wrapper.vm.newInterfaceType = "BackboneInterface";
wrapper.vm.newInterfaceBackboneListenMode = true;
wrapper.vm.newInterfaceBackboneListenIp = "0.0.0.0";
wrapper.vm.newInterfaceBackboneListenPort = 5151;
wrapper.vm.newInterfaceBackboneListenDevice = "eth1";
wrapper.vm.newInterfacePreferIPV6 = true;
wrapper.vm.newInterfaceTargetHost = "leaked.example";
wrapper.vm.newInterfaceTargetPort = 9999;
await wrapper.vm.saveInterface();
const payload = mockAxios.post.mock.calls[0][1];
expect(payload.listen_ip).toBe("0.0.0.0");
expect(payload.listen_port).toBe(5151);
expect(payload.device).toBe("eth1");
expect(payload.prefer_ipv6).toBe(true);
expect(payload.target_host).toBe(null);
expect(payload.target_port).toBe(null);
});
it("sends RNode flow_control + id_callsign + id_interval", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "Radio";
wrapper.vm.newInterfaceType = "RNodeInterface";
wrapper.vm.newInterfacePort = "/dev/ttyUSB0";
wrapper.vm.RNodeMHzValue = 868;
wrapper.vm.newInterfaceBandwidth = 125000;
wrapper.vm.newInterfaceTxpower = 7;
wrapper.vm.newInterfaceSpreadingFactor = 8;
wrapper.vm.newInterfaceCodingRate = 5;
wrapper.vm.newInterfaceFlowControl = true;
wrapper.vm.newInterfaceIDCallsign = "NOCALL";
wrapper.vm.newInterfaceIDInterval = 600;
wrapper.vm.newInterfaceAirtimeLimitLong = 1.5;
wrapper.vm.newInterfaceAirtimeLimitShort = 33.0;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
flow_control: true,
id_callsign: "NOCALL",
id_interval: 600,
airtime_limit_long: 1.5,
airtime_limit_short: 33.0,
})
);
});
it("sends KISS serial + framing options", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "KISS";
wrapper.vm.newInterfaceType = "KISSInterface";
wrapper.vm.newInterfacePort = "/dev/ttyACM0";
wrapper.vm.newInterfaceSpeed = 19200;
wrapper.vm.newInterfaceDatabits = 8;
wrapper.vm.newInterfaceParity = "N";
wrapper.vm.newInterfaceStopbits = 1;
wrapper.vm.newInterfacePreamble = 200;
wrapper.vm.newInterfaceTXTail = 30;
wrapper.vm.newInterfacePersistence = 128;
wrapper.vm.newInterfaceSlotTime = 25;
wrapper.vm.newInterfaceFlowControl = true;
wrapper.vm.newInterfaceIDCallsign = "BEACON";
wrapper.vm.newInterfaceIDInterval = 1200;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
speed: 19200,
databits: 8,
parity: "N",
stopbits: 1,
preamble: 200,
txtail: 30,
persistence: 128,
slottime: 25,
flow_control: true,
id_callsign: "BEACON",
id_interval: 1200,
})
);
});
it("sends AX25KISS callsign + ssid", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "AX25";
wrapper.vm.newInterfaceType = "AX25KISSInterface";
wrapper.vm.newInterfacePort = "/dev/ttyACM1";
wrapper.vm.newInterfaceCallsign = "N0CALL";
wrapper.vm.newInterfaceSSID = 7;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
callsign: "N0CALL",
ssid: 7,
})
);
});
it("sends I2P connectable=false when peer disables it", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "I2P";
wrapper.vm.newInterfaceType = "I2PInterface";
wrapper.vm.I2PSettings.newInterfacePeers = ["abcdef.b32.i2p"];
wrapper.vm.newInterfaceConnectable = false;
wrapper.vm.config = { ...(wrapper.vm.config || {}), is_transport_enabled: true };
wrapper.vm.reticulumInstance = {
...(wrapper.vm.reticulumInstance || {}),
enable_transport: true,
};
wrapper.vm.existingInterfaces = {};
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
type: "I2PInterface",
peers: ["abcdef.b32.i2p"],
connectable: false,
})
);
});
it("surfaces backend port-in-use errors to the user", async () => {
const wrapper = mountPage();
const ToastUtils = (await import("../../meshchatx/src/frontend/js/ToastUtils")).default;
mockAxios.post.mockRejectedValueOnce({
response: {
status: 409,
data: { message: "The TCP port 4242 on 0.0.0.0 is already in use" },
},
});
wrapper.vm.newInterfaceName = "TCPS";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "0.0.0.0";
wrapper.vm.newInterfaceListenPort = 4242;
await wrapper.vm.saveInterface();
expect(ToastUtils.error).toHaveBeenCalledWith(expect.stringContaining("already in use"));
});
it("sends internal mode and recursive_prs / announces_from_internal", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "InternalLAN";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "127.0.0.1";
wrapper.vm.newInterfaceListenPort = 4242;
wrapper.vm.sharedInterfaceSettings.mode = "internal";
wrapper.vm.sharedInterfaceSettings.recursive_prs = true;
wrapper.vm.sharedInterfaceSettings.announces_from_internal = false;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
mode: "internal",
recursive_prs: true,
announces_from_internal: false,
})
);
});
it("sends BackboneInterface fast-flapping options in listener mode", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "Backbone";
wrapper.vm.newInterfaceType = "BackboneInterface";
wrapper.vm.newInterfaceBackboneListenMode = true;
wrapper.vm.newInterfaceBackboneListenIp = "0.0.0.0";
wrapper.vm.newInterfaceBackboneListenPort = 5151;
wrapper.vm.newInterfaceBlockFastFlapping = false;
wrapper.vm.newInterfaceFastFlappingBlockTime = 30;
wrapper.vm.newInterfaceFastFlappingThreshold = 10;
wrapper.vm.newInterfaceFastFlappingGrace = 2;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
block_fast_flapping: false,
fast_flapping_block_time: 30,
fast_flapping_threshold: 10,
fast_flapping_grace: 2,
})
);
});
it("sends discovery location_cmd when discoverable", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "TCPS";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "0.0.0.0";
wrapper.vm.newInterfaceListenPort = 4242;
wrapper.vm.discovery.discoverable = true;
wrapper.vm.discovery.discovery_name = "Public";
wrapper.vm.discovery.location_cmd = "/usr/local/bin/gps-loc";
wrapper.vm.sharedInterfaceSettings.mode = "gateway";
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
discoverable: "yes",
location_cmd: "/usr/local/bin/gps-loc",
mode: "gateway",
})
);
});
it("sends HTTPInterface client tunnel payload", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "HttpClient";
wrapper.vm.newInterfaceType = "HTTPInterface";
wrapper.vm.newInterfaceHttpTunnelMode = "client";
wrapper.vm.newInterfaceHttpServerUrl = "https://example.com:8080/";
wrapper.vm.newInterfaceHttpPollInterval = 0.2;
wrapper.vm.newInterfaceHttpMtu = 2048;
wrapper.vm.newInterfaceHttpVersion = 1;
wrapper.vm.newInterfaceHttpUserAgent = "RNS-HTTP-Tunnel/1.0";
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
type: "HTTPInterface",
mode: "client",
server_url: "https://example.com:8080/",
poll_interval: 0.2,
mtu: 2048,
http_version: 1,
user_agent: "RNS-HTTP-Tunnel/1.0",
})
);
});
it("blocks HTTPInterface client save without server_url", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "HttpClient";
wrapper.vm.newInterfaceType = "HTTPInterface";
wrapper.vm.newInterfaceHttpTunnelMode = "client";
wrapper.vm.newInterfaceHttpServerUrl = " ";
await wrapper.vm.saveInterface();
expect(mockAxios.post).not.toHaveBeenCalled();
});
});