From 57e91dc4ce2c27db7637d56a2ab18ab7bb2bceac Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:55:36 +0200 Subject: [PATCH 1/8] fix(factory-reset): parse the omnect-device-service result shape of feature version 4 Adapt the factory-reset result wire types to the shape omnect-device-service publishes on FactoryResetV1 with factory_reset feature version 4: - status is a u32 with the codes success, invalid, error, configError and warning, plus a catch-all so a future code parses instead of failing the whole update - error and context arrive as null when nothing went wrong - data_wiped tells whether the destructive phase had already started Raise the required omnect-device-service version to 0.46.0. Signed-off-by: Jan Zachmann 50990105+JanZachmann@users.noreply.github.com --- src/app/src/types/factory_reset.rs | 38 +++++-- src/app/src/types/ods.rs | 39 +++++-- .../src/update/device/network/verification.rs | 5 +- src/app/src/update/websocket.rs | 104 ++++++++++++++++-- .../src/omnect_device_service_client.rs | 22 ++-- src/ui/src/App.vue | 9 +- src/ui/src/composables/core/sync.ts | 3 +- src/ui/src/composables/core/types.ts | 33 ++++-- src/ui/tests/factory-reset.spec.ts | 88 ++++++++++++++- src/ui/tests/fixtures/network-test-harness.ts | 6 +- src/ui/tests/network-configuration.spec.ts | 4 +- src/ui/tests/version-mismatch.spec.ts | 12 +- 12 files changed, 295 insertions(+), 68 deletions(-) diff --git a/src/app/src/types/factory_reset.rs b/src/app/src/types/factory_reset.rs index f2d604be..985f0c91 100644 --- a/src/app/src/types/factory_reset.rs +++ b/src/app/src/types/factory_reset.rs @@ -5,22 +5,39 @@ use std::fmt; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub enum FactoryResetStatus { + /// No result received yet. Distinct from `Unrecognized` so the UI can stay + /// silent while a result is missing and still report one it cannot name. #[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 FactoryResetStatus { + /// `Warning` counts as success: the reset completed, only a partition + /// needed a retry. + #[must_use] + pub const fn is_success(self) -> bool { + matches!(self, Self::Success | Self::Warning) + } } 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 +48,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 17700159..da872b35 100644 --- a/src/app/src/types/ods.rs +++ b/src/app/src/types/ods.rs @@ -170,34 +170,48 @@ impl From for NetworkStatus { } } -/// Factory reset result status — ODS sends numeric values (`serde_repr`) +/// Factory reset result status — ODS sends numeric values (`serde_repr`). +/// `u32` matches the serialization in omnect-os-init. #[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. +/// ODS sends twin reports as merge patches, so every optional key arrives +/// explicitly as `null` rather than being omitted. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct OdsFactoryResetResult { pub status: OdsFactoryResetResultStatus, + #[serde(default)] pub context: Option, - pub error: String, + #[serde(default)] + pub error: Option, + #[serde(default)] pub paths: Vec, + #[serde(default)] + pub data_wiped: bool, } impl From for FactoryResetResult { @@ -207,6 +221,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/update/device/network/verification.rs b/src/app/src/update/device/network/verification.rs index a348c588..31f82f7f 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/websocket.rs b/src/app/src/update/websocket.rs index cd56b005..7fe880ec 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -199,20 +199,104 @@ 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!(result.status.is_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}}"#, + ); - 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/"]); + assert_eq!(result.status, FactoryResetStatus::Error); + assert!(!result.status.is_success()); + assert_eq!(result.error.as_deref(), Some("format failed")); + assert_eq!(result.context.as_deref(), Some("factory partition")); + assert!(result.data_wiped); + } + + #[test] + fn warning_counts_as_success() { + 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); + assert!(result.status.is_success()); + } + + #[test] + fn abort_before_wipe_reports_data_not_wiped() { + let result = parse_result( + r#"{"keys":["network"],"result":{"status":1,"error":"unknown preserve key","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_code_parses_as_unrecognized() { + // ODS reports a status it does not know itself as u32::MAX; any + // future code must not fail the whole update. + let result = parse_result( + r#"{"keys":["network"],"result":{"status":4294967295,"error":null,"context":null,"paths":[],"data_wiped":false}}"#, + ); + + assert_eq!(result.status, FactoryResetStatus::Unrecognized); + assert!(!result.status.is_success()); + } + + #[test] + fn unrecognized_status_is_distinct_from_no_result() { + // The UI stays silent on Unknown (no result yet) but must still + // report a result whose status it cannot name. + assert_ne!( + FactoryResetStatus::Unrecognized, + FactoryResetStatus::Unknown + ); + assert_eq!(FactoryResetStatus::default(), FactoryResetStatus::Unknown); + } + + #[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!(result.paths.is_empty()); + assert!(!result.data_wiped); } } diff --git a/src/backend/src/omnect_device_service_client.rs b/src/backend/src/omnect_device_service_client.rs index c5721d73..524be528 100644 --- a/src/backend/src/omnect_device_service_client.rs +++ b/src/backend/src/omnect_device_service_client.rs @@ -156,7 +156,11 @@ 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). + // TODO: remove me, as soon as the omnect-device-service release carrying + // factory-reset feature version 4 is tagged — replace 0.46.0 with that tag. + const REQUIRED_CLIENT_VERSION: &str = ">=0.46.0"; // API endpoint constants const STATUS_ENDPOINT: &str = "/status/v1"; @@ -406,15 +410,15 @@ mod tests { #[test] fn required_version_parses_correctly() { let version_req = OmnectDeviceServiceClient::required_version(); - assert_eq!(version_req.to_string(), ">=0.39.0"); + assert_eq!(version_req.to_string(), ">=0.46.0"); } #[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("0.46.0").unwrap())); + assert!(version_req.matches(&Version::parse("0.47.0").unwrap())); assert!(version_req.matches(&Version::parse("1.0.0").unwrap())); } @@ -422,8 +426,10 @@ mod tests { 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())); + // 0.45.2 is the last release publishing the factory-reset result in + // the previous shape. + assert!(!version_req.matches(&Version::parse("0.45.2").unwrap())); + assert!(!version_req.matches(&Version::parse("0.41.0").unwrap())); assert!(!version_req.matches(&Version::parse("0.1.0").unwrap())); } } @@ -449,7 +455,7 @@ mod tests { #[test] fn detects_version_mismatch_when_below_requirement() { - let status = create_test_status("0.38.0"); + let status = create_test_status("0.45.2"); let current_version = status.system_info.omnect_device_service_version; let required_version = OmnectDeviceServiceClient::required_version(); @@ -462,7 +468,7 @@ mod tests { #[test] fn detects_no_mismatch_when_matching_requirement() { - let status = create_test_status("0.40.0"); + let status = create_test_status("0.46.0"); let current_version = status.system_info.omnect_device_service_version; let required_version = OmnectDeviceServiceClient::required_version(); diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index 5bc8afb8..d56a29a7 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,7 @@ const acknowledgeUpdateValidation = () => { const factoryResetModalSuccess = ref(false) const factoryResetError = ref(null) const factoryResetContext = ref(null) +const factoryResetDataWiped = ref(false) const updateValidationIsRollback = ref(false) // Watch authentication state to redirect to login if session is lost @@ -132,7 +134,8 @@ 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 showFactoryResetResultModal.value = true } } @@ -200,6 +203,10 @@ watch(
OK diff --git a/src/ui/src/composables/core/sync.ts b/src/ui/src/composables/core/sync.ts index c827b5e6..0b2407e3 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 8c1b137f..dd9c0b62 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, @@ -215,7 +217,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,8 +240,9 @@ export interface ViewModel { result: { status: FactoryResetStatusString context: string | null - error: string + error: string | null paths: string[] + dataWiped: boolean } | null } | null updateValidationStatus: { status: string } | null @@ -312,13 +315,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 — the user sees it as success. + */ +export function isFactoryResetSuccess(status: FactoryResetStatusString): boolean { + return status === 'success' || status === 'warning' +} + /** * Convert DeviceOperationState variant to typed object */ diff --git a/src/ui/tests/factory-reset.spec.ts b/src/ui/tests/factory-reset.spec.ts index 2c76e7d1..78f5a385 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,84 @@ test.describe('Device Factory Reset - Reconnection', () => { }); expect(flashDetected).toBe(false); }); + + test('warning status shows the success modal', 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: 'second format attempt', + paths: ['network'], + data_wiped: true, + }, + }); + + await expect(page.getByText('Factory Reset Completed', { exact: true })).toBeVisible({ timeout: 5000 }); + }); + + test('failure after data was wiped warns the device needs another reset', 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(); + }); + + 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: 'unknown preserve key', + 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(); + }); + + test('unknown status code still shows a result modal', async ({ page }) => { + await mockPortalAuth(page); + await setupAndLogin(page, { factoryResetResultAcked: false }); + + // ODS reports a status it does not know itself as u32::MAX. The result must + // still reach the user instead of being swallowed as "no result". + await publishToWebsocket('FactoryResetV1', { + keys: ['network'], + result: { + status: 4294967295, + error: null, + context: null, + paths: [], + data_wiped: false, + }, + }); + + await expect(page.getByText('Factory Reset Failed', { exact: true })).toBeVisible({ timeout: 5000 }); + }); }); diff --git a/src/ui/tests/fixtures/network-test-harness.ts b/src/ui/tests/fixtures/network-test-harness.ts index c8d7b68f..70b85c06 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.0', + current: '0.46.0', 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.0', current: '0.46.0', 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 72498def..15a0aa6c 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.0', current: '0.46.0', 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.0', current: '0.46.0', 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 821906df..7849f22a 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.0', + current: '0.45.2', 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.2')).toBeVisible(); + await expect(page.getByText('Required version >=0.46.0')).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.0', + current: '0.46.0', mismatch: false, }, updateValidationStatus: { From f56db0cdebc1f4c5d566cd4b1c6142a05e29d71e Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:12:17 +0200 Subject: [PATCH 2/8] feat(wifi): probe wifi-commissioning-service via /api/v1/service-info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the service-info endpoint as the single availability probe. It returns version, interface name and live BLE state in one call, replacing the separate status and version requests. Report the version requirement the same way as for omnect-device-service: a semver requirement string plus a required/current/mismatch triple. Unlike the device service a stale wifi-commissioning-service does not block the UI, since WiFi is optional and gated on the socket being present — the mismatch shows in the WiFi section of the device info. Signed-off-by: Jan Zachmann 50990105+JanZachmann@users.noreply.github.com --- src/app/src/types/wifi.rs | 19 +- src/app/src/update/mod.rs | 3 +- src/app/src/update/wifi.rs | 42 ++-- src/backend/src/main.rs | 5 +- src/backend/src/wifi_commissioning_client.rs | 214 +++++++++++++----- .../src/components/device/DeviceInfoCore.vue | 9 +- src/ui/src/composables/core/types.ts | 20 +- src/ui/tests/device.spec.ts | 25 +- src/ui/tests/wifi.spec.ts | 5 +- 9 files changed, 242 insertions(+), 100 deletions(-) diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs index 1eff772d..a328bc1f 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,9 @@ pub enum WifiAvailability { }, Unavailable { socket_present: bool, - version: Option, - min_required_version: String, + /// `None` when the service could not be reached at all, so there is no + /// version to compare. + version_info: Option, }, } @@ -70,8 +72,7 @@ pub enum WifiState { Unknown, Unavailable { socket_present: bool, - version: Option, - min_required_version: String, + version_info: Option, }, Ready { interface_name: String, @@ -150,3 +151,13 @@ pub struct WifiForgetResponse { pub struct WifiVersionResponse { pub version: String, } + +/// Capabilities of the running wifi-commissioning-service instance +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct WifiServiceInfoResponse { + pub status: String, + /// 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/mod.rs b/src/app/src/update/mod.rs index 62f5e5d6..cfc04b1e 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/wifi.rs b/src/app/src/update/wifi.rs index 7142bb4c..369a34b0 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/main.rs b/src/backend/src/main.rs index beb0c3fb..82852c0f 100644 --- a/src/backend/src/main.rs +++ b/src/backend/src/main.rs @@ -293,10 +293,11 @@ fn optimal_worker_count() -> usize { } async fn initialize_wifi_client() -> (Option, WifiAvailability) { + // No socket means the service is not installed at all, so there is no + // version to report. 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/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs index 9e2096fa..04410677 100644 --- a/src/backend/src/wifi_commissioning_client.rs +++ b/src/backend/src/wifi_commissioning_client.rs @@ -6,13 +6,14 @@ use log::info; #[cfg(feature = "mock")] use mockall::automock; pub use omnect_ui_core::types::{ - WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, + VersionInfo, WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, WifiForgetRequest, WifiForgetResponse, WifiSavedNetworksResponse, WifiScanResultsResponse, - WifiScanStartedResponse, WifiStatusResponse, WifiVersionResponse, + WifiScanStartedResponse, WifiServiceInfoResponse, WifiStatusResponse, WifiVersionResponse, }; use reqwest::Client; +use semver::{Version, VersionReq}; use serde::Serialize; -use std::{fmt::Debug, path::Path}; +use std::{fmt::Debug, path::Path, sync::OnceLock}; use trait_variant::make; // --- Client trait --- @@ -28,6 +29,7 @@ pub trait WifiCommissioningClient { 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")] @@ -53,71 +55,88 @@ impl WifiCommissioningServiceClient { 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"; + + // The floor is the oldest wifi-commissioning-service serving + // /api/v1/service-info, which is the availability probe used here. + const REQUIRED_CLIENT_VERSION: &str = ">=0.2.1"; + + 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") + }) + } - pub const MIN_REQUIRED_VERSION: semver::Version = semver::Version::new(0, 1, 0); + /// A version that does not parse counts as a mismatch: it cannot be shown + /// to satisfy the requirement. + fn version_info(current: &str) -> VersionInfo { + let mismatch = match Version::parse(current) { + Ok(parsed) => !Self::required_version().matches(&parsed), + Err(e) => { + log::warn!("failed to parse WiFi service version '{current}': {e:#}"); + true + } + }; - 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 - } - }; + VersionInfo { + required: Self::REQUIRED_CLIENT_VERSION.to_string(), + current: current.to_string(), + mismatch, + } + } - 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(), - } - } - (Err(e), _) => { - log::error!("WiFi service status probe failed: {e:#}"); - WifiAvailability::Unavailable { + pub async fn check_availability(&self) -> WifiAvailability { + // A service older than the floor has no /api/v1/service-info and answers + // 404, which lands here as an error — expected, not a fault. + let info = match self.service_info().await { + Ok(info) => info, + Err(e) => { + log::warn!("WiFi service-info probe failed: {e:#}"); + return WifiAvailability::Unavailable { socket_present: true, - version: None, - min_required_version: Self::MIN_REQUIRED_VERSION.to_string(), - } + version_info: None, + }; } - (_, 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(), - } + }; + + let version_info = Self::version_info(&info.version); + + 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, } } @@ -232,6 +251,11 @@ impl WifiCommissioningClient for WifiCommissioningServiceClient { 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") + } } #[cfg(test)] @@ -366,6 +390,72 @@ mod tests { WifiCommissioningServiceClient::VERSION_ENDPOINT, "/api/v1/version" ); + assert_eq!( + WifiCommissioningServiceClient::SERVICE_INFO_ENDPOINT, + "/api/v1/service-info" + ); + } + } + + mod version_requirements { + use super::*; + + #[test] + fn required_version_parses_correctly() { + let version_req = WifiCommissioningServiceClient::required_version(); + assert_eq!(version_req.to_string(), ">=0.2.1"); + } + + #[test] + fn accepts_the_floor_and_newer() { + for current in ["0.2.1", "0.3.0", "1.0.0"] { + let info = WifiCommissioningServiceClient::version_info(current); + assert!(!info.mismatch, "{current} should satisfy the requirement"); + assert_eq!(info.current, current); + assert_eq!(info.required, ">=0.2.1"); + } + } + + #[test] + fn rejects_versions_below_the_floor() { + // 0.2.0 introduced /api/v1/service-info, 0.1.0 has no such endpoint. + for current in ["0.2.0", "0.1.0"] { + let info = WifiCommissioningServiceClient::version_info(current); + assert!( + info.mismatch, + "{current} should not satisfy the requirement" + ); + } + } + + #[test] + fn unparseable_version_counts_as_mismatch() { + let info = WifiCommissioningServiceClient::version_info("not-a-version"); + assert!(info.mismatch); + assert_eq!(info.current, "not-a-version"); + } + } + + mod service_info_response { + use super::*; + + #[test] + fn deserializes_all_fields() { + 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.status, "ok"); + assert!(resp.ble_enabled); + assert_eq!(resp.interface_name, "wlan0"); + assert_eq!(resp.version, "0.2.1"); + } + + #[test] + fn deserializes_with_ble_disabled() { + let json = + r#"{"status":"ok","ble_enabled":false,"interface_name":"wlan0","version":"0.2.1"}"#; + let resp: WifiServiceInfoResponse = serde_json::from_str(json).unwrap(); + assert!(!resp.ble_enabled); } } } diff --git a/src/ui/src/components/device/DeviceInfoCore.vue b/src/ui/src/components/device/DeviceInfoCore.vue index e7933a60..58d68817 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})`) + // No versionInfo means the service did not answer the probe at all. + const info = viewModel.wifiState.versionInfo + infoMap.set( + 'WiFi commissioning service version', + info ? `${info.current} (required: ${info.required})` : 'unknown' + ) } } diff --git a/src/ui/src/composables/core/types.ts b/src/ui/src/composables/core/types.ts index dd9c0b62..2fac5430 100644 --- a/src/ui/src/composables/core/types.ts +++ b/src/ui/src/composables/core/types.ts @@ -202,9 +202,16 @@ export interface WifiSavedNetworkType { flags: string } +/** Shared by the omnect-device-service healthcheck and the WiFi service probe. */ +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 @@ -249,7 +256,7 @@ export interface ViewModel { 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 @@ -504,8 +511,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 539ee908..4ef57909 100644 --- a/src/ui/tests/device.spec.ts +++ b/src/ui/tests/device.spec.ts @@ -14,7 +14,7 @@ test.describe('Device Info', () => { } 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 +40,34 @@ 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.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(page.getByText('WiFi commissioning service version')).toBeVisible(); - await expect(page.getByText('0.0.9 (minimum required: 0.1.0)')).toBeVisible(); + await expect(page.getByText('unknown', { exact: true })).toBeVisible(); }); 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(page.getByText('0.2.1', { exact: true })).toBeVisible(); + await expect(page.getByText('required:')).not.toBeVisible(); }); }); diff --git a/src/ui/tests/wifi.spec.ts b/src/ui/tests/wifi.spec.ts index 9ca41b60..0b75bcf5 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 }), }); }); From e550e3d69b0043d997fb4925732917b14220b66c Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:17:33 +0200 Subject: [PATCH 3/8] refactor(wifi): drop the now unused /api/v1/version probe service-info reports the version, so the separate version request has no caller left. Also correct the version floor comment: 0.2.0 already serves service-info, the floor tracks the released tag. Signed-off-by: Jan Zachmann 50990105+JanZachmann@users.noreply.github.com --- src/app/src/types/wifi.rs | 5 ----- src/backend/src/wifi_commissioning_client.rs | 17 +++-------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs index a328bc1f..2add78f6 100644 --- a/src/app/src/types/wifi.rs +++ b/src/app/src/types/wifi.rs @@ -147,11 +147,6 @@ pub struct WifiForgetResponse { pub status: String, } -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct WifiVersionResponse { - pub version: String, -} - /// Capabilities of the running wifi-commissioning-service instance #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct WifiServiceInfoResponse { diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs index 04410677..4f702dc3 100644 --- a/src/backend/src/wifi_commissioning_client.rs +++ b/src/backend/src/wifi_commissioning_client.rs @@ -8,7 +8,7 @@ use mockall::automock; pub use omnect_ui_core::types::{ VersionInfo, WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, WifiForgetRequest, WifiForgetResponse, WifiSavedNetworksResponse, WifiScanResultsResponse, - WifiScanStartedResponse, WifiServiceInfoResponse, WifiStatusResponse, WifiVersionResponse, + WifiScanStartedResponse, WifiServiceInfoResponse, WifiStatusResponse, }; use reqwest::Client; use semver::{Version, VersionReq}; @@ -28,7 +28,6 @@ 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; } @@ -54,11 +53,10 @@ 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"; - // The floor is the oldest wifi-commissioning-service serving - // /api/v1/service-info, which is the availability probe used here. + // Tracks the released wifi-commissioning-service tag, not the oldest version + // that answers the probe. Raise it together with a released tag. const REQUIRED_CLIENT_VERSION: &str = ">=0.2.1"; fn required_version() -> &'static VersionReq { @@ -247,11 +245,6 @@ 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") @@ -386,10 +379,6 @@ mod tests { WifiCommissioningServiceClient::FORGET_ENDPOINT, "/api/v1/networks/forget" ); - assert_eq!( - WifiCommissioningServiceClient::VERSION_ENDPOINT, - "/api/v1/version" - ); assert_eq!( WifiCommissioningServiceClient::SERVICE_INFO_ENDPOINT, "/api/v1/service-info" From 6c540305687aaaa5abf21930ea05ac1f4062a034 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Fri, 18 Sep 2026 16:03:02 +0200 Subject: [PATCH 4/8] feat(factory-reset): drop mode 4 from the accepted reset modes Mode 4 ran a customer-supplied wipe hook in the initramfs. The hook is gone and the initramfs no longer accepts the mode: a trigger carrying it fails to parse, the device boots normally and no result is reported at all, so a reset requested with it would never be answered. The web API rejects the value now, instead of passing it on. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- src/backend/src/omnect_device_service_client.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/src/omnect_device_service_client.rs b/src/backend/src/omnect_device_service_client.rs index 524be528..9752f35f 100644 --- a/src/backend/src/omnect_device_service_client.rs +++ b/src/backend/src/omnect_device_service_client.rs @@ -23,7 +23,6 @@ pub enum FactoryResetMode { Mode1 = 1, Mode2 = 2, Mode3 = 3, - Mode4 = 4, } #[derive(Debug, Default, Deserialize, Serialize)] From 1b00764bd095202e071189e899c36248f8b18765 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:59:40 +0200 Subject: [PATCH 5/8] fix(clippy): use assert_eq! for the empty paths check Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- src/app/src/update/websocket.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index 7fe880ec..e8b0e03e 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -295,7 +295,7 @@ mod tests { assert_eq!(result.status, FactoryResetStatus::Success); assert_eq!(result.error, None); assert_eq!(result.context, None); - assert!(result.paths.is_empty()); + assert_eq!(result.paths, Vec::::new()); assert!(!result.data_wiped); } } From 2f7c1a0016c3674de5777bb892924e3e4902ebaf Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:04:37 +0100 Subject: [PATCH 6/8] fix(deps): bump ctutils, rustls-webpki and webpki-root-certs Fixes RUSTSEC-2026-0385, RUSTSEC-2026-0382 and RUSTSEC-2026-0383. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 777948bd..ccd411a8 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", ] From 40d939c81951b1dd8751c944ea9a9762766b87ac Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:04:38 +0100 Subject: [PATCH 7/8] fix: test the wcs probe and factory reset results, require ods 0.46.1 Move the wcs availability decision into a pure function with a test per branch and share one version check with the ods client. The result dialog names an unknown status, shows a warning's context, and no longer says a new reset fixes a failure after data was changed. Test status codes the UI does not know, reject mode 4, and cut comments and tests that add nothing. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- src/app/src/types/factory_reset.rs | 12 +- src/app/src/types/ods.rs | 5 - src/app/src/types/wifi.rs | 3 +- src/app/src/update/websocket.rs | 39 ++--- src/backend/src/lib.rs | 1 + src/backend/src/main.rs | 3 +- .../src/omnect_device_service_client.rs | 136 ++++++--------- src/backend/src/version.rs | 50 ++++++ src/backend/src/wifi_commissioning_client.rs | 157 +++++++++++------- src/ui/src/App.vue | 8 +- .../src/components/device/DeviceInfoCore.vue | 10 +- src/ui/src/composables/core/types.ts | 3 +- src/ui/tests/device.spec.ts | 24 ++- src/ui/tests/factory-reset.spec.ts | 44 ++--- src/ui/tests/fixtures/network-test-harness.ts | 6 +- src/ui/tests/network-configuration.spec.ts | 4 +- src/ui/tests/version-mismatch.spec.ts | 12 +- 17 files changed, 277 insertions(+), 240 deletions(-) create mode 100644 src/backend/src/version.rs diff --git a/src/app/src/types/factory_reset.rs b/src/app/src/types/factory_reset.rs index 985f0c91..e816be1f 100644 --- a/src/app/src/types/factory_reset.rs +++ b/src/app/src/types/factory_reset.rs @@ -5,8 +5,7 @@ use std::fmt; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub enum FactoryResetStatus { - /// No result received yet. Distinct from `Unrecognized` so the UI can stay - /// silent while a result is missing and still report one it cannot name. + /// No result received yet. #[default] Unknown, Success, @@ -19,15 +18,6 @@ pub enum FactoryResetStatus { Unrecognized, } -impl FactoryResetStatus { - /// `Warning` counts as success: the reset completed, only a partition - /// needed a retry. - #[must_use] - pub const fn is_success(self) -> bool { - matches!(self, Self::Success | Self::Warning) - } -} - impl fmt::Display for FactoryResetStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/src/app/src/types/ods.rs b/src/app/src/types/ods.rs index da872b35..b04a3d94 100644 --- a/src/app/src/types/ods.rs +++ b/src/app/src/types/ods.rs @@ -171,7 +171,6 @@ impl From for NetworkStatus { } /// Factory reset result status — ODS sends numeric values (`serde_repr`). -/// `u32` matches the serialization in omnect-os-init. #[derive(Debug, Clone, Deserialize_repr, PartialEq, Eq)] #[repr(u32)] pub enum OdsFactoryResetResultStatus { @@ -199,14 +198,10 @@ impl From for FactoryResetStatus { } /// Factory reset result. -/// ODS sends twin reports as merge patches, so every optional key arrives -/// explicitly as `null` rather than being omitted. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct OdsFactoryResetResult { pub status: OdsFactoryResetResultStatus, - #[serde(default)] pub context: Option, - #[serde(default)] pub error: Option, #[serde(default)] pub paths: Vec, diff --git a/src/app/src/types/wifi.rs b/src/app/src/types/wifi.rs index 2add78f6..166e6c27 100644 --- a/src/app/src/types/wifi.rs +++ b/src/app/src/types/wifi.rs @@ -11,8 +11,7 @@ pub enum WifiAvailability { }, Unavailable { socket_present: bool, - /// `None` when the service could not be reached at all, so there is no - /// version to compare. + /// `None` when the probe gave no usable version. version_info: Option, }, } diff --git a/src/app/src/update/websocket.rs b/src/app/src/update/websocket.rs index e8b0e03e..987f4744 100644 --- a/src/app/src/update/websocket.rs +++ b/src/app/src/update/websocket.rs @@ -216,7 +216,6 @@ mod tests { ); assert_eq!(result.status, FactoryResetStatus::Success); - assert!(result.status.is_success()); assert_eq!(result.error, None); assert_eq!(result.context, None); assert_eq!(result.paths, vec!["network"]); @@ -230,26 +229,24 @@ mod tests { ); assert_eq!(result.status, FactoryResetStatus::Error); - assert!(!result.status.is_success()); assert_eq!(result.error.as_deref(), Some("format failed")); assert_eq!(result.context.as_deref(), Some("factory partition")); assert!(result.data_wiped); } #[test] - fn warning_counts_as_success() { + 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); - assert!(result.status.is_success()); } #[test] fn abort_before_wipe_reports_data_not_wiped() { let result = parse_result( - r#"{"keys":["network"],"result":{"status":1,"error":"unknown preserve key","context":null,"paths":[],"data_wiped":false}}"#, + 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); @@ -266,26 +263,18 @@ mod tests { } #[test] - fn unknown_status_code_parses_as_unrecognized() { - // ODS reports a status it does not know itself as u32::MAX; any - // future code must not fail the whole update. - let result = parse_result( - r#"{"keys":["network"],"result":{"status":4294967295,"error":null,"context":null,"paths":[],"data_wiped":false}}"#, - ); - - assert_eq!(result.status, FactoryResetStatus::Unrecognized); - assert!(!result.status.is_success()); - } - - #[test] - fn unrecognized_status_is_distinct_from_no_result() { - // The UI stays silent on Unknown (no result yet) but must still - // report a result whose status it cannot name. - assert_ne!( - FactoryResetStatus::Unrecognized, - FactoryResetStatus::Unknown - ); - assert_eq!(FactoryResetStatus::default(), FactoryResetStatus::Unknown); + 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] diff --git a/src/backend/src/lib.rs b/src/backend/src/lib.rs index ef3ad6c4..9f4d9a8a 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 82852c0f..43d38de0 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::{ @@ -293,8 +294,6 @@ fn optimal_worker_count() -> usize { } async fn initialize_wifi_client() -> (Option, WifiAvailability) { - // No socket means the service is not installed at all, so there is no - // version to report. let unavailable = WifiAvailability::Unavailable { socket_present: false, version_info: None, diff --git a/src/backend/src/omnect_device_service_client.rs b/src/backend/src/omnect_device_service_client.rs index 9752f35f..931490be 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)] @@ -87,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 { @@ -157,9 +149,7 @@ impl Clone for MockDeviceServiceClient { impl OmnectDeviceServiceClient { // The floor is the oldest omnect-device-service that publishes the // factory-reset result in the shape parsed here (feature version 4). - // TODO: remove me, as soon as the omnect-device-service release carrying - // factory-reset feature version 4 is tagged — replace 0.46.0 with that tag. - const REQUIRED_CLIENT_VERSION: &str = ">=0.46.0"; + const REQUIRED_CLIENT_VERSION: &str = ">=0.46.1"; // API endpoint constants const STATUS_ENDPOINT: &str = "/status/v1"; @@ -185,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 "/" @@ -315,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(), @@ -406,77 +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.46.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.46.0").unwrap())); - assert!(version_req.matches(&Version::parse("0.47.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(); - - // 0.45.2 is the last release publishing the factory-reset result in - // the previous shape. - assert!(!version_req.matches(&Version::parse("0.45.2").unwrap())); - assert!(!version_req.matches(&Version::parse("0.41.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.45.2"); - 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.46.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() { @@ -547,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 00000000..508244b4 --- /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 4f702dc3..b384200f 100644 --- a/src/backend/src/wifi_commissioning_client.rs +++ b/src/backend/src/wifi_commissioning_client.rs @@ -1,19 +1,21 @@ #![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")] use mockall::automock; pub use omnect_ui_core::types::{ - VersionInfo, WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, + WifiAvailability, WifiConnectRequest, WifiConnectResponse, WifiDisconnectResponse, WifiForgetRequest, WifiForgetResponse, WifiSavedNetworksResponse, WifiScanResultsResponse, WifiScanStartedResponse, WifiServiceInfoResponse, WifiStatusResponse, }; use reqwest::Client; -use semver::{Version, VersionReq}; use serde::Serialize; -use std::{fmt::Debug, path::Path, sync::OnceLock}; +use std::{fmt::Debug, path::Path}; use trait_variant::make; // --- Client trait --- @@ -55,51 +57,35 @@ impl WifiCommissioningServiceClient { const FORGET_ENDPOINT: &str = "/api/v1/networks/forget"; const SERVICE_INFO_ENDPOINT: &str = "/api/v1/service-info"; - // Tracks the released wifi-commissioning-service tag, not the oldest version - // that answers the probe. Raise it together with a released tag. + // Tracks the released wifi-commissioning-service tag. Raise it together with a released tag. const REQUIRED_CLIENT_VERSION: &str = ">=0.2.1"; - 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") - }) + pub async fn check_availability(&self) -> WifiAvailability { + Self::availability(self.service_info().await) } - /// A version that does not parse counts as a mismatch: it cannot be shown - /// to satisfy the requirement. - fn version_info(current: &str) -> VersionInfo { - let mismatch = match Version::parse(current) { - Ok(parsed) => !Self::required_version().matches(&parsed), - Err(e) => { - log::warn!("failed to parse WiFi service version '{current}': {e:#}"); - true - } + fn availability(probe: Result) -> WifiAvailability { + let unknown_version = WifiAvailability::Unavailable { + socket_present: true, + version_info: None, }; - VersionInfo { - required: Self::REQUIRED_CLIENT_VERSION.to_string(), - current: current.to_string(), - mismatch, - } - } - - pub async fn check_availability(&self) -> WifiAvailability { - // A service older than the floor has no /api/v1/service-info and answers - // 404, which lands here as an error — expected, not a fault. - let info = match self.service_info().await { + // 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 WifiAvailability::Unavailable { - socket_present: true, - version_info: None, - }; + return unknown_version; } }; - let version_info = Self::version_info(&info.version); + 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; + } + }; if version_info.mismatch { log::warn!( @@ -386,42 +372,91 @@ mod tests { } } - mod version_requirements { + mod availability { use super::*; + use omnect_ui_core::types::VersionInfo; + + fn service_info(version: &str, interface_name: &str) -> WifiServiceInfoResponse { + WifiServiceInfoResponse { + status: "ok".to_string(), + 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 required_version_parses_correctly() { - let version_req = WifiCommissioningServiceClient::required_version(); - assert_eq!(version_req.to_string(), ">=0.2.1"); + fn probe_error_has_no_version() { + let probe = Err(anyhow::anyhow!("404 Not Found")); + assert_eq!( + WifiCommissioningServiceClient::availability(probe), + unavailable(None) + ); } #[test] - fn accepts_the_floor_and_newer() { - for current in ["0.2.1", "0.3.0", "1.0.0"] { - let info = WifiCommissioningServiceClient::version_info(current); - assert!(!info.mismatch, "{current} should satisfy the requirement"); - assert_eq!(info.current, current); - assert_eq!(info.required, ">=0.2.1"); - } + 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 rejects_versions_below_the_floor() { - // 0.2.0 introduced /api/v1/service-info, 0.1.0 has no such endpoint. - for current in ["0.2.0", "0.1.0"] { - let info = WifiCommissioningServiceClient::version_info(current); - assert!( - info.mismatch, - "{current} should not satisfy the requirement" - ); - } + 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 unparseable_version_counts_as_mismatch() { - let info = WifiCommissioningServiceClient::version_info("not-a-version"); - assert!(info.mismatch); - assert_eq!(info.current, "not-a-version"); + 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(), + } + ); + } } } diff --git a/src/ui/src/App.vue b/src/ui/src/App.vue index d56a29a7..e59b05e0 100644 --- a/src/ui/src/App.vue +++ b/src/ui/src/App.vue @@ -95,6 +95,7 @@ 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 @@ -136,6 +137,7 @@ watch( factoryResetContext.value = result.context ?? null factoryResetModalSuccess.value = isFactoryResetSuccess(result.status) factoryResetDataWiped.value = result.dataWiped + factoryResetUnrecognized.value = result.status === 'unrecognized' showFactoryResetResultModal.value = true } } @@ -199,13 +201,15 @@ watch(
diff --git a/src/ui/src/components/device/DeviceInfoCore.vue b/src/ui/src/components/device/DeviceInfoCore.vue index 58d68817..30c7ad69 100644 --- a/src/ui/src/components/device/DeviceInfoCore.vue +++ b/src/ui/src/components/device/DeviceInfoCore.vue @@ -43,12 +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) { - // No versionInfo means the service did not answer the probe at all. const info = viewModel.wifiState.versionInfo - infoMap.set( - 'WiFi commissioning service version', - info ? `${info.current} (required: ${info.required})` : 'unknown' - ) + 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/types.ts b/src/ui/src/composables/core/types.ts index 2fac5430..1c1dfc80 100644 --- a/src/ui/src/composables/core/types.ts +++ b/src/ui/src/composables/core/types.ts @@ -202,7 +202,6 @@ export interface WifiSavedNetworkType { flags: string } -/** Shared by the omnect-device-service healthcheck and the WiFi service probe. */ export interface VersionInfoType { required: string current: string @@ -333,7 +332,7 @@ export function factoryResetStatusToString(status: any): FactoryResetStatusStrin /** * A warning means the reset completed, only a partition needed a second format - * attempt — the user sees it as success. + * attempt. */ export function isFactoryResetSuccess(status: FactoryResetStatusString): boolean { return status === 'success' || status === 'warning' diff --git a/src/ui/tests/device.spec.ts b/src/ui/tests/device.spec.ts index 4ef57909..ac5071a7 100644 --- a/src/ui/tests/device.spec.ts +++ b/src/ui/tests/device.spec.ts @@ -13,6 +13,13 @@ 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_info: null }); await setupAndLogin(page); @@ -59,15 +66,22 @@ test.describe('Device Info', () => { 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(page.getByText('WiFi commissioning service version')).toBeVisible(); - await expect(page.getByText('unknown', { exact: true })).toBeVisible(); + 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.2.1", interface_name: "wlan0" }); await setupAndLogin(page); - await expect(page.getByText('WiFi commissioning service version')).toBeVisible(); - await expect(page.getByText('0.2.1', { exact: true })).toBeVisible(); - await expect(page.getByText('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 78f5a385..788c8f1e 100644 --- a/src/ui/tests/factory-reset.spec.ts +++ b/src/ui/tests/factory-reset.spec.ts @@ -199,7 +199,7 @@ test.describe('Device Factory Reset - Reconnection', () => { expect(flashDetected).toBe(false); }); - test('warning status shows the success modal', async ({ page }) => { + test('warning status shows the success modal with its context', async ({ page }) => { await mockPortalAuth(page); await setupAndLogin(page, { factoryResetResultAcked: false }); @@ -210,16 +210,17 @@ test.describe('Device Factory Reset - Reconnection', () => { result: { status: 4, error: null, - context: 'second format attempt', + 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(); }); - test('failure after data was wiped warns the device needs another reset', async ({ page }) => { + test('failure after data was wiped warns that preserved data may be lost', async ({ page }) => { await mockPortalAuth(page); await setupAndLogin(page, { factoryResetResultAcked: false }); @@ -248,7 +249,7 @@ test.describe('Device Factory Reset - Reconnection', () => { keys: ['network'], result: { status: 1, - error: 'unknown preserve key', + error: 'factory reset mode 4 is not supported', context: null, paths: [], data_wiped: false, @@ -259,23 +260,24 @@ test.describe('Device Factory Reset - Reconnection', () => { await expect(page.getByTestId('factory-reset-data-wiped')).not.toBeVisible(); }); - test('unknown status code still shows a result modal', async ({ page }) => { - await mockPortalAuth(page); - await setupAndLogin(page, { factoryResetResultAcked: false }); + 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, + }, + }); - // ODS reports a status it does not know itself as u32::MAX. The result must - // still reach the user instead of being swallowed as "no result". - await publishToWebsocket('FactoryResetV1', { - keys: ['network'], - result: { - status: 4294967295, - 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(); }); - - await expect(page.getByText('Factory Reset Failed', { exact: true })).toBeVisible({ timeout: 5000 }); - }); + } }); diff --git a/src/ui/tests/fixtures/network-test-harness.ts b/src/ui/tests/fixtures/network-test-harness.ts index 70b85c06..c1429fda 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.46.0', - current: '0.46.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.46.0', current: '0.46.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 15a0aa6c..be6cfcec 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.46.0', current: '0.46.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.46.0', current: '0.46.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 7849f22a..58fac39a 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.46.0', - current: '0.45.2', + 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.45.2')).toBeVisible(); - await expect(page.getByText('Required version >=0.46.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.46.0', - current: '0.46.0', + required: '>=0.46.1', + current: '0.46.1', mismatch: false, }, updateValidationStatus: { From 718ecf6696026c36b7b988f86f06911e66cbf450 Mon Sep 17 00:00:00 2001 From: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:07:22 +0100 Subject: [PATCH 8/8] fix: drop unused wcs service-info status, tighten factory reset e2e checks wifi-commissioning-service always sends status "ok" on HTTP 200 for service-info; a failure is a non-2xx answer, which is already reported as unavailable. The field is not needed. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com> --- README.md | 2 +- project-context.md | 1 + src/app/src/types/wifi.rs | 1 - src/backend/src/wifi_commissioning_client.rs | 13 +------------ src/ui/tests/factory-reset.spec.ts | 2 ++ 5 files changed, 5 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 8a1d53ce..7ec47faf 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 ed42bbff..58291bd1 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/wifi.rs b/src/app/src/types/wifi.rs index 166e6c27..6d59404f 100644 --- a/src/app/src/types/wifi.rs +++ b/src/app/src/types/wifi.rs @@ -149,7 +149,6 @@ pub struct WifiForgetResponse { /// Capabilities of the running wifi-commissioning-service instance #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct WifiServiceInfoResponse { - pub status: String, /// Live BLE transport state: `true` only once BLE actually started. pub ble_enabled: bool, pub interface_name: String, diff --git a/src/backend/src/wifi_commissioning_client.rs b/src/backend/src/wifi_commissioning_client.rs index b384200f..96b03e3a 100644 --- a/src/backend/src/wifi_commissioning_client.rs +++ b/src/backend/src/wifi_commissioning_client.rs @@ -378,7 +378,6 @@ mod tests { fn service_info(version: &str, interface_name: &str) -> WifiServiceInfoResponse { WifiServiceInfoResponse { - status: "ok".to_string(), ble_enabled: true, interface_name: interface_name.to_string(), version: version.to_string(), @@ -464,22 +463,12 @@ mod tests { use super::*; #[test] - fn deserializes_all_fields() { + 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.status, "ok"); - assert!(resp.ble_enabled); assert_eq!(resp.interface_name, "wlan0"); assert_eq!(resp.version, "0.2.1"); } - - #[test] - fn deserializes_with_ble_disabled() { - let json = - r#"{"status":"ok","ble_enabled":false,"interface_name":"wlan0","version":"0.2.1"}"#; - let resp: WifiServiceInfoResponse = serde_json::from_str(json).unwrap(); - assert!(!resp.ble_enabled); - } } } diff --git a/src/ui/tests/factory-reset.spec.ts b/src/ui/tests/factory-reset.spec.ts index 788c8f1e..1e1e71a8 100644 --- a/src/ui/tests/factory-reset.spec.ts +++ b/src/ui/tests/factory-reset.spec.ts @@ -218,6 +218,7 @@ test.describe('Device Factory Reset - Reconnection', () => { 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 }) => { @@ -238,6 +239,7 @@ test.describe('Device Factory Reset - Reconnection', () => { 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 }) => {