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> { 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>>); #[async_trait] impl SubscriptionSource for MutableSource { async fn fetch(&self, _: &str) -> anyhow::Result> { 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::>(), ["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() ); }