//! Synthetic end-to-end coverage for the serialized TUI application boundary. use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::{Duration, Instant}; use crossterm::event::KeyCode; use wireguard_admin::app::{FirstIssueAttempt, RestartChoice, RestartStage}; use wireguard_admin::backup::BackupReason; use wireguard_admin::config::{ResolvedSettings, SettingsFile}; use wireguard_admin::domain::{ApplyStatus, ClientStates, PublicKey, Snapshot}; use wireguard_admin::persistence::SavedStore; use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation}; use wireguard_admin::tui::events::{ ApplicationWorker, CommandResult, FirstIssueStatus, QrStatus, SecretText, SensitiveCommand, ShutdownProgress, StatisticsReader, UiCommand, WorkerConfig, WorkerEvent, }; use wireguard_admin::tui::forms::{FormAction, FormController}; use wireguard_admin::tui::state::SessionView; use wireguard_admin::vault::WireGuardPrivateKey; const SETTINGS: &str = r#" interface_name = "wg0" server_config_path = "server.conf" client_source_directory = "clients" server_endpoint = "vpn.example.test:51820" ipv4_allocation_range = "10.20.0.0/24" ipv6_allocation_range = "fd20::/64" dns_addresses = ["10.20.0.1"] systemd_unit = "wg-quick@wg0.service" restart_timeout_seconds = 5 "#; struct Fixture { _root: tempfile::TempDir, settings: ResolvedSettings, store: SavedStore, server_identity: PublicKey, } fn fixture() -> Fixture { let root = tempfile::tempdir().expect("synthetic fixture directory"); let settings = SettingsFile::parse_toml(SETTINGS) .expect("settings parse") .validate() .expect("settings validate") .resolve_paths(root.path()); fs::create_dir_all(&settings.client_source_directory).expect("source directory"); fs::write(ResolvedSettings::settings_path(root.path()), SETTINGS).expect("settings file"); let server_key = WireGuardPrivateKey::generate().expect("synthetic server key"); let server_identity = server_key.public_key(); fs::write( &settings.server_config_path, format!( "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.1/24, fd20::1/64\n\n# Fixture administrator note.\n", server_key.to_base64() ), ) .expect("server file"); fs::set_permissions( &settings.server_config_path, fs::Permissions::from_mode(0o600), ) .expect("private synthetic server file"); let store = SavedStore::new(root.path(), settings.clone()).expect("saved store"); Fixture { _root: root, settings, store, server_identity, } } #[test] fn saved_peer_projection_counts_legacy_peers_without_inventing_managed_state() { let fixture = fixture(); let legacy_key = WireGuardPrivateKey::generate() .expect("synthetic legacy peer key") .public_key(); let mut server_config = fs::read_to_string(&fixture.settings.server_config_path).expect("synthetic server config"); server_config.push_str(&format!( "\n[Peer]\nPublicKey = {legacy_key}\nAllowedIPs = 10.20.0.2/32\n" )); fs::write(&fixture.settings.server_config_path, server_config) .expect("write synthetic legacy peer"); let app = wireguard_admin::app::Application::open_from_saved_store(fixture.store.clone()) .expect("application opens with an inspect-only legacy peer"); let view = SessionView::from_application(&app, 0); assert_eq!(view.saved_peer_count, Some(1)); assert_eq!(view.clients.len(), 1); assert_eq!(view.clients[0].identity, Some(legacy_key)); assert_eq!(view.clients[0].mutable_identity, None); assert!(view.client_states.is_empty()); assert!(!view.has_draft); } #[derive(Clone, Default)] struct CountingStatistics(Arc); impl StatisticsReader for CountingStatistics { fn read(&mut self, _interface: &str, _timeout: Duration) -> RuntimeObservation { self.0.fetch_add(1, Ordering::SeqCst); RuntimeObservation { observed_at: None, interface: InterfaceObservation::Unavailable, } } } fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker { let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states"); let app = wireguard_admin::app::Application::open(fixture.store.clone(), states) .expect("application opens on synthetic files"); ApplicationWorker::spawn( app, reader, WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(5), }, ) .expect("worker starts") } fn wait_for(worker: &mut ApplicationWorker, request_id: u64) -> WorkerEvent { let deadline = Instant::now() + Duration::from_secs(3); while Instant::now() < deadline { if let Some(event) = worker.try_receive() { if matches!(&event, WorkerEvent::OperationFinished { operation, .. } if operation.request_id == request_id) { return event; } } else { thread::yield_now(); } } panic!("worker did not finish request {request_id}"); } fn execute(worker: &mut ApplicationWorker, command: UiCommand) -> CommandResult { let operation = worker.submit(command).expect("command accepted"); match wait_for(worker, operation.request_id) { WorkerEvent::OperationFinished { result, .. } => result.expect("operation succeeds"), _ => unreachable!("wait_for returns the requested completion"), } } fn finish(mut worker: ApplicationWorker) { assert_eq!( worker.shutdown(Duration::from_secs(2)), ShutdownProgress::Stopped ); } #[test] fn terminal_pty_smoke() { const CHILD: &str = "WG_ADMIN_SYNTHETIC_PTY_CHILD"; if std::env::var_os(CHILD).is_some() { let fixture = fixture(); let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown) .expect("empty states"); let app = wireguard_admin::app::Application::open(fixture.store, states) .expect("synthetic application opens"); let resize = thread::spawn(|| { thread::sleep(Duration::from_millis(230)); assert!( Command::new("stty") .args(["cols", "40", "rows", "8"]) .status() .expect("shrink synthetic PTY") .success() ); thread::sleep(Duration::from_millis(40)); assert!( Command::new("stty") .args(["cols", "80", "rows", "24"]) .status() .expect("grow synthetic PTY") .success() ); }); let result = wireguard_admin::tui::terminal::run_with_statistics( app, CountingStatistics::default(), WorkerConfig { refresh_interval: Duration::from_secs(60), read_timeout: Duration::from_millis(5), }, ); resize.join().expect("resize sequence completes"); assert!(result.is_ok(), "synthetic terminal exits cleanly"); return; } if Command::new("script").arg("--version").output().is_err() { eprintln!("PTY smoke incomplete: `script` utility is unavailable"); return; } let executable = std::env::current_exe().expect("test executable path"); let command = format!( "stty cols 80 rows 24; env {CHILD}=1 {} --exact terminal_pty_smoke --nocapture", shell_quote(&executable.to_string_lossy()) ); let mut child = Command::new("script") .args(["-qefc", &command, "/dev/null"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("start isolated pseudo-terminal"); use std::io::Write as _; let mut input = child.stdin.take().expect("script stdin"); let help_workflow = thread::spawn(move || { thread::sleep(Duration::from_millis(180)); for key in [ b"?".as_slice(), b"\x1b[B", b"\x1b[6~", b"\x1b[A", b"\x1b[5~", b"?", b"\x1b", ] { input.write_all(key).expect("send Help navigation key"); input.flush().expect("flush Help navigation key"); thread::sleep(Duration::from_millis(35)); } thread::sleep(Duration::from_millis(70)); input .write_all(b"\x03") .expect("send handled Ctrl-C exit to synthetic TUI"); }); let output = child.wait_with_output().expect("collect PTY result"); help_workflow.join().expect("Help input completes"); assert!( output.status.success(), "synthetic PTY child exits successfully" ); let transcript = String::from_utf8_lossy(&output.stdout); assert!( transcript.contains("\u{1b}[?1049h"), "alternate screen entered" ); assert!( transcript.contains("\u{1b}[?1049l"), "alternate screen restored" ); assert!( transcript.contains("\u{1b}[?25l"), "cursor hidden during TUI" ); assert!( transcript.contains("\u{1b}[?25h"), "cursor restored after TUI" ); assert!( transcript.contains("Help "), "synthetic PTY entered the Help panel" ); assert!( transcript.contains("PgUp/PgDn"), "Help footer advertised page navigation" ); } fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } fn path(parent: &Path, name: &str) -> PathBuf { parent.join(name) } #[test] fn worker_setup_create_commit_and_delivery_keep_saved_and_runtime_boundaries() { let fixture = fixture(); let invalid_source = b"this fixture contains no valid client configuration\n"; let source_path = fixture.settings.client_source_directory.join("broken.conf"); fs::write(&source_path, invalid_source).expect("synthetic invalid source file"); let reader = CountingStatistics::default(); let reads = Arc::clone(&reader.0); let mut worker = worker(&fixture, reader); let setup = worker .submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new( "synthetic vault password".to_owned(), ))) .expect("setup submitted"); match wait_for(&mut worker, setup.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { match result.unwrap() { CommandResult::InitialSetupPrepared(report) => { assert!(report.scan_complete); assert!(matches!( report.reconciliation.candidate_summaries()[0].1, wireguard_admin::vault::import::CandidateStatus::Invalid(_) )); } _ => panic!("setup report returned"), } assert!(view.vault_initialized); assert!(view.document_issues.is_empty()); } _ => unreachable!(), } let created = execute( &mut worker, UiCommand::CreateClient { alias: "synthetic laptop".to_owned(), manual_addresses: None, created_utc: Some("2026-10-21T12:00:00Z".to_owned()), }, ); let identity = match created { CommandResult::ClientCreated(identity) => identity, _ => panic!("create returns identity"), }; let renamed = execute( &mut worker, UiCommand::RenameClient { identity, alias: "renamed synthetic laptop".to_owned(), }, ); assert!(matches!(renamed, CommandResult::ClientRenamed)); assert!(matches!( execute(&mut worker, UiCommand::Commit), CommandResult::Committed(_) )); assert!( fs::read_to_string(&fixture.settings.server_config_path) .expect("saved server file") .contains("# Fixture administrator note.") ); assert_eq!( fs::read(&source_path).expect("original source preserved"), invalid_source ); let failed_parent = path(fixture._root.path(), "failed-output"); fs::create_dir(&failed_parent).expect("failed output directory"); let failed_path = path(&failed_parent, "client.conf"); let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity, path: failed_path, include_qr: false, reissue: false, attempt: FirstIssueAttempt::Initial, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => summary.prepared_generation, _ => panic!("destination is prepared before publication"), }; fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o500)) .expect("make fixture destination unwritable"); let failed = worker .submit(UiCommand::DeliverFirstIssue { attempt: FirstIssueAttempt::Initial, overwrite_confirmed: false, prepared_generation, }) .expect("failed publication submitted"); match wait_for(&mut worker, failed.request_id) { WorkerEvent::OperationFinished { result, .. } => match result.unwrap() { CommandResult::FirstIssue(summary) => { assert!(matches!(summary.status, FirstIssueStatus::Failed(_))); assert_eq!( summary.retry, Some(wireguard_admin::app::FirstIssueRetry::Available) ); } _ => panic!("failure report returned"), }, _ => unreachable!(), } fs::set_permissions(&failed_parent, fs::Permissions::from_mode(0o700)) .expect("restore fixture destination permissions"); let output = path(fixture._root.path(), "delivered.conf"); fs::write(&output, b"keep before overwrite confirmation").expect("synthetic existing output"); let attempt = FirstIssueAttempt::Retry { confirm_possible_overwrite: false, confirm_manual_cleanup: false, }; let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity, path: output.clone(), include_qr: true, reissue: false, attempt, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => { assert!(summary.overwrite_required); summary.prepared_generation } _ => panic!("overwrite target is prepared"), }; let declined = worker .submit(UiCommand::DeliverFirstIssue { attempt, overwrite_confirmed: false, prepared_generation, }) .expect("declined overwrite is handled by worker"); assert!(matches!( wait_for(&mut worker, declined.request_id), WorkerEvent::OperationFinished { result: Err(_), .. } )); assert_eq!( fs::read(&output).unwrap(), b"keep before overwrite confirmation" ); let attempt = FirstIssueAttempt::Retry { confirm_possible_overwrite: true, confirm_manual_cleanup: false, }; let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity, path: output.clone(), include_qr: true, reissue: false, attempt, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => summary.prepared_generation, _ => panic!("confirmed overwrite target is prepared"), }; let delivered = execute( &mut worker, UiCommand::DeliverFirstIssue { attempt, overwrite_confirmed: true, prepared_generation, }, ); match delivered { CommandResult::FirstIssue(summary) => { assert_eq!(summary.status, FirstIssueStatus::Delivered); assert!(summary.activation_warning); assert_eq!(summary.qr, QrStatus::Ready); let mut disclosure = wireguard_admin::tui::forms::FormController::default(); disclosure.open_disclosure( summary.qr_output.expect("QR matrix returned"), "fixture".to_owned(), ); assert!(disclosure.qr_output().is_some()); assert!(matches!( disclosure.handle_key(crossterm::event::KeyCode::Esc), wireguard_admin::tui::forms::FormAction::Cancel )); assert!(disclosure.qr_output().is_none(), "Esc clears the QR owner"); } _ => panic!("delivery result returned"), } assert!( fs::read_to_string(output) .expect("delivered file") .contains("[Interface]") ); let wrong_password_path = path(fixture._root.path(), "wrong-password.conf"); let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity, path: wrong_password_path.clone(), include_qr: false, reissue: true, attempt: FirstIssueAttempt::Initial, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => summary.prepared_generation, _ => panic!("reissue destination is prepared"), }; let wrong_password = "synthetic wrong vault password"; let reissue = worker .submit_sensitive(SensitiveCommand::ReissueClient { identity, password: SecretText::new(wrong_password.to_owned()), overwrite_confirmed: false, prepared_generation, }) .expect("reissue submitted"); match wait_for(&mut worker, reissue.request_id) { WorkerEvent::OperationFinished { result, .. } => { let error = result.expect_err("wrong password rejected"); assert!(!error.message.contains(wrong_password)); } _ => unreachable!(), } assert!(!wrong_password_path.exists()); let reissued_path = path(fixture._root.path(), "reissued.conf"); let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity, path: reissued_path.clone(), include_qr: false, reissue: true, attempt: FirstIssueAttempt::Initial, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => summary.prepared_generation, _ => panic!("authorized reissue destination prepared"), }; let reissue = worker .submit_sensitive(SensitiveCommand::ReissueClient { identity, password: SecretText::new("synthetic vault password".to_owned()), overwrite_confirmed: false, prepared_generation, }) .expect("password-authorized reissue submitted"); match wait_for(&mut worker, reissue.request_id) { WorkerEvent::OperationFinished { result, .. } => match result.unwrap() { CommandResult::Reissued(summary) => { assert!(summary.activation_warning); assert_eq!(summary.qr, QrStatus::NotRequested); } _ => panic!("reissue result returned"), }, _ => unreachable!(), } assert!(reissued_path.exists()); let deletion = worker .submit(UiCommand::DeleteClient { identity }) .expect("delete selected full-key identity"); match wait_for(&mut worker, deletion.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!(matches!( result.unwrap(), CommandResult::ClientDeleted { .. } )); let row = &view.client_states[&identity]; assert!(row.saved.is_some(), "saved peer remains until Commit"); assert!(row.draft.is_none()); } _ => unreachable!(), } let discarded = worker .submit(UiCommand::DiscardDraft) .expect("discard after draft deletion"); match wait_for(&mut worker, discarded.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!(matches!(result.unwrap(), CommandResult::DraftDiscarded)); let row = &view.client_states[&identity]; assert!(row.saved.is_some() && row.draft.is_some()); } _ => unreachable!(), } assert_eq!( reads.load(Ordering::SeqCst), 0, "no runtime adapter is called by Commit or delivery" ); finish(worker); } #[test] fn create_form_submits_automatic_dual_stack_client_without_changing_saved_state() { let fixture = fixture(); let reader = CountingStatistics::default(); let reads = Arc::clone(&reader.0); let mut worker = worker(&fixture, reader); let setup = worker .submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new( "synthetic vault password".to_owned(), ))) .expect("setup submitted"); assert!(matches!( wait_for(&mut worker, setup.request_id), WorkerEvent::OperationFinished { result: Ok(CommandResult::InitialSetupPrepared(_)), .. } )); let saved_before_create = fixture.store.read_saved().expect("saved state reads"); let mut form = FormController::default(); form.open_create(); for character in "form-created device".chars() { assert!(matches!( form.handle_key(KeyCode::Char(character)), FormAction::None )); } assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); assert!(matches!(form.handle_key(KeyCode::Enter), FormAction::None)); let FormAction::Submit(command @ UiCommand::CreateClient { .. }) = form.handle_key(KeyCode::Enter) else { panic!("blank address fields submit an automatically addressed client"); }; let UiCommand::CreateClient { alias, manual_addresses, created_utc, } = &command else { unreachable!(); }; assert_eq!(alias, "form-created device"); assert!(manual_addresses.is_none()); let timestamp = created_utc.as_deref().expect("form includes creation time"); assert!(wireguard_admin::wireguard::metadata::render_block(alias, Some(timestamp)).is_ok()); let operation = worker.submit(command).expect("form command accepted"); let (identity, view) = match wait_for(&mut worker, operation.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { let CommandResult::ClientCreated(identity) = result.expect("create succeeds") else { panic!("create returns client identity"); }; (identity, view) } _ => unreachable!("wait_for returns the requested completion"), }; let row = view .client_states .get(&identity) .expect("created client appears in draft projection"); assert!( row.saved.is_none(), "client remains absent from saved state" ); let draft = row.draft.as_ref().expect("created client is in draft"); assert_eq!(draft.alias, "form-created device"); assert_eq!( draft.addresses.ipv4, "10.20.0.2".parse::().unwrap() ); let ipv6_range = "fd20::/64".parse::().unwrap(); assert!( draft .addresses .ipv6 .is_some_and(|address| ipv6_range.contains(&address)), "automatic allocation includes an address from the configured IPv6 range" ); let saved_after_create = fixture .store .read_saved() .expect("saved state remains readable"); assert_eq!(saved_after_create.settings, saved_before_create.settings); assert_eq!( saved_after_create.server_config, saved_before_create.server_config ); assert_eq!( saved_after_create.internal_state, saved_before_create.internal_state ); assert_eq!(saved_after_create.vault, saved_before_create.vault); assert_eq!( reads.load(Ordering::SeqCst), 0, "no runtime restart/read occurs" ); finish(worker); } #[test] fn worker_backup_restore_preview_cancel_and_confirm_are_separate_saved_operations() { let fixture = fixture(); let mut worker = worker(&fixture, CountingStatistics::default()); let setup = worker .submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new( "synthetic vault password".to_owned(), ))) .expect("setup submitted"); let _ = wait_for(&mut worker, setup.request_id); assert!(matches!( execute(&mut worker, UiCommand::Commit), CommandResult::Committed(_) )); assert!(matches!( execute(&mut worker, UiCommand::CreateBackup), CommandResult::BackupCreated(_) )); let backups = execute(&mut worker, UiCommand::ListBackups); let archive_name = match backups { CommandResult::Backups(entries) => { let entry = entries .iter() .find(|entry| entry.reason == BackupReason::Manual) .expect("manual archive listed with its reason"); assert!(!entry.file_name.is_empty()); entry.file_name.clone() } _ => panic!("backup list returned"), }; let created = execute( &mut worker, UiCommand::CreateClient { alias: "first-issue capability to invalidate".to_owned(), manual_addresses: None, created_utc: None, }, ); let issued_identity = match created { CommandResult::ClientCreated(identity) => identity, _ => panic!("client created for restore invalidation test"), }; assert!(matches!( execute(&mut worker, UiCommand::Commit), CommandResult::Committed(_) )); let preview = execute( &mut worker, UiCommand::PreviewRestore { file_name: archive_name.clone(), }, ); let preview_generation = match preview { CommandResult::RestorePreview(summary) => { assert_eq!(summary.file_name, archive_name); assert_eq!(summary.reason, BackupReason::Manual); summary.preview_generation } _ => panic!("restore preview returned"), }; assert!(matches!( execute(&mut worker, UiCommand::CancelRestorePreview), CommandResult::Cancelled )); // Cancellation clears the worker-owned preview; confirmation cannot use it. let confirmation = worker .submit(UiCommand::ConfirmRestore { preview_generation }) .expect("confirmation is handled and rejected by worker"); assert!(matches!( wait_for(&mut worker, confirmation.request_id), WorkerEvent::OperationFinished { result: Err(_), .. } )); let preview = execute( &mut worker, UiCommand::PreviewRestore { file_name: archive_name.clone(), }, ); let preview_generation = match preview { CommandResult::RestorePreview(summary) => summary.preview_generation, _ => panic!("restore preview returned"), }; let saved_before_restore = fs::read(&fixture.settings.server_config_path).unwrap(); let archived_directory = fixture .settings .data_directory .join("archives-before-restore-failure"); fs::rename(&fixture.settings.archives_directory, &archived_directory) .expect("hold valid archive directory aside"); fs::write(&fixture.settings.archives_directory, b"not a directory") .expect("block precautionary restore backup"); let restore = worker .submit(UiCommand::ConfirmRestore { preview_generation }) .expect("restore confirmation reaches worker"); match wait_for(&mut worker, restore.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!( result.is_err(), "precautionary backup failure blocks replacement" ); assert!(!view.has_draft); } _ => unreachable!(), } fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file"); fs::rename(&archived_directory, &fixture.settings.archives_directory) .expect("restore valid archive directory"); assert_eq!( fs::read(&fixture.settings.server_config_path).unwrap(), saved_before_restore ); let preview = execute( &mut worker, UiCommand::PreviewRestore { file_name: archive_name, }, ); let preview_generation = match preview { CommandResult::RestorePreview(summary) => summary.preview_generation, _ => panic!("restore preview returned after backup failure"), }; let restore = worker .submit(UiCommand::ConfirmRestore { preview_generation }) .expect("confirmed restore accepted"); match wait_for(&mut worker, restore.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!(matches!(result.unwrap(), CommandResult::Restored(_))); assert_eq!(view.applied, wireguard_admin::domain::ApplyStatus::Unknown); assert!(!view.has_draft); } _ => unreachable!(), } let output = path(fixture._root.path(), "restored-client.conf"); let prepared = execute( &mut worker, UiCommand::PrepareDelivery { identity: issued_identity, path: output, include_qr: false, reissue: false, attempt: FirstIssueAttempt::Initial, }, ); let prepared_generation = match prepared { CommandResult::DeliveryDestination(summary) => summary.prepared_generation, _ => panic!("destination preparation is separate from issue authorization"), }; let invalidated = worker .submit(UiCommand::DeliverFirstIssue { attempt: FirstIssueAttempt::Initial, overwrite_confirmed: false, prepared_generation, }) .expect("delivery denial returned through worker"); assert!(matches!( wait_for(&mut worker, invalidated.request_id), WorkerEvent::OperationFinished { result: Err(_), .. } )); finish(worker); } #[test] fn worker_commit_stops_at_mandatory_backup_failure_and_keeps_draft_and_saved_bytes() { let fixture = fixture(); let mut worker = worker(&fixture, CountingStatistics::default()); let setup = worker .submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new( "synthetic vault password".to_owned(), ))) .expect("setup submitted"); let _ = wait_for(&mut worker, setup.request_id); assert!(matches!( execute(&mut worker, UiCommand::Commit), CommandResult::Committed(_) )); let original = fs::read(&fixture.settings.server_config_path).expect("saved server bytes"); let created = execute( &mut worker, UiCommand::CreateClient { alias: "draft only".to_owned(), manual_addresses: None, created_utc: None, }, ); assert!(matches!(created, CommandResult::ClientCreated(_))); let archived_directory = fixture .settings .data_directory .join("archives-before-commit-failure"); fs::rename(&fixture.settings.archives_directory, &archived_directory) .expect("hold valid archive directory aside"); fs::write(&fixture.settings.archives_directory, b"not a directory") .expect("block mandatory archive publication"); let commit = worker .submit(UiCommand::Commit) .expect("Commit dispatch accepts operation"); match wait_for(&mut worker, commit.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { assert!(result.is_err(), "backup error reaches the worker result"); assert!(view.has_draft, "draft survives failed Commit"); } _ => unreachable!(), } fs::remove_file(&fixture.settings.archives_directory).expect("remove blocker file"); fs::rename(&archived_directory, &fixture.settings.archives_directory) .expect("restore valid archive directory"); assert_eq!( fs::read(&fixture.settings.server_config_path).expect("saved server bytes remain"), original ); assert!(matches!( execute(&mut worker, UiCommand::PreviewRestart), CommandResult::RestartPreview(summary) if summary.has_draft )); match execute(&mut worker, UiCommand::Restart(RestartChoice::Cancel)) { CommandResult::Restarted(report) => { assert_eq!(report.stage, RestartStage::Cancelled); assert!(!report.restart_submitted); } _ => panic!("restart cancel report returned"), } finish(worker); } #[test] fn worker_restore_refreshes_operational_allocation_settings_before_next_create() { let fixture = fixture(); let states = ClientStates::new(Snapshot::default(), None, ApplyStatus::Unknown).expect("empty states"); let mut original_app = wireguard_admin::app::Application::open(fixture.store.clone(), states) .expect("original application opens"); original_app .prepare_initial_setup_from_sources("synthetic vault password") .expect("initial vault setup"); original_app .commit() .expect("initial saved state committed"); let changed_settings_text = SETTINGS .replace("vpn.example.test:51820", "vpn2.example.test:51821") .replace("10.20.0.0/24", "10.30.0.0/24") .replace("fd20::/64", "fd30::/64"); fs::write( ResolvedSettings::settings_path(fixture._root.path()), &changed_settings_text, ) .expect("install changed operational fixture settings"); let changed_settings = SettingsFile::parse_toml(&changed_settings_text) .expect("changed settings parse") .validate() .expect("changed settings validate") .resolve_paths(fixture._root.path()); let changed_store = SavedStore::new(fixture._root.path(), changed_settings) .expect("changed settings preserve trusted paths"); let changed_app = wireguard_admin::app::Application::open_from_saved_store(changed_store) .expect("changed settings app opens"); let changed_archive = changed_app .create_manual_backup() .expect("archive changed operational settings"); let changed_archive_name = changed_archive .file_name() .expect("archive filename") .to_string_lossy() .into_owned(); fs::write( ResolvedSettings::settings_path(fixture._root.path()), SETTINGS, ) .expect("restore original current settings before worker session"); let mut worker = worker(&fixture, CountingStatistics::default()); let preview = execute( &mut worker, UiCommand::PreviewRestore { file_name: changed_archive_name, }, ); match preview { CommandResult::RestorePreview(summary) => { assert!( summary .changes .settings_fields_changed .contains(&"server endpoint") ); assert!( summary .changes .settings_fields_changed .contains(&"IPv4 allocation range") ); } _ => panic!("worker returns changed-settings restore preview"), } let restored = worker .submit(UiCommand::ConfirmRestore { preview_generation: worker.current_generation(), }) .expect("restore confirmation submitted"); assert!(matches!( wait_for(&mut worker, restored.request_id), WorkerEvent::OperationFinished { result: Ok(CommandResult::Restored(_)), .. } )); let create = worker .submit(UiCommand::CreateClient { alias: "uses restored allocation".to_owned(), manual_addresses: None, created_utc: None, }) .expect("create after restore submitted"); match wait_for(&mut worker, create.request_id) { WorkerEvent::OperationFinished { result, view, .. } => { let identity = match result.unwrap() { CommandResult::ClientCreated(identity) => identity, _ => panic!("client creation result returned"), }; let client = view.client_states[&identity] .draft .as_ref() .expect("client is staged in draft"); assert!(client.addresses.ipv4.to_string().starts_with("10.30.")); assert!( client .addresses .ipv6 .expect("dual-stack client IPv6") .to_string() .starts_with("fd30:") ); } _ => unreachable!(), } finish(worker); } #[test] fn worker_setup_reconciliation_keeps_match_mismatch_server_only_file_only_and_invalid_visible() { use wireguard_admin::vault::import::{CandidateStatus, ServerIdentityComparison}; let fixture = fixture(); let matched_secret = WireGuardPrivateKey::generate().expect("matched client key"); let mismatch_secret = WireGuardPrivateKey::generate().expect("mismatched client key"); let file_only_secret = WireGuardPrivateKey::generate().expect("file-only client key"); let wrong_server = WireGuardPrivateKey::generate().expect("mismatch fixture key"); let server_only = WireGuardPrivateKey::generate().expect("server-only public identity"); let matched = matched_secret.public_key(); let mismatch = mismatch_secret.public_key(); let server_only_identity = server_only.public_key(); let server_text = format!( "{}\n[Peer]\nPublicKey = {matched}\nAllowedIPs = 10.20.0.2/32\n\n[Peer]\nPublicKey = {mismatch}\nAllowedIPs = 10.20.0.3/32\n\n[Peer]\nPublicKey = {server_only_identity}\nAllowedIPs = 10.20.0.5/32\n", fs::read_to_string(&fixture.settings.server_config_path).unwrap() ); fs::write(&fixture.settings.server_config_path, server_text).expect("server peer fixtures"); let source_files = [ ( "matched.conf", format!( "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.2/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = vpn.example.test:51820\n", matched_secret.to_base64(), fixture.server_identity ), ), ( "mismatch.conf", format!( "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.3/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n", mismatch_secret.to_base64(), wrong_server.public_key() ), ), ( "file-only.conf", format!( "[Interface]\nPrivateKey = {}\nAddress = 10.20.0.4/32\n[Peer]\nPublicKey = {}\nAllowedIPs = 0.0.0.0/0\nEndpoint = vpn.example.test:51820\n", file_only_secret.to_base64(), fixture.server_identity ), ), ("invalid.conf", "PrivateKey = malformed\n".to_owned()), ]; let originals = source_files .iter() .map(|(name, text)| { let path = fixture.settings.client_source_directory.join(name); fs::write(&path, text).expect("synthetic client source"); (path, text.as_bytes().to_vec()) }) .collect::>(); let mut worker = worker(&fixture, CountingStatistics::default()); let setup = worker .submit_sensitive(SensitiveCommand::PrepareInitialSetup(SecretText::new( "synthetic vault password".to_owned(), ))) .expect("setup submitted"); match wait_for(&mut worker, setup.request_id) { WorkerEvent::OperationFinished { result, .. } => match result.unwrap() { CommandResult::InitialSetupPrepared(report) => { let statuses = report .reconciliation .candidate_summaries() .into_iter() .collect::>(); assert!(matches!( statuses.get("matched.conf"), Some(CandidateStatus::Matched { server_identity: ServerIdentityComparison::Matches, .. }) )); assert!(matches!( statuses.get("mismatch.conf"), Some(CandidateStatus::Matched { server_identity: ServerIdentityComparison::Mismatch, .. }) )); assert!(matches!( statuses.get("file-only.conf"), Some(CandidateStatus::FileOnly) )); assert!(matches!( statuses.get("invalid.conf"), Some(CandidateStatus::Invalid(_)) )); assert!( report .reconciliation .server_only() .contains(&server_only_identity) ); } _ => panic!("setup reconciliation returned"), }, _ => unreachable!(), } for (path, expected) in originals { assert_eq!( fs::read(path).unwrap(), expected, "source files are read-only" ); } finish(worker); }