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

457 lines
17 KiB
Rust

//! Explicit opt-in contract against an owned, disposable fixture. No production credential path.
mod support;
use axum::{
body::Body,
extract::{Request, State},
http::StatusCode,
response::Response,
};
use hermes_grocy_mcp::{mcp::McpServer, model::AccessMode, setup};
use rmcp::{ServiceExt, model::CallToolRequestParams};
use serde_json::{Value, json};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use support::{MemoryStore, selection};
#[derive(Clone)]
struct Proxy {
target: String,
http: reqwest::Client,
outside: Arc<AtomicUsize>,
}
async fn forward(State(state): State<Proxy>, req: Request) -> Response {
let path = req.uri().path();
let route = if path.starts_with("/api/") {
path
} else if let Some(s) = path.strip_prefix("/household") {
if s.starts_with("/api/") { s } else { "" }
} else {
""
};
if route.is_empty() {
state.outside.fetch_add(1, Ordering::SeqCst);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Body::empty())
.unwrap();
}
let url = format!(
"{}{}{}",
state.target,
route,
req.uri()
.query()
.map(|q| format!("?{q}"))
.unwrap_or_default()
);
let mut r = state.http.request(req.method().clone(), url);
for name in ["GROCY-API-KEY", "content-type", "accept-encoding"] {
if let Some(v) = req.headers().get(name) {
r = r.header(name, v)
}
}
let bytes = axum::body::to_bytes(req.into_body(), 9 * 1024 * 1024)
.await
.unwrap();
let res = r.body(bytes).send().await.unwrap();
let status = res.status();
let headers = res.headers().clone();
let bytes = res.bytes().await.unwrap();
if !status.is_success() && status != StatusCode::NOT_FOUND {
eprintln!(
"Fixture API {}: {}",
status,
String::from_utf8_lossy(&bytes[..bytes.len().min(600)])
)
}
let mut response = Response::builder().status(status);
if let Some(v) = headers.get("content-type") {
response = response.header("content-type", v)
}
response.body(Body::from(bytes)).unwrap()
}
fn id(v: &Value) -> u64 {
let v = &v["write"]["receipt"]["created_object_id"];
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.expect("created ID in receipt")
}
fn numeric(v: &Value) -> f64 {
v.as_f64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
.unwrap()
}
#[tokio::test]
#[ignore = "requires scripts/docker_fixture.py with a disposable synthetic instance"]
async fn docker_household_contract() {
let path = std::env::var("GROCY_CONTRACT_MANIFEST").expect("fixture manifest");
let fixture: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let outside = Arc::new(AtomicUsize::new(0));
let proxy = Proxy {
target: fixture["url"].as_str().unwrap().into(),
http: reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap(),
outside: outside.clone(),
};
let app = axum::Router::new().fallback(forward).with_state(proxy);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let api = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let store = Arc::new(MemoryStore {
url: format!("http://127.0.0.1:{port}/household"),
access: AccessMode::Full,
key: fixture["api_key"].as_str().unwrap().into(),
});
let report = setup::doctor(selection(), store.clone()).await.unwrap();
assert_eq!(report.version.as_deref(), Some("4.7.1"));
assert!(report.household_access);
let server = McpServer::new(store.clone(), selection(), AccessMode::Full);
let (a, b) = tokio::io::duplex(65536);
let serving =
tokio::spawn(async move { server.serve(a).await.unwrap().waiting().await.unwrap() });
let client = ().serve(b).await.unwrap();
assert!(client.list_all_tools().await.unwrap().len() > 50);
macro_rules! call {
($name:expr,$args:expr) => {{
let result = client
.call_tool(
CallToolRequestParams::new($name)
.with_arguments($args.as_object().unwrap().clone()),
)
.await
.unwrap();
assert_ne!(
result.is_error,
Some(true),
"{}: {:?}",
$name,
result.structured_content
);
let text: Value =
serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap();
assert_eq!(Some(text.clone()), result.structured_content);
text
}};
}
macro_rules! create {($entity:expr,$fields:expr)=>{id(&call!("records_create",json!({"entity":$entity,"fields":$fields})))}}
let fixture_http = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let account:Value=fixture_http.post(format!("http://127.0.0.1:{port}/api/objects/userfields")).header("GROCY-API-KEY",&store.key).json(&json!({"entity":"users","name":format!("account{}",std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()),"caption":"Account-only definition","type":"text-single-line"})).send().await.unwrap().json().await.unwrap();
let account_id = numeric(&account["created_object_id"]) as u64;
let definitions = call!("records_list", json!({"entity":"userfields","limit":500}));
assert!(
!definitions["data"]["items"]
.as_array()
.unwrap()
.iter()
.any(|r| r["entity"] == "users")
);
for (tool, args) in [
(
"records_get",
json!({"entity":"userfields","id":account_id}),
),
(
"records_update",
json!({"entity":"userfields","id":account_id,"fields":{"caption":"Changed"}}),
),
(
"records_delete",
json!({"entity":"userfields","id":account_id}),
),
] {
let denied = client
.call_tool(
CallToolRequestParams::new(tool).with_arguments(args.as_object().unwrap().clone()),
)
.await
.unwrap();
assert_eq!(denied.is_error, Some(true));
}
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
.to_string();
let name = |s: &str| format!("Contract {s} {suffix}");
let unit = create!("quantity_units", json!({"name":name("unit")}));
let kilo = create!("quantity_units", json!({"name":name("kilounit")}));
let loc = create!("locations", json!({"name":name("location")}));
let loc2 = create!("locations", json!({"name":name("location2")}));
let product = create!(
"products",
json!({"name":name("product"),"location_id":loc,"qu_id_purchase":unit,"qu_id_stock":unit,"qu_id_consume":unit,"qu_id_price":unit})
);
call!(
"records_update",
json!({"entity":"products","id":product,"fields":{"description":"Synthetic contract product"}})
);
let added = call!(
"stock_add",
json!({"product_id":product,"amount":"10","location_id":loc,"best_before_date":"2999-01-01"})
);
assert!(added["write"]["receipt"].is_array());
let stock = call!("stock_list", json!({}));
assert!(
stock["data"]["items"]
.as_array()
.unwrap()
.iter()
.any(|r| numeric(&r["product_id"]) == product as f64)
);
let entries = call!("stock_entries", json!({"product_id":product}));
let entry = numeric(&entries["data"]["items"][0]["id"]) as u64;
let entry_edit = call!(
"stock_entry_put",
json!({"entry_id":entry,"amount":"10","price":"1.25"})
);
let stock_note = format!("stocknote{suffix}");
create!(
"userfields",
json!({"entity":"stock","name":stock_note,"caption":"Stock note","type":"text-single-line"})
);
call!(
"userfields_update",
json!({"entity":"stock","id":entry,"stock_transaction_id":entry_edit["write"]["receipt"][0]["transaction_id"],"fields":{stock_note.clone():"Stock value"}})
);
let stock_fields = call!("userfields_get", json!({"entity":"stock","id":entry}));
assert_eq!(stock_fields["data"][&stock_note], "Stock value");
call!("stock_open", json!({"product_id":product,"amount":"1"}));
call!(
"stock_transfer",
json!({"product_id":product,"amount":"1","location_id_from":loc,"location_id_to":loc2})
);
let consumed = call!(
"stock_consume",
json!({"product_id":product,"amount":"1","location_id":loc})
);
let booking = &consumed["write"]["receipt"][0];
let transaction = booking["transaction_id"].clone();
call!("stock_transaction", json!({"transaction_id":transaction}));
call!(
"stock_undo_transaction",
json!({"transaction_id":transaction})
);
call!(
"stock_inventory",
json!({"product_id":product,"new_amount":"9","location_id":loc})
);
create!(
"quantity_unit_conversions",
json!({"from_qu_id":unit,"to_qu_id":kilo,"factor":0.001,"product_id":product})
);
call!(
"records_update",
json!({"entity":"products","id":product,"fields":{"qu_id_stock":kilo}})
);
let details = call!("stock_product", json!({"product_id":product}));
assert_eq!(
numeric(&details["data"]["product"]["qu_id_stock"]),
kilo as f64
);
call!(
"stock_add",
json!({"product_id":product,"amount":"1000","quantity_unit_id":unit,"best_before_date":"2999-01-01"})
);
call!("stock_volatile", json!({}));
let list = create!("shopping_lists", json!({"name":name("shopping")}));
call!(
"shopping_add_product",
json!({"product_id":product,"list_id":list,"product_amount":2,"qu_id":kilo})
);
let exported = call!("shopping_export", json!({"list_id":list}));
assert!(!exported["data"]["items"].as_array().unwrap().is_empty());
call!(
"shopping_remove_product",
json!({"product_id":product,"list_id":list,"product_amount":1})
);
call!("shopping_clear", json!({"list_id":list}));
let recipe = create!(
"recipes",
json!({"name":name("recipe"),"base_servings":1,"desired_servings":1})
);
create!(
"recipes_pos",
json!({"recipe_id":recipe,"product_id":product,"amount":0.001,"qu_id":kilo})
);
let nested = create!(
"recipes",
json!({"name":name("nested"),"base_servings":1,"desired_servings":1})
);
create!(
"recipes_nestings",
json!({"recipe_id":nested,"includes_recipe_id":recipe,"servings":1})
);
call!("recipe_fulfillment", json!({"recipe_id":recipe}));
call!("recipe_copy", json!({"recipe_id":recipe}));
let used = call!("recipe_consume", json!({"recipe_id":recipe}));
assert!(used["write"]["receipt"].is_null()); // Grocy returns 204 for this action.
let log = call!("records_list", json!({"entity":"stock_log","limit":500}));
let row = log["data"]["items"]
.as_array()
.unwrap()
.iter()
.rev()
.find(|r| {
r["recipe_id"]
.as_str()
.is_some_and(|s| s == recipe.to_string())
|| r["recipe_id"].as_u64() == Some(recipe)
})
.expect("recipe consume booking");
let tx = row["transaction_id"].clone();
call!("stock_undo_transaction", json!({"transaction_id":tx}));
let section = create!("meal_plan_sections", json!({"name":name("section")}));
let meal = create!(
"meal_plan",
json!({"day":"2030-01-01","type":"recipe","recipe_id":recipe,"recipe_servings":1,"section_id":section})
);
call!(
"records_update",
json!({"entity":"meal_plan","id":meal,"fields":{"note":"Synthetic meal"}})
);
let chore = create!(
"chores",
json!({"name":name("chore"),"period_type":"daily","period_interval":1})
);
let executed = call!(
"chore_execute",
json!({"chore_id":chore,"tracked_time":"2030-01-01 10:00:00"})
);
call!(
"chore_undo_execution",
json!({"execution_id":executed["write"]["receipt"]["id"]})
);
let task = create!(
"tasks",
json!({"name":name("task"),"due_date":"2030-01-01 10:00:00"})
);
call!("task_complete", json!({"task_id":task}));
call!("task_undo", json!({"task_id":task}));
call!(
"records_update",
json!({"entity":"tasks","id":task,"fields":{"due_date":"2030-01-02 10:00:00"}})
);
let battery = create!(
"batteries",
json!({"name":name("battery"),"charge_interval_days":7})
);
let charged = call!(
"battery_charge",
json!({"battery_id":battery,"tracked_time":"2030-01-01 10:00:00"})
);
call!(
"battery_undo_charge",
json!({"charge_cycle_id":charged["write"]["receipt"]["id"]})
);
let equipment = create!("equipment", json!({"name":name("equipment")}));
let note_name = format!("contractnote{suffix}");
create!(
"userfields",
json!({"entity":"equipment","name":note_name,"caption":"Contract note","type":"text-single-line"})
);
call!(
"userfields_update",
json!({"entity":"equipment","id":equipment,"fields":{note_name.clone():"Synthetic only"}})
);
let fields = call!(
"userfields_get",
json!({"entity":"equipment","id":equipment})
);
assert_eq!(fields["data"][&note_name], "Synthetic only");
let entity = create!(
"userentities",
json!({"name":name("entity"),"caption":"Synthetic"})
);
let custom = create!("userobjects", json!({"userentity_id":entity}));
create!(
"userfields",
json!({"entity":format!("userentity-{}",name("entity")),"name":"customnote","caption":"Custom note","type":"text-single-line"})
);
call!(
"userfields_update",
json!({"entity":"userobjects","id":custom,"fields":{"customnote":"Custom value"}})
);
let custom_fields = call!(
"userfields_get",
json!({"entity":"userobjects","id":custom})
);
assert_eq!(custom_fields["data"]["customnote"], "Custom value");
call!("assignment_users", json!({}));
use base64::{Engine, engine::general_purpose::STANDARD};
let file = format!("manual-{suffix}.pdf");
let bytes = b"%PDF-1.4\nSynthetic test\n%%EOF";
call!(
"file_upload",
json!({"group":"equipmentmanuals","filename":file,"content_base64":STANDARD.encode(bytes)})
);
let downloaded = client
.call_tool(
CallToolRequestParams::new("file_get").with_arguments(
json!({"group":"equipmentmanuals","filename":file})
.as_object()
.unwrap()
.clone(),
),
)
.await
.unwrap();
assert_ne!(downloaded.is_error, Some(true));
let wire = serde_json::to_value(&downloaded.content).unwrap();
assert!(wire.to_string().contains(&STANDARD.encode(bytes)));
call!(
"file_delete",
json!({"group":"equipmentmanuals","filename":file})
);
call!("calendar_export", json!({}));
call!(
"records_delete",
json!({"entity":"equipment","id":equipment})
);
client.cancel().await.unwrap();
serving.await.unwrap();
// Root API enrollment and read-only rejection are independent of deployment prefix.
let readonly = Arc::new(MemoryStore {
url: format!("http://127.0.0.1:{port}/api"),
access: AccessMode::ReadOnly,
key: store.key.clone(),
});
setup::doctor(selection(), readonly.clone()).await.unwrap();
let server = McpServer::new(readonly, selection(), AccessMode::ReadOnly);
let (a, b) = tokio::io::duplex(65536);
let serving =
tokio::spawn(async move { server.serve(a).await.unwrap().waiting().await.unwrap() });
let client = ().serve(b).await.unwrap();
assert!(
!client
.list_all_tools()
.await
.unwrap()
.iter()
.any(|t| t.name == "stock_add")
);
let blocked = client
.call_tool(
CallToolRequestParams::new("stock_add").with_arguments(
json!({"product_id":product,"amount":1})
.as_object()
.unwrap()
.clone(),
),
)
.await
.unwrap();
assert_eq!(blocked.is_error, Some(true));
client.cancel().await.unwrap();
serving.await.unwrap();
assert_eq!(outside.load(Ordering::SeqCst), 0);
api.abort();
}