Files
hermes-grocy-mcp/tests/setup.rs

218 lines
7.4 KiB
Rust

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<hermes_grocy_mcp::model::PrivateProfile>,
> {
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<String> {
self.inputs += 1;
Ok(self.url.clone())
}
fn secret(
&mut self,
_p: &str,
_empty: bool,
) -> hermes_grocy_mcp::error::Result<secrecy::SecretString> {
self.secrets += 1;
Ok(secrecy::SecretString::from("synthetic-key"))
}
fn choose(
&mut self,
_p: &str,
_o: &[String],
_d: usize,
) -> hermes_grocy_mcp::error::Result<usize> {
Ok(0)
}
fn confirm(&mut self, p: &str, _d: bool) -> hermes_grocy_mcp::error::Result<bool> {
Ok(p != "Save this profile?" || self.final_confirm)
}
fn providers(
&mut self,
) -> hermes_grocy_mcp::error::Result<
Vec<hermes_grocy_mcp::credentials::providers::ProviderCandidate>,
> {
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();
}