Files
hermes-grocy-mcp/tests/client_boundary.rs
2026-10-01 00:48:16 +03:00

259 lines
8.5 KiB
Rust

use hermes_grocy_mcp::{client::ApiBase, model::TransportPolicy};
#[test]
fn api_boundary() {
for (input, path) in [
("https://grocy.example/household", "/household/api"),
("https://grocy.example/household/api/", "/household/api"),
("https://grocy.example", "/api"),
] {
assert_eq!(
ApiBase::parse(input, TransportPolicy::HttpsOnly)
.unwrap()
.path(),
path
);
}
for input in [
"https://user:pass@grocy.example/api",
"https://@grocy.example/api",
"http://2130706433/api",
"https://grocy.example/api?q=1",
"https://grocy.example/api#x",
"https://grocy.example/a/../api",
"https://grocy.example/%2e/api",
"https://grocy.example/a%2fb/api",
"https://grocy.example/%252f/api",
"http://192.168.1.2/api",
"http://localhost/api",
] {
assert!(
ApiBase::parse(input, TransportPolicy::LoopbackDevelopment).is_err(),
"accepted {input}"
);
}
assert!(ApiBase::parse("http://127.0.0.1:8000", TransportPolicy::HttpsOnly).is_err());
assert_eq!(
ApiBase::parse(
"http://127.0.0.1:8000",
TransportPolicy::LoopbackDevelopment
)
.unwrap()
.path(),
"/api"
);
}
use axum::{Router, http::StatusCode, response::IntoResponse};
use hermes_grocy_mcp::{
client::{ApiClient, ApiRequest, HttpMethod, RequestBody, ResponseKind},
model::{AccessMode, PrivateProfile},
};
use secrecy::SecretString;
use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
};
fn profile(url: &str) -> PrivateProfile {
PrivateProfile {
api_base: ApiBase::parse(url, TransportPolicy::LoopbackDevelopment).unwrap(),
api_key: SecretString::from("synthetic-key"),
access: AccessMode::Full,
timezone: None,
defaults: serde_json::Value::Null,
ca_path: None,
transport: TransportPolicy::LoopbackDevelopment,
unsafe_storage: false,
}
}
#[tokio::test]
async fn write_dispatch_boundary() {
let count = Arc::new(AtomicUsize::new(0));
let counted = count.clone();
let app = Router::new().fallback(move |req: axum::extract::Request| {
let counted = counted.clone();
async move {
counted.fetch_add(1, Ordering::SeqCst);
assert!(req.uri().path().starts_with("/household/api/"));
assert_eq!(req.headers().get("GROCY-API-KEY").unwrap(), "synthetic-key");
match req.uri().path() {
"/household/api/redirect" => (
StatusCode::FOUND,
[("location", "http://127.0.0.1:1/secret")],
"",
)
.into_response(),
"/household/api/compressed" => {
(StatusCode::OK, [("content-encoding", "gzip")], "data").into_response()
}
"/household/api/large-json" => (
StatusCode::OK,
[("content-type", "application/json")],
"x".repeat(2 * 1024 * 1024 + 1),
)
.into_response(),
"/household/api/large-file" => {
(StatusCode::OK, "x".repeat(8 * 1024 * 1024 + 1)).into_response()
}
"/household/api/error" => {
(StatusCode::BAD_REQUEST, "synthetic-private-marker").into_response()
}
"/household/api/empty" => StatusCode::NO_CONTENT.into_response(),
_ => (
StatusCode::OK,
[("content-type", "application/json")],
"{\"id\":7}",
)
.into_response(),
}
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let client = ApiClient::new(profile(&format!("http://127.0.0.1:{port}/household"))).unwrap();
for (route, kind, code) in [
("/redirect", ResponseKind::Json, "API_REDIRECT"),
("/compressed", ResponseKind::Json, "COMPRESSION_REFUSED"),
("/large-json", ResponseKind::Json, "RESPONSE_TOO_LARGE"),
("/large-file", ResponseKind::Binary, "RESPONSE_TOO_LARGE"),
("/error", ResponseKind::Json, "API_REJECTED"),
] {
let mut r = ApiRequest::get(route);
r.kind = kind;
let err = client.request(r).await.err().unwrap();
assert_eq!(err.code, code);
assert!(!err.to_string().contains("synthetic-private-marker"));
}
assert_eq!(count.load(Ordering::SeqCst), 5);
let success = client
.request(ApiRequest::get("/data"))
.await
.unwrap()
.json()
.unwrap();
assert_eq!(success["id"], 7);
let mut empty = ApiRequest::get("/empty");
empty.method = HttpMethod::Put;
assert!(
client
.request(empty)
.await
.unwrap()
.json()
.unwrap()
.is_null()
);
let dead = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = dead.local_addr().unwrap().port();
let attempts = Arc::new(AtomicUsize::new(0));
let accepted = attempts.clone();
let dropper = tokio::spawn(async move {
let (s, _) = dead.accept().await.unwrap();
accepted.fetch_add(1, Ordering::SeqCst);
drop(s);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
});
let c = ApiClient::new(profile(&format!("http://127.0.0.1:{port}"))).unwrap();
let mut r = ApiRequest::get("/stock");
r.method = HttpMethod::Post;
r.body = RequestBody::Json(serde_json::json!({"amount":1}));
assert_eq!(
c.request(r).await.err().unwrap().code,
"UNKNOWN_WRITE_OUTCOME"
);
dropper.await.unwrap();
assert_eq!(attempts.load(Ordering::SeqCst), 1);
let tls = ApiClient::new(profile(&format!("https://127.0.0.1:{port}"))).unwrap();
assert_eq!(
tls.request(ApiRequest::get("/data"))
.await
.err()
.unwrap()
.code,
"CONNECTION_FAILED"
);
let mut path = BTreeMap::new();
path.insert("id".into(), "a/b ?#%".into());
assert_eq!(
ApiBase::parse("https://grocy.example/prefix", TransportPolicy::HttpsOnly)
.unwrap()
.route("/objects/{id}", &path)
.unwrap()
.path(),
"/prefix/api/objects/a%2Fb%20%3F%23%25"
);
server.abort();
}
#[tokio::test]
async fn untrusted_tls_certificate_is_rejected() {
use std::process::{Command, Stdio};
let dir = tempfile::tempdir().unwrap();
let cert = dir.path().join("cert.pem");
let key = dir.path().join("key.pem");
assert!(
Command::new("openssl")
.args([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-days",
"1",
"-subj",
"/CN=localhost",
"-keyout"
])
.arg(&key)
.arg("-out")
.arg(&cert)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap()
.success()
);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let mut server = Command::new("openssl")
.args([
"s_server",
"-accept",
&format!("127.0.0.1:{port}"),
"-www",
"-quiet",
"-cert",
])
.arg(cert)
.arg("-key")
.arg(key)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut ready = false;
for _ in 0..50 {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
ready = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(ready);
let result = ApiClient::new(profile(&format!("https://127.0.0.1:{port}")))
.unwrap()
.request(ApiRequest::get("/data"))
.await;
let _ = server.kill();
let _ = server.wait();
assert_eq!(result.err().unwrap().code, "CONNECTION_FAILED");
}