use hermes_grocy_mcp::{ model::{ProfileSelection, ProviderIdentity}, setup::hermes, }; use std::os::unix::fs::PermissionsExt; fn selection() -> ProfileSelection { ProfileSelection { profile: "default".into(), provider: ProviderIdentity { bus_name: "org.kde.secretservicecompat".into(), executable: "/usr/bin/wallet".into(), }, } } #[tokio::test] async fn guided_setup_and_hermes_preservation() { let block = hermes::render_config( std::path::Path::new("/opt/bin/hermes-grocy-mcp"), &selection(), ) .unwrap(); let data: serde_json::Value = serde_json::from_str(&block).unwrap(); assert_eq!(data["grocy"]["command"], "/opt/bin/hermes-grocy-mcp"); assert!(!block.contains("api_key")); let temp = tempfile::tempdir().unwrap(); let fake = temp.path().join("hermes"); let state = temp.path().join("servers.json"); std::fs::write(&state, "{\"other\":{\"command\":\"untouched\"}}").unwrap(); let script = format!( "#!/usr/bin/env python3\nimport json,sys,pathlib\np=pathlib.Path({:?})\na=sys.argv[1:]\nif a==['config','path']: print(str(p))\nelif a==['config','get','mcp_servers','--json']:print(p.read_text())\nelif a[:3]==['config','set','mcp_servers.grocy']:\n d=json.loads(p.read_text());d['grocy']=json.loads(a[3]);p.write_text(json.dumps(d))\nelif a==['mcp','test','grocy']:pass\nelse:sys.exit(2)\n", state.to_string_lossy().to_string() ); std::fs::write(&fake, script).unwrap(); std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o700)).unwrap(); hermes::connect_using( &fake, std::path::Path::new("/opt/bin/hermes-grocy-mcp"), &selection(), ) .await .unwrap(); let data: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&state).unwrap()).unwrap(); assert_eq!(data["other"]["command"], "untouched"); assert_eq!(data["grocy"]["command"], "/opt/bin/hermes-grocy-mcp"); let before = std::fs::read(&state).unwrap(); let mut conflict = selection(); conflict.profile = "other-profile".into(); assert_eq!( hermes::connect_using( &fake, std::path::Path::new("/opt/bin/hermes-grocy-mcp"), &conflict ) .await .err() .unwrap() .code, "HERMES_ENTRY_CONFLICT" ); assert_eq!(std::fs::read(&state).unwrap(), before); } struct RetryStore { attempts: std::sync::atomic::AtomicUsize, } impl hermes_grocy_mcp::credentials::CredentialStore for RetryStore { fn load( &self, _s: ProfileSelection, ) -> futures_util::future::BoxFuture< 'static, hermes_grocy_mcp::error::Result, > { Box::pin(async { Err(hermes_grocy_mcp::error::AppError::new( "PROFILE_NOT_FOUND", "Missing", )) }) } fn put( &self, _s: ProfileSelection, p: hermes_grocy_mcp::model::PrivateProfile, _c: hermes_grocy_mcp::credentials::EnrollmentConsent, ) -> futures_util::future::BoxFuture<'static, hermes_grocy_mcp::error::Result<()>> { use secrecy::ExposeSecret; assert_eq!(p.api_key.expose_secret(), "synthetic-key"); let attempt = self .attempts .fetch_add(1, std::sync::atomic::Ordering::SeqCst); Box::pin(async move { if attempt == 0 { Err(hermes_grocy_mcp::error::AppError::new( "WALLET_LOCKED", "Locked", )) } else { Ok(()) } }) } fn forget( &self, _s: ProfileSelection, ) -> futures_util::future::BoxFuture<'static, hermes_grocy_mcp::error::Result<()>> { Box::pin(async { Ok(()) }) } } struct ScriptUi { url: String, inputs: usize, secrets: usize, final_confirm: bool, } impl hermes_grocy_mcp::setup::SetupUi for ScriptUi { fn message(&mut self, _s: &str) {} fn input(&mut self, _p: &str, _d: Option<&str>) -> hermes_grocy_mcp::error::Result { self.inputs += 1; Ok(self.url.clone()) } fn secret( &mut self, _p: &str, _empty: bool, ) -> hermes_grocy_mcp::error::Result { self.secrets += 1; Ok(secrecy::SecretString::from("synthetic-key")) } fn choose( &mut self, _p: &str, _o: &[String], _d: usize, ) -> hermes_grocy_mcp::error::Result { Ok(0) } fn confirm(&mut self, p: &str, _d: bool) -> hermes_grocy_mcp::error::Result { Ok(p != "Save this profile?" || self.final_confirm) } fn providers( &mut self, ) -> hermes_grocy_mcp::error::Result< Vec, > { Ok(vec![ hermes_grocy_mcp::credentials::providers::ProviderCandidate { label: "Synthetic wallet".into(), identity: Some(selection().provider), recommended: true, }, ]) } fn protection( &mut self, _s: &ProfileSelection, ) -> hermes_grocy_mcp::credentials::protection::ProtectionEvidence { hermes_grocy_mcp::credentials::protection::ProtectionEvidence::Protected } } #[tokio::test] async fn wallet_retry_preserves_setup_answers() { use hermes_grocy_mcp::{ credentials::preferences::PreferenceStore, model::TransportPolicy, setup, }; use std::sync::{ Arc, atomic::{AtomicUsize, Ordering}, }; let app = axum::Router::new().fallback(|req: axum::extract::Request| async move { assert!(req.uri().path().starts_with("/api/")); axum::Json(if req.uri().path().ends_with("info") { serde_json::json!({"grocy_version":{"Version":"4.7.1"}}) } else { serde_json::json!([]) }) }); let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = l.local_addr().unwrap().port(); let server = tokio::spawn(async move { axum::serve(l, app).await.unwrap() }); for approve in [false, true] { let d = tempfile::tempdir().unwrap(); let store = Arc::new(RetryStore { attempts: AtomicUsize::new(0), }); let mut ui = ScriptUi { url: format!("http://127.0.0.1:{port}"), inputs: 0, secrets: 0, final_confirm: approve, }; let opts = setup::SetupOptions { profile: "default".into(), preferences: PreferenceStore { directory: d.path().join("prefs"), }, ca_path: None, transport: TransportPolicy::LoopbackDevelopment, access: None, connect_hermes: false, }; let result = setup::run(opts, &mut ui, store.clone()).await; assert_eq!(ui.inputs, 1); assert_eq!(ui.secrets, 1); if approve { assert!(result.is_ok()); assert_eq!(store.attempts.load(Ordering::SeqCst), 2); assert!(d.path().join("prefs/default.json").exists()); } else { assert_eq!(result.err().unwrap().code, "CANCELLED"); assert_eq!(store.attempts.load(Ordering::SeqCst), 0); assert!(!d.path().join("prefs/default.json").exists()); } } server.abort(); }