Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/app/src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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();
Expand Down
5 changes: 4 additions & 1 deletion src/app/src/update/device/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -299,7 +300,9 @@ pub fn handle_ack_rollback(model: &mut Model) -> Command<Effect, Event> {
}

// 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,
Expand Down
11 changes: 9 additions & 2 deletions src/backend/src/services/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 78 additions & 0 deletions src/ui/tests/network-rollback.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading