Files
wireguard-admin/tests/tui_workflow.rs
T
2026-10-29 00:01:35 +02:00

1141 lines
41 KiB
Rust

//! 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::{ClientStates, PublicKey, RuntimeObservation, Snapshot};
use wireguard_admin::persistence::SavedStore;
use wireguard_admin::runtime::{InterfaceObservation, RuntimeObservation as Statistics};
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<AtomicUsize>);
impl StatisticsReader for CountingStatistics {
fn read(&mut self, _interface: &str, _timeout: Duration) -> Statistics {
self.0.fetch_add(1, Ordering::SeqCst);
Statistics {
observed_at: None,
interface: InterfaceObservation::Unavailable,
}
}
}
fn worker(fixture: &Fixture, reader: CountingStatistics) -> ApplicationWorker {
let states = ClientStates::new(Snapshot::default(), None, RuntimeObservation::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, RuntimeObservation::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::<std::net::Ipv4Addr>().unwrap()
);
let ipv6_range = "fd20::/64".parse::<ipnet::Ipv6Net>().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::tui::state::AppliedState::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, RuntimeObservation::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::<Vec<_>>();
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::<std::collections::BTreeMap<_, _>>();
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);
}