use super::map::{Call, map_request};
use super::*;
use axum::http::Method;
fn stub_daemon(dir: &Path, reply: impl Into<String>) -> PathBuf {
let socket = dir.join("sockets").join("memory.sock");
let reply = reply.into();
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
tokio::spawn(async move {
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
let Ok((mut conn, _)) = listener.accept().await else {
return;
};
let mut sink = Vec::new();
let _ = conn.read_to_end(&mut sink).await;
let _ = conn.write_all(reply.as_bytes()).await;
let _ = conn.write_all(b"\n").await;
let _ = conn.flush().await;
});
socket
}
#[test]
fn error_status_maps_every_documented_code() {
let cases = [
(CODE_NOT_FOUND, StatusCode::NOT_FOUND),
(CODE_REFUSED, StatusCode::CONFLICT),
(CODE_INVALID_PARAMS, StatusCode::BAD_REQUEST),
(CODE_INVALID_REQUEST, StatusCode::BAD_REQUEST),
(CODE_METHOD_NOT_FOUND, StatusCode::NOT_IMPLEMENTED),
(CODE_INTERNAL_ERROR, StatusCode::INTERNAL_SERVER_ERROR),
(CODE_PARSE_ERROR, StatusCode::INTERNAL_SERVER_ERROR),
(-31999, StatusCode::INTERNAL_SERVER_ERROR),
];
for (code, expected) in cases {
let err = MemoryRpcError::Refused {
code,
message: "refused".to_string(),
};
assert_eq!(err.status(), expected, "code {code}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_a_dead_socket_as_unreachable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let err = call(
&tmp.path().join("absent.sock"),
METHOD_HEALTH,
json!({}),
Duration::from_secs(2),
)
.await
.expect_err("a dead socket is not a success");
assert!(matches!(err, MemoryRpcError::Unreachable(_)), "{err:?}");
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_a_jsonrpc_error_with_the_http_status_it_came_from() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(
tmp.path(),
r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32004,"message":"no palace 'ghost'"}}"#,
);
let err = call(
&socket,
METHOD_PALACE_GET,
json!({ "palace_id": "ghost" }),
Duration::from_secs(5),
)
.await
.expect_err("an error frame is not a success");
assert_eq!(err.status(), StatusCode::NOT_FOUND);
assert!(err.message().contains("no palace 'ghost'"), "{err:?}");
}
#[tokio::test(flavor = "multi_thread")]
async fn call_reports_an_empty_answer_as_malformed() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(tmp.path(), r#"{"jsonrpc":"2.0","id":1}"#);
let err = call(&socket, METHOD_HEALTH, json!({}), Duration::from_secs(5))
.await
.expect_err("an empty answer is not a success");
assert!(matches!(err, MemoryRpcError::Malformed(_)), "{err:?}");
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test(flavor = "multi_thread")]
async fn call_returns_the_daemon_result() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let socket = stub_daemon(
tmp.path(),
r#"{"jsonrpc":"2.0","id":1,"result":{"status":"ok","version":"9.9.9"}}"#,
);
let result = call(&socket, METHOD_HEALTH, json!({}), Duration::from_secs(5))
.await
.expect("the exchange succeeds");
assert_eq!(result["version"], json!("9.9.9"));
}
#[tokio::test(flavor = "multi_thread")]
async fn a_response_over_the_shared_default_is_read() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let big = "x".repeat(9 * 1024 * 1024);
let reply = json!({ "jsonrpc": "2.0", "id": 1, "result": { "blob": big } }).to_string();
assert!(
reply.len() as u64 > trusty_common::uds::MAX_FRAME_BYTES,
"the fixture must exceed the shared default to prove anything"
);
let socket = stub_daemon(tmp.path(), reply);
let result = call(&socket, METHOD_KG_GRAPH, json!({}), Duration::from_secs(20))
.await
.expect("an oversized-but-permitted response is read");
assert_eq!(result["blob"].as_str().map(str::len), Some(9 * 1024 * 1024));
}
#[tokio::test(flavor = "multi_thread")]
async fn open_stream_reports_a_dead_socket_as_unreachable() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let err = open_stream(
&tmp.path().join("absent.sock"),
METHOD_ACTIVITY_STREAM,
json!({}),
STREAM_OPEN_TIMEOUT,
)
.await
.expect_err("a dead socket cannot open a stream");
assert!(matches!(err, MemoryRpcError::Unreachable(_)), "{err:?}");
}
#[test]
fn the_frame_budget_is_at_least_the_listeners() {
let listener = declared_u64_const(
&trusty_common::test_harness::test_repo_root()
.expect("the running checkout's workspace root")
.join("crates/trusty-memory/src/transport/uds.rs"), "pub const MAX_FRAME_BYTES: u64 = ",
);
assert!(
MAX_FRAME_BYTES >= listener,
"this client's frame budget ({MAX_FRAME_BYTES}) is under the listener's ({listener}); \
a response trusty-memory already produced would come back as FrameTooLarge"
);
}
#[test]
fn every_memory_method_this_bridge_dials_is_declared_by_the_daemon() {
let uds_rs = trusty_common::test_harness::test_repo_root()
.expect("the running checkout's workspace root")
.join("crates/trusty-memory/src/transport/uds.rs"); let source = std::fs::read_to_string(&uds_rs)
.unwrap_or_else(|e| panic!("read {}: {e}", uds_rs.display()));
for method in [
METHOD_HEALTH,
METHOD_STATUS,
METHOD_CONFIG,
METHOD_PALACES_LIST,
METHOD_PALACE_GET,
METHOD_DRAWERS_LIST,
METHOD_LOGS_TAIL,
METHOD_DREAM_STATUS,
METHOD_DREAM_RUN,
METHOD_ADMIN_STOP,
METHOD_KG_SUBJECTS_WITH_COUNTS,
METHOD_KG_ALL,
METHOD_KG_COUNT,
METHOD_KG_GRAPH,
METHOD_KG_GRAPH_SEED,
METHOD_KG_GRAPH_NEIGHBORS,
METHOD_ACTIVITY,
METHOD_ACTIVITY_STREAM,
] {
let quoted = format!("\"{method}\"");
assert!(
source.contains("ed),
"{method} is not declared in {} — the daemon renamed it and this bridge \
would answer method_not_found",
uds_rs.display()
);
}
}
fn declared_u64_const(path: &std::path::Path, prefix: &str) -> u64 {
let source =
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let decl = source
.lines()
.find_map(|line| {
line.trim()
.strip_prefix(prefix)?
.strip_suffix(';')
.map(str::to_owned)
})
.unwrap_or_else(|| {
panic!(
"no `{prefix}…;` in {} — the declaration moved, so this floor is unverified",
path.display()
)
});
decl.split('*')
.map(|factor| {
factor
.trim()
.parse::<u64>()
.unwrap_or_else(|e| panic!("parse `{decl}`: {e}"))
})
.product()
}
#[test]
fn socket_path_matches_the_daemon_resolver() {
let _guard = crate::detect::ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let resolved = socket_path();
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert_eq!(
resolved.expect("the override resolves"),
tmp.path().join(MEMORY_SERVICE).join("trusty-memory.sock")
);
}
fn mapped(method: &Method, path: &str, query: Option<&str>) -> Call {
map_request(method, path, query).expect("mapped")
}
fn unary(method: &'static str, params: Value) -> Call {
Call::Unary { method, params }
}
#[test]
fn maps_every_endpoint_the_spa_calls() {
assert_eq!(
mapped(&Method::GET, "health", None),
unary(METHOD_HEALTH, json!({}))
);
assert_eq!(
mapped(&Method::GET, "api/v1/status", None),
unary(METHOD_STATUS, json!({}))
);
assert_eq!(
mapped(&Method::GET, "api/v1/config", None),
unary(METHOD_CONFIG, json!({}))
);
assert_eq!(
mapped(&Method::GET, "api/v1/palaces", None),
unary(METHOD_PALACES_LIST, json!({}))
);
assert_eq!(
mapped(&Method::GET, "api/v1/palaces", Some("counts=false")),
unary(METHOD_PALACES_LIST, json!({ "counts": false }))
);
assert_eq!(
mapped(&Method::GET, "api/v1/palaces/izzie", None),
unary(METHOD_PALACE_GET, json!({ "palace_id": "izzie" }))
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/drawers",
Some("limit=200")
),
unary(
METHOD_DRAWERS_LIST,
json!({ "palace_id": "izzie", "limit": 200 })
)
);
assert_eq!(
mapped(&Method::GET, "api/v1/logs/tail", Some("n=200")),
unary(METHOD_LOGS_TAIL, json!({ "n": 200 }))
);
assert_eq!(
mapped(&Method::GET, "api/v1/dream/status", None),
unary(METHOD_DREAM_STATUS, json!({}))
);
assert_eq!(
mapped(&Method::POST, "api/v1/dream/run", None),
unary(METHOD_DREAM_RUN, json!({}))
);
assert_eq!(
mapped(&Method::POST, "api/v1/admin/stop", None),
unary(METHOD_ADMIN_STOP, json!({}))
);
assert_eq!(
mapped(&Method::GET, "api/v1/activity", Some("limit=50&offset=0")),
unary(METHOD_ACTIVITY, json!({ "limit": 50, "offset": 0 }))
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/kg",
Some("subject=tag%3Arust")
),
unary(
METHOD_KG_QUERY_TOOL,
json!({ "palace": "izzie", "subject": "tag:rust" })
)
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/kg/subjects_with_counts",
Some("limit=200")
),
unary(
METHOD_KG_SUBJECTS_WITH_COUNTS,
json!({ "palace_id": "izzie", "limit": 200 })
)
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/kg/all",
Some("limit=50&offset=100")
),
unary(
METHOD_KG_ALL,
json!({ "palace_id": "izzie", "limit": 50, "offset": 100 })
)
);
assert_eq!(
mapped(&Method::GET, "api/v1/palaces/izzie/kg/count", None),
unary(METHOD_KG_COUNT, json!({ "palace_id": "izzie" }))
);
assert_eq!(
mapped(&Method::GET, "api/v1/palaces/izzie/kg/graph", None),
unary(METHOD_KG_GRAPH, json!({ "palace_id": "izzie" }))
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/kg/graph/seed",
Some("limit=75")
),
unary(
METHOD_KG_GRAPH_SEED,
json!({ "palace_id": "izzie", "limit": 75 })
)
);
assert_eq!(
mapped(
&Method::GET,
"api/v1/palaces/izzie/kg/graph/neighbors",
Some("node=rust&direction=both&max_hops=1")
),
unary(
METHOD_KG_GRAPH_NEIGHBORS,
json!({
"palace_id": "izzie",
"node": "rust",
"direction": "both",
"max_hops": 1
})
)
);
assert_eq!(
mapped(&Method::GET, "sse", None),
Call::Stream {
method: METHOD_ACTIVITY_STREAM,
params: json!({})
}
);
}
#[test]
fn refuses_an_unmapped_path() {
let err = map_request(&Method::POST, "api/v1/palaces", None).expect_err("not mapped");
assert!(
err.contains("no trusty-memory socket method serves"),
"{err}"
);
assert!(
err.contains("api/v1/palaces"),
"the refusal must name itself: {err}"
);
}
#[test]
fn a_kg_read_without_a_subject_is_refused() {
let err = map_request(&Method::GET, "api/v1/palaces/izzie/kg", None).expect_err("no subject");
assert!(err.contains("`subject`"), "{err}");
}
#[test]
fn query_json_coerces_integers() {
let Call::Unary { params, .. } = mapped(&Method::GET, "api/v1/logs/tail", Some("n=500")) else {
panic!("logs/tail is unary");
};
assert_eq!(params["n"], json!(500), "must arrive as a number");
}
#[test]
fn query_json_is_an_empty_object_for_no_query() {
let Call::Unary { params, .. } = mapped(&Method::GET, "api/v1/status", None) else {
panic!("status is unary");
};
assert_eq!(params, json!({}));
assert!(params.is_object(), "never null");
}