From 85e59ca5b545a4cd59a90bf93b4217b748e4a3c7 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:52:31 +0100 Subject: [PATCH 1/6] feat(wifi): implement WiFi management via wifi-commissioning-service - Add scanning, connecting, disconnecting, and saved-network management through the wifi-commissioning-service (unix domain socket) - E2E coverage: auth flow, WiFi operations, form interaction edge cases Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- Cargo.lock | 37 +- Cargo.toml | 2 +- scripts/build-and-deploy-image.sh | 8 +- src/app/Cargo.toml | 4 + src/app/src/events.rs | 118 +++ src/app/src/lib.rs | 1 + src/app/src/macros.rs | 28 + src/app/src/model.rs | 3 + src/app/src/types/mod.rs | 2 + src/app/src/types/wifi.rs | 73 ++ src/app/src/update/auth.rs | 61 +- src/app/src/update/mod.rs | 7 +- src/app/src/update/websocket.rs | 42 +- src/app/src/update/wifi.rs | 909 ++++++++++++++++++ src/app/src/wifi_psk.rs | 48 + src/backend/src/api.rs | 96 ++ src/backend/src/config.rs | 20 + src/backend/src/lib.rs | 1 + src/backend/src/main.rs | 77 ++ src/backend/src/wifi_commissioning_client.rs | 373 +++++++ src/shared_types/build.rs | 14 +- .../src/components/network/DeviceNetworks.vue | 9 +- .../components/network/WifiConnectDialog.vue | 68 ++ .../components/network/WifiForgetDialog.vue | 41 + src/ui/src/components/network/WifiPanel.vue | 146 +++ src/ui/src/composables/core/index.ts | 26 + src/ui/src/composables/core/state.ts | 2 + src/ui/src/composables/core/sync.ts | 4 + src/ui/src/composables/core/timers.ts | 55 ++ src/ui/src/composables/core/types.ts | 116 +++ src/ui/src/composables/useCore.ts | 6 + src/ui/src/pages/SetPassword.vue | 10 + src/ui/tests/auth.spec.ts | 50 + src/ui/tests/wifi.spec.ts | 578 +++++++++++ 34 files changed, 3004 insertions(+), 31 deletions(-) create mode 100644 src/app/src/types/wifi.rs create mode 100644 src/app/src/update/wifi.rs create mode 100644 src/app/src/wifi_psk.rs create mode 100644 src/backend/src/wifi_commissioning_client.rs create mode 100644 src/ui/src/components/network/WifiConnectDialog.vue create mode 100644 src/ui/src/components/network/WifiForgetDialog.vue create mode 100644 src/ui/src/components/network/WifiPanel.vue create mode 100644 src/ui/tests/wifi.spec.ts diff --git a/Cargo.lock b/Cargo.lock index 665c3c16..cf184835 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1545,6 +1545,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -2224,7 +2230,7 @@ dependencies = [ [[package]] name = "omnect-ui" -version = "1.1.2" +version = "1.2.0" dependencies = [ "actix-cors", "actix-files", @@ -2266,7 +2272,7 @@ dependencies = [ [[package]] name = "omnect-ui-core" -version = "1.1.2" +version = "1.2.0" dependencies = [ "base64 0.22.1", "console_log", @@ -2274,12 +2280,16 @@ dependencies = [ "crux_http", "crux_macros", "getrandom 0.3.4", + "hex", + "hmac", "lazy_static", "log", + "pbkdf2", "serde", "serde_json", "serde_repr", "serde_valid", + "sha1", "wasm-bindgen", ] @@ -2408,6 +2418,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -3286,6 +3306,17 @@ dependencies = [ "regex", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3299,7 +3330,7 @@ dependencies = [ [[package]] name = "shared_types" -version = "1.1.2" +version = "1.2.0" dependencies = [ "anyhow", "crux_core", diff --git a/Cargo.toml b/Cargo.toml index c96bef9c..664162e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,4 @@ edition = "2024" homepage = "https://www.omnect.io/home" license = "MIT OR Apache-2.0" repository = "git@github.com:omnect/omnect-ui.git" -version = "1.1.2" +version = "1.2.0" diff --git a/scripts/build-and-deploy-image.sh b/scripts/build-and-deploy-image.sh index f3d05054..83cb660f 100755 --- a/scripts/build-and-deploy-image.sh +++ b/scripts/build-and-deploy-image.sh @@ -176,9 +176,9 @@ if [[ "$DEPLOY" == "true" ]]; then echo "Copying image to device $DEVICE_HOST..." if [ -n "$DEVICE_PASS" ]; then - sshpass -p "$DEVICE_PASS" scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" + sshpass -p "$DEVICE_PASS" scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" else - scp "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "$IMAGE_TAR" "${DEVICE_USER}@${DEVICE_HOST}:/tmp/" fi echo "Loading image on device and restarting container..." @@ -203,9 +203,9 @@ if [[ "$DEPLOY" == "true" ]]; then sudo iotedge system restart" if [ -n "$DEVICE_PASS" ]; then - sshpass -p "$DEVICE_PASS" ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" + sshpass -p "$DEVICE_PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" else - ssh "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR "${DEVICE_USER}@${DEVICE_HOST}" "$CMD" fi echo "Cleaning up local tar file..." diff --git a/src/app/Cargo.toml b/src/app/Cargo.toml index 06dfc48d..ed5b820e 100644 --- a/src/app/Cargo.toml +++ b/src/app/Cargo.toml @@ -32,6 +32,10 @@ serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = { version = "1.0", default-features = false } serde_repr = { version = "0.1", default-features = false } serde_valid = { version = "2.0", default-features = false } +hex = { version = "0.4", default-features = false, features = ["alloc"] } +hmac = { version = "0.12", default-features = false } +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +sha1 = { version = "0.10", default-features = false } wasm-bindgen = { version = "0.2", default-features = false } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/app/src/events.rs b/src/app/src/events.rs index b803f41e..bb58072a 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -102,6 +102,122 @@ pub enum WebSocketEvent { Disconnected, } +/// WiFi management events +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum WifiEvent { + // User actions + CheckAvailability, + Scan, + Connect { + ssid: String, + password: String, + }, + Disconnect, + GetStatus, + GetSavedNetworks, + ForgetNetwork { + ssid: String, + }, + // Timer ticks (driven by Shell) + ScanPollTick, + ConnectPollTick, + // Responses from HTTP effects + #[serde(skip)] + CheckAvailabilityResponse(Result), + #[serde(skip)] + ScanResponse(Result<(), String>), + #[serde(skip)] + ScanResultsResponse(Result), + #[serde(skip)] + ConnectResponse(Result<(), String>), + #[serde(skip)] + DisconnectResponse(Result<(), String>), + #[serde(skip)] + StatusResponse(Result), + #[serde(skip)] + SavedNetworksResponse(Result), + #[serde(skip)] + ForgetNetworkResponse(Result<(), String>), +} + +/// API response types for WiFi (match backend JSON shapes) +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiScanResultsApiResponse { + pub status: String, + pub state: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiNetworkApiResponse { + pub ssid: String, + pub mac: String, + pub ch: u16, + pub rssi: i16, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiStatusApiResponse { + pub status: String, + pub state: String, + pub ssid: Option, + pub ip_address: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiSavedNetworksApiResponse { + pub status: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct WifiSavedNetworkApiResponse { + pub ssid: String, + pub flags: String, +} + +/// Custom Debug for WifiEvent to redact password +impl fmt::Debug for WifiEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WifiEvent::Connect { ssid, .. } => f + .debug_struct("Connect") + .field("ssid", ssid) + .field("password", &"") + .finish(), + WifiEvent::CheckAvailability => write!(f, "CheckAvailability"), + WifiEvent::Scan => write!(f, "Scan"), + WifiEvent::Disconnect => write!(f, "Disconnect"), + WifiEvent::GetStatus => write!(f, "GetStatus"), + WifiEvent::GetSavedNetworks => write!(f, "GetSavedNetworks"), + WifiEvent::ForgetNetwork { ssid } => { + f.debug_struct("ForgetNetwork").field("ssid", ssid).finish() + } + WifiEvent::ScanPollTick => write!(f, "ScanPollTick"), + WifiEvent::ConnectPollTick => write!(f, "ConnectPollTick"), + WifiEvent::CheckAvailabilityResponse(r) => { + f.debug_tuple("CheckAvailabilityResponse").field(r).finish() + } + WifiEvent::ScanResponse(r) => f.debug_tuple("ScanResponse").field(r).finish(), + WifiEvent::ScanResultsResponse(r) => { + f.debug_tuple("ScanResultsResponse").field(r).finish() + } + WifiEvent::ConnectResponse(r) => f.debug_tuple("ConnectResponse").field(r).finish(), + WifiEvent::DisconnectResponse(r) => { + f.debug_tuple("DisconnectResponse").field(r).finish() + } + WifiEvent::StatusResponse(r) => f.debug_tuple("StatusResponse").field(r).finish(), + WifiEvent::SavedNetworksResponse(r) => { + f.debug_tuple("SavedNetworksResponse").field(r).finish() + } + WifiEvent::ForgetNetworkResponse(r) => { + f.debug_tuple("ForgetNetworkResponse").field(r).finish() + } + } + } +} + /// UI action events #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub enum UiEvent { @@ -118,6 +234,7 @@ pub enum Event { Device(DeviceEvent), WebSocket(WebSocketEvent), Ui(UiEvent), + Wifi(WifiEvent), } /// Custom Debug implementation for AuthEvent to redact sensitive data @@ -180,6 +297,7 @@ impl fmt::Debug for Event { Event::Device(e) => write!(f, "Device({e:?})"), Event::WebSocket(e) => write!(f, "WebSocket({e:?})"), Event::Ui(e) => write!(f, "Ui({e:?})"), + Event::Wifi(e) => write!(f, "Wifi({e:?})"), } } } diff --git a/src/app/src/lib.rs b/src/app/src/lib.rs index 8ac8fb1a..bb124e14 100644 --- a/src/app/src/lib.rs +++ b/src/app/src/lib.rs @@ -5,6 +5,7 @@ pub mod macros; pub mod model; pub mod types; pub mod update; +pub mod wifi_psk; #[cfg(target_arch = "wasm32")] pub mod wasm; diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index a1b32010..bdb2eea0 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -420,6 +420,34 @@ macro_rules! http_get { }; } +/// Macro for authenticated GET requests expecting JSON response. +/// Does not set loading state — used for background polling and status checks. +/// +/// # Example +/// ```ignore +/// auth_get!(Wifi, WifiEvent, model, "/wifi/status", StatusResponse, "WiFi status", +/// expect_json: WifiStatusApiResponse) +/// ``` +#[macro_export] +macro_rules! auth_get { + ($domain:ident, $domain_event:ident, $model:expr, $endpoint:expr, $response_event:ident, $action:expr, expect_json: $response_type:ty) => {{ + if let Some(token) = &$model.auth_token { + $crate::HttpCmd::get($crate::build_url($endpoint)) + .header("Authorization", format!("Bearer {token}")) + .build() + .then_send(|result| { + let event_result: Result<$response_type, String> = + $crate::process_json_response($action, result); + $crate::events::Event::$domain($crate::events::$domain_event::$response_event( + event_result, + )) + }) + } else { + $crate::handle_auth_error($model, $action) + } + }}; +} + /// Silent HTTP GET - no loading state, custom success/error event handlers. /// /// Used for background polling where failures should not show errors to user. diff --git a/src/app/src/model.rs b/src/app/src/model.rs index 795db071..55d613d4 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -67,6 +67,9 @@ pub struct Model { // Overlay spinner state pub overlay_spinner: OverlaySpinnerState, + + // WiFi state + pub wifi_state: WifiState, } impl Model { diff --git a/src/app/src/types/mod.rs b/src/app/src/types/mod.rs index 00a2a0ce..269b99e0 100644 --- a/src/app/src/types/mod.rs +++ b/src/app/src/types/mod.rs @@ -18,6 +18,7 @@ pub mod factory_reset; pub mod network; pub mod ods; pub mod update; +pub mod wifi; // Re-export all types for backward compatibility pub use auth::*; @@ -27,3 +28,4 @@ pub use factory_reset::*; pub use network::*; pub use ods::*; pub use update::*; +pub use wifi::*; diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs new file mode 100644 index 00000000..be73b7d0 --- /dev/null +++ b/src/app/src/types/wifi.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +/// WiFi service availability info returned by the backend +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiAvailability { + pub available: bool, + pub interface_name: Option, +} + +/// A WiFi network discovered during scanning +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiNetwork { + pub ssid: String, + pub mac: String, + pub channel: u16, + pub rssi: i16, +} + +/// A saved WiFi network from wpa_supplicant +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiSavedNetwork { + pub ssid: String, + pub flags: String, +} + +/// WiFi connection status from the service +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WifiConnectionStatus { + pub state: WifiConnectionState, + pub ssid: Option, + pub ip_address: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiScanState { + #[default] + Idle, + Scanning, + Finished, + Error(String), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiConnectionState { + #[default] + Idle, + Connecting, + Connected, + Failed(String), +} + +/// Top-level WiFi state machine exposed in the ViewModel +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum WifiState { + #[default] + Unavailable, + Ready { + interface_name: String, + status: WifiConnectionStatus, + scan_state: WifiScanState, + scan_results: Vec, + saved_networks: Vec, + scan_poll_attempt: u32, + connect_poll_attempt: u32, + }, +} diff --git a/src/app/src/update/auth.rs b/src/app/src/update/auth.rs index b83cb1c1..4276237e 100644 --- a/src/app/src/update/auth.rs +++ b/src/app/src/update/auth.rs @@ -1,12 +1,12 @@ use base64::prelude::*; -use crux_core::Command; +use crux_core::{render::render, Command}; use crate::{ auth_post, auth_post_basic, - events::{AuthEvent, Event}, + events::{AuthEvent, Event, WifiEvent}, handle_response, model::Model, - types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest}, + types::{AuthToken, SetPasswordRequest, UpdatePasswordRequest, WifiState}, unauth_post, Effect, }; @@ -20,12 +20,20 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { map: |token| AuthToken { token }) } - AuthEvent::LoginResponse(result) => handle_response!(model, result, { - on_success: |model, auth| { - model.auth_token = Some(auth.token); - model.is_authenticated = true; - }, - }), + AuthEvent::LoginResponse(result) => { + model.stop_loading(); + match result { + Ok(auth) => { + model.auth_token = Some(auth.token); + model.is_authenticated = true; + post_auth_commands(model) + } + Err(e) => { + model.set_error(e); + render() + } + } + } AuthEvent::Logout => { auth_post!(Auth, AuthEvent, model, "/logout", LogoutResponse, "Logout") @@ -44,13 +52,21 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { map: |token| AuthToken { token }) } - AuthEvent::SetPasswordResponse(result) => handle_response!(model, result, { - on_success: |model, auth| { - model.requires_password_set = false; - model.auth_token = Some(auth.token); - model.is_authenticated = true; - }, - }), + AuthEvent::SetPasswordResponse(result) => { + model.stop_loading(); + match result { + Ok(auth) => { + model.requires_password_set = false; + model.auth_token = Some(auth.token); + model.is_authenticated = true; + post_auth_commands(model) + } + Err(e) => { + model.set_error(e); + render() + } + } + } AuthEvent::UpdatePassword { current_password, @@ -85,6 +101,19 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { } } +/// After successful authentication, fetch WiFi data if WiFi is available +fn post_auth_commands(model: &mut Model) -> Command { + if matches!(model.wifi_state, WifiState::Ready { .. }) { + Command::all([ + render(), + super::wifi::handle(WifiEvent::GetStatus, model), + super::wifi::handle(WifiEvent::GetSavedNetworks, model), + ]) + } else { + render() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/src/update/mod.rs b/src/app/src/update/mod.rs index 46df55f9..acca95f1 100644 --- a/src/app/src/update/mod.rs +++ b/src/app/src/update/mod.rs @@ -2,6 +2,7 @@ mod auth; mod device; mod ui; mod websocket; +mod wifi; use crux_core::{render::render, Command}; @@ -17,11 +18,15 @@ pub fn update(event: Event, model: &mut Model) -> Command { match event { Event::Initialize => { model.start_loading(); - render() + Command::all([ + render(), + wifi::handle(crate::events::WifiEvent::CheckAvailability, model), + ]) } Event::Auth(auth_event) => auth::handle(auth_event, model), Event::Device(device_event) => device::handle(device_event, model), Event::WebSocket(ws_event) => websocket::handle(ws_event, model), Event::Ui(ui_event) => ui::handle(ui_event, model), + Event::Wifi(wifi_event) => wifi::handle(wifi_event, model), } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index e4331c8b..2756c405 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -1,12 +1,17 @@ +use std::collections::HashMap; + use crux_core::Command; use crate::{ events::{Event, WebSocketEvent}, model::Model, parse_ods_update, - types::ods::{ - OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, - OdsUpdateValidationStatus, + types::{ + ods::{ + OdsFactoryReset, OdsNetworkStatus, OdsOnlineStatus, OdsSystemInfo, OdsTimeouts, + OdsUpdateValidationStatus, + }, + NetworkFormData, NetworkFormState, }, update_field, CentrifugoCmd, Effect, }; @@ -40,6 +45,37 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command { + if let WifiState::Ready { + interface_name: $iface, + status: $status, + scan_state: $scan_state, + scan_results: $scan_results, + saved_networks: $saved, + scan_poll_attempt: $scan_poll, + connect_poll_attempt: $connect_poll, + } = &mut $model.wifi_state + { + $body + } else { + log::warn!("WiFi event received but state is Unavailable"); + Command::done() + } + }; +} + +pub fn handle(event: WifiEvent, model: &mut Model) -> Command { + match event { + WifiEvent::CheckAvailability => { + unauth_post!( + Wifi, WifiEvent, model, + "/wifi/available", + CheckAvailabilityResponse, "Check WiFi availability", + method: get, + expect_json: WifiAvailability + ) + } + + WifiEvent::CheckAvailabilityResponse(result) => match result { + Ok(availability) if availability.available => { + let iface = availability.interface_name.unwrap_or_default(); + model.wifi_state = WifiState::Ready { + interface_name: iface, + status: WifiConnectionStatus::default(), + scan_state: WifiScanState::Idle, + scan_results: Vec::new(), + saved_networks: Vec::new(), + scan_poll_attempt: 0, + connect_poll_attempt: 0, + }; + // Only fetch status and saved networks if authenticated + if model.is_authenticated { + Command::all([ + render(), + handle(WifiEvent::GetStatus, model), + handle(WifiEvent::GetSavedNetworks, model), + ]) + } else { + render() + } + } + Ok(_) => { + // WiFi not available + model.wifi_state = WifiState::Unavailable; + render() + } + Err(e) => { + log::error!("WiFi availability check failed: {e}"); + model.wifi_state = WifiState::Unavailable; + render() + } + }, + + WifiEvent::Scan => { + with_ready_state!(model, |_iface, + _status, + scan_state, + _scan_results, + _saved, + scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Scanning; + *scan_poll = 0; + auth_post!( + Wifi, + WifiEvent, + model, + "/wifi/scan", + ScanResponse, + "WiFi scan" + ) + }) + } + + WifiEvent::ScanResponse(result) => { + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Error(e); + render() + } + ) + } else { + // Scan started; Shell will drive polling via ScanPollTick + render() + } + } + + WifiEvent::ScanPollTick => { + with_ready_state!(model, |_iface, + _status, + scan_state, + _scan_results, + _saved, + scan_poll, + _connect_poll| { + if !matches!(scan_state, WifiScanState::Scanning) { + return Command::done(); + } + if *scan_poll >= SCAN_POLL_MAX_ATTEMPTS { + *scan_state = WifiScanState::Error("Scan timed out".to_string()); + return render(); + } + *scan_poll += 1; + auth_get!( + Wifi, WifiEvent, model, + "/wifi/scan/results", + ScanResultsResponse, "WiFi scan results", + expect_json: WifiScanResultsApiResponse + ) + }) + } + + WifiEvent::ScanResultsResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + scan_results, + _saved, + _scan_poll, + _connect_poll| { + if response.state == "finished" { + // Deduplicate by SSID, keeping strongest signal per SSID + let mut best: HashMap = HashMap::new(); + for net in response.networks { + let entry = + best.entry(net.ssid.clone()).or_insert_with(|| WifiNetwork { + ssid: net.ssid.clone(), + mac: net.mac.clone(), + channel: net.ch, + rssi: net.rssi, + }); + if net.rssi > entry.rssi { + *entry = WifiNetwork { + ssid: net.ssid, + mac: net.mac, + channel: net.ch, + rssi: net.rssi, + }; + } + } + // Sort by RSSI descending (strongest first) + let mut networks: Vec = best.into_values().collect(); + networks.sort_by(|a, b| b.rssi.cmp(&a.rssi)); + + *scan_results = networks; + *scan_state = WifiScanState::Finished; + } + // If state is still "scanning", keep polling (Shell timer continues) + render() + } + ) + } + Err(e) => { + with_ready_state!( + model, + |_iface, + _status, + scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + *scan_state = WifiScanState::Error(e); + render() + } + ) + } + }, + + WifiEvent::Connect { ssid, password } => { + // Input validation + if ssid.trim().is_empty() { + return with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + _status.state = + WifiConnectionState::Failed("SSID cannot be empty".to_string()); + render() + } + ); + } + if password.is_empty() { + return with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + _status.state = + WifiConnectionState::Failed("Password cannot be empty".to_string()); + render() + } + ); + } + + // Compute WPA PSK + let psk = wifi_psk::compute_wpa_psk(&password, &ssid); + + with_ready_state!(model, |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + connect_poll| { + status.state = WifiConnectionState::Connecting; + *connect_poll = 0; + + #[derive(serde::Serialize)] + struct ConnectBody { + ssid: String, + psk: String, + } + let body = ConnectBody { ssid, psk }; + auth_post!( + Wifi, WifiEvent, model, + "/wifi/connect", + ConnectResponse, "WiFi connect", + body_json: &body + ) + }) + } + + WifiEvent::ConnectResponse(result) => { + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + status.state = WifiConnectionState::Failed(e); + render() + } + ) + } else { + // Connect request accepted; Shell will drive polling via ConnectPollTick + model.stop_loading(); + render() + } + } + + WifiEvent::ConnectPollTick => { + with_ready_state!(model, |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + connect_poll| { + if !matches!(status.state, WifiConnectionState::Connecting) { + return Command::done(); + } + if *connect_poll >= CONNECT_POLL_MAX_ATTEMPTS { + status.state = WifiConnectionState::Failed("Connection timed out".to_string()); + return render(); + } + *connect_poll += 1; + auth_get!( + Wifi, WifiEvent, model, + "/wifi/status", + StatusResponse, "WiFi connect status", + expect_json: WifiStatusApiResponse + ) + }) + } + + WifiEvent::StatusResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + let was_connecting = + matches!(status.state, WifiConnectionState::Connecting); + + status.ssid = response.ssid; + status.ip_address = response.ip_address; + + match response.state.as_str() { + "connected" => { + status.state = WifiConnectionState::Connected; + if was_connecting { + // Auto-refresh saved networks after successful connect + return Command::all([ + render(), + handle(WifiEvent::GetSavedNetworks, model), + ]); + } + } + "failed" if was_connecting => { + status.state = + WifiConnectionState::Failed("Connection failed".to_string()); + } + "idle" if !was_connecting => { + status.state = WifiConnectionState::Idle; + } + _ => { + // "connecting" or other states — keep polling + } + } + render() + } + ) + } + Err(e) => { + log::error!("WiFi status poll failed: {e}"); + render() + } + }, + + WifiEvent::Disconnect => { + auth_post!( + Wifi, + WifiEvent, + model, + "/wifi/disconnect", + DisconnectResponse, + "WiFi disconnect" + ) + } + + WifiEvent::DisconnectResponse(result) => { + model.stop_loading(); + if let Err(e) = result { + with_ready_state!( + model, + |_iface, + status, + _scan_state, + _scan_results, + _saved, + _scan_poll, + _connect_poll| { + status.state = WifiConnectionState::Failed(e); + render() + } + ) + } else { + // Refresh status after disconnect + handle(WifiEvent::GetStatus, model) + } + } + + WifiEvent::GetStatus => { + auth_get!( + Wifi, WifiEvent, model, + "/wifi/status", + StatusResponse, "WiFi status", + expect_json: WifiStatusApiResponse + ) + } + + WifiEvent::GetSavedNetworks => { + auth_get!( + Wifi, WifiEvent, model, + "/wifi/networks", + SavedNetworksResponse, "WiFi saved networks", + expect_json: WifiSavedNetworksApiResponse + ) + } + + WifiEvent::SavedNetworksResponse(result) => match result { + Ok(response) => { + with_ready_state!( + model, + |_iface, + _status, + _scan_state, + _scan_results, + saved, + _scan_poll, + _connect_poll| { + *saved = response + .networks + .into_iter() + .map(|n| WifiSavedNetwork { + ssid: n.ssid, + flags: n.flags, + }) + .collect(); + render() + } + ) + } + Err(e) => { + log::error!("Failed to load saved networks: {e}"); + render() + } + }, + + WifiEvent::ForgetNetwork { ssid } => { + #[derive(serde::Serialize)] + struct ForgetBody { + ssid: String, + } + let body = ForgetBody { ssid }; + auth_post!( + Wifi, WifiEvent, model, + "/wifi/networks/forget", + ForgetNetworkResponse, "WiFi forget network", + body_json: &body + ) + } + + WifiEvent::ForgetNetworkResponse(result) => { + model.stop_loading(); + if let Err(e) = result { + log::error!("Failed to forget network: {e}"); + render() + } else { + // Auto-refresh saved networks and status after forget + Command::all([ + render(), + handle(WifiEvent::GetSavedNetworks, model), + handle(WifiEvent::GetStatus, model), + ]) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::WifiAvailability; + + fn model_with_ready_state() -> Model { + Model { + auth_token: Some("test-token".to_string()), + is_authenticated: true, + wifi_state: WifiState::Ready { + interface_name: "wlan0".to_string(), + status: WifiConnectionStatus::default(), + scan_state: WifiScanState::Idle, + scan_results: Vec::new(), + saved_networks: Vec::new(), + scan_poll_attempt: 0, + connect_poll_attempt: 0, + }, + ..Default::default() + } + } + + mod check_availability { + use super::*; + + #[test] + fn available_response_transitions_to_ready() { + let mut model = Model::default(); + let result = Ok(WifiAvailability { + available: true, + interface_name: Some("wlan0".to_string()), + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + + match &model.wifi_state { + WifiState::Ready { interface_name, .. } => { + assert_eq!(interface_name, "wlan0"); + } + _ => panic!("Expected Ready state"), + } + } + + #[test] + fn unavailable_response_stays_unavailable() { + let mut model = Model::default(); + let result = Ok(WifiAvailability { + available: false, + interface_name: None, + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!(model.wifi_state, WifiState::Unavailable); + } + + #[test] + fn error_response_stays_unavailable() { + let mut model = Model::default(); + let result = Err("network error".to_string()); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!(model.wifi_state, WifiState::Unavailable); + } + } + + mod scan { + use super::*; + use crate::events::WifiNetworkApiResponse; + + #[test] + fn scan_sets_scanning_state() { + let mut model = model_with_ready_state(); + let _ = handle(WifiEvent::Scan, &mut model); + + if let WifiState::Ready { + scan_state, + scan_poll_attempt, + .. + } = &model.wifi_state + { + assert_eq!(*scan_state, WifiScanState::Scanning); + assert_eq!(*scan_poll_attempt, 0); + } else { + panic!("Expected Ready state"); + } + } + + #[test] + fn scan_error_sets_error_state() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::ScanResponse(Err("scan failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert_eq!(*scan_state, WifiScanState::Error("scan failed".to_string())); + } + } + + #[test] + fn scan_poll_increments_attempt() { + let mut model = model_with_ready_state(); + // Set scanning state + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let _ = handle(WifiEvent::ScanPollTick, &mut model); + + if let WifiState::Ready { + scan_poll_attempt, .. + } = &model.wifi_state + { + assert_eq!(*scan_poll_attempt, 1); + } + } + + #[test] + fn scan_poll_timeout() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { + scan_state, + scan_poll_attempt, + .. + } = &mut model.wifi_state + { + *scan_state = WifiScanState::Scanning; + *scan_poll_attempt = SCAN_POLL_MAX_ATTEMPTS; + } + let _ = handle(WifiEvent::ScanPollTick, &mut model); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert!( + matches!(scan_state, WifiScanState::Error(msg) if msg.contains("timed out")) + ); + } + } + + #[test] + fn scan_results_deduplicate_by_ssid() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let response = WifiScanResultsApiResponse { + status: "ok".to_string(), + state: "finished".to_string(), + networks: vec![ + WifiNetworkApiResponse { + ssid: "MyNet".to_string(), + mac: "aa:bb:cc:dd:ee:ff".to_string(), + ch: 6, + rssi: -70, + }, + WifiNetworkApiResponse { + ssid: "MyNet".to_string(), + mac: "11:22:33:44:55:66".to_string(), + ch: 11, + rssi: -50, + }, + WifiNetworkApiResponse { + ssid: "Other".to_string(), + mac: "ff:ff:ff:ff:ff:ff".to_string(), + ch: 1, + rssi: -80, + }, + ], + }; + let _ = handle(WifiEvent::ScanResultsResponse(Ok(response)), &mut model); + + if let WifiState::Ready { + scan_state, + scan_results, + .. + } = &model.wifi_state + { + assert_eq!(*scan_state, WifiScanState::Finished); + assert_eq!(scan_results.len(), 2); + // Strongest first + assert_eq!(scan_results[0].ssid, "MyNet"); + assert_eq!(scan_results[0].rssi, -50); + assert_eq!(scan_results[1].ssid, "Other"); + } + } + + #[test] + fn scan_results_while_still_scanning_keeps_state() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { scan_state, .. } = &mut model.wifi_state { + *scan_state = WifiScanState::Scanning; + } + let response = WifiScanResultsApiResponse { + status: "ok".to_string(), + state: "scanning".to_string(), + networks: vec![], + }; + let _ = handle(WifiEvent::ScanResultsResponse(Ok(response)), &mut model); + + if let WifiState::Ready { scan_state, .. } = &model.wifi_state { + assert_eq!(*scan_state, WifiScanState::Scanning); + } + } + } + + mod connect { + use super::*; + + #[test] + fn empty_ssid_fails_validation() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: " ".to_string(), + password: "secret".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("SSID") + )); + } + } + + #[test] + fn empty_password_fails_validation() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: "MyNet".to_string(), + password: "".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("Password") + )); + } + } + + #[test] + fn valid_connect_sets_connecting_state() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::Connect { + ssid: "MyNet".to_string(), + password: "password123".to_string(), + }, + &mut model, + ); + + if let WifiState::Ready { + status, + connect_poll_attempt, + .. + } = &model.wifi_state + { + assert_eq!(status.state, WifiConnectionState::Connecting); + assert_eq!(*connect_poll_attempt, 0); + } + } + + #[test] + fn connect_error_sets_failed() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let _ = handle( + WifiEvent::ConnectResponse(Err("auth failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg == "auth failed" + )); + } + } + + #[test] + fn connect_poll_timeout() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { + status, + connect_poll_attempt, + .. + } = &mut model.wifi_state + { + status.state = WifiConnectionState::Connecting; + *connect_poll_attempt = CONNECT_POLL_MAX_ATTEMPTS; + } + let _ = handle(WifiEvent::ConnectPollTick, &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("timed out") + )); + } + } + + #[test] + fn status_connected_while_connecting_transitions() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let response = WifiStatusApiResponse { + status: "ok".to_string(), + state: "connected".to_string(), + ssid: Some("MyNet".to_string()), + ip_address: Some("192.168.1.100".to_string()), + }; + let _ = handle(WifiEvent::StatusResponse(Ok(response)), &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert_eq!(status.state, WifiConnectionState::Connected); + assert_eq!(status.ssid.as_deref(), Some("MyNet")); + assert_eq!(status.ip_address.as_deref(), Some("192.168.1.100")); + } + } + + #[test] + fn status_failed_while_connecting_transitions() { + let mut model = model_with_ready_state(); + if let WifiState::Ready { status, .. } = &mut model.wifi_state { + status.state = WifiConnectionState::Connecting; + } + let response = WifiStatusApiResponse { + status: "ok".to_string(), + state: "failed".to_string(), + ssid: None, + ip_address: None, + }; + let _ = handle(WifiEvent::StatusResponse(Ok(response)), &mut model); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg.contains("failed") + )); + } + } + } + + mod disconnect { + use super::*; + + #[test] + fn disconnect_error_sets_failed() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::DisconnectResponse(Err("disconnect failed".to_string())), + &mut model, + ); + + if let WifiState::Ready { status, .. } = &model.wifi_state { + assert!(matches!( + &status.state, + WifiConnectionState::Failed(msg) if msg == "disconnect failed" + )); + } + } + } + + mod forget_network { + use super::*; + use crate::events::WifiSavedNetworkApiResponse; + + #[test] + fn forget_error_logs_and_renders() { + let mut model = model_with_ready_state(); + let _ = handle( + WifiEvent::ForgetNetworkResponse(Err("not found".to_string())), + &mut model, + ); + // No crash, renders + } + + #[test] + fn saved_networks_response_updates_state() { + let mut model = model_with_ready_state(); + let response = WifiSavedNetworksApiResponse { + status: "ok".to_string(), + networks: vec![ + WifiSavedNetworkApiResponse { + ssid: "Home".to_string(), + flags: "[CURRENT]".to_string(), + }, + WifiSavedNetworkApiResponse { + ssid: "Work".to_string(), + flags: "".to_string(), + }, + ], + }; + let _ = handle(WifiEvent::SavedNetworksResponse(Ok(response)), &mut model); + + if let WifiState::Ready { saved_networks, .. } = &model.wifi_state { + assert_eq!(saved_networks.len(), 2); + assert_eq!(saved_networks[0].ssid, "Home"); + assert_eq!(saved_networks[0].flags, "[CURRENT]"); + } + } + } + + mod unavailable_state { + use super::*; + + #[test] + fn scan_on_unavailable_state_returns_done() { + let mut model = Model::default(); + assert_eq!(model.wifi_state, WifiState::Unavailable); + let _ = handle(WifiEvent::Scan, &mut model); + // Should not panic, just returns done + } + } +} diff --git a/src/app/src/wifi_psk.rs b/src/app/src/wifi_psk.rs new file mode 100644 index 00000000..b4a1f335 --- /dev/null +++ b/src/app/src/wifi_psk.rs @@ -0,0 +1,48 @@ +use pbkdf2::pbkdf2_hmac; +use sha1::Sha1; + +const WPA_PSK_ITERATIONS: u32 = 4096; +const WPA_PSK_KEY_LENGTH: usize = 32; + +/// Compute WPA PSK from password and SSID per IEEE 802.11i. +/// +/// Uses PBKDF2(HMAC-SHA1, password, SSID, 4096, 256 bits) → 64-char hex. +pub fn compute_wpa_psk(password: &str, ssid: &str) -> String { + let mut key = [0u8; WPA_PSK_KEY_LENGTH]; + pbkdf2_hmac::( + password.as_bytes(), + ssid.as_bytes(), + WPA_PSK_ITERATIONS, + &mut key, + ); + hex::encode(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// IEEE 802.11i test vector + #[test] + fn ieee_test_vector() { + let psk = compute_wpa_psk("password", "IEEE"); + assert_eq!( + psk, + "f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e" + ); + } + + #[test] + fn different_ssid_produces_different_psk() { + let psk1 = compute_wpa_psk("password", "NetworkA"); + let psk2 = compute_wpa_psk("password", "NetworkB"); + assert_ne!(psk1, psk2); + } + + #[test] + fn output_is_64_hex_chars() { + let psk = compute_wpa_psk("testpass", "testssid"); + assert_eq!(psk.len(), 64); + assert!(psk.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/src/backend/src/api.rs b/src/backend/src/api.rs index 505d1d48..d504801f 100644 --- a/src/backend/src/api.rs +++ b/src/backend/src/api.rs @@ -9,6 +9,9 @@ use crate::{ marker, network::{NetworkConfigRequest, NetworkConfigService}, }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiConnectRequest, WifiForgetRequest, + }, }; use actix_files::NamedFile; use actix_multipart::Multipart; @@ -287,3 +290,96 @@ where HttpResponse::Ok().body(token) } } + +// --- WiFi API handlers --- +// Stored as separate web::Data resources to avoid changing the Api generic structure. + +use crate::wifi_commissioning_client::WifiCommissioningServiceClient; + +type WifiClient = Option; + +pub async fn wifi_available(availability: web::Data) -> impl Responder { + debug!("wifi_available() called"); + HttpResponse::Ok().json(availability.as_ref()) +} + +macro_rules! wifi_client_or_404 { + ($wifi:expr) => { + match $wifi.as_ref().as_ref() { + Some(client) => client, + None => return HttpResponse::NotFound().body("WiFi service unavailable"), + } + }; +} + +pub async fn wifi_scan(wifi: web::Data) -> impl Responder { + debug!("wifi_scan() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result(client.scan().await.map(|_| ()), "wifi_scan") +} + +pub async fn wifi_scan_results(wifi: web::Data) -> impl Responder { + debug!("wifi_scan_results() called"); + let client = wifi_client_or_404!(wifi); + match client.scan_results().await { + Ok(results) => HttpResponse::Ok().json(&results), + Err(e) => { + error!("wifi_scan_results failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_connect( + body: web::Json, + wifi: web::Data, +) -> impl Responder { + debug!("wifi_connect() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result( + client.connect(body.into_inner()).await.map(|_| ()), + "wifi_connect", + ) +} + +pub async fn wifi_disconnect(wifi: web::Data) -> impl Responder { + debug!("wifi_disconnect() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result(client.disconnect().await.map(|_| ()), "wifi_disconnect") +} + +pub async fn wifi_status(wifi: web::Data) -> impl Responder { + debug!("wifi_status() called"); + let client = wifi_client_or_404!(wifi); + match client.status().await { + Ok(status) => HttpResponse::Ok().json(&status), + Err(e) => { + error!("wifi_status failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_saved_networks(wifi: web::Data) -> impl Responder { + debug!("wifi_saved_networks() called"); + let client = wifi_client_or_404!(wifi); + match client.saved_networks().await { + Ok(networks) => HttpResponse::Ok().json(&networks), + Err(e) => { + error!("wifi_saved_networks failed: {e:#}"); + HttpResponse::InternalServerError().body(e.to_string()) + } + } +} + +pub async fn wifi_forget_network( + body: web::Json, + wifi: web::Data, +) -> impl Responder { + debug!("wifi_forget_network() called"); + let client = wifi_client_or_404!(wifi); + handle_service_result( + client.forget_network(body.into_inner()).await.map(|_| ()), + "wifi_forget_network", + ) +} diff --git a/src/backend/src/config.rs b/src/backend/src/config.rs index 699c6b06..e8e3e93b 100644 --- a/src/backend/src/config.rs +++ b/src/backend/src/config.rs @@ -24,6 +24,9 @@ pub struct AppConfig { #[cfg_attr(feature = "mock", allow(dead_code))] pub iot_edge: IoTEdgeConfig, + /// WiFi commissioning service configuration + pub wifi: WifiConfig, + /// Path configuration pub paths: PathConfig, @@ -81,6 +84,11 @@ pub struct PathConfig { pub local_update_file: PathBuf, } +#[derive(Clone, Debug)] +pub struct WifiConfig { + pub socket_path: PathBuf, +} + impl AppConfig { /// Get or load the application configuration /// @@ -116,6 +124,7 @@ impl AppConfig { let device_service = DeviceServiceConfig::load()?; let certificate = CertificateConfig::load()?; let iot_edge = IoTEdgeConfig::load()?; + let wifi = WifiConfig::load(); let paths = PathConfig::load()?; let tenant = env::var("TENANT").unwrap_or_else(|_| "cp".to_string()); @@ -126,6 +135,7 @@ impl AppConfig { device_service, certificate, iot_edge, + wifi, paths, tenant, }) @@ -271,6 +281,16 @@ impl IoTEdgeConfig { } } +impl WifiConfig { + fn load() -> Self { + let socket_path = env::var("WIFI_COMMISSIONING_SOCKET_PATH") + .unwrap_or_else(|_| "/socket/wifi-commissioning.sock".to_string()) + .into(); + + Self { socket_path } + } +} + impl PathConfig { fn load() -> Result { #[cfg(not(any(test, feature = "mock")))] diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index fb5103d3..f8f14457 100644 --- a/src/backend/src/lib.rs +++ b/src/backend/src/lib.rs @@ -5,6 +5,7 @@ pub mod keycloak_client; pub mod middleware; pub mod omnect_device_service_client; pub mod services; +pub mod wifi_commissioning_client; // Re-exports from services for backward compatibility pub use services::auth; diff --git a/src/backend/src/main.rs b/src/backend/src/main.rs index 485ed458..7e6f15f9 100644 --- a/src/backend/src/main.rs +++ b/src/backend/src/main.rs @@ -5,6 +5,7 @@ mod keycloak_client; mod middleware; mod omnect_device_service_client; mod services; +mod wifi_commissioning_client; use crate::{ api::Api, @@ -16,6 +17,9 @@ use crate::{ certificate::{CertificateService, CreateCertPayload}, network::NetworkConfigService, }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiCommissioningServiceClient, + }, }; use actix_cors::Cors; use actix_multipart::form::MultipartFormConfig; @@ -282,6 +286,35 @@ fn optimal_worker_count() -> usize { workers } +async fn initialize_wifi_client() -> (Option, WifiAvailability) { + let unavailable = WifiAvailability { + available: false, + interface_name: None, + }; + + let config = &AppConfig::get().wifi; + + let Some(client) = WifiCommissioningServiceClient::try_new(&config.socket_path) else { + return (None, unavailable); + }; + + // Probe the service to discover the WiFi interface name + match client.status().await { + Ok(status) => { + let availability = WifiAvailability { + available: true, + interface_name: status.interface_name, + }; + info!("WiFi service available: {availability:?}"); + (Some(client), availability) + } + Err(e) => { + log::error!("WiFi service probe failed: {e:#}"); + (None, unavailable) + } + } +} + async fn run_server( service_client: OmnectDeviceServiceClient, ) -> Result<( @@ -292,6 +325,10 @@ async fn run_server( .await .context("failed to create api")?; + let (wifi_client, wifi_availability) = initialize_wifi_client().await; + let wifi_data: Data> = Data::new(wifi_client); + let wifi_availability_data = Data::new(wifi_availability); + let tls_config = load_tls_config().context("failed to load tls config")?; let config = &AppConfig::get(); let ui_port = config.ui.port; @@ -327,6 +364,8 @@ async fn run_server( .app_data(Data::new(token_manager.clone())) .app_data(Data::new(api.clone())) .app_data(Data::new(static_files())) + .app_data(wifi_data.clone()) + .app_data(wifi_availability_data.clone()) .route("/", web::get().to(UiApi::index)) .route("/config.js", web::get().to(UiApi::config)) .route( @@ -384,6 +423,44 @@ async fn run_server( "/ack-update-validation", web::post().to(UiApi::ack_update_validation), ) + // WiFi management routes + .route("/wifi/available", web::get().to(api::wifi_available)) + .route( + "/wifi/scan", + web::post().to(api::wifi_scan).wrap(middleware::AuthMw), + ) + .route( + "/wifi/scan/results", + web::get() + .to(api::wifi_scan_results) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/connect", + web::post().to(api::wifi_connect).wrap(middleware::AuthMw), + ) + .route( + "/wifi/disconnect", + web::post() + .to(api::wifi_disconnect) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/status", + web::get().to(api::wifi_status).wrap(middleware::AuthMw), + ) + .route( + "/wifi/networks", + web::get() + .to(api::wifi_saved_networks) + .wrap(middleware::AuthMw), + ) + .route( + "/wifi/networks/forget", + web::post() + .to(api::wifi_forget_network) + .wrap(middleware::AuthMw), + ) .service(ResourceFiles::new("/static", static_files())) .default_service(web::route().to(UiApi::index)) }) diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs new file mode 100644 index 00000000..4498d9c6 --- /dev/null +++ b/src/backend/src/wifi_commissioning_client.rs @@ -0,0 +1,373 @@ +#![cfg_attr(feature = "mock", allow(dead_code, unused_imports))] + +use crate::http_client::{handle_http_response, unix_socket_client}; +use anyhow::{Context, Result}; +use log::info; +#[cfg(feature = "mock")] +use mockall::automock; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; +use std::path::Path; +use trait_variant::make; + +// --- Request DTOs --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiConnectRequest { + pub ssid: String, + pub psk: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiForgetRequest { + pub ssid: String, +} + +// --- Response DTOs --- + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiScanStartedResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiScanResultsResponse { + pub status: String, + pub state: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiNetwork { + pub ssid: String, + pub mac: String, + pub ch: u16, + pub rssi: i16, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiConnectResponse { + pub status: String, + pub state: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiDisconnectResponse { + pub status: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiStatusResponse { + pub status: String, + pub state: String, + pub ssid: Option, + pub ip_address: Option, + pub interface_name: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WifiSavedNetworksResponse { + pub status: String, + pub networks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct WifiSavedNetwork { + pub ssid: String, + pub flags: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct WifiForgetResponse { + pub status: String, +} + +// --- Availability response (our own, not from the service) --- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WifiAvailability { + pub available: bool, + pub interface_name: Option, +} + +// --- Client trait --- + +#[make(Send)] +#[cfg_attr(feature = "mock", automock)] +pub trait WifiCommissioningClient { + async fn scan(&self) -> Result; + async fn scan_results(&self) -> Result; + async fn connect(&self, request: WifiConnectRequest) -> Result; + async fn disconnect(&self) -> Result; + async fn status(&self) -> Result; + async fn saved_networks(&self) -> Result; + async fn forget_network(&self, request: WifiForgetRequest) -> Result; +} + +#[cfg(feature = "mock")] +impl Clone for MockWifiCommissioningClient { + fn clone(&self) -> Self { + Self::new() + } +} + +// --- Client implementation --- + +#[derive(Clone)] +pub struct WifiCommissioningServiceClient { + client: Client, +} + +impl WifiCommissioningServiceClient { + const SCAN_ENDPOINT: &str = "/api/v1/scan"; + const SCAN_RESULTS_ENDPOINT: &str = "/api/v1/scan/results"; + const CONNECT_ENDPOINT: &str = "/api/v1/connect"; + const DISCONNECT_ENDPOINT: &str = "/api/v1/disconnect"; + const STATUS_ENDPOINT: &str = "/api/v1/status"; + const NETWORKS_ENDPOINT: &str = "/api/v1/networks"; + const FORGET_ENDPOINT: &str = "/api/v1/networks/forget"; + + /// Try to create a client. Returns `None` if the socket does not exist. + pub fn try_new(socket_path: &Path) -> Option { + let path_str = socket_path.to_string_lossy(); + + if !socket_path.exists() { + info!("WiFi socket not found at {path_str}, WiFi management disabled"); + return None; + } + + match unix_socket_client(&path_str) { + Ok(client) => { + info!("WiFi commissioning client created for socket {path_str}"); + Some(Self { client }) + } + Err(e) => { + log::error!("Failed to create WiFi socket client at {path_str}: {e:#}"); + None + } + } + } + + fn build_url(path: &str) -> String { + let normalized = path.trim_start_matches('/'); + format!("http://localhost/{normalized}") + } + + async fn get(&self, path: &str) -> Result { + let url = Self::build_url(path); + info!("WiFi GET {url}"); + + let res = self + .client + .get(&url) + .send() + .await + .context(format!("failed to send GET to {url}"))?; + + handle_http_response(res, &format!("WiFi GET {url}")).await + } + + async fn post(&self, path: &str) -> Result { + let url = Self::build_url(path); + info!("WiFi POST {url}"); + + let res = self + .client + .post(&url) + .send() + .await + .context(format!("failed to send POST to {url}"))?; + + handle_http_response(res, &format!("WiFi POST {url}")).await + } + + async fn post_json(&self, path: &str, body: impl Debug + Serialize) -> Result { + let url = Self::build_url(path); + info!("WiFi POST {url} with body: {body:?}"); + + let res = self + .client + .post(&url) + .json(&body) + .send() + .await + .context(format!("failed to send POST to {url}"))?; + + handle_http_response(res, &format!("WiFi POST {url}")).await + } +} + +impl WifiCommissioningClient for WifiCommissioningServiceClient { + async fn scan(&self) -> Result { + let body = self.post(Self::SCAN_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse scan response") + } + + async fn scan_results(&self) -> Result { + let body = self.get(Self::SCAN_RESULTS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse scan results") + } + + async fn connect(&self, request: WifiConnectRequest) -> Result { + let body = self.post_json(Self::CONNECT_ENDPOINT, request).await?; + serde_json::from_str(&body).context("failed to parse connect response") + } + + async fn disconnect(&self) -> Result { + let body = self.post(Self::DISCONNECT_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse disconnect response") + } + + async fn status(&self) -> Result { + let body = self.get(Self::STATUS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse status response") + } + + async fn saved_networks(&self) -> Result { + let body = self.get(Self::NETWORKS_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse saved networks response") + } + + async fn forget_network(&self, request: WifiForgetRequest) -> Result { + let body = self.post_json(Self::FORGET_ENDPOINT, request).await?; + serde_json::from_str(&body).context("failed to parse forget response") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod build_url { + use super::*; + + #[test] + fn normalizes_path_with_leading_slash() { + let url = WifiCommissioningServiceClient::build_url("/api/v1/scan"); + assert_eq!(url, "http://localhost/api/v1/scan"); + } + + #[test] + fn normalizes_path_without_leading_slash() { + let url = WifiCommissioningServiceClient::build_url("api/v1/scan"); + assert_eq!(url, "http://localhost/api/v1/scan"); + } + } + + mod dto_serialization { + use super::*; + + #[test] + fn connect_request_serializes_correctly() { + let req = WifiConnectRequest { + ssid: "MyNetwork".to_string(), + psk: "a".repeat(64), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"ssid\":\"MyNetwork\"")); + assert!(json.contains("\"psk\":\"")); + } + + #[test] + fn forget_request_serializes_correctly() { + let req = WifiForgetRequest { + ssid: "OldNetwork".to_string(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("\"ssid\":\"OldNetwork\"")); + } + + #[test] + fn status_response_deserializes_with_all_fields() { + let json = r#"{"status":"ok","state":"connected","ssid":"MyNet","ip_address":"192.168.1.100","interface_name":"wlan0"}"#; + let resp: WifiStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "connected"); + assert_eq!(resp.ssid.as_deref(), Some("MyNet")); + assert_eq!(resp.ip_address.as_deref(), Some("192.168.1.100")); + assert_eq!(resp.interface_name.as_deref(), Some("wlan0")); + } + + #[test] + fn status_response_deserializes_without_optional_fields() { + let json = r#"{"status":"ok","state":"idle","ssid":null,"ip_address":null,"interface_name":"wlan0"}"#; + let resp: WifiStatusResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "idle"); + assert!(resp.ssid.is_none()); + assert!(resp.ip_address.is_none()); + assert_eq!(resp.interface_name.as_deref(), Some("wlan0")); + } + + #[test] + fn scan_results_deserializes_network_list() { + let json = r#"{"status":"ok","state":"finished","networks":[{"ssid":"Net1","mac":"aa:bb:cc:dd:ee:ff","ch":6,"rssi":-55}]}"#; + let resp: WifiScanResultsResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.state, "finished"); + assert_eq!(resp.networks.len(), 1); + assert_eq!(resp.networks[0].ssid, "Net1"); + assert_eq!(resp.networks[0].ch, 6); + assert_eq!(resp.networks[0].rssi, -55); + } + + #[test] + fn saved_networks_deserializes_with_flags() { + let json = r#"{"status":"ok","networks":[{"ssid":"Home","flags":"[CURRENT]"},{"ssid":"Work","flags":""}]}"#; + let resp: WifiSavedNetworksResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.networks.len(), 2); + assert_eq!(resp.networks[0].flags, "[CURRENT]"); + assert!(resp.networks[1].flags.is_empty()); + } + } + + mod try_new { + use super::*; + + #[test] + fn returns_none_for_nonexistent_socket() { + let result = + WifiCommissioningServiceClient::try_new(Path::new("/tmp/nonexistent.sock")); + assert!(result.is_none()); + } + } + + mod constants { + use super::*; + + #[test] + fn api_endpoints_are_correctly_defined() { + assert_eq!( + WifiCommissioningServiceClient::SCAN_ENDPOINT, + "/api/v1/scan" + ); + assert_eq!( + WifiCommissioningServiceClient::SCAN_RESULTS_ENDPOINT, + "/api/v1/scan/results" + ); + assert_eq!( + WifiCommissioningServiceClient::CONNECT_ENDPOINT, + "/api/v1/connect" + ); + assert_eq!( + WifiCommissioningServiceClient::DISCONNECT_ENDPOINT, + "/api/v1/disconnect" + ); + assert_eq!( + WifiCommissioningServiceClient::STATUS_ENDPOINT, + "/api/v1/status" + ); + assert_eq!( + WifiCommissioningServiceClient::NETWORKS_ENDPOINT, + "/api/v1/networks" + ); + assert_eq!( + WifiCommissioningServiceClient::FORGET_ENDPOINT, + "/api/v1/networks/forget" + ); + } + } +} diff --git a/src/shared_types/build.rs b/src/shared_types/build.rs index 1b80feb7..abcff210 100644 --- a/src/shared_types/build.rs +++ b/src/shared_types/build.rs @@ -1,10 +1,11 @@ use anyhow::Result; use crux_core::typegen::TypeGen; use omnect_ui_core::{ - events::{AuthEvent, DeviceEvent, UiEvent, WebSocketEvent}, + events::{AuthEvent, DeviceEvent, UiEvent, WebSocketEvent, WifiEvent}, types::{ DeviceOperationState, FactoryResetStatus, NetworkChangeState, NetworkConfigRequest, - NetworkFormData, NetworkFormState, UploadState, + NetworkFormData, NetworkFormState, UploadState, WifiConnectionState, WifiConnectionStatus, + WifiNetwork, WifiSavedNetwork, WifiScanState, WifiState, }, App, }; @@ -22,6 +23,7 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; gen.register_type::()?; + gen.register_type::()?; // Explicitly register other enums/structs to ensure all variants are traced gen.register_type::()?; @@ -32,6 +34,14 @@ fn main() -> Result<()> { gen.register_type::()?; gen.register_type::()?; + // Register WiFi types + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + gen.register_type::()?; + // Register ODS types gen.register_type::()?; gen.register_type::()?; diff --git a/src/ui/src/components/network/DeviceNetworks.vue b/src/ui/src/components/network/DeviceNetworks.vue index fa04a793..dab5c828 100644 --- a/src/ui/src/components/network/DeviceNetworks.vue +++ b/src/ui/src/components/network/DeviceNetworks.vue @@ -1,11 +1,16 @@ + + diff --git a/src/ui/src/components/network/WifiForgetDialog.vue b/src/ui/src/components/network/WifiForgetDialog.vue new file mode 100644 index 00000000..ac84672e --- /dev/null +++ b/src/ui/src/components/network/WifiForgetDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/ui/src/components/network/WifiPanel.vue b/src/ui/src/components/network/WifiPanel.vue new file mode 100644 index 00000000..94d886a4 --- /dev/null +++ b/src/ui/src/components/network/WifiPanel.vue @@ -0,0 +1,146 @@ + + + diff --git a/src/ui/src/composables/core/index.ts b/src/ui/src/composables/core/index.ts index c7625c51..02b5841d 100644 --- a/src/ui/src/composables/core/index.ts +++ b/src/ui/src/composables/core/index.ts @@ -48,6 +48,7 @@ import { EventVariantDevice, EventVariantWebSocket, EventVariantUi, + EventVariantWifi, AuthEventVariantLogin, AuthEventVariantLogout, AuthEventVariantSetPassword, @@ -69,6 +70,14 @@ import { UiEventVariantClearError, UiEventVariantClearSuccess, UiEventVariantSetBrowserHostname, + WifiEventVariantScan, + WifiEventVariantConnect, + WifiEventVariantDisconnect, + WifiEventVariantGetStatus, + WifiEventVariantGetSavedNetworks, + WifiEventVariantForgetNetwork, + WifiEventVariantScanPollTick, + WifiEventVariantConnectPollTick, } from '../../../../shared_types/generated/typescript/types/shared_types' // Re-export types for external use @@ -80,6 +89,12 @@ export type { NetworkFormDataType, OverlaySpinnerStateType, FactoryResetStatusString, + WifiStateType, + WifiNetworkType, + WifiSavedNetworkType, + WifiConnectionStatusType, + WifiScanStateType, + WifiConnectionStateType, SystemInfo, NetworkStatus, OnlineStatus, @@ -295,5 +310,16 @@ export function useCore() { sendEventToCore(new EventVariantDevice(new DeviceEventVariantAckFactoryResetResult())), ackUpdateValidation: () => sendEventToCore(new EventVariantDevice(new DeviceEventVariantAckUpdateValidation())), + + // WiFi management + wifiScan: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantScan())), + wifiConnect: (ssid: string, password: string) => + sendEventToCore(new EventVariantWifi(new WifiEventVariantConnect(ssid, password))), + wifiDisconnect: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantDisconnect())), + wifiGetStatus: () => sendEventToCore(new EventVariantWifi(new WifiEventVariantGetStatus())), + wifiGetSavedNetworks: () => + sendEventToCore(new EventVariantWifi(new WifiEventVariantGetSavedNetworks())), + wifiForgetNetwork: (ssid: string) => + sendEventToCore(new EventVariantWifi(new WifiEventVariantForgetNetwork(ssid))), } } diff --git a/src/ui/src/composables/core/state.ts b/src/ui/src/composables/core/state.ts index 0bab9469..b5148e17 100644 --- a/src/ui/src/composables/core/state.ts +++ b/src/ui/src/composables/core/state.ts @@ -52,6 +52,8 @@ export const viewModel = reactive({ firmwareUploadState: { type: 'idle' }, // Overlay spinner state overlaySpinner: { overlay: false, title: '', text: null, timedOut: false, progress: null, countdownSeconds: null }, + // WiFi state + wifiState: { type: 'unavailable' }, }) /** diff --git a/src/ui/src/composables/core/sync.ts b/src/ui/src/composables/core/sync.ts index fc624335..a40274dd 100644 --- a/src/ui/src/composables/core/sync.ts +++ b/src/ui/src/composables/core/sync.ts @@ -12,6 +12,7 @@ import { convertNetworkChangeState, convertNetworkFormState, convertUploadState, + convertWifiState, } from './types' import { setViewModelUpdater } from './effects' import { Model as GeneratedViewModel } from '../../../../shared_types/generated/typescript/types/shared_types' @@ -199,6 +200,9 @@ export function updateViewModelFromCore(): void { // Firmware upload state viewModel.firmwareUploadState = convertUploadState(coreViewModel.firmwareUploadState) + // WiFi state + viewModel.wifiState = convertWifiState(coreViewModel.wifiState) + // Auto-subscribe logic based on authentication state transition if (viewModel.isAuthenticated && !wasAuthenticated) { console.log('[useCore] User authenticated, triggering subscription') diff --git a/src/ui/src/composables/core/timers.ts b/src/ui/src/composables/core/timers.ts index 6f7fc465..915bbc7d 100644 --- a/src/ui/src/composables/core/timers.ts +++ b/src/ui/src/composables/core/timers.ts @@ -12,10 +12,13 @@ import { viewModel, isInitialized, wasmModule } from './state' import type { Event } from '../../../../shared_types/generated/typescript/types/shared_types' import { EventVariantDevice, + EventVariantWifi, DeviceEventVariantReconnectionCheckTick, DeviceEventVariantReconnectionTimeout, DeviceEventVariantNewIpCheckTick, DeviceEventVariantNewIpCheckTimeout, + WifiEventVariantScanPollTick, + WifiEventVariantConnectPollTick, } from '../../../../shared_types/generated/typescript/types/shared_types' // Timer callback type - will be set by index.ts to avoid circular dependency @@ -34,6 +37,8 @@ export function setEventSender(callback: (event: Event) => Promise): void const RECONNECTION_POLL_INTERVAL_MS = Number(import.meta.env.VITE_RECONNECTION_POLL_INTERVAL_MS) || 5000 // 5 seconds const NEW_IP_POLL_INTERVAL_MS = Number(import.meta.env.VITE_NEW_IP_POLL_INTERVAL_MS) || 5000 // 5 seconds +const WIFI_SCAN_POLL_INTERVAL_MS = 500 +const WIFI_CONNECT_POLL_INTERVAL_MS = 1000 // Optional test overrides for reconnection timeouts (production values come from Core) const REBOOT_TIMEOUT_OVERRIDE_MS = import.meta.env.VITE_REBOOT_TIMEOUT_MS ? Number(import.meta.env.VITE_REBOOT_TIMEOUT_MS) : null @@ -51,6 +56,8 @@ let reconnectionCountdownDeadline: number | null = null let newIpIntervalId: ReturnType | null = null let newIpTimeoutId: ReturnType | null = null let newIpCountdownIntervalId: ReturnType | null = null +let wifiScanPollIntervalId: ReturnType | null = null +let wifiConnectPollIntervalId: ReturnType | null = null // Countdown deadline for network changes (Unix timestamp in milliseconds) let countdownDeadline: number | null = null @@ -409,4 +416,52 @@ export function initializeTimerWatchers(): void { }, { deep: true } ) + + // Watch WiFi scan state for scan polling + watch( + () => viewModel.wifiState.type === 'ready' ? (viewModel.wifiState as any).scanState?.type : null, + (newType, oldType) => { + if (newType === oldType) return + + if (newType === 'scanning') { + // Start scan poll interval + if (wifiScanPollIntervalId !== null) clearInterval(wifiScanPollIntervalId) + wifiScanPollIntervalId = setInterval(() => { + if (isInitialized.value && wasmModule.value && sendEventCallback) { + sendEventCallback(new EventVariantWifi(new WifiEventVariantScanPollTick())) + } + }, WIFI_SCAN_POLL_INTERVAL_MS) + } else { + // Stop scan poll interval + if (wifiScanPollIntervalId !== null) { + clearInterval(wifiScanPollIntervalId) + wifiScanPollIntervalId = null + } + } + } + ) + + // Watch WiFi connection state for connect polling + watch( + () => viewModel.wifiState.type === 'ready' ? (viewModel.wifiState as any).status?.state?.type : null, + (newType, oldType) => { + if (newType === oldType) return + + if (newType === 'connecting') { + // Start connect poll interval + if (wifiConnectPollIntervalId !== null) clearInterval(wifiConnectPollIntervalId) + wifiConnectPollIntervalId = setInterval(() => { + if (isInitialized.value && wasmModule.value && sendEventCallback) { + sendEventCallback(new EventVariantWifi(new WifiEventVariantConnectPollTick())) + } + }, WIFI_CONNECT_POLL_INTERVAL_MS) + } else { + // Stop connect poll interval + if (wifiConnectPollIntervalId !== null) { + clearInterval(wifiConnectPollIntervalId) + wifiConnectPollIntervalId = null + } + } + } + ) } \ No newline at end of file diff --git a/src/ui/src/composables/core/types.ts b/src/ui/src/composables/core/types.ts index b62cb041..09fd5b90 100644 --- a/src/ui/src/composables/core/types.ts +++ b/src/ui/src/composables/core/types.ts @@ -27,6 +27,19 @@ export { NetworkConfigRequest } from '../../../../shared_types/generated/typescr // Import types and variant classes for conversions import { + WifiState, + WifiStateVariantunavailable, + WifiStateVariantready, + WifiConnectionState, + WifiConnectionStateVariantidle, + WifiConnectionStateVariantconnecting, + WifiConnectionStateVariantconnected, + WifiConnectionStateVariantfailed, + WifiScanState, + WifiScanStateVariantidle, + WifiScanStateVariantscanning, + WifiScanStateVariantfinished, + WifiScanStateVarianterror, DeviceOperationState, DeviceOperationStateVariantidle, DeviceOperationStateVariantrebooting, @@ -69,6 +82,7 @@ export { FactoryResetStatus, UploadState, DeviceNetwork, + WifiState, } // ============================================================================ @@ -121,6 +135,49 @@ export interface OverlaySpinnerStateType { countdownSeconds: number | null } +export type WifiScanStateType = + | { type: 'idle' } + | { type: 'scanning' } + | { type: 'finished' } + | { type: 'error'; message: string } + +export type WifiConnectionStateType = + | { type: 'idle' } + | { type: 'connecting' } + | { type: 'connected' } + | { type: 'failed'; message: string } + +export interface WifiConnectionStatusType { + state: WifiConnectionStateType + ssid: string | null + ipAddress: string | null +} + +export interface WifiNetworkType { + ssid: string + mac: string + channel: number + rssi: number +} + +export interface WifiSavedNetworkType { + ssid: string + flags: string +} + +export type WifiStateType = + | { type: 'unavailable' } + | { + type: 'ready' + interfaceName: string + status: WifiConnectionStatusType + scanState: WifiScanStateType + scanResults: WifiNetworkType[] + savedNetworks: WifiSavedNetworkType[] + scanPollAttempt: number + connectPollAttempt: number + } + export type FactoryResetStatusString = 'unknown' | 'modeSupported' | 'modeUnsupported' | 'backupRestoreError' | 'configurationError' // ============================================================================ @@ -195,6 +252,9 @@ export interface ViewModel { // Overlay spinner state overlaySpinner: OverlaySpinnerStateType + + // WiFi state + wifiState: WifiStateType } // ============================================================================ @@ -351,3 +411,59 @@ export function convertUploadState(state: UploadState): UploadStateType { } return { type: 'idle' } } + +/** + * Convert WifiScanState variant to typed object + */ +export function convertWifiScanState(state: WifiScanState): WifiScanStateType { + if (state instanceof WifiScanStateVariantidle) return { type: 'idle' } + if (state instanceof WifiScanStateVariantscanning) return { type: 'scanning' } + if (state instanceof WifiScanStateVariantfinished) return { type: 'finished' } + if (state instanceof WifiScanStateVarianterror) return { type: 'error', message: state.value } + return { type: 'idle' } +} + +/** + * Convert WifiConnectionState variant to typed object + */ +export function convertWifiConnectionState(state: WifiConnectionState): WifiConnectionStateType { + if (state instanceof WifiConnectionStateVariantidle) return { type: 'idle' } + if (state instanceof WifiConnectionStateVariantconnecting) return { type: 'connecting' } + if (state instanceof WifiConnectionStateVariantconnected) return { type: 'connected' } + if (state instanceof WifiConnectionStateVariantfailed) return { type: 'failed', message: state.value } + return { type: 'idle' } +} + +/** + * Convert WifiState variant to typed object + */ +export function convertWifiState(state: WifiState): WifiStateType { + if (state instanceof WifiStateVariantunavailable) { + return { type: 'unavailable' } + } + if (state instanceof WifiStateVariantready) { + return { + type: 'ready', + interfaceName: state.interface_name, + status: { + state: convertWifiConnectionState(state.status.state), + ssid: state.status.ssid || null, + ipAddress: state.status.ipAddress || null, + }, + scanState: convertWifiScanState(state.scan_state), + scanResults: state.scan_results.map(n => ({ + ssid: n.ssid, + mac: n.mac, + channel: n.channel, + rssi: n.rssi, + })), + savedNetworks: state.saved_networks.map(n => ({ + ssid: n.ssid, + flags: n.flags, + })), + scanPollAttempt: state.scan_poll_attempt, + connectPollAttempt: state.connect_poll_attempt, + } + } + return { type: 'unavailable' } +} diff --git a/src/ui/src/composables/useCore.ts b/src/ui/src/composables/useCore.ts index 7871d13a..ae87dc14 100644 --- a/src/ui/src/composables/useCore.ts +++ b/src/ui/src/composables/useCore.ts @@ -23,6 +23,12 @@ export type { NetworkFormDataType, OverlaySpinnerStateType, FactoryResetStatusString, + WifiStateType, + WifiNetworkType, + WifiSavedNetworkType, + WifiConnectionStatusType, + WifiScanStateType, + WifiConnectionStateType, SystemInfo, NetworkStatus, OnlineStatus, diff --git a/src/ui/src/pages/SetPassword.vue b/src/ui/src/pages/SetPassword.vue index 8e0befca..a0b3ac99 100644 --- a/src/ui/src/pages/SetPassword.vue +++ b/src/ui/src/pages/SetPassword.vue @@ -6,6 +6,9 @@ import { useCore } from "../composables/useCore" import { useCoreInitialization } from "../composables/useCoreInitialization" import { usePasswordForm } from "../composables/usePasswordForm" import { useAuthNavigation } from "../composables/useAuthNavigation" +import { removeUser, login } from "../auth/auth-service" + +const PORTAL_AUTH_ERROR = "portal authentication required" const { viewModel, setPassword } = useCore() const { password, repeatPassword, errorMsg, validatePasswords } = usePasswordForm() @@ -23,6 +26,13 @@ watch( { flush: 'sync' } ) +watch(errorMsg, (msg) => { + if (msg === PORTAL_AUTH_ERROR) { + // Backend session lost — clear stale OIDC user and re-authenticate + removeUser().then(() => login()) + } +}) + const handleSubmit = async (): Promise => { if (!validatePasswords()) return await setPassword(password.value) diff --git a/src/ui/tests/auth.spec.ts b/src/ui/tests/auth.spec.ts index bf908dd4..fc57a4a9 100644 --- a/src/ui/tests/auth.spec.ts +++ b/src/ui/tests/auth.spec.ts @@ -146,6 +146,56 @@ test.describe('Authentication', () => { await redirectPromise; }); + test('re-triggers OIDC login when session expires during submission', async ({ page }) => { + // Simulate: OIDC user exists in localStorage (router guard passes) + // but backend rejects because portal_validated session flag is missing. + // The frontend should clear the stale OIDC user and redirect to Keycloak. + await mockPortalAuth(page); + await page.route('**/set-password', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 401, + contentType: 'text/plain', + body: 'portal authentication required', + }); + } else { + await route.continue(); + } + }); + + // Mock OIDC discovery so signinRedirect() can proceed + await page.route('**/.well-known/openid-configuration', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + issuer: 'http://localhost:8080', + authorization_endpoint: 'http://localhost:8080/auth', + token_endpoint: 'http://localhost:8080/token', + jwks_uri: 'http://localhost:8080/certs', + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }), + }); + }); + + await page.goto('/set-password'); + await expect(page.getByRole('heading', { name: /set password/i })).toBeVisible(); + + // Intercept the Keycloak redirect to verify it happens + const oidcRedirect = page.waitForURL(/localhost:8080/, { timeout: 5000 }).catch(() => null); + + await page.locator('input[type="password"]').nth(0).fill('new-password'); + await page.locator('input[type="password"]').nth(1).fill('new-password'); + await page.getByRole('button', { name: /set password/i }).click(); + + // Should redirect to Keycloak for re-authentication + await oidcRedirect; + // Page navigated away from the app — either to Keycloak or chrome-error (no real Keycloak) + await expect(page).not.toHaveURL(/localhost:5173/, { timeout: 5000 }); + }); + test('no auth errors on set-password page when WiFi is available', async ({ page }) => { await mockPortalAuth(page); await page.route('**/require-set-password', async (route) => { diff --git a/src/ui/tests/wifi.spec.ts b/src/ui/tests/wifi.spec.ts new file mode 100644 index 00000000..c7eb4e20 --- /dev/null +++ b/src/ui/tests/wifi.spec.ts @@ -0,0 +1,578 @@ +import { test, expect, Page } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; +import { publishToCentrifugo } from './fixtures/centrifugo'; + +// Run all tests in this file serially to avoid state interference +test.describe.configure({ mode: 'serial' }); + +// --- Mock helpers --- + +async function mockWifiAvailable(page: Page, available = true, interfaceName = 'wlan0') { + await page.route('**/wifi/available', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ available, interfaceName: available ? interfaceName : null }), + }); + }); +} + +async function mockWifiStatus(page: Page, state = 'idle', ssid: string | null = null, ipAddress: string | null = null) { + await page.route('**/wifi/status', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state, ssid, ip_address: ipAddress }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiScanStart(page: Page) { + await page.route('**/wifi/scan', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'scanning' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiScanResults(page: Page, state = 'finished', networks: any[] = []) { + await page.route('**/wifi/scan/results', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state, networks }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiConnect(page: Page, succeed = true) { + await page.route('**/wifi/connect', async (route) => { + if (route.request().method() === 'POST') { + if (succeed) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'connecting' }), + }); + } else { + await route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ error: 'Connection refused' }), + }); + } + } else { + await route.continue(); + } + }); +} + +async function mockWifiDisconnect(page: Page) { + await page.route('**/wifi/disconnect', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiSavedNetworks(page: Page, networks: any[] = []) { + await page.route('**/wifi/networks', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', networks }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockWifiForget(page: Page) { + await page.route('**/wifi/networks/forget', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok' }), + }); + } else { + await route.continue(); + } + }); +} + +async function mockHealthcheck(page: Page) { + await page.route('**/healthcheck', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + versionInfo: { current: '1.0.0', required: '1.0.0', mismatch: false }, + updateValidationStatus: { status: 'NoUpdate' }, + networkRollbackOccurred: false, + factoryResetResultAcked: true, + updateValidationAcked: true, + }), + }); + }); +} + +const defaultAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: 'localhost', dhcp: true, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: false, + ipv4: { addrs: [{ addr: '192.168.1.50', dhcp: true, prefix_len: 24 }], dns: [], gateways: [] }, + }, +]; + +const scanNetworks = [ + { ssid: 'HomeNetwork', mac: 'aa:bb:cc:dd:ee:01', ch: 6, rssi: -45 }, + { ssid: 'OfficeWiFi', mac: 'aa:bb:cc:dd:ee:02', ch: 11, rssi: -65 }, + { ssid: 'GuestNet', mac: 'aa:bb:cc:dd:ee:03', ch: 1, rssi: -80 }, +]; + +const savedNetworks = [ + { ssid: 'HomeNetwork', flags: '[CURRENT]' }, + { ssid: 'OldNetwork', flags: '' }, +]; + +/** Set up all route mocks before navigation */ +async function setupMocks(page: Page, wifiAvailable = true) { + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + await mockHealthcheck(page); + await mockWifiAvailable(page, wifiAvailable); + await mockWifiStatus(page, 'idle'); + await mockWifiSavedNetworks(page, []); +} + +/** Login, publish network data via Centrifugo, navigate to Network page */ +async function loginAndNavigate(page: Page) { + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + // Publish network adapter data via Centrifugo (after login so WebSocket is subscribed) + await publishToCentrifugo('NetworkStatusV1', { network_status: defaultAdapters }); + + // Navigate to network page + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.locator('.text-h4', { hasText: 'Network' })).toBeVisible({ timeout: 5000 }); + + // Wait for adapter tabs to appear from Centrifugo data + await expect(page.getByRole('tab', { name: /eth0/i })).toBeVisible({ timeout: 10000 }); +} + +// Static-IP adapters for tests that interact with the network config form +const staticAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.50', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, +]; + +// Adapter state after WiFi connects and the OS assigns a new IP to wlan0 +const wlan0AfterWifiAdapters = [ + { + name: 'eth0', + mac: 'aa:bb:cc:dd:ee:f0', + online: true, + ipv4: { addrs: [{ addr: '192.168.1.100', dhcp: false, prefix_len: 24 }], dns: ['8.8.8.8'], gateways: ['192.168.1.1'] }, + }, + { + name: 'wlan0', + mac: 'aa:bb:cc:dd:ee:f1', + online: true, + ipv4: { addrs: [{ addr: '192.168.100.50', dhcp: false, prefix_len: 24 }], dns: ['10.0.0.1'], gateways: ['192.168.100.1'] }, + }, +]; + +async function mockNetworkConfigSuccess(page: Page) { + await page.route('**/network', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ rollbackTimeoutSeconds: 90, uiPort: 5173, rollbackEnabled: false }), + }); + } else { + await route.continue(); + } + }); +} + +/** Login, publish custom adapters, navigate to Network page */ +async function loginAndNavigateWith(page: Page, adapters: any[]) { + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + await publishToCentrifugo('NetworkStatusV1', { network_status: adapters }); + + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.locator('.text-h4', { hasText: 'Network' })).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole('tab', { name: /eth0/i })).toBeVisible({ timeout: 10000 }); +} + +test.describe('WiFi Management', () => { + test('WiFi unavailable - no WiFi panel shown', async ({ page }) => { + await setupMocks(page, false); + + await page.goto('/'); + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + await publishToCentrifugo('NetworkStatusV1', { network_status: defaultAdapters }); + + await page.getByRole('link', { name: /network/i }).click(); + await expect(page.getByRole('tab', { name: /wlan0/i })).toBeVisible({ timeout: 10000 }); + + // Click wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // WiFi panel should NOT be visible + await expect(page.getByText('WiFi Connection')).not.toBeVisible(); + }); + + test('WiFi panel visible on WiFi adapter tab only', async ({ page }) => { + await setupMocks(page); + await loginAndNavigate(page); + + // eth0 tab should not show WiFi panel + await page.getByRole('tab', { name: /eth0/i }).click(); + await expect(page.getByText('WiFi Connection')).not.toBeVisible(); + + // wlan0 tab should show WiFi panel + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Available Networks')).toBeVisible(); + await expect(page.getByText('Saved Networks', { exact: true })).toBeVisible(); + }); + + test('WiFi icon shown on WiFi adapter tab', async ({ page }) => { + await setupMocks(page); + await loginAndNavigate(page); + + // wlan0 tab should have WiFi icon + const wlan0Tab = page.getByRole('tab', { name: /wlan0/i }); + await expect(wlan0Tab.locator('.mdi-wifi')).toBeVisible(); + + // eth0 tab should NOT have WiFi icon + const eth0Tab = page.getByRole('tab', { name: /eth0/i }); + await expect(eth0Tab.locator('.mdi-wifi')).not.toBeVisible(); + }); + + test('scan flow - discovers networks', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible(); + + // Click scan button + await page.locator('[data-cy=wifi-scan-button]').click(); + + // Networks should appear (after polling) + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-network-OfficeWiFi"]')).toBeVisible(); + await expect(page.locator('[data-cy="wifi-network-GuestNet"]')).toBeVisible(); + }); + + test('connect flow - password dialog and connection', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await mockWifiConnect(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Scan first + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + + // Click on a network to connect + await page.locator('[data-cy="wifi-network-HomeNetwork"]').click(); + + // Password dialog should open + await expect(page.getByText('Connect to HomeNetwork')).toBeVisible(); + await page.locator('[data-cy=wifi-password-input] input').fill('mypassword'); + + // Mock status to return connected after connect + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'connected', 'HomeNetwork', '192.168.1.100'); + + await page.locator('[data-cy=wifi-connect-button]').click(); + + // Should show SSID and disconnect button + await expect(page.locator('[data-cy=wifi-disconnect-button]')).toBeVisible({ timeout: 15000 }); + }); + + test('disconnect flow', async ({ page }) => { + // Start with connected status + await setupMocks(page); + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'connected', 'HomeNetwork', '192.168.1.100'); + await mockWifiDisconnect(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Should show SSID and disconnect button + await expect(page.getByText('HomeNetwork')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy=wifi-disconnect-button]')).toBeVisible(); + + // Mock status to return idle after disconnect + await page.unroute('**/wifi/status'); + await mockWifiStatus(page, 'idle'); + + await page.locator('[data-cy=wifi-disconnect-button]').click(); + + // Should show not connected + await expect(page.getByText('Not connected')).toBeVisible({ timeout: 10000 }); + }); + + test('saved networks and forget', async ({ page }) => { + await setupMocks(page); + await page.unroute('**/wifi/networks'); + await mockWifiSavedNetworks(page, savedNetworks); + await mockWifiForget(page); + await loginAndNavigate(page); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + + // Saved networks should be visible + await expect(page.locator('[data-cy="wifi-saved-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-saved-OldNetwork"]')).toBeVisible(); + + // Click forget on OldNetwork + await page.locator('[data-cy="wifi-forget-OldNetwork"]').click(); + + // Confirmation dialog + await expect(page.getByText('Forget Network')).toBeVisible(); + await expect(page.getByRole('strong')).toHaveText('OldNetwork'); + + // After confirming, mock returns only HomeNetwork + await page.unroute('**/wifi/networks'); + await mockWifiSavedNetworks(page, [{ ssid: 'HomeNetwork', flags: '[CURRENT]' }]); + + await page.locator('[data-cy=wifi-forget-confirm-button]').click(); + + // OldNetwork should disappear + await expect(page.locator('[data-cy="wifi-saved-OldNetwork"]')).not.toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-cy="wifi-saved-HomeNetwork"]')).toBeVisible(); + }); +}); + +test.describe('WiFi and Network Config Interaction', () => { + // Both v-window-items are rendered in the DOM at all times. All helpers below scope + // their selectors to the currently active tab panel to avoid strict-mode violations. + const activePanel = (page: Page) => page.locator('.v-window-item--active'); + const activeIpField = (page: Page) => + activePanel(page).getByRole('textbox', { name: /IP Address/i }); + const activeApplyButton = (page: Page) => + activePanel(page).locator('[data-cy=network-apply-button]'); + const activeDiscardButton = (page: Page) => + activePanel(page).locator('[data-cy=network-discard-button]'); + + test('network config form syncs with new IP after WiFi assigns one (clean form)', async ({ page }) => { + // This test exposes a bug: after a NetworkStatusV1 update (simulating WiFi assigning a new + // IP to wlan0), the network config form should show the new IP, not the stale one. + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // Verify initial IP in form + await expect(activeIpField(page)).toHaveValue('192.168.1.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + + // Simulate WiFi connecting and the OS assigning a new IP to wlan0 + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Form must sync to the WiFi-assigned IP and remain clean + await expect(activeIpField(page)).toHaveValue('192.168.100.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + }); + + test('network config form preserves user edits when WiFi assigns a new IP (dirty form)', async ({ page }) => { + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Simulate WiFi assigning a different IP to wlan0 + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Dirty flag must protect the user's edit — form must NOT be overwritten + await expect(activeIpField(page)).toHaveValue('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled(); + }); + + test('WiFi scan does not reset or modify network config form state', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Trigger WiFi scan + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + + // Network config form must be unchanged + await expect(activeIpField(page)).toHaveValue('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled(); + }); + + test('discard after WiFi assigns new IP resets form to WiFi-assigned IP', async ({ page }) => { + await setupMocks(page); + await loginAndNavigateWith(page, staticAdapters); + + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + + // User edits the IP → form becomes dirty + await activeIpField(page).fill('192.168.1.99'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Simulate WiFi assigning a new IP to wlan0 while user has unsaved edits + await publishToCentrifugo('NetworkStatusV1', { network_status: wlan0AfterWifiAdapters }); + + // Discard changes — must reload from the current network_status, not the original + await activeDiscardButton(page).click(); + + // Form must show the WiFi-assigned IP (192.168.100.50), not the user edit or the original + await expect(activeIpField(page)).toHaveValue('192.168.100.50', { timeout: 5000 }); + await expect(activeApplyButton(page)).toBeDisabled(); + }); + + test('network config apply succeeds while WiFi scan is in progress', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + // Scan stays in scanning state indefinitely + await page.route('**/wifi/scan/results', async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'ok', state: 'scanning', networks: [] }), + }); + } else { + await route.continue(); + } + }); + await mockNetworkConfigSuccess(page); + await loginAndNavigateWith(page, staticAdapters); + + // Start WiFi scan on wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy=wifi-scan-button]').click(); + // Scan spinner should be visible (scan in progress) + await expect(page.locator('[data-cy=wifi-scan-button]')).toBeVisible(); + + // Switch to eth0 tab (no network form unsaved changes) and edit config + await page.getByRole('tab', { name: /eth0/i }).click(); + // Wait for networkFormStartEdit('eth0') to be processed before editing + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 5000 }); + await activeIpField(page).fill('192.168.1.200'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Apply eth0 config while WiFi scan is still in progress + await activeApplyButton(page).click(); + + // Config should apply independently of WiFi scan state + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 10000 }); + }); + + test('network config apply succeeds while WiFi is connecting', async ({ page }) => { + await setupMocks(page); + await mockWifiScanStart(page); + await mockWifiScanResults(page, 'finished', scanNetworks); + await mockWifiConnect(page); + // Status stays at 'connecting' indefinitely so WiFi never completes + await mockWifiStatus(page, 'connecting', 'HomeNetwork', null); + await mockNetworkConfigSuccess(page); + await loginAndNavigateWith(page, staticAdapters); + + // Start connecting to a WiFi AP on wlan0 tab + await page.getByRole('tab', { name: /wlan0/i }).click(); + await expect(page.getByText('WiFi Connection')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy=wifi-scan-button]').click(); + await expect(page.locator('[data-cy="wifi-network-HomeNetwork"]')).toBeVisible({ timeout: 10000 }); + await page.locator('[data-cy="wifi-network-HomeNetwork"]').click(); + await page.locator('[data-cy=wifi-password-input] input').fill('mypassword'); + await page.locator('[data-cy=wifi-connect-button]').click(); + // WiFi is now in Connecting state + await expect(page.getByText('Connecting to HomeNetwork...')).toBeVisible({ timeout: 5000 }); + + // Switch to eth0 tab and edit config + await page.getByRole('tab', { name: /eth0/i }).click(); + // Wait for networkFormStartEdit('eth0') to be processed before editing + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 5000 }); + await activeIpField(page).fill('192.168.1.200'); + await expect(activeApplyButton(page)).toBeEnabled({ timeout: 5000 }); + + // Apply eth0 config while WiFi connection is still in progress + await activeApplyButton(page).click(); + + // Config should apply independently of WiFi connecting state + await expect(activeApplyButton(page)).toBeDisabled({ timeout: 10000 }); + }); +}); From 46987769866c3b7ef233e3e6b00b0cb7aae86c9a Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Fri, 27 Feb 2026 20:53:44 +0100 Subject: [PATCH 2/6] refactor(ui): consolidate healthcheck state and modernize e2e test infrastructure - **Model & Synchronization:** Moved and from the persistent state to ephemeral . This ensures that these one-time notification flags do not persist across device reboots or accidental state synchronization cycles, preventing redundant modal popups. - **Core Logic:** Implemented in the Crux Core to decouple the initial application hydration from subsequent polling cycles. This allows for a deterministic startup sequence where the UI can react to the device state immediately after WASM initialization. - **Test Infrastructure:** - Refactored and to utilize for modal suppression, replacing the fragile injection pattern. - Introduced and in the harness to reduce boilerplate and improve reliability of complex network redirection tests. - Standardized healthcheck mocking across all E2E tests to ensure consistent behavior during version-mismatch and rollback scenarios. - **Backend & Utilities:** Added to handle cases where the backend returns valid JSON alongside non-2xx status codes (e.g., 503 during version mismatch). Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- Cargo.lock | 8 +- project-context.md | 6 + src/app/src/events.rs | 1 + src/app/src/http_helpers.rs | 57 ++++ src/app/src/macros.rs | 3 +- src/app/src/model.rs | 4 + src/app/src/update/device/mod.rs | 56 ++-- src/app/src/update/device/reconnection.rs | 117 +++++-- src/app/src/update/websocket.rs | 62 ++-- .../src/services/auth/authorization.rs | 317 ++++++------------ src/backend/src/services/auth/password.rs | 49 +-- src/ui/src/App.vue | 85 ++--- src/ui/src/auth/validate-portal-token.ts | 31 ++ src/ui/src/composables/core/index.ts | 6 + src/ui/src/composables/core/state.ts | 3 + src/ui/src/composables/core/sync.ts | 99 +++--- src/ui/src/composables/core/types.ts | 4 + src/ui/src/pages/Callback.vue | 27 +- src/ui/src/plugins/router.ts | 24 +- src/ui/tests/factory-reset.spec.ts | 7 +- src/ui/tests/fixtures/network-test-harness.ts | 134 ++++++-- src/ui/tests/fixtures/test-setup.ts | 54 ++- src/ui/tests/network-configuration.spec.ts | 78 +---- src/ui/tests/network-multi-adapter.spec.ts | 112 +++---- src/ui/tests/wifi.spec.ts | 63 +--- 25 files changed, 699 insertions(+), 708 deletions(-) create mode 100644 src/ui/src/auth/validate-portal-token.ts diff --git a/Cargo.lock b/Cargo.lock index cf184835..ead26c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -832,9 +832,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -1408,9 +1408,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", diff --git a/project-context.md b/project-context.md index 4a468f0a..6271d5fb 100644 --- a/project-context.md +++ b/project-context.md @@ -155,6 +155,7 @@ omnect-ui/ │ │ ├── wasm.rs # WASM FFI bindings │ │ ├── macros.rs # URL and log macros │ │ ├── http_helpers.rs # HTTP request utilities +│ │ ├── wifi_psk.rs # WiFi PSK utilities │ │ ├── commands/ # Custom side-effect commands │ │ │ ├── mod.rs │ │ │ └── centrifugo.rs # Centrifugo WebSocket commands @@ -164,6 +165,7 @@ omnect-ui/ │ │ │ ├── common.rs # Common shared types │ │ │ ├── device.rs # Device information types │ │ │ ├── network.rs # Network configuration types +│ │ │ ├── wifi.rs # WiFi types │ │ │ ├── ods.rs # ODS-specific DTOs │ │ │ ├── factory_reset.rs │ │ │ └── update.rs # Update validation types @@ -171,6 +173,7 @@ omnect-ui/ │ │ ├── mod.rs # Main dispatcher │ │ ├── auth.rs # Auth event handlers │ │ ├── ui.rs # UI state handlers +│ │ ├── wifi.rs # WiFi event handlers │ │ ├── websocket.rs # WebSocket state handlers │ │ └── device/ # Device domain handlers │ │ ├── mod.rs @@ -187,11 +190,13 @@ omnect-ui/ │ │ │ ├── http_client.rs # Internal HTTP client │ │ │ ├── keycloak_client.rs │ │ │ ├── omnect_device_service_client.rs +│ │ │ ├── wifi_commissioning_client.rs │ │ │ └── services/ # Business logic services │ │ │ ├── mod.rs │ │ │ ├── certificate.rs │ │ │ ├── firmware.rs │ │ │ ├── network.rs +│ │ │ ├── marker.rs │ │ │ └── auth/ # Auth logic │ │ │ ├── mod.rs │ │ │ ├── authorization.rs # JWT/SSO validation @@ -242,6 +247,7 @@ omnect-ui/ │ ├── smoke.spec.ts │ ├── update.spec.ts │ ├── version-mismatch.spec.ts +│ ├── wifi.spec.ts │ └── fixtures/ └── project-context.md # This file ``` diff --git a/src/app/src/events.rs b/src/app/src/events.rs index bb58072a..6a03586d 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -60,6 +60,7 @@ pub enum DeviceEvent { RunUpdate { validate_iothub_connection: bool, }, + FetchInitialHealthcheck, ReconnectionCheckTick, ReconnectionTimeout, NewIpCheckTick, diff --git a/src/app/src/http_helpers.rs b/src/app/src/http_helpers.rs index c25dcc34..d7aad44f 100644 --- a/src/app/src/http_helpers.rs +++ b/src/app/src/http_helpers.rs @@ -62,6 +62,23 @@ pub fn extract_error_message(action: &str, response: &mut Response>) -> } } +/// Parse JSON from response body regardless of HTTP status. +/// +/// Unlike `parse_json_response`, this does not check for 2xx status. +/// Used for endpoints like healthcheck where the body is valid JSON +/// even on error status codes (e.g., 503 for version mismatch). +pub fn parse_json_response_any_status( + action: &str, + response: &mut Response>, +) -> Result { + match response.take_body() { + Some(body) => { + serde_json::from_slice(&body).map_err(|e| format!("{action}: JSON parse error: {e}")) + } + None => Err(format!("{action}: Empty response body")), + } +} + /// Parse JSON from response body. /// /// Returns error if response is not successful or JSON parsing fails. @@ -178,9 +195,49 @@ where #[cfg(test)] mod tests { use super::*; + use crux_http::http::StatusCode; + use crux_http::testing::ResponseBuilder; + + fn make_response(status: StatusCode, body: &[u8]) -> Response> { + ResponseBuilder::with_status(status) + .body(body.to_vec()) + .build() + } #[test] fn test_build_url() { assert_eq!(build_url("/test"), "https://relative/test"); } + + #[test] + fn parse_json_response_any_status_parses_on_503() { + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Info { + ok: bool, + } + + let mut response = make_response(StatusCode::ServiceUnavailable, b"{\"ok\":false}"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert_eq!(result.unwrap(), Info { ok: false }); + } + + #[test] + fn parse_json_response_any_status_parses_on_200() { + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Info { + value: u32, + } + + let mut response = make_response(StatusCode::Ok, b"{\"value\":42}"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert_eq!(result.unwrap(), Info { value: 42 }); + } + + #[test] + fn parse_json_response_any_status_returns_error_on_invalid_json() { + let mut response = make_response(StatusCode::Ok, b"not json"); + let result: Result = parse_json_response_any_status("test", &mut response); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("JSON parse error")); + } } diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index bdb2eea0..435d9a03 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -44,7 +44,8 @@ macro_rules! update_field { pub use crate::http_helpers::{ build_url, check_response_status, extract_error_message, extract_string_response, handle_auth_error, handle_request_error, is_response_success, map_http_error, - parse_json_response, process_json_response, process_status_response, BASE_URL, + parse_json_response, parse_json_response_any_status, process_json_response, + process_status_response, BASE_URL, }; /// Macro for unauthenticated POST requests with standard error handling. diff --git a/src/app/src/model.rs b/src/app/src/model.rs index 55d613d4..eeabbd33 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -62,6 +62,10 @@ pub struct Model { pub should_show_rollback_modal: bool, pub default_rollback_enabled: bool, + // Version mismatch state (derived from healthcheck) + pub version_mismatch: bool, + pub version_mismatch_message: Option, + // Firmware upload state pub firmware_upload_state: UploadState, diff --git a/src/app/src/update/device/mod.rs b/src/app/src/update/device/mod.rs index 52b43e40..0eb65823 100644 --- a/src/app/src/update/device/mod.rs +++ b/src/app/src/update/device/mod.rs @@ -116,13 +116,7 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { handle_set_network_config_response(result, model) } - DeviceEvent::AckRollbackResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckRollbackResponse(result) => handle_ack_response(result, model), DeviceEvent::LoadUpdate { file_path } => { let request = LoadUpdateRequest { file_path }; @@ -161,6 +155,8 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { Some("The device is restarting with the updated firmware.".to_string()), ), + DeviceEvent::FetchInitialHealthcheck => build_initial_healthcheck_cmd(), + DeviceEvent::HealthcheckResponse(result) => handle_healthcheck_response(result, model), // Device reconnection events (reboot/factory reset/update) @@ -178,21 +174,9 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { DeviceEvent::AckFactoryResetResult => handle_ack_factory_reset_result(model), DeviceEvent::AckUpdateValidation => handle_ack_update_validation(model), - DeviceEvent::AckFactoryResetResultResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckFactoryResetResultResponse(result) => handle_ack_response(result, model), - DeviceEvent::AckUpdateValidationResponse(result) => { - model.stop_loading(); - if let Err(e) = result { - model.set_error(e); - } - crux_core::render::render() - } + DeviceEvent::AckUpdateValidationResponse(result) => handle_ack_response(result, model), // Network form events DeviceEvent::NetworkFormStartEdit { adapter_name } => { @@ -207,6 +191,36 @@ pub fn handle(event: DeviceEvent, model: &mut Model) -> Command { } } +fn handle_ack_response(result: Result<(), String>, model: &mut Model) -> Command { + model.stop_loading(); + if let Err(e) = result { + model.set_error(e); + } + crux_core::render::render() +} + +fn build_initial_healthcheck_cmd() -> Command { + // crux_http converts 4xx/5xx to HttpError::Http but preserves the body. + // The backend returns 503 on version mismatch with a valid JSON body, + // so we must parse the body from both success and error paths. + crate::HttpCmd::get(crate::http_helpers::build_url("/healthcheck")) + .build() + .then_send(|result| { + let event_result = match result { + Ok(mut response) => crate::http_helpers::parse_json_response_any_status( + "Healthcheck", + &mut response, + ), + Err(crux_http::HttpError::Http { + body: Some(body), .. + }) => serde_json::from_slice(&body) + .map_err(|e| format!("Healthcheck: JSON parse error: {e}")), + Err(e) => Err(e.to_string()), + }; + Event::Device(DeviceEvent::HealthcheckResponse(event_result)) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/app/src/update/device/reconnection.rs b/src/app/src/update/device/reconnection.rs index 0f7606a5..ff70ca24 100644 --- a/src/app/src/update/device/reconnection.rs +++ b/src/app/src/update/device/reconnection.rs @@ -71,21 +71,39 @@ pub fn handle_healthcheck_response( result: Result, model: &mut Model, ) -> Command { - // Update healthcheck info if success if let Ok(info) = &result { model.healthcheck = Some(info.clone()); + update_version_info(info, model); } + advance_device_operation_state(&result, model); + advance_network_change_state(&result, model); + crux_core::render::render() +} + +fn update_version_info(info: &crate::types::HealthcheckInfo, model: &mut Model) { + model.version_mismatch = info.version_info.mismatch; + model.version_mismatch_message = if info.version_info.mismatch { + Some(format!( + "Current version: {}. Required version {}. Please consider to update omnect Secure OS.", + info.version_info.current, info.version_info.required, + )) + } else { + None + }; +} - // Handle reconnection state machine +fn advance_device_operation_state( + result: &Result, + model: &mut Model, +) { match &model.device_operation_state { DeviceOperationState::Rebooting | DeviceOperationState::FactoryResetting | DeviceOperationState::Updating => { - // First check - if it fails, mark as "waiting" let is_updating = matches!(model.device_operation_state, DeviceOperationState::Updating); - // For updates, we also check the status field + // For updates, completion requires a terminal status field (not just reachability) let update_done = if is_updating { result.as_ref().ok().is_some_and(is_update_complete) } else { @@ -93,24 +111,18 @@ pub fn handle_healthcheck_response( }; if result.is_err() { - // Device went offline - mark it model.device_went_offline = true; - // Transition to waiting let operation = model.device_operation_state.operation_name(); model.device_operation_state = DeviceOperationState::WaitingReconnection { operation, attempt: model.reconnection_attempt, }; } else if (update_done || !is_updating) && model.device_went_offline { - // Device came back online after going offline - reconnection successful let operation = model.device_operation_state.operation_name(); model.device_operation_state = DeviceOperationState::ReconnectionSuccessful { operation }; - // Invalidate session as backend restart clears tokens model.invalidate_session(); - - // Clear overlay spinner model.overlay_spinner.clear(); // Clear stale firmware page state so the update page is fresh on re-login. @@ -124,21 +136,19 @@ pub fn handle_healthcheck_response( } } } - // else: healthcheck succeeded but device never went offline - keep checking + // else: device never went offline - keep checking } DeviceOperationState::WaitingReconnection { operation, .. } => { let is_update = operation == "Update"; if result.is_err() { - // Still offline - mark it model.device_went_offline = true; - // Update attempt count model.device_operation_state = DeviceOperationState::WaitingReconnection { operation: operation.clone(), attempt: model.reconnection_attempt, }; } else { - // Consider update done when status is Succeeded, Recovered, or NoUpdate + // For updates, completion requires a terminal status field let update_done = if is_update { result.as_ref().ok().is_some_and(is_update_complete) } else { @@ -146,15 +156,11 @@ pub fn handle_healthcheck_response( }; if update_done && model.device_went_offline { - // Success! Device is back online (or update finished) AND it went offline model.device_operation_state = DeviceOperationState::ReconnectionSuccessful { operation: operation.clone(), }; - // Invalidate session as backend restart clears tokens model.invalidate_session(); - - // Clear overlay spinner model.overlay_spinner.clear(); // Clear stale firmware page state so the update page is fresh on re-login. @@ -168,13 +174,17 @@ pub fn handle_healthcheck_response( } } } - // else: healthcheck succeeded but device never went offline - keep checking + // else: device never went offline - keep checking } } - _ => {} // Do nothing for other states + _ => {} } +} - // Handle network change state machine for IP change polling +fn advance_network_change_state( + result: &Result, + model: &mut Model, +) { match &model.network_change_state { NetworkChangeState::WaitingForNewIp { new_ip, ui_port, .. @@ -183,36 +193,30 @@ pub fn handle_healthcheck_response( // Clone values before reassigning state to avoid borrow conflict let new_ip = new_ip.clone(); let port = *ui_port; - // New IP is reachable model.network_change_state = NetworkChangeState::NewIpReachable { new_ip: new_ip.clone(), ui_port: port, }; - // Clear any leftover messages model.success_message = None; model.error_message = None; - // Update overlay for redirect model.overlay_spinner = OverlaySpinnerState::new("Network settings applied") .with_text(format!("Redirecting to new IP: {new_ip}:{port}")); } } NetworkChangeState::WaitingForOldIp { .. } => { if result.is_ok() { - // Old IP is reachable - Rollback successful + // Old IP is reachable — rollback successful. + // No success message here: the "Network Settings Rolled Back" modal + // is triggered by the `network_rollback_occurred` flag in the healthcheck response. model.network_change_state = NetworkChangeState::Idle; model.overlay_spinner.clear(); model.invalidate_session(); - // Clear any leftover messages model.success_message = None; model.error_message = None; - // Do not show success message here. The "Network Settings Rolled Back" modal - // will be triggered by the `network_rollback_occurred` flag in the healthcheck response. } } _ => {} } - - crux_core::render::render() } #[cfg(test)] @@ -876,5 +880,58 @@ mod tests { )); } } + + mod derived_state { + use super::*; + + #[test] + fn version_mismatch_sets_model_fields() { + let mut model = Model::default(); + let info = HealthcheckInfo { + version_info: VersionInfo { + required: ">=1.0.0".to_string(), + current: "0.9.0".to_string(), + mismatch: true, + }, + ..Default::default() + }; + + let _ = handle_healthcheck_response(Ok(info), &mut model); + + assert!(model.version_mismatch); + let msg = model.version_mismatch_message.unwrap(); + assert!(msg.contains("0.9.0")); + assert!(msg.contains(">=1.0.0")); + } + + #[test] + fn no_version_mismatch_clears_fields() { + let mut model = Model { + version_mismatch: true, + version_mismatch_message: Some("old".to_string()), + ..Default::default() + }; + + let _ = + handle_healthcheck_response(Ok(create_healthcheck("valid", false)), &mut model); + + assert!(!model.version_mismatch); + assert!(model.version_mismatch_message.is_none()); + } + + #[test] + fn error_response_does_not_change_derived_state() { + let mut model = Model { + version_mismatch: true, + ..Default::default() + }; + + let _ = + handle_healthcheck_response(Err("Connection failed".to_string()), &mut model); + + // Derived state unchanged on error + assert!(model.version_mismatch); + } + } } } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index 2756c405..649bd960 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -45,37 +45,7 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command Command Result<()> { let Some(tenant_list) = &claims.tenant_list else { bail!("failed to authorize user: no tenant list in token"); }; ensure!( - tenant_list.contains(tenant), + tenant_list.contains(&tenant.to_string()), "failed to authorize user: insufficient permissions for tenant" ); + Ok(()) + } - // Validate role-based authorization + async fn validate_role( + claims: &TokenClaims, + service_client: &ServiceClient, + ) -> Result<()> { let Some(roles) = &claims.roles else { bail!("failed to authorize user: no roles in token"); }; - // FleetAdministrator has full access if roles.iter().any(|r| r == "FleetAdministrator") { return Ok(()); } - // FleetOperator requires fleet validation if roles.iter().any(|r| r == "FleetOperator") { let Some(fleet_list) = &claims.fleet_list else { bail!("failed to authorize user: no fleet list in token"); @@ -104,57 +111,56 @@ mod tests { } } + async fn run_auth(claims: TokenClaims) -> anyhow::Result<()> { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(move |_| { + let c = claims.clone(); + Box::pin(async move { Ok(c) }) + }); + let device_mock = DeviceServiceClient::default(); + AuthorizationService::validate_token_and_claims(&sso_mock, &device_mock, "valid_token") + .await + } + + async fn run_auth_with_fleet( + claims: TokenClaims, + fleet_id: &'static str, + ) -> anyhow::Result<()> { + let mut sso_mock = SingleSignOnProvider::default(); + sso_mock.expect_verify_token().returning(move |_| { + let c = claims.clone(); + Box::pin(async move { Ok(c) }) + }); + let mut device_mock = DeviceServiceClient::default(); + device_mock + .expect_fleet_id() + .returning(move || Box::pin(async move { Ok(fleet_id.to_string()) })); + AuthorizationService::validate_token_and_claims(&sso_mock, &device_mock, "valid_token") + .await + } + mod fleet_administrator { use super::*; #[tokio::test] async fn with_valid_tenant_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["cp"]), + None, + )) .await; - assert!(result.is_ok()); } #[tokio::test] async fn with_invalid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["invalid_tenant"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["invalid_tenant"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -165,26 +171,12 @@ mod tests { #[tokio::test] async fn with_multiple_tenants_including_valid_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetAdministrator"]), - Some(vec!["other_tenant", "cp", "another_tenant"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetAdministrator"]), + Some(vec!["other_tenant", "cp", "another_tenant"]), + None, + )) .await; - assert!(result.is_ok()); } } @@ -194,58 +186,29 @@ mod tests { #[tokio::test] async fn with_matching_fleet_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-123"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-123"]), + ), + "fleet-123", ) .await; - assert!(result.is_ok()); } #[tokio::test] async fn with_non_matching_fleet_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-456"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456"]), + ), + "fleet-123", ) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -256,55 +219,26 @@ mod tests { #[tokio::test] async fn with_multiple_fleets_including_match_succeeds() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - Some(vec!["fleet-456", "fleet-123", "fleet-789"]), - )) - }) - }); - - let mut device_mock = DeviceServiceClient::default(); - device_mock - .expect_fleet_id() - .returning(|| Box::pin(async { Ok("fleet-123".to_string()) })); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", + let result = run_auth_with_fleet( + create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + Some(vec!["fleet-456", "fleet-123", "fleet-789"]), + ), + "fleet-123", ) .await; - assert!(result.is_ok()); } #[tokio::test] async fn without_fleet_list_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["cp"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -315,27 +249,12 @@ mod tests { #[tokio::test] async fn with_invalid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetOperator"]), - Some(vec!["invalid_tenant"]), - Some(vec!["fleet-123"]), - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetOperator"]), + Some(vec!["invalid_tenant"]), + Some(vec!["fleet-123"]), + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -350,27 +269,12 @@ mod tests { #[tokio::test] async fn with_valid_tenant_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { - Ok(create_claims( - Some(vec!["FleetObserver"]), - Some(vec!["cp"]), - None, - )) - }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) + let result = run_auth(create_claims( + Some(vec!["FleetObserver"]), + Some(vec!["cp"]), + None, + )) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() @@ -385,21 +289,8 @@ mod tests { #[tokio::test] async fn without_tenant_list_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock.expect_verify_token().returning(|_| { - Box::pin(async { Ok(create_claims(Some(vec!["FleetAdministrator"]), None, None)) }) - }); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) - .await; - - assert!(result.is_err()); + let result = + run_auth(create_claims(Some(vec!["FleetAdministrator"]), None, None)).await; assert!( result .unwrap_err() @@ -410,21 +301,7 @@ mod tests { #[tokio::test] async fn without_roles_fails() { - let mut sso_mock = SingleSignOnProvider::default(); - sso_mock - .expect_verify_token() - .returning(|_| Box::pin(async { Ok(create_claims(None, Some(vec!["cp"]), None)) })); - - let device_mock = DeviceServiceClient::default(); - - let result = AuthorizationService::validate_token_and_claims( - &sso_mock, - &device_mock, - "valid_token", - ) - .await; - - assert!(result.is_err()); + let result = run_auth(create_claims(None, Some(vec!["cp"]), None)).await; assert!( result .unwrap_err() @@ -443,17 +320,13 @@ mod tests { sso_mock .expect_verify_token() .returning(|_| Box::pin(async { Err(anyhow::anyhow!("invalid token signature")) })); - let device_mock = DeviceServiceClient::default(); - let result = AuthorizationService::validate_token_and_claims( &sso_mock, &device_mock, "invalid_token", ) .await; - - assert!(result.is_err()); assert!( result .unwrap_err() diff --git a/src/backend/src/services/auth/password.rs b/src/backend/src/services/auth/password.rs index 3a7df41c..99a1d0f2 100644 --- a/src/backend/src/services/auth/password.rs +++ b/src/backend/src/services/auth/password.rs @@ -18,6 +18,9 @@ use std::sync::{LazyLock, Mutex, MutexGuard}; #[allow(dead_code)] static PASSWORD_FILE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +const MAX_RETRIES: u32 = 3; +const RETRY_DELAY_MS: u64 = 100; + /// Service for password management operations pub struct PasswordService; @@ -75,6 +78,24 @@ impl PasswordService { .context("failed to hash password") } + /// Atomically write a password hash to file and verify it can be read back. + /// + /// Writes to a `.tmp` file first, then renames to replace the target atomically. + fn write_password_hash_atomic( + password_file: &std::path::Path, + hash: &str, + password: &str, + ) -> Result<()> { + let temp_path = password_file.with_extension("tmp"); + let mut file = File::create(&temp_path).context("failed to create temp password file")?; + file.write_all(hash.as_bytes()) + .context("failed to write password file")?; + file.sync_all().context("failed to sync password file")?; + std::fs::rename(&temp_path, password_file).context("failed to replace password file")?; + // Verify that the password can be read back and validated + Self::validate_password(password).context("failed to verify stored password") + } + /// Store or update a password /// /// # Arguments @@ -87,35 +108,15 @@ impl PasswordService { let password_file = &AppConfig::get().paths.password_file; let hash = Self::hash_password(password)?; - - let max_retries = 3; let mut last_error = anyhow!("Unknown error"); - for i in 0..max_retries { - let temp_file_path = password_file.with_extension("tmp"); - - let result = (|| -> Result<()> { - let mut file = - File::create(&temp_file_path).context("failed to create temp password file")?; - - file.write_all(hash.as_bytes()) - .context("failed to write password file")?; - - file.sync_all().context("failed to sync password file")?; - - std::fs::rename(&temp_file_path, password_file) - .context("failed to replace password file")?; - - // Verify that the password can be read back and validated - Self::validate_password(password).context("failed to verify stored password") - })(); - - match result { - Ok(_) => return Ok(()), + for i in 0..MAX_RETRIES { + match Self::write_password_hash_atomic(password_file, &hash, password) { + Ok(()) => return Ok(()), Err(e) => { log::warn!("store_or_update_password attempt {} failed: {:#}", i + 1, e); last_error = e; - std::thread::sleep(std::time::Duration::from_millis(100)); + std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS)); } } } diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 5e9bff82..e21d346d 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -1,6 +1,5 @@