diff --git a/src/app/src/macros.rs b/src/app/src/macros.rs index 296d4e9..676a7fa 100644 --- a/src/app/src/macros.rs +++ b/src/app/src/macros.rs @@ -42,6 +42,21 @@ pub use crate::http_helpers::{ /// Macro for unauthenticated POST requests with standard error handling. /// Requires domain parameters for event wrapping. /// +/// # Patterns +/// +/// Pattern 0: Simple POST without body (status only) +/// ```ignore +/// unauth_post!(Device, DeviceEvent, model, "/ack-rollback", AckRollbackResponse, "Acknowledge rollback") +/// ``` +/// +/// Pattern 1: POST with JSON body expecting JSON response +/// ```ignore +/// unauth_post!(Auth, AuthEvent, model, "/endpoint", Response, "Action", +/// body_json: &request, +/// expect_json: ResponseType +/// ) +/// ``` +/// /// Pattern 2: POST with JSON body expecting status only /// ```ignore /// unauth_post!(Auth, AuthEvent, model, "/set-password", SetPasswordResponse, "Set password", @@ -58,6 +73,23 @@ pub use crate::http_helpers::{ /// ``` #[macro_export] macro_rules! unauth_post { + // Pattern 0: Simple POST without body (status only) + ($domain:ident, $domain_event:ident, $model:expr, $endpoint:expr, $response_event:ident, $action:expr) => {{ + $model.start_loading(); + let cmd = crux_core::Command::all([ + crux_core::render::render(), + $crate::HttpCmd::post($crate::build_url($endpoint)) + .build() + .then_send(|result| { + let event_result = $crate::process_status_response($action, result); + $crate::events::Event::$domain( + $crate::events::$domain_event::$response_event(event_result), + ) + }), + ]); + cmd + }}; + // Pattern 1: POST with JSON body expecting JSON response ($domain:ident, $domain_event:ident, $model:expr, $endpoint:expr, $response_event:ident, $action:expr, body_json: $body:expr, expect_json: $response_type:ty) => {{ $model.start_loading(); diff --git a/src/app/src/update/device/network.rs b/src/app/src/update/device/network.rs index e20f252..860f6ab 100644 --- a/src/app/src/update/device/network.rs +++ b/src/app/src/update/device/network.rs @@ -4,6 +4,7 @@ use crate::auth_post; use crate::events::{DeviceEvent, Event, UiEvent}; use crate::http_get_silent; use crate::model::Model; +use crate::unauth_post; use crate::types::{ HealthcheckInfo, NetworkChangeState, NetworkConfigRequest, NetworkFormData, NetworkFormState, OverlaySpinnerState, @@ -299,7 +300,9 @@ pub fn handle_ack_rollback(model: &mut Model) -> Command { } // Send POST request to backend to clear the marker file - auth_post!( + // Note: Using unauth_post instead of auth_post because this may be called before login + // (the rollback notification appears in App.vue onMounted, before authentication) + unauth_post!( Device, DeviceEvent, model, diff --git a/src/backend/src/services/network.rs b/src/backend/src/services/network.rs index df1fbe5..ffd2fd2 100644 --- a/src/backend/src/services/network.rs +++ b/src/backend/src/services/network.rs @@ -253,8 +253,15 @@ impl NetworkConfigService { /// Clear the rollback occurred marker (called when UI acknowledges it) pub fn clear_rollback_occurred() { - let _ = fs::remove_file(network_rollback_occurred_file!()); - info!("rollback occurred marker cleared"); + let marker_file = network_rollback_occurred_file!(); + info!("Attempting to clear rollback occurred marker at: {marker_file:?}"); + match fs::remove_file(marker_file) { + Ok(()) => info!("Successfully removed rollback occurred marker file"), + Err(e) if e.kind() == ErrorKind::NotFound => { + info!("Rollback occurred marker file does not exist (already cleared)") + } + Err(e) => error!("Failed to remove rollback occurred marker file: {e}"), + } } /// Mark that a rollback has occurred (sets marker file) diff --git a/src/ui/tests/network-rollback.spec.ts b/src/ui/tests/network-rollback.spec.ts new file mode 100644 index 0000000..cdaa16b --- /dev/null +++ b/src/ui/tests/network-rollback.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; +import { mockConfig, mockLoginSuccess, mockRequireSetPassword } from './fixtures/mock-api'; +import { publishToCentrifugo } from './fixtures/centrifugo'; + +test.describe('Network Rollback Status', () => { + test('rollback status is cleared after ack and does not reappear on re-login', async ({ page, context }) => { + // Track healthcheck calls + let healthcheckRollbackStatus = true; + + await mockConfig(page); + await mockLoginSuccess(page); + await mockRequireSetPassword(page); + + // Mock healthcheck with rollback occurred status + await page.route('**/healthcheck', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + version_info: { + required: '>=0.39.0', + current: '0.40.0', + mismatch: false, + }, + update_validation_status: { + status: 'valid', + }, + network_rollback_occurred: healthcheckRollbackStatus, + }), + }); + }); + + // Mock ack-rollback endpoint + await page.route('**/ack-rollback', async (route) => { + if (route.request().method() === 'POST') { + // Simulate clearing the rollback status on the backend + healthcheckRollbackStatus = false; + await route.fulfill({ + status: 200, + }); + } + }); + + // Step 1: Navigate to page - rollback notification appears on mount (before login) + await page.goto('/'); + + // The rollback notification dialog appears immediately (from healthcheck in onMounted) + await expect(page.getByText('Network Settings Rolled Back')).toBeVisible({ timeout: 10000 }); + + // Step 2: Acknowledge the rollback message + // This should call /ack-rollback (now without auth requirement) and clear the backend marker + await page.getByRole('button', { name: /ok/i }).click(); + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); + + // Wait a moment for the async POST to /ack-rollback to complete + await page.waitForTimeout(500); + + // Now we can log in + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + // Step 3: Reload the page to simulate logout and re-login + await page.reload(); + + // The rollback notification should NOT appear again because we acknowledged it + // and the /ack-rollback call cleared the backend marker file + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible({ timeout: 3000 }); + + // Can proceed with login + await page.getByPlaceholder(/enter your password/i).fill('password'); + await page.getByRole('button', { name: /log in/i }).click(); + await expect(page.getByText('Common Info')).toBeVisible({ timeout: 10000 }); + + // Verify no rollback notification + await expect(page.getByText('Network Settings Rolled Back')).not.toBeVisible(); + }); +});