diff --git a/apps/backend/src/modules/wifi/wifi.ts b/apps/backend/src/modules/wifi/wifi.ts index d2e27f04..3e2058b2 100644 --- a/apps/backend/src/modules/wifi/wifi.ts +++ b/apps/backend/src/modules/wifi/wifi.ts @@ -35,6 +35,7 @@ import { import { getMockHotspotConfig } from "../../mocks/providers/wifi.ts"; import { type ConnectionUUID, + type MacAddress, nmConnDelete, nmConnect, nmConnGetFields, @@ -70,6 +71,7 @@ import { getMacAddressForWifiInterface, getWifiIdToMacAddress, type SSID, + type WifiInterface, type WifiInterfaceId, } from "./wifi-interfaces.ts"; @@ -257,6 +259,32 @@ export function broadcastWifiState() { broadcastMsg("status", { wifi: wifiBuildMsg() }); } +/* + Record one saved infrastructure connection on the interface(s) it can be used + from. A profile bound to a MAC that a present adapter reports is attributed to + that adapter only. Any other profile — no bound MAC (created outside CeraUI: + nmtui, `nmcli device wifi connect`, a baked image profile) or a bound MAC that + matches no present adapter (MAC randomization / swapped adapter) — is registered + on every adapter, mirroring the scan path where all interfaces see all networks. + Without this fallback an active-but-unbound connection resolves no UUID and the + UI shows "Connect" on the network the device is already connected to. +*/ +export function registerSavedWifiConnection( + interfaces: Record, + macAddress: MacAddress, + ssid: SSID, + uuid: ConnectionUUID, +): void { + const boundInterface = macAddress ? interfaces[macAddress] : undefined; + if (boundInterface) { + boundInterface.saved[ssid] = uuid; + return; + } + for (const wifiInterface of Object.values(interfaces)) { + wifiInterface.saved[ssid] = uuid; + } +} + export async function wifiUpdateSavedConns() { // Retry transient nmcli connection-list failures with exponential backoff (T7). const connections = await pollWithBackoff(() => nmConnsGet("uuid,type"), { @@ -305,9 +333,12 @@ export async function wifiUpdateSavedConns() { if (mode === "ap") { void handleHotspotConn(macAddress, uuid); } else if (mode === "infrastructure") { - if (macAddress && wifiInterfacesByMacAddress[macAddress]) { - wifiInterfacesByMacAddress[macAddress].saved[ssid] = uuid; - } + registerSavedWifiConnection( + wifiInterfacesByMacAddress, + macAddress, + ssid, + uuid, + ); } } catch (err) { if (err instanceof Error) { diff --git a/apps/backend/src/tests/wifi-saved-connection.test.ts b/apps/backend/src/tests/wifi-saved-connection.test.ts new file mode 100644 index 00000000..c77411c1 --- /dev/null +++ b/apps/backend/src/tests/wifi-saved-connection.test.ts @@ -0,0 +1,80 @@ +/* + * Regression: an ACTIVE, connected saved network showed a "Connect" button + * instead of "Disconnect" (confirmed on real hardware — SSID "SOMOS - 701"). + * + * Root cause lived in wifiUpdateSavedConns: a saved infrastructure profile was + * added to an interface's `saved` map ONLY when its `802-11-wireless.mac-address` + * matched a currently-present adapter. A profile with no bound MAC (created + * outside CeraUI — nmtui, `nmcli device wifi connect`, a baked image profile) or + * a bound MAC that no longer matches the live adapter (MAC randomization / swapped + * adapter) was silently dropped. The scan path (wifiUpdateScanResult) has no such + * requirement, so the network still rendered `active: true` in `available` while + * being absent from `saved` — the frontend `getWifiUUID` then resolved `undefined` + * and the UI offered "Connect" on the already-connected network. + * + * `registerSavedWifiConnection` is the extracted keying step. The "unbound" case + * below is the exact reproduction: before the fix the connection was NOT recorded + * in `saved`, so the `toBe(UUID)` assertions would have failed (the value was + * `undefined`). + */ +import { describe, expect, it } from "bun:test"; +import { registerSavedWifiConnection } from "../modules/wifi/wifi.ts"; +import type { SSID, WifiInterface } from "../modules/wifi/wifi-interfaces.ts"; + +const SSID_NAME: SSID = "SOMOS - 701"; +const UUID = "11112222-3333-4444-5555-666677778888"; +const ADAPTER_A = "aa:bb:cc:dd:ee:01"; +const ADAPTER_B = "aa:bb:cc:dd:ee:02"; + +function makeInterface(id: number, ifname: string): WifiInterface { + return { + id, + ifname, + conn: null, + hw: "Test Adapter", + available: new Map(), + saved: {}, + }; +} + +describe("registerSavedWifiConnection", () => { + it("records an unbound profile (empty MAC) so an active network resolves a UUID", () => { + const interfaces = { [ADAPTER_A]: makeInterface(0, "wlan0") }; + + registerSavedWifiConnection(interfaces, "", SSID_NAME, UUID); + + // Pre-fix this profile was dropped (empty MAC matched no adapter) and the + // value here was `undefined` — the "Connect"-on-connected-network bug. + expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBe(UUID); + }); + + it("records a profile whose bound MAC matches no present adapter on every adapter", () => { + const interfaces = { + [ADAPTER_A]: makeInterface(0, "wlan0"), + [ADAPTER_B]: makeInterface(1, "wlan1"), + }; + + // A stale/randomized bound MAC that matches neither present adapter. + registerSavedWifiConnection( + interfaces, + "de:ad:be:ef:00:99", + SSID_NAME, + UUID, + ); + + expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBe(UUID); + expect(interfaces[ADAPTER_B]?.saved[SSID_NAME]).toBe(UUID); + }); + + it("attributes a MAC-bound profile to only its adapter (multi-adapter disambiguation)", () => { + const interfaces = { + [ADAPTER_A]: makeInterface(0, "wlan0"), + [ADAPTER_B]: makeInterface(1, "wlan1"), + }; + + registerSavedWifiConnection(interfaces, ADAPTER_B, SSID_NAME, UUID); + + expect(interfaces[ADAPTER_B]?.saved[SSID_NAME]).toBe(UUID); + expect(interfaces[ADAPTER_A]?.saved[SSID_NAME]).toBeUndefined(); + }); +});