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("defaults I2P connectable off and requires a peer", async () => { const wrapper = mountPage(); const ToastUtils = (await import("../../meshchatx/src/frontend/js/ToastUtils")).default; expect(wrapper.vm.newInterfaceConnectable).toBe(false); wrapper.vm.newInterfaceName = "I2P"; wrapper.vm.newInterfaceType = "I2PInterface"; wrapper.vm.I2PSettings.newInterfacePeers = [" "]; 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(ToastUtils.error).toHaveBeenCalledWith("interfaces.i2p_peers_required"); expect(mockAxios.post).not.toHaveBeenCalled(); wrapper.vm.I2PSettings.newInterfacePeers = ["abcdef.b32.i2p"]; await wrapper.vm.saveInterface(); expect(mockAxios.post).toHaveBeenCalledWith( "/api/v1/reticulum/interfaces/add", expect.objectContaining({ type: "I2PInterface", peers: ["abcdef.b32.i2p"], connectable: false, }) ); }); it("shows I2P SAM copy and a connectable warning when inbound is on", async () => { const wrapper = mountPage(); wrapper.vm.newInterfaceType = "I2PInterface"; await wrapper.vm.$nextTick(); expect(wrapper.text()).toContain("interfaces.i2p_sam_required"); expect(wrapper.text()).toContain("interfaces.i2p_connectable_hint"); expect(wrapper.text()).not.toContain("interfaces.i2p_connectable_warning"); wrapper.vm.newInterfaceConnectable = true; await wrapper.vm.$nextTick(); expect(wrapper.text()).toContain("interfaces.i2p_connectable_warning"); expect(wrapper.text()).toContain("interfaces.i2p_peers_add"); }); 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 gravity and announces_to_internal", async () => { const wrapper = mountPage(); wrapper.vm.newInterfaceName = "GravityLAN"; wrapper.vm.newInterfaceType = "TCPServerInterface"; wrapper.vm.newInterfaceListenIp = "127.0.0.1"; wrapper.vm.newInterfaceListenPort = 4242; wrapper.vm.sharedInterfaceSettings.gravity = 2; wrapper.vm.sharedInterfaceSettings.announces_to_internal = true; await wrapper.vm.saveInterface(); expect(mockAxios.post).toHaveBeenCalledWith( "/api/v1/reticulum/interfaces/add", expect.objectContaining({ gravity: 2, announces_to_internal: true, }) ); }); 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(); }); });