diff --git a/Cargo.lock b/Cargo.lock index 665c3c16..2e54e45a 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", @@ -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.1" dependencies = [ "actix-cors", "actix-files", @@ -2266,7 +2272,7 @@ dependencies = [ [[package]] name = "omnect-ui-core" -version = "1.1.2" +version = "1.2.1" 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.1" dependencies = [ "anyhow", "crux_core", diff --git a/Cargo.toml b/Cargo.toml index c96bef9c..916131d0 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.1" 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/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..9d4285a9 100644 --- a/src/app/src/events.rs +++ b/src/app/src/events.rs @@ -18,6 +18,7 @@ pub enum AuthEvent { password: String, }, CheckRequiresPasswordSet, + RestoreSession(String), #[serde(skip)] LoginResponse(Result), #[serde(skip)] @@ -60,6 +61,7 @@ pub enum DeviceEvent { RunUpdate { validate_iothub_connection: bool, }, + FetchInitialHealthcheck, ReconnectionCheckTick, ReconnectionTimeout, NewIpCheckTick, @@ -102,6 +104,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 +236,7 @@ pub enum Event { Device(DeviceEvent), WebSocket(WebSocketEvent), Ui(UiEvent), + Wifi(WifiEvent), } /// Custom Debug implementation for AuthEvent to redact sensitive data @@ -149,6 +268,7 @@ impl fmt::Debug for AuthEvent { }, AuthEvent::Logout => write!(f, "Logout"), AuthEvent::CheckRequiresPasswordSet => write!(f, "CheckRequiresPasswordSet"), + AuthEvent::RestoreSession(_) => write!(f, "RestoreSession()"), AuthEvent::LogoutResponse(r) => f.debug_tuple("LogoutResponse").field(r).finish(), AuthEvent::SetPasswordResponse(result) => match result { Ok(_) => f @@ -180,6 +300,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/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/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..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. @@ -420,6 +421,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..eeabbd33 100644 --- a/src/app/src/model.rs +++ b/src/app/src/model.rs @@ -62,11 +62,18 @@ 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, // 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..6d4d4d1b 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, @@ -82,6 +98,25 @@ pub fn handle(event: AuthEvent, model: &mut Model) -> Command { model.requires_password_set = requires; }, }), + + AuthEvent::RestoreSession(token) => { + model.auth_token = Some(token); + model.is_authenticated = true; + post_auth_commands(model) + } + } +} + +/// 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() } } @@ -346,6 +381,25 @@ mod tests { } } + mod restore_session { + use super::*; + + #[test] + fn sets_authenticated_and_stores_token() { + let mut model = Model::default(); + + let _ = handle( + AuthEvent::RestoreSession("restored-token-123".into()), + &mut model, + ); + + assert!(model.is_authenticated); + assert_eq!(model.auth_token, Some("restored-token-123".into())); + assert!(!model.is_loading); + assert!(model.error_message.is_none()); + } + } + mod check_requires_password_set { use super::*; 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/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..649bd960 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,7 @@ pub fn handle(event: WebSocketEvent, model: &mut Model) -> Command 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..c65720e9 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; @@ -284,6 +287,101 @@ where return HttpResponse::InternalServerError().body("failed to insert token into session"); } - HttpResponse::Ok().body(token) + HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .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..f4e4fb6c 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, @@ -79,6 +82,12 @@ pub struct PathConfig { pub password_file: PathBuf, pub host_update_file: PathBuf, pub local_update_file: PathBuf, + pub session_key_path: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct WifiConfig { + pub socket_path: PathBuf, } impl AppConfig { @@ -116,6 +125,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 +136,7 @@ impl AppConfig { device_service, certificate, iot_edge, + wifi, paths, tenant, }) @@ -271,6 +282,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")))] @@ -298,6 +319,7 @@ impl PathConfig { let password_file = config_dir.join("password"); let host_update_file = host_data_dir.join("update.tar"); let local_update_file = data_dir.join("update.tar"); + let session_key_path = data_dir.join("session.key"); Ok(Self { app_config_path, @@ -305,6 +327,7 @@ impl PathConfig { password_file, host_update_file, local_update_file, + session_key_path, }) } } 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..9acefe4e 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, @@ -12,10 +13,13 @@ use crate::{ keycloak_client::KeycloakProvider, omnect_device_service_client::{DeviceServiceClient, OmnectDeviceServiceClient}, services::{ - auth::TokenManager, + auth::{SessionKeyService, TokenManager}, certificate::{CertificateService, CreateCertPayload}, network::NetworkConfigService, }, + wifi_commissioning_client::{ + WifiAvailability, WifiCommissioningClient, WifiCommissioningServiceClient, + }, }; use actix_cors::Cors; use actix_multipart::form::MultipartFormConfig; @@ -27,7 +31,7 @@ use actix_session::{ }; use actix_web::{ App, HttpServer, - cookie::{Key, SameSite}, + cookie::SameSite, web::{self, Data}, }; use actix_web_static_files::ResourceFiles; @@ -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,10 +325,14 @@ 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; - let session_key = Key::generate(); + let session_key = SessionKeyService::load_or_generate(&config.paths.session_key_path); let token_manager = TokenManager::new(&config.centrifugo.client_token); let server = HttpServer::new(move || { @@ -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/services/auth/authorization.rs b/src/backend/src/services/auth/authorization.rs index 1ed66887..55fcaa69 100644 --- a/src/backend/src/services/auth/authorization.rs +++ b/src/backend/src/services/auth/authorization.rs @@ -3,7 +3,8 @@ //! Handles token validation and role-based access control independent of HTTP concerns. use crate::{ - config::AppConfig, keycloak_client::SingleSignOnProvider, + config::AppConfig, + keycloak_client::{SingleSignOnProvider, TokenClaims}, omnect_device_service_client::DeviceServiceClient, }; use anyhow::{Result, bail, ensure}; @@ -39,27 +40,33 @@ impl AuthorizationService { { let claims = single_sign_on.verify_token(token).await?; let tenant = &AppConfig::get().tenant; + Self::validate_tenant(&claims, tenant)?; + Self::validate_role(&claims, service_client).await + } - // Validate tenant authorization + fn validate_tenant(claims: &TokenClaims, tenant: &str) -> 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/mod.rs b/src/backend/src/services/auth/mod.rs index 61c52a67..27c101d9 100644 --- a/src/backend/src/services/auth/mod.rs +++ b/src/backend/src/services/auth/mod.rs @@ -1,7 +1,9 @@ pub mod authorization; pub mod password; +pub mod session_key; pub mod token; pub use authorization::AuthorizationService; pub use password::PasswordService; +pub use session_key::SessionKeyService; pub use token::TokenManager; 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/backend/src/services/auth/session_key.rs b/src/backend/src/services/auth/session_key.rs new file mode 100644 index 00000000..fe569eb8 --- /dev/null +++ b/src/backend/src/services/auth/session_key.rs @@ -0,0 +1,129 @@ +//! Session key management service +//! +//! Loads or generates the actix-session signing/encryption key. The key is +//! persisted to disk so that server restarts do not invalidate existing browser +//! session cookies. + +use actix_web::cookie::Key; +use log::{info, warn}; + +/// actix-web's cookie::Key requires at least 64 bytes (512 bits). +const SESSION_KEY_LEN: usize = 64; + +/// Service for session key management +pub struct SessionKeyService; + +impl SessionKeyService { + /// Return a session key loaded from `path`, or generate and save a new one. + /// + /// Falls back to an ephemeral (in-memory only) key when the path is + /// unreadable for reasons other than the file not existing yet. + pub fn load_or_generate(path: &std::path::Path) -> Key { + match std::fs::read(path) { + Ok(bytes) if bytes.len() >= SESSION_KEY_LEN => { + info!("loaded session key from {}", path.display()); + Key::from(&bytes) + } + Ok(_) => { + warn!( + "session key at {} is too short, regenerating", + path.display() + ); + Self::generate_and_save(path) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + info!( + "no session key found at {}, generating new key", + path.display() + ); + Self::generate_and_save(path) + } + Err(e) => { + warn!( + "failed to read session key from {}: {e:#}, using ephemeral key", + path.display() + ); + Key::generate() + } + } + } + + fn generate_and_save(path: &std::path::Path) -> Key { + let key = Key::generate(); + if let Err(e) = std::fs::write(path, key.master()) { + warn!("failed to persist session key to {}: {e:#}", path.display()); + } + key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_and_saves_key_when_file_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + assert!(!path.exists()); + let _ = SessionKeyService::load_or_generate(&path); + assert!(path.exists()); + + let saved = std::fs::read(&path).unwrap(); + assert_eq!(saved.len(), SESSION_KEY_LEN); + } + + #[test] + fn loads_existing_valid_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + // Write a known 64-byte key. + let original_bytes: Vec = (0..SESSION_KEY_LEN as u8).collect(); + std::fs::write(&path, &original_bytes).unwrap(); + + let key = SessionKeyService::load_or_generate(&path); + + // The master slice must match the bytes we wrote. + assert_eq!(key.master(), original_bytes.as_slice()); + } + + #[test] + fn regenerates_when_file_too_short() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + // Write fewer than SESSION_KEY_LEN bytes. + std::fs::write(&path, b"tooshort").unwrap(); + + let _ = SessionKeyService::load_or_generate(&path); + + // The file should now contain a full-length key. + let saved = std::fs::read(&path).unwrap(); + assert_eq!(saved.len(), SESSION_KEY_LEN); + } + + #[test] + fn returns_ephemeral_key_on_read_error() { + // Point at a path that is unreadable for a reason other than NotFound: + // use the directory itself as the key path. + let dir = tempfile::tempdir().unwrap(); + let not_a_file = dir.path(); // reading a directory returns an error + + // Should not panic and should return a usable key. + let key = SessionKeyService::load_or_generate(not_a_file); + assert_eq!(key.master().len(), SESSION_KEY_LEN); + } + + #[test] + fn round_trip_key_is_stable_across_loads() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.key"); + + let first = SessionKeyService::load_or_generate(&path); + let second = SessionKeyService::load_or_generate(&path); + + assert_eq!(first.master(), second.master()); + } +} 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/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 @@