diff --git a/Cargo.lock b/Cargo.lock index 777948b..ccd411a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,9 +921,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "12218662ccac37d9714f2191252553d67585b97df8c839cbe49e0bde278dc865" dependencies = [ "cmov", ] @@ -2992,9 +2992,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -3961,9 +3961,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] diff --git a/README.md b/README.md index 8a1d53c..7ec47fa 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ The device overview page displays a real-time snapshot of the device's health an - **OS Variant & Version**: omnect Secure OS build name and version string - **Boot Time**: Timestamp of the last system boot - **Wait-Online Timeout**: Configured network wait timeout (seconds) -- **Service Versions**: omnect-device-service, Azure SDK, and WiFi commissioning service versions (the WiFi entry also shows the minimum required version when the installed version is too old) +- **Service Versions**: omnect-device-service, Azure SDK, and WiFi commissioning service versions (when the installed WiFi service is too old, the entry shows the required version next to it) --- diff --git a/project-context.md b/project-context.md index ed42bbf..58291bd 100644 --- a/project-context.md +++ b/project-context.md @@ -203,6 +203,7 @@ omnect-ui/ │ │ │ ├── keycloak_client.rs │ │ │ ├── omnect_device_service_client.rs │ │ │ ├── wifi_commissioning_client.rs +│ │ │ ├── version.rs # Service version requirement check │ │ │ └── services/ # Business logic services │ │ │ ├── mod.rs │ │ │ ├── certificate.rs diff --git a/src/app/src/types/factory_reset.rs b/src/app/src/types/factory_reset.rs index f2d604b..e816be1 100644 --- a/src/app/src/types/factory_reset.rs +++ b/src/app/src/types/factory_reset.rs @@ -5,22 +5,29 @@ use std::fmt; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub enum FactoryResetStatus { + /// No result received yet. #[default] Unknown, - ModeSupported, - ModeUnsupported, - BackupRestoreError, - ConfigurationError, + Success, + Invalid, + Error, + ConfigError, + /// Reset succeeded, but a partition needed a second format attempt. + Warning, + /// A status code this version does not know. + Unrecognized, } impl fmt::Display for FactoryResetStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Unknown => write!(f, "unknown"), - Self::ModeSupported => write!(f, "modeSupported"), - Self::ModeUnsupported => write!(f, "modeUnsupported"), - Self::BackupRestoreError => write!(f, "backupRestoreError"), - Self::ConfigurationError => write!(f, "configurationError"), + Self::Success => write!(f, "success"), + Self::Invalid => write!(f, "invalid"), + Self::Error => write!(f, "error"), + Self::ConfigError => write!(f, "configError"), + Self::Warning => write!(f, "warning"), + Self::Unrecognized => write!(f, "unrecognized"), } } } @@ -31,8 +38,11 @@ impl fmt::Display for FactoryResetStatus { pub struct FactoryResetResult { pub status: FactoryResetStatus, pub context: Option, - pub error: String, + pub error: Option, pub paths: Vec, + /// `true` once the reset started wiping data. On a failure this separates a + /// safe abort from one that left the device half wiped. + pub data_wiped: bool, } /// Factory reset state from WebSocket diff --git a/src/app/src/types/ods.rs b/src/app/src/types/ods.rs index 1770015..b04a3d9 100644 --- a/src/app/src/types/ods.rs +++ b/src/app/src/types/ods.rs @@ -170,34 +170,43 @@ impl From for NetworkStatus { } } -/// Factory reset result status — ODS sends numeric values (`serde_repr`) +/// Factory reset result status — ODS sends numeric values (`serde_repr`). #[derive(Debug, Clone, Deserialize_repr, PartialEq, Eq)] -#[repr(u8)] +#[repr(u32)] pub enum OdsFactoryResetResultStatus { - ModeSupported = 0, - ModeUnsupported = 1, - BackupRestoreError = 2, - ConfigurationError = 3, + Success = 0, + Invalid = 1, + Error = 2, + ConfigError = 3, + Warning = 4, + /// A future status code must parse instead of failing the whole update. + #[serde(other)] + Unrecognized = u32::MAX, } impl From for FactoryResetStatus { fn from(ods: OdsFactoryResetResultStatus) -> Self { match ods { - OdsFactoryResetResultStatus::ModeSupported => Self::ModeSupported, - OdsFactoryResetResultStatus::ModeUnsupported => Self::ModeUnsupported, - OdsFactoryResetResultStatus::BackupRestoreError => Self::BackupRestoreError, - OdsFactoryResetResultStatus::ConfigurationError => Self::ConfigurationError, + OdsFactoryResetResultStatus::Success => Self::Success, + OdsFactoryResetResultStatus::Invalid => Self::Invalid, + OdsFactoryResetResultStatus::Error => Self::Error, + OdsFactoryResetResultStatus::ConfigError => Self::ConfigError, + OdsFactoryResetResultStatus::Warning => Self::Warning, + OdsFactoryResetResultStatus::Unrecognized => Self::Unrecognized, } } } -/// Factory reset result +/// Factory reset result. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct OdsFactoryResetResult { pub status: OdsFactoryResetResultStatus, pub context: Option, - pub error: String, + pub error: Option, + #[serde(default)] pub paths: Vec, + #[serde(default)] + pub data_wiped: bool, } impl From for FactoryResetResult { @@ -207,6 +216,7 @@ impl From for FactoryResetResult { context: ods.context, error: ods.error, paths: ods.paths, + data_wiped: ods.data_wiped, } } } diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs index 1eff772..6d59404 100644 --- a/src/app/src/types/wifi.rs +++ b/src/app/src/types/wifi.rs @@ -1,3 +1,4 @@ +use super::update::VersionInfo; use serde::{Deserialize, Serialize}; /// `WiFi` service availability info returned by the backend @@ -10,8 +11,8 @@ pub enum WifiAvailability { }, Unavailable { socket_present: bool, - version: Option, - min_required_version: String, + /// `None` when the probe gave no usable version. + version_info: Option, }, } @@ -70,8 +71,7 @@ pub enum WifiState { Unknown, Unavailable { socket_present: bool, - version: Option, - min_required_version: String, + version_info: Option, }, Ready { interface_name: String, @@ -146,7 +146,11 @@ pub struct WifiForgetResponse { pub status: String, } +/// Capabilities of the running wifi-commissioning-service instance #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct WifiVersionResponse { +pub struct WifiServiceInfoResponse { + /// Live BLE transport state: `true` only once BLE actually started. + pub ble_enabled: bool, + pub interface_name: String, pub version: String, } diff --git a/src/app/src/update/device/network/verification.rs b/src/app/src/update/device/network/verification.rs index a348c58..31f82f7 100644 --- a/src/app/src/update/device/network/verification.rs +++ b/src/app/src/update/device/network/verification.rs @@ -479,10 +479,11 @@ mod tests { factory_reset: Some(FactoryReset { keys: vec!["key1".to_string()], result: Some(FactoryResetResult { - status: FactoryResetStatus::ModeSupported, + status: FactoryResetStatus::Success, context: None, - error: String::new(), + error: None, paths: vec![], + data_wiped: true, }), }), ..Default::default() diff --git a/src/app/src/update/mod.rs b/src/app/src/update/mod.rs index 62f5e5d..cfc04b1 100644 --- a/src/app/src/update/mod.rs +++ b/src/app/src/update/mod.rs @@ -101,8 +101,7 @@ mod tests { Event::Wifi(WifiEvent::CheckAvailabilityResponse(Ok( WifiAvailability::Unavailable { socket_present: false, - version: None, - min_required_version: "0.1.0".into(), + version_info: None, }, ))), &mut model, diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index cd56b00..987f474 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -199,20 +199,93 @@ mod tests { assert_eq!(model.factory_reset, Some(expected_status)); } - #[test] - fn parses_integer_status_from_ods() { + fn parse_result(json: &str) -> crate::types::FactoryResetResult { let mut model = Model::default(); + let _ = handle(WebSocketEvent::FactoryResetUpdated(json.into()), &mut model); + model + .factory_reset + .expect("factory_reset should be set") + .result + .expect("result should be set") + } + + #[test] + fn parses_successful_result() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":0,"error":null,"context":null,"paths":["network"],"data_wiped":true}}"#, + ); - // ODS sends status as integer (serde_repr): 0=ModeSupported, 1=ModeUnsupported, etc. - let json = r#"{"keys":["network"],"result":{"status":0,"error":"0","paths":["/etc/systemd/network/"]}}"#; + assert_eq!(result.status, FactoryResetStatus::Success); + assert_eq!(result.error, None); + assert_eq!(result.context, None); + assert_eq!(result.paths, vec!["network"]); + assert!(result.data_wiped); + } - let _ = handle(WebSocketEvent::FactoryResetUpdated(json.into()), &mut model); + #[test] + fn parses_error_result_with_message() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":2,"error":"format failed","context":"factory partition","paths":[],"data_wiped":true}}"#, + ); + + assert_eq!(result.status, FactoryResetStatus::Error); + assert_eq!(result.error.as_deref(), Some("format failed")); + assert_eq!(result.context.as_deref(), Some("factory partition")); + assert!(result.data_wiped); + } - let factory_reset = model.factory_reset.expect("factory_reset should be set"); - let result = factory_reset.result.expect("result should be set"); - assert_eq!(result.status, FactoryResetStatus::ModeSupported); - assert_eq!(result.error, "0"); - assert_eq!(result.paths, vec!["/etc/systemd/network/"]); + #[test] + fn parses_warning_result() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":4,"error":null,"context":"second format attempt","paths":[],"data_wiped":true}}"#, + ); + + assert_eq!(result.status, FactoryResetStatus::Warning); + } + + #[test] + fn abort_before_wipe_reports_data_not_wiped() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":1,"error":"factory reset mode 4 is not supported","context":null,"paths":[],"data_wiped":false}}"#, + ); + + assert_eq!(result.status, FactoryResetStatus::Invalid); + assert!(!result.data_wiped); + } + + #[test] + fn config_error_maps_to_config_error() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":3,"error":"bad config","context":null,"paths":[],"data_wiped":false}}"#, + ); + + assert_eq!(result.status, FactoryResetStatus::ConfigError); + } + + #[test] + fn unknown_status_codes_parse_as_unrecognized() { + for code in [5, u32::MAX] { + let result = parse_result(&format!( + r#"{{"keys":["network"],"result":{{"status":{code},"error":null,"context":null,"paths":[],"data_wiped":false}}}}"# + )); + + assert_eq!( + result.status, + FactoryResetStatus::Unrecognized, + "code {code}" + ); + } + } + + #[test] + fn missing_optional_keys_fall_back_to_defaults() { + let result = parse_result(r#"{"keys":["network"],"result":{"status":0}}"#); + + assert_eq!(result.status, FactoryResetStatus::Success); + assert_eq!(result.error, None); + assert_eq!(result.context, None); + assert_eq!(result.paths, Vec::::new()); + assert!(!result.data_wiped); } } diff --git a/src/app/src/update/wifi.rs b/src/app/src/update/wifi.rs index 7142bb4..369a34b 100644 --- a/src/app/src/update/wifi.rs +++ b/src/app/src/update/wifi.rs @@ -88,13 +88,11 @@ pub fn handle(event: WifiEvent, model: &mut Model) -> Command { } Ok(WifiAvailability::Unavailable { socket_present, - version, - min_required_version, + version_info, }) => { model.wifi_state = WifiState::Unavailable { socket_present, - version, - min_required_version, + version_info, }; render() } @@ -102,8 +100,7 @@ pub fn handle(event: WifiEvent, model: &mut Model) -> Command { log::error!("WiFi availability check failed: {e}"); model.wifi_state = WifiState::Unavailable { socket_present: false, - version: None, - min_required_version: "0.1.0".to_string(), // Fallback + version_info: None, }; render() } @@ -509,7 +506,7 @@ mod tests { use super::*; use crate::{ EffectTestExt, - types::{WifiAvailability, WifiSavedNetwork}, + types::{VersionInfo, WifiAvailability, WifiSavedNetwork}, }; fn model_with_ready_state() -> Model { @@ -566,19 +563,39 @@ mod tests { #[test] fn check_availability_unavailable_response() { + let mut model = Model::default(); + let version_info = VersionInfo { + required: ">=0.2.1".to_string(), + current: "0.2.0".to_string(), + mismatch: true, + }; + let result = Ok(WifiAvailability::Unavailable { + socket_present: true, + version_info: Some(version_info.clone()), + }); + let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); + assert_eq!( + model.wifi_state, + WifiState::Unavailable { + socket_present: true, + version_info: Some(version_info), + } + ); + } + + #[test] + fn unreachable_service_reports_no_version_info() { let mut model = Model::default(); let result = Ok(WifiAvailability::Unavailable { socket_present: true, - version: Some("0.0.9".to_string()), - min_required_version: "0.1.0".to_string(), + version_info: None, }); let _ = handle(WifiEvent::CheckAvailabilityResponse(result), &mut model); assert_eq!( model.wifi_state, WifiState::Unavailable { socket_present: true, - version: Some("0.0.9".to_string()), - min_required_version: "0.1.0".to_string() + version_info: None, } ); } @@ -592,8 +609,7 @@ mod tests { model.wifi_state, WifiState::Unavailable { socket_present: false, - version: None, - min_required_version: "0.1.0".to_string() + version_info: None, } ); } diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index ef3ad6c..9f4d9a8 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 version; pub mod wifi_commissioning_client; // Re-exports from services for backward compatibility diff --git a/src/backend/src/main.rs b/src/backend/src/main.rs index beb0c3f..43d38de 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 version; mod wifi_commissioning_client; use crate::{ @@ -295,8 +296,7 @@ fn optimal_worker_count() -> usize { async fn initialize_wifi_client() -> (Option, WifiAvailability) { let unavailable = WifiAvailability::Unavailable { socket_present: false, - version: None, - min_required_version: WifiCommissioningServiceClient::MIN_REQUIRED_VERSION.to_string(), + version_info: None, }; let config = &AppConfig::get().wifi; diff --git a/src/backend/src/omnect_device_service_client.rs b/src/backend/src/omnect_device_service_client.rs index c5721d7..931490b 100644 --- a/src/backend/src/omnect_device_service_client.rs +++ b/src/backend/src/omnect_device_service_client.rs @@ -4,16 +4,16 @@ use crate::{ config::AppConfig, http_client::{handle_http_response, unix_socket_client}, services::marker, + version::{VersionInfo, version_info}, }; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, bail}; use log::info; #[cfg(feature = "mock")] use mockall::automock; use reqwest::Client; -use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::{env, fmt::Debug, path::PathBuf, sync::OnceLock}; +use std::{env, fmt::Debug, path::PathBuf}; use trait_variant::make; #[derive(Clone, Debug, Default, Deserialize_repr, PartialEq, Eq, Serialize_repr)] @@ -23,7 +23,6 @@ pub enum FactoryResetMode { Mode1 = 1, Mode2 = 2, Mode3 = 3, - Mode4 = 4, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -88,14 +87,6 @@ pub struct Ipv4AddrInfo { pub addr: String, } -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VersionInfo { - pub required: String, - pub current: String, - pub mismatch: bool, -} - #[derive(Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct HealthcheckInfo { @@ -156,7 +147,9 @@ impl Clone for MockDeviceServiceClient { } impl OmnectDeviceServiceClient { - const REQUIRED_CLIENT_VERSION: &str = ">=0.39.0"; + // The floor is the oldest omnect-device-service that publishes the + // factory-reset result in the shape parsed here (feature version 4). + const REQUIRED_CLIENT_VERSION: &str = ">=0.46.1"; // API endpoint constants const STATUS_ENDPOINT: &str = "/status/v1"; @@ -182,14 +175,6 @@ impl OmnectDeviceServiceClient { }) } - fn required_version() -> &'static VersionReq { - static REQUIRED_VERSION: OnceLock = OnceLock::new(); - REQUIRED_VERSION.get_or_init(|| { - VersionReq::parse(Self::REQUIRED_CLIENT_VERSION) - .expect("invalid REQUIRED_CLIENT_VERSION constant") - }) - } - #[allow(clippy::unused_self)] fn build_url(&self, path: &str) -> String { // Normalize path to always start with a single "/" @@ -312,18 +297,13 @@ impl DeviceServiceClient for OmnectDeviceServiceClient { async fn healthcheck_info(&self) -> Result { let status = self.status().await?; - let current_version = status.system_info.omnect_device_service_version; - - let required_version = Self::required_version(); - let parsed_current = Version::parse(¤t_version) - .map_err(|e| anyhow!("failed to parse current version: {e}"))?; + let version_info = version_info( + Self::REQUIRED_CLIENT_VERSION, + &status.system_info.omnect_device_service_version, + )?; Ok(HealthcheckInfo { - version_info: VersionInfo { - required: Self::REQUIRED_CLIENT_VERSION.to_string(), - current: current_version, - mismatch: !required_version.matches(&parsed_current), - }, + version_info, update_validation_status: status.update_validation_status, network_rollback_occurred: marker::NETWORK_ROLLBACK_OCCURRED.exists(), factory_reset_result_acked: marker::FACTORY_RESET_RESULT_ACKED.exists(), @@ -403,75 +383,59 @@ mod tests { mod version_requirements { use super::*; - #[test] - fn required_version_parses_correctly() { - let version_req = OmnectDeviceServiceClient::required_version(); - assert_eq!(version_req.to_string(), ">=0.39.0"); + fn mismatch(current: &str) -> bool { + version_info(OmnectDeviceServiceClient::REQUIRED_CLIENT_VERSION, current) + .unwrap() + .mismatch } #[test] - fn required_version_matches_valid_versions() { - let version_req = OmnectDeviceServiceClient::required_version(); - - assert!(version_req.matches(&Version::parse("0.39.0").unwrap())); - assert!(version_req.matches(&Version::parse("0.40.0").unwrap())); - assert!(version_req.matches(&Version::parse("1.0.0").unwrap())); + fn accepts_the_floor_and_newer() { + for current in ["0.46.1", "0.47.0", "1.0.0"] { + assert!( + !mismatch(current), + "{current} should satisfy the requirement" + ); + } } #[test] - fn required_version_rejects_older_versions() { - let version_req = OmnectDeviceServiceClient::required_version(); - - assert!(!version_req.matches(&Version::parse("0.38.9").unwrap())); - assert!(!version_req.matches(&Version::parse("0.30.0").unwrap())); - assert!(!version_req.matches(&Version::parse("0.1.0").unwrap())); + fn rejects_older_versions() { + for current in ["0.46.0", "0.45.10", "0.1.0"] { + assert!( + mismatch(current), + "{current} should not satisfy the requirement" + ); + } } } - mod healthcheck_info { + mod factory_reset_request { use super::*; - use crate::services::network::NetworkConfigService; - - fn create_test_status(version: &str) -> Status { - Status { - network_status: NetworkStatus { - network_interfaces: vec![], - }, - system_info: SystemInfo { - fleet_id: Some("test-fleet".to_string()), - omnect_device_service_version: version.to_string(), - }, - update_validation_status: UpdateValidationStatus { - status: "idle".to_string(), - }, - } - } #[test] - fn detects_version_mismatch_when_below_requirement() { - let status = create_test_status("0.38.0"); - let current_version = status.system_info.omnect_device_service_version; - - let required_version = OmnectDeviceServiceClient::required_version(); - let parsed_current = Version::parse(¤t_version).unwrap(); - - let mismatch = !required_version.matches(&parsed_current); - - assert!(mismatch); + fn accepts_modes_1_to_3() { + for (json, mode) in [ + (r#"{"mode":1,"preserve":[]}"#, FactoryResetMode::Mode1), + (r#"{"mode":2,"preserve":[]}"#, FactoryResetMode::Mode2), + ( + r#"{"mode":3,"preserve":["network"]}"#, + FactoryResetMode::Mode3, + ), + ] { + let request: FactoryReset = serde_json::from_str(json).unwrap(); + assert_eq!(request.mode, mode); + } } #[test] - fn detects_no_mismatch_when_matching_requirement() { - let status = create_test_status("0.40.0"); - let current_version = status.system_info.omnect_device_service_version; - - let required_version = OmnectDeviceServiceClient::required_version(); - let parsed_current = Version::parse(¤t_version).unwrap(); - - let mismatch = !required_version.matches(&parsed_current); - - assert!(!mismatch); + fn rejects_mode_4() { + assert!(serde_json::from_str::(r#"{"mode":4,"preserve":[]}"#).is_err()); } + } + + mod healthcheck_info { + use super::*; #[test] fn healthcheck_includes_network_rollback_status() { @@ -542,7 +506,8 @@ mod tests { #[test] fn required_version_constant_is_valid_semver_requirement() { - let version_req = VersionReq::parse(OmnectDeviceServiceClient::REQUIRED_CLIENT_VERSION); + let version_req = + semver::VersionReq::parse(OmnectDeviceServiceClient::REQUIRED_CLIENT_VERSION); assert!(version_req.is_ok()); } } diff --git a/src/backend/src/version.rs b/src/backend/src/version.rs new file mode 100644 index 0000000..508244b --- /dev/null +++ b/src/backend/src/version.rs @@ -0,0 +1,50 @@ +use anyhow::{Result, anyhow}; +pub use omnect_ui_core::types::VersionInfo; +use semver::{Version, VersionReq}; + +/// Compares the version a service reports with the version this client needs. +pub fn version_info(required: &str, current: &str) -> Result { + let required_version = VersionReq::parse(required) + .map_err(|e| anyhow!("failed to parse requirement '{required}': {e}"))?; + let current_version = + Version::parse(current).map_err(|e| anyhow!("failed to parse version '{current}': {e}"))?; + + Ok(VersionInfo { + required: required.to_string(), + current: current.to_string(), + mismatch: !required_version.matches(¤t_version), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matching_version_has_no_mismatch() { + let info = version_info(">=1.2.0", "1.2.0").unwrap(); + assert_eq!( + info, + VersionInfo { + required: ">=1.2.0".to_string(), + current: "1.2.0".to_string(), + mismatch: false, + } + ); + } + + #[test] + fn older_version_is_a_mismatch() { + assert!(version_info(">=1.2.0", "1.1.9").unwrap().mismatch); + } + + #[test] + fn version_that_does_not_parse_is_an_error() { + assert!(version_info(">=1.2.0", "not-a-version").is_err()); + } + + #[test] + fn requirement_that_does_not_parse_is_an_error() { + assert!(version_info("not-a-requirement", "1.2.0").is_err()); + } +} diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs index 9e2096f..96b03e3 100644 --- a/src/backend/src/wifi_commissioning_client.rs +++ b/src/backend/src/wifi_commissioning_client.rs @@ -1,6 +1,9 @@ #![cfg_attr(feature = "mock", allow(dead_code, unused_imports))] -use crate::http_client::{handle_http_response, unix_socket_client}; +use crate::{ + http_client::{handle_http_response, unix_socket_client}, + version::version_info, +}; use anyhow::{Context, Result}; use log::info; #[cfg(feature = "mock")] @@ -8,7 +11,7 @@ use mockall::automock; pub use omnect_ui_core::types::{ WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, WifiForgetRequest, WifiForgetResponse, WifiSavedNetworksResponse, WifiScanResultsResponse, - WifiScanStartedResponse, WifiStatusResponse, WifiVersionResponse, + WifiScanStartedResponse, WifiServiceInfoResponse, WifiStatusResponse, }; use reqwest::Client; use serde::Serialize; @@ -27,7 +30,7 @@ pub trait WifiCommissioningClient { async fn status(&self) -> Result; async fn saved_networks(&self) -> Result; async fn forget_network(&self, request: WifiForgetRequest) -> Result; - async fn version(&self) -> Result; + async fn service_info(&self) -> Result; } #[cfg(feature = "mock")] @@ -52,72 +55,72 @@ impl WifiCommissioningServiceClient { const STATUS_ENDPOINT: &str = "/api/v1/status"; const NETWORKS_ENDPOINT: &str = "/api/v1/networks"; const FORGET_ENDPOINT: &str = "/api/v1/networks/forget"; - const VERSION_ENDPOINT: &str = "/api/v1/version"; + const SERVICE_INFO_ENDPOINT: &str = "/api/v1/service-info"; - pub const MIN_REQUIRED_VERSION: semver::Version = semver::Version::new(0, 1, 0); + // Tracks the released wifi-commissioning-service tag. Raise it together with a released tag. + const REQUIRED_CLIENT_VERSION: &str = ">=0.2.1"; pub async fn check_availability(&self) -> WifiAvailability { - let status_result = self.status().await; - let version_result = self.version().await; - - match (status_result, version_result) { - (Ok(status), Ok(version_response)) => { - let is_version_compatible = match semver::Version::parse(&version_response.version) - { - Ok(v) => v >= Self::MIN_REQUIRED_VERSION, - Err(e) => { - log::warn!( - "Failed to parse WiFi service version '{}': {}", - version_response.version, - e - ); - false - } - }; - - if is_version_compatible { - log::info!( - "WiFi service available (version {})", - version_response.version - ); - - if let Some(interface_name) = status.interface_name { - return WifiAvailability::Available { - version: version_response.version, - interface_name, - }; - } - log::error!("WiFi service reported OK status but no interface name"); - } else { - log::warn!( - "WiFi service version '{}' is lower than required minimum {}", - version_response.version, - Self::MIN_REQUIRED_VERSION - ); - } - - WifiAvailability::Unavailable { - socket_present: true, - version: Some(version_response.version), - min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), - } + Self::availability(self.service_info().await) + } + + fn availability(probe: Result) -> WifiAvailability { + let unknown_version = WifiAvailability::Unavailable { + socket_present: true, + version_info: None, + }; + + // A service without the service-info endpoint answers 404, which ends up here. + let info = match probe { + Ok(info) => info, + Err(e) => { + log::warn!("WiFi service-info probe failed: {e:#}"); + return unknown_version; } - (Err(e), _) => { - log::error!("WiFi service status probe failed: {e:#}"); - WifiAvailability::Unavailable { - socket_present: true, - version: None, - min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), - } + }; + + let version_info = match version_info(Self::REQUIRED_CLIENT_VERSION, &info.version) { + Ok(version_info) => version_info, + Err(e) => { + log::warn!("WiFi service version check failed: {e:#}"); + return unknown_version; } - (_, Err(e)) => { - log::error!("WiFi service version probe failed: {e:#}"); - WifiAvailability::Unavailable { - socket_present: true, - version: None, - min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), - } + }; + + if version_info.mismatch { + log::warn!( + "WiFi service version '{}' does not satisfy {}", + info.version, + Self::REQUIRED_CLIENT_VERSION + ); + return WifiAvailability::Unavailable { + socket_present: true, + version_info: Some(version_info), + }; + } + + if info.interface_name.is_empty() { + log::error!("WiFi service reported no interface name"); + return WifiAvailability::Unavailable { + socket_present: true, + version_info: Some(version_info), + }; + } + + log::info!( + "WiFi service available (version {}, interface {}, BLE {})", + info.version, + info.interface_name, + if info.ble_enabled { + "enabled" + } else { + "disabled" } + ); + + WifiAvailability::Available { + version: info.version, + interface_name: info.interface_name, } } @@ -228,9 +231,9 @@ impl WifiCommissioningClient for WifiCommissioningServiceClient { serde_json::from_str(&body).context("failed to parse forget response") } - async fn version(&self) -> Result { - let body = self.get(Self::VERSION_ENDPOINT).await?; - serde_json::from_str(&body).context("failed to parse version response") + async fn service_info(&self) -> Result { + let body = self.get(Self::SERVICE_INFO_ENDPOINT).await?; + serde_json::from_str(&body).context("failed to parse service info response") } } @@ -363,9 +366,109 @@ mod tests { "/api/v1/networks/forget" ); assert_eq!( - WifiCommissioningServiceClient::VERSION_ENDPOINT, - "/api/v1/version" + WifiCommissioningServiceClient::SERVICE_INFO_ENDPOINT, + "/api/v1/service-info" + ); + } + } + + mod availability { + use super::*; + use omnect_ui_core::types::VersionInfo; + + fn service_info(version: &str, interface_name: &str) -> WifiServiceInfoResponse { + WifiServiceInfoResponse { + ble_enabled: true, + interface_name: interface_name.to_string(), + version: version.to_string(), + } + } + + fn unavailable(version_info: Option) -> WifiAvailability { + WifiAvailability::Unavailable { + socket_present: true, + version_info, + } + } + + fn version(current: &str, mismatch: bool) -> VersionInfo { + VersionInfo { + required: ">=0.2.1".to_string(), + current: current.to_string(), + mismatch, + } + } + + #[test] + fn probe_error_has_no_version() { + let probe = Err(anyhow::anyhow!("404 Not Found")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(None) + ); + } + + #[test] + fn older_version_is_a_mismatch() { + let probe = Ok(service_info("0.1.9", "wlan0")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(Some(version("0.1.9", true))) + ); + } + + #[test] + fn version_that_does_not_parse_has_no_version() { + let probe = Ok(service_info("x", "wlan0")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(None) + ); + } + + #[test] + fn empty_interface_is_unavailable() { + let probe = Ok(service_info("0.2.1", "")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(Some(version("0.2.1", false))) ); } + + #[test] + fn mismatch_is_checked_before_the_interface() { + let probe = Ok(service_info("0.1.9", "")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(Some(version("0.1.9", true))) + ); + } + + #[test] + fn matching_version_with_interface_is_available() { + for current in ["0.2.1", "1.0.0"] { + let probe = Ok(service_info(current, "wlan0")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + WifiAvailability::Available { + version: current.to_string(), + interface_name: "wlan0".to_string(), + } + ); + } + } + } + + mod service_info_response { + use super::*; + + #[test] + fn deserializes_service_payload() { + let json = + r#"{"status":"ok","ble_enabled":true,"interface_name":"wlan0","version":"0.2.1"}"#; + let resp: WifiServiceInfoResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.interface_name, "wlan0"); + assert_eq!(resp.version, "0.2.1"); + } } } diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 5bc8afb..e59b05e 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -8,6 +8,7 @@ import OmnectLogo from "./components/branding/OmnectLogo.vue" import OverlaySpinner from "./components/feedback/OverlaySpinner.vue" import UserMenu from "./components/UserMenu.vue" import { useCore } from "./composables/useCore" +import { isFactoryResetSuccess } from "./composables/core/types" import { useSnackbar } from "./composables/useSnackbar" import { useMessageWatchers } from "./composables/useMessageWatchers" @@ -93,6 +94,8 @@ const acknowledgeUpdateValidation = () => { const factoryResetModalSuccess = ref(false) const factoryResetError = ref(null) const factoryResetContext = ref(null) +const factoryResetDataWiped = ref(false) +const factoryResetUnrecognized = ref(false) const updateValidationIsRollback = ref(false) // Watch authentication state to redirect to login if session is lost @@ -132,7 +135,9 @@ watch( // Snapshot once so the template is decoupled from the live ViewModel during close animation factoryResetError.value = result.error ?? null factoryResetContext.value = result.context ?? null - factoryResetModalSuccess.value = result.status === 'modeSupported' + factoryResetModalSuccess.value = isFactoryResetSuccess(result.status) + factoryResetDataWiped.value = result.dataWiped + factoryResetUnrecognized.value = result.status === 'unrecognized' showFactoryResetResultModal.value = true } } @@ -196,10 +201,16 @@ watch(
OK diff --git a/src/ui/src/components/device/DeviceInfoCore.vue b/src/ui/src/components/device/DeviceInfoCore.vue index e7933a6..30c7ad6 100644 --- a/src/ui/src/components/device/DeviceInfoCore.vue +++ b/src/ui/src/components/device/DeviceInfoCore.vue @@ -43,9 +43,12 @@ const deviceInfo = computed(() => { if (viewModel.wifiState.type === 'ready') { infoMap.set('WiFi commissioning service version', viewModel.wifiState.version || 'unknown') } else if (viewModel.wifiState.type === 'unavailable' && viewModel.wifiState.socketPresent) { - const versionStr = viewModel.wifiState.version || 'unknown' - const minReq = viewModel.wifiState.minRequiredVersion - infoMap.set('WiFi commissioning service version', `${versionStr} (minimum required: ${minReq})`) + const info = viewModel.wifiState.versionInfo + let version = 'unknown' + if (info) { + version = info.mismatch ? `${info.current} (required: ${info.required})` : info.current + } + infoMap.set('WiFi commissioning service version', version) } } diff --git a/src/ui/src/composables/core/sync.ts b/src/ui/src/composables/core/sync.ts index c827b5e..0b2407e 100644 --- a/src/ui/src/composables/core/sync.ts +++ b/src/ui/src/composables/core/sync.ts @@ -128,8 +128,9 @@ export function updateViewModelFromCore(): void { ? { status: factoryResetStatusToString(coreViewModel.factoryReset.result.status), context: coreViewModel.factoryReset.result.context || null, - error: coreViewModel.factoryReset.result.error, + error: coreViewModel.factoryReset.result.error || null, paths: coreViewModel.factoryReset.result.paths, + dataWiped: coreViewModel.factoryReset.result.dataWiped, } : null, } diff --git a/src/ui/src/composables/core/types.ts b/src/ui/src/composables/core/types.ts index 8c1b137..1c1dfc8 100644 --- a/src/ui/src/composables/core/types.ts +++ b/src/ui/src/composables/core/types.ts @@ -63,10 +63,12 @@ import { NetworkFormStateVariantsubmitting, FactoryResetStatus, FactoryResetStatusVariantunknown, - FactoryResetStatusVariantmodeSupported, - FactoryResetStatusVariantmodeUnsupported, - FactoryResetStatusVariantbackupRestoreError, - FactoryResetStatusVariantconfigurationError, + FactoryResetStatusVariantsuccess, + FactoryResetStatusVariantinvalid, + FactoryResetStatusVarianterror, + FactoryResetStatusVariantconfigError, + FactoryResetStatusVariantwarning, + FactoryResetStatusVariantunrecognized, UploadState, UploadStateVariantidle, UploadStateVariantuploading, @@ -200,9 +202,15 @@ export interface WifiSavedNetworkType { flags: string } +export interface VersionInfoType { + required: string + current: string + mismatch: boolean +} + export type WifiStateType = | { type: 'unknown' } - | { type: 'unavailable', socketPresent: boolean, version: string | null, minRequiredVersion: string } + | { type: 'unavailable', socketPresent: boolean, versionInfo: VersionInfoType | null } | { type: 'ready' interfaceName: string @@ -215,7 +223,7 @@ export type WifiStateType = connectPollAttempt: number } -export type FactoryResetStatusString = 'unknown' | 'modeSupported' | 'modeUnsupported' | 'backupRestoreError' | 'configurationError' +export type FactoryResetStatusString = 'unknown' | 'success' | 'invalid' | 'error' | 'configError' | 'warning' | 'unrecognized' // ============================================================================ // ViewModel Interface @@ -238,15 +246,16 @@ export interface ViewModel { result: { status: FactoryResetStatusString context: string | null - error: string + error: string | null paths: string[] + dataWiped: boolean } | null } | null updateValidationStatus: { status: string } | null updateManifest: UpdateManifest | null timeouts: { waitOnlineTimeout: { nanos: number; secs: bigint } } | null healthcheck: { - versionInfo: { required: string; current: string; mismatch: boolean } + versionInfo: VersionInfoType updateValidationStatus: { status: string } networkRollbackOccurred: boolean updateValidationAcked: boolean @@ -312,13 +321,23 @@ export interface ViewModel { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function factoryResetStatusToString(status: any): FactoryResetStatusString { if (status instanceof FactoryResetStatusVariantunknown) return 'unknown' - if (status instanceof FactoryResetStatusVariantmodeSupported) return 'modeSupported' - if (status instanceof FactoryResetStatusVariantmodeUnsupported) return 'modeUnsupported' - if (status instanceof FactoryResetStatusVariantbackupRestoreError) return 'backupRestoreError' - if (status instanceof FactoryResetStatusVariantconfigurationError) return 'configurationError' + if (status instanceof FactoryResetStatusVariantsuccess) return 'success' + if (status instanceof FactoryResetStatusVariantinvalid) return 'invalid' + if (status instanceof FactoryResetStatusVarianterror) return 'error' + if (status instanceof FactoryResetStatusVariantconfigError) return 'configError' + if (status instanceof FactoryResetStatusVariantwarning) return 'warning' + if (status instanceof FactoryResetStatusVariantunrecognized) return 'unrecognized' return 'unknown' } +/** + * A warning means the reset completed, only a partition needed a second format + * attempt. + */ +export function isFactoryResetSuccess(status: FactoryResetStatusString): boolean { + return status === 'success' || status === 'warning' +} + /** * Convert DeviceOperationState variant to typed object */ @@ -491,8 +510,13 @@ export function convertWifiState(state: WifiState): WifiStateType { return { type: 'unavailable', socketPresent: s.socket_present, - version: s.version || null, - minRequiredVersion: s.min_required_version, + versionInfo: s.version_info + ? { + required: s.version_info.required, + current: s.version_info.current, + mismatch: s.version_info.mismatch, + } + : null, } } if (state instanceof WifiStateVariantready) { diff --git a/src/ui/tests/device.spec.ts b/src/ui/tests/device.spec.ts index 539ee90..ac5071a 100644 --- a/src/ui/tests/device.spec.ts +++ b/src/ui/tests/device.spec.ts @@ -13,8 +13,15 @@ test.describe('Device Info', () => { }); } + function wifiVersionValue(page: Page) { + return page + .locator('dl > div') + .filter({ has: page.getByText('WiFi commissioning service version', { exact: true }) }) + .locator('dd'); + } + test('displays system info via WebSocket', async ({ page }) => { - await mockWifiAvailable(page, { state: 'unavailable', socket_present: false, version: null, min_required_version: "0.1.0" }); + await mockWifiAvailable(page, { state: 'unavailable', socket_present: false, version_info: null }); await setupAndLogin(page); const systemInfo = { @@ -40,23 +47,41 @@ test.describe('Device Info', () => { }); test('hides WiFi commissioning service version when socket is missing', async ({ page }) => { - await mockWifiAvailable(page, { state: 'unavailable', socket_present: false, version: null, min_required_version: "0.1.0" }); + await mockWifiAvailable(page, { state: 'unavailable', socket_present: false, version_info: null }); await setupAndLogin(page); await expect(page.getByText('WiFi commissioning service version')).not.toBeVisible(); }); test('displays WiFi commissioning service version with hint when incompatible', async ({ page }) => { - await mockWifiAvailable(page, { state: 'unavailable', socket_present: true, version: "0.0.9", min_required_version: "0.1.0" }); + await mockWifiAvailable(page, { + state: 'unavailable', + socket_present: true, + version_info: { required: '>=0.2.1', current: '0.2.0', mismatch: true }, + }); await setupAndLogin(page); await expect(page.getByText('WiFi commissioning service version')).toBeVisible(); - await expect(page.getByText('0.0.9 (minimum required: 0.1.0)')).toBeVisible(); + await expect(page.getByText('0.2.0 (required: >=0.2.1)')).toBeVisible(); + }); + + test('shows unknown version when the service does not answer the probe', async ({ page }) => { + await mockWifiAvailable(page, { state: 'unavailable', socket_present: true, version_info: null }); + await setupAndLogin(page); + await expect(wifiVersionValue(page)).toHaveText('unknown'); + }); + + test('shows the version without hint when the service is unavailable for another reason', async ({ page }) => { + await mockWifiAvailable(page, { + state: 'unavailable', + socket_present: true, + version_info: { required: '>=0.2.1', current: '0.2.1', mismatch: false }, + }); + await setupAndLogin(page); + await expect(wifiVersionValue(page)).toHaveText('0.2.1'); }); test('displays WiFi commissioning service version without hint when compatible', async ({ page }) => { - await mockWifiAvailable(page, { state: 'available', version: "0.1.1", interface_name: "wlan0" }); + await mockWifiAvailable(page, { state: 'available', version: "0.2.1", interface_name: "wlan0" }); await setupAndLogin(page); - await expect(page.getByText('WiFi commissioning service version')).toBeVisible(); - await expect(page.getByText('0.1.1', { exact: true })).toBeVisible(); - await expect(page.getByText('minimum required')).not.toBeVisible(); + await expect(wifiVersionValue(page)).toHaveText('0.2.1'); }); }); diff --git a/src/ui/tests/factory-reset.spec.ts b/src/ui/tests/factory-reset.spec.ts index 2c76e7d..1e1e71a 100644 --- a/src/ui/tests/factory-reset.spec.ts +++ b/src/ui/tests/factory-reset.spec.ts @@ -148,10 +148,10 @@ test.describe('Device Factory Reset - Reconnection', () => { await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); // ODS publishes the factory reset result via WebSocket after republishing. - // status=0 maps to OdsFactoryResetResultStatus::ModeSupported → factoryResetIsSuccess=true. + // status=0 is Success; error and context are null when nothing went wrong. await publishToWebsocket('FactoryResetV1', { keys: ['network'], - result: { status: 0, error: '0', paths: ['/etc/systemd/network/'] }, + result: { status: 0, error: null, context: null, paths: ['network'], data_wiped: true }, }); await expect(page.getByText('Factory Reset Completed', { exact: true })).toBeVisible({ timeout: 5000 }); @@ -169,10 +169,10 @@ test.describe('Device Factory Reset - Reconnection', () => { await route.fulfill({ status: 200 }); }); - // Trigger the factory reset success modal via WebSocket (status: 0 = ModeSupported) + // Trigger the factory reset success modal via WebSocket (status: 0 = Success) await publishToWebsocket('FactoryResetV1', { keys: ['network'], - result: { status: 0, error: '0', paths: ['/etc/systemd/network/'] }, + result: { status: 0, error: null, context: null, paths: ['network'], data_wiped: true }, }); await expect(page.getByText('Factory Reset Completed', { exact: true })).toBeVisible({ timeout: 5000 }); @@ -198,4 +198,88 @@ test.describe('Device Factory Reset - Reconnection', () => { }); expect(flashDetected).toBe(false); }); + + test('warning status shows the success modal with its context', async ({ page }) => { + await mockPortalAuth(page); + await setupAndLogin(page, { factoryResetResultAcked: false }); + + // status=4 is Warning: the reset worked, a partition just needed a second + // format attempt. + await publishToWebsocket('FactoryResetV1', { + keys: ['network'], + result: { + status: 4, + error: null, + context: 'etc reformatted twice', + paths: ['network'], + data_wiped: true, + }, + }); + + await expect(page.getByText('Factory Reset Completed', { exact: true })).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('etc reformatted twice')).toBeVisible(); + await expect(page.getByTestId('factory-reset-data-wiped')).not.toBeVisible(); + }); + + test('failure after data was wiped warns that preserved data may be lost', async ({ page }) => { + await mockPortalAuth(page); + await setupAndLogin(page, { factoryResetResultAcked: false }); + + await publishToWebsocket('FactoryResetV1', { + keys: ['network'], + result: { + status: 2, + error: 'format failed', + context: 'factory partition', + paths: [], + data_wiped: true, + }, + }); + + await expect(page.getByText('Factory Reset Failed', { exact: true })).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('format failed')).toBeVisible(); + await expect(page.getByTestId('factory-reset-data-wiped')).toBeVisible(); + await expect(page.getByText('The device reported an unknown result.')).not.toBeVisible(); + }); + + test('failure before any wipe does not warn about wiped data', async ({ page }) => { + await mockPortalAuth(page); + await setupAndLogin(page, { factoryResetResultAcked: false }); + + // status=1 is Invalid — rejected before the destructive phase started. + await publishToWebsocket('FactoryResetV1', { + keys: ['network'], + result: { + status: 1, + error: 'factory reset mode 4 is not supported', + context: null, + paths: [], + data_wiped: false, + }, + }); + + await expect(page.getByText('Factory Reset Failed', { exact: true })).toBeVisible({ timeout: 5000 }); + await expect(page.getByTestId('factory-reset-data-wiped')).not.toBeVisible(); + }); + + for (const status of [5, 4294967295]) { + test(`unknown status code ${status} still shows a result modal`, async ({ page }) => { + await mockPortalAuth(page); + await setupAndLogin(page, { factoryResetResultAcked: false }); + + await publishToWebsocket('FactoryResetV1', { + keys: ['network'], + result: { + status, + error: null, + context: null, + paths: [], + data_wiped: false, + }, + }); + + await expect(page.getByText('Factory Reset Failed', { exact: true })).toBeVisible({ timeout: 5000 }); + await expect(page.getByText('The device reported an unknown result.')).toBeVisible(); + }); + } }); diff --git a/src/ui/tests/fixtures/network-test-harness.ts b/src/ui/tests/fixtures/network-test-harness.ts index c8d7b68..c1429fd 100644 --- a/src/ui/tests/fixtures/network-test-harness.ts +++ b/src/ui/tests/fixtures/network-test-harness.ts @@ -213,8 +213,8 @@ export class NetworkTestHarness { contentType: 'application/json', body: JSON.stringify({ versionInfo: { - required: '>=0.39.0', - current: '0.40.0', + required: '>=0.46.1', + current: '0.46.1', mismatch: false, }, updateValidationStatus: { @@ -604,7 +604,7 @@ export class NetworkTestHarness { 'Access-Control-Allow-Credentials': 'true', }, body: JSON.stringify({ - versionInfo: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + versionInfo: { required: '>=0.46.1', current: '0.46.1', mismatch: false }, updateValidationStatus: { status: 'valid' }, networkRollbackOccurred: this.networkRollbackOccurred, }), diff --git a/src/ui/tests/network-configuration.spec.ts b/src/ui/tests/network-configuration.spec.ts index 72498de..be6cfce 100644 --- a/src/ui/tests/network-configuration.spec.ts +++ b/src/ui/tests/network-configuration.spec.ts @@ -338,7 +338,7 @@ test.describe('Network Configuration - Comprehensive E2E Tests', () => { status: 200, contentType: 'application/json', body: JSON.stringify({ - versionInfo: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + versionInfo: { required: '>=0.46.1', current: '0.46.1', mismatch: false }, updateValidationStatus: { status: 'valid' }, networkRollbackOccurred: healthcheckRollbackStatus, }), @@ -469,7 +469,7 @@ test.describe('Network Configuration - Comprehensive E2E Tests', () => { status: 200, contentType: 'application/json', body: JSON.stringify({ - versionInfo: { required: '>=0.39.0', current: '0.40.0', mismatch: false }, + versionInfo: { required: '>=0.46.1', current: '0.46.1', mismatch: false }, updateValidationStatus: { status: 'valid' }, networkRollbackOccurred: true, }), diff --git a/src/ui/tests/version-mismatch.spec.ts b/src/ui/tests/version-mismatch.spec.ts index 821906d..58fac39 100644 --- a/src/ui/tests/version-mismatch.spec.ts +++ b/src/ui/tests/version-mismatch.spec.ts @@ -13,8 +13,8 @@ test.describe('Version Mismatch', () => { contentType: 'application/json', body: JSON.stringify({ versionInfo: { - required: '>=0.39.0', - current: '0.35.0', + required: '>=0.46.1', + current: '0.45.10', mismatch: true, }, updateValidationStatus: { @@ -30,8 +30,8 @@ test.describe('Version Mismatch', () => { // The version mismatch dialog should appear await expect(page.getByText('omnect-device-service version mismatch')).toBeVisible({ timeout: 10000 }); - await expect(page.getByText('Current version: 0.35.0')).toBeVisible(); - await expect(page.getByText('Required version >=0.39.0')).toBeVisible(); + await expect(page.getByText('Current version: 0.45.10')).toBeVisible(); + await expect(page.getByText('Required version >=0.46.1')).toBeVisible(); await expect(page.getByText('Please consider to update omnect Secure OS')).toBeVisible(); // Dialog should be persistent (no close button, can't dismiss) @@ -50,8 +50,8 @@ test.describe('Version Mismatch', () => { contentType: 'application/json', body: JSON.stringify({ versionInfo: { - required: '>=0.39.0', - current: '0.40.0', + required: '>=0.46.1', + current: '0.46.1', mismatch: false, }, updateValidationStatus: { diff --git a/src/ui/tests/wifi.spec.ts b/src/ui/tests/wifi.spec.ts index 9ca41b6..0b75bcf 100644 --- a/src/ui/tests/wifi.spec.ts +++ b/src/ui/tests/wifi.spec.ts @@ -16,12 +16,11 @@ async function mockWifiAvailable(page: Page, available = true, interfaceName = ' body: JSON.stringify(available ? { state: "available", interface_name: interfaceName, - version: "0.1.0" + version: "0.2.1" } : { state: "unavailable", socket_present: false, - version: "0.1.0", - min_required_version: "0.1.0" + version_info: null }), }); });