1141 lines
41 KiB
Rust
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);
|
|
}
|