summaryrefslogtreecommitdiff
path: root/tests/controller_acceptance.rs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/controller_acceptance.rs')
-rw-r--r--tests/controller_acceptance.rs355
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()
+ );
+}