diff options
| author | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
|---|---|---|
| committer | Andrew Guschin <guschin@altlinux.org> | 2026-10-30 03:10:41 +0400 |
| commit | d608ffeeda1a479c9dc0e5f6ad269ba07a510d7f (patch) | |
| tree | 3824ec9da5ca0adac913cc47e3e20054c3dc843a /tests/controller_acceptance.rs | |
| parent | 40cf3276cdc74bf6f1795990ed600891a637d1fe (diff) | |
Implement Linux proxy controller, subscription state, and CLI/TUI
Diffstat (limited to 'tests/controller_acceptance.rs')
| -rw-r--r-- | tests/controller_acceptance.rs | 355 |
1 files changed, 355 insertions, 0 deletions
diff --git a/tests/controller_acceptance.rs b/tests/controller_acceptance.rs new file mode 100644 index 0000000..0d83cd1 --- /dev/null +++ b/tests/controller_acceptance.rs @@ -0,0 +1,355 @@ +use async_trait::async_trait; +use roentgen::controller::{Caller, Controller, Operation}; +use roentgen::runtime::OfflineRuntime; +use roentgen::subscription::SubscriptionSource; + +struct FixtureSource(String); +impl FixtureSource { + fn new(value: &str) -> Self { + Self(value.into()) + } +} +#[async_trait] +impl SubscriptionSource for FixtureSource { + async fn fetch(&self, _: &str) -> anyhow::Result<Vec<u8>> { + Ok(self.0.as_bytes().to_vec()) + } +} +use tempfile::TempDir; + +#[tokio::test] +async fn importing_requires_explicit_selection_and_enabling() { + let home = TempDir::new().unwrap(); + let mut controller = Controller::open( + home.path(), + Box::new(OfflineRuntime), + Box::new(FixtureSource::new( + "trojan://secret@example.com:443?security=tls&type=tcp#First\n", + )), + ) + .unwrap(); + let root = Caller::root(); + controller + .execute( + &root, + Operation::AddSubscription { + name: "Personal".into(), + url: "https://provider.example/token".into(), + }, + ) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert!(!controller.status().unwrap().requested_enabled); + let entries = controller.configurations(None).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "First"); + assert_eq!(entries[0].status, "supported"); + assert!(controller.execute(&root, Operation::Enable).await.is_err()); + assert!(!controller.status().unwrap().requested_enabled); +} + +#[derive(Clone)] +struct MutableSource(std::sync::Arc<std::sync::Mutex<Option<String>>>); +#[async_trait] +impl SubscriptionSource for MutableSource { + async fn fetch(&self, _: &str) -> anyhow::Result<Vec<u8>> { + self.0 + .lock() + .unwrap() + .as_ref() + .map(|v| v.as_bytes().to_vec()) + .ok_or_else(|| anyhow::anyhow!("Fixture fetch failed")) + } +} + +struct GuardedRuntime { + armed: bool, + routing: &'static str, +} +#[async_trait] +impl roentgen::runtime::Runtime for GuardedRuntime { + async fn arm(&mut self) -> anyhow::Result<()> { + self.armed = true; + self.routing = "blocked"; + Ok(()) + } + async fn apply( + &mut self, + connection: Option<&roentgen::model::Trojan>, + _: u64, + ) -> anyhow::Result<()> { + anyhow::ensure!(self.armed, "Attempted unprotected activation"); + self.routing = if connection.is_some() { + "tun" + } else { + "blocked" + }; + Ok(()) + } + async fn disable(&mut self) -> anyhow::Result<()> { + self.armed = false; + self.routing = "direct"; + Ok(()) + } + fn routing(&self) -> &str { + self.routing + } +} + +async fn fixture() -> (TempDir, Controller, MutableSource) { + let home = TempDir::new().unwrap(); + let source = MutableSource(std::sync::Arc::new(std::sync::Mutex::new(Some( + "trojan://secret@example.com:443?security=tls#First\n".into(), + )))); + let mut controller = Controller::open( + home.path(), + Box::new(GuardedRuntime { + armed: false, + routing: "direct", + }), + Box::new(source.clone()), + ) + .unwrap(); + controller + .execute( + &Caller::root(), + Operation::AddSubscription { + name: "Personal".into(), + url: "https://provider.example/token".into(), + }, + ) + .await + .unwrap(); + (home, controller, source) +} + +#[tokio::test] +async fn losing_a_selection_keeps_enabled_intent_and_blocks_without_replacement() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + *source.0.lock().unwrap() = Some("trojan://another@other.example:443#Replacement\n".into()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + let status = controller.status().unwrap(); + assert!(status.requested_enabled); + assert!(status.selected_configuration.is_none()); + assert_eq!(status.routing, "blocked"); +} + +#[tokio::test] +async fn failed_fetch_preserves_selection_but_an_empty_received_list_clears_it() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + *source.0.lock().unwrap() = None; + assert!( + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .is_err() + ); + assert_eq!( + controller + .status() + .unwrap() + .selected_configuration + .as_deref(), + Some(id.as_str()) + ); + *source.0.lock().unwrap() = Some(String::new()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert!(controller.configurations(None).unwrap().is_empty()); +} + +#[tokio::test] +async fn mixed_base64_import_keeps_all_rows_and_prohibits_invalid_selection() { + use base64::Engine; + let (_home, mut controller, source) = fixture().await; + let body = "trojan://secret@example.com:443#Good\nvless://other@example.com:443#Unsupported\ntrojan://missing-port@host#Broken\n"; + *source.0.lock().unwrap() = Some(base64::engine::general_purpose::STANDARD.encode(body)); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + let entries = controller.configurations(None).unwrap(); + assert_eq!( + entries + .iter() + .map(|e| e.status.as_str()) + .collect::<Vec<_>>(), + ["supported", "unsupported", "invalid"] + ); + for entry in entries.iter().skip(1) { + assert!( + controller + .execute( + &Caller::root(), + Operation::Select { + id: entry.id.clone() + } + ) + .await + .is_err() + ); + } + let public = serde_json::to_string(&entries).unwrap(); + assert!(!public.contains("secret")); + assert!(!public.contains("missing-port")); +} + +#[tokio::test] +async fn denied_operations_leave_durable_state_unchanged() { + let (_home, mut controller, _source) = fixture().await; + let before = serde_json::to_string(&controller.status().unwrap()).unwrap(); + assert!( + controller + .execute( + &Caller::denied(), + Operation::DeleteSubscription { + name: "Personal".into() + } + ) + .await + .is_err() + ); + assert_eq!( + serde_json::to_string(&controller.status().unwrap()).unwrap(), + before + ); + assert_eq!(controller.subscriptions().len(), 1); +} + +#[tokio::test] +async fn explicit_unset_and_deletion_never_disable_or_select_another_entry() { + let (_home, mut controller, _source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Unset) + .await + .unwrap(); + assert!(controller.status().unwrap().requested_enabled); + assert_eq!(controller.status().unwrap().routing, "blocked"); + assert_eq!(controller.configurations(None).unwrap().len(), 1); + controller + .execute(&Caller::root(), Operation::Select { id }) + .await + .unwrap(); + controller + .execute( + &Caller::root(), + Operation::DeleteSubscription { + name: "Personal".into(), + }, + ) + .await + .unwrap(); + assert!(controller.status().unwrap().requested_enabled); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); + assert_eq!(controller.status().unwrap().routing, "blocked"); +} + +#[tokio::test] +async fn persisted_intent_restores_under_protection_after_controller_restart() { + let (home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + controller + .execute(&Caller::root(), Operation::Enable) + .await + .unwrap(); + drop(controller); + let mut restored = Controller::open( + home.path(), + Box::new(GuardedRuntime { + armed: false, + routing: "unknown", + }), + Box::new(source), + ) + .unwrap(); + restored.restore().await.unwrap(); + assert_eq!(restored.status().unwrap().selected_configuration, Some(id)); + assert!(restored.status().unwrap().requested_enabled); + assert_eq!(restored.status().unwrap().routing, "tun"); +} + +#[tokio::test] +async fn label_changes_keep_identity_but_duplicate_candidates_clear_selection() { + let (_home, mut controller, source) = fixture().await; + let id = controller.configurations(None).unwrap()[0].id.clone(); + controller + .execute(&Caller::root(), Operation::Select { id: id.clone() }) + .await + .unwrap(); + *source.0.lock().unwrap() = Some("trojan://secret@example.com:443#Renamed\n".into()); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert_eq!( + controller.status().unwrap().selected_configuration, + Some(id) + ); + *source.0.lock().unwrap() = Some( + "trojan://secret@example.com:443#Duplicate1\ntrojan://secret@example.com:443#Duplicate2\n" + .into(), + ); + controller + .execute(&Caller::root(), Operation::Refresh { name: None }) + .await + .unwrap(); + assert!( + controller + .status() + .unwrap() + .selected_configuration + .is_none() + ); +} |