Skip to content

Commit 7b7a67d

Browse files
tcconnallyclaude
andcommitted
test/ci: grpc CI lane, error sanitization, and unit tests for src/grpc.rs (#354)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent daef6c7 commit 7b7a67d

2 files changed

Lines changed: 213 additions & 2 deletions

File tree

.github/workflows/test.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,26 @@ jobs:
108108
- name: Test (no default features)
109109
run: cargo test --no-default-features --verbose
110110

111+
# Guard the optional gRPC transport (#354): src/grpc.rs is entirely
112+
# feature-gated and was previously absent from every CI lane, so the whole
113+
# external wire surface shipped unverified. tonic-build invokes protoc at
114+
# build time, so install protobuf-compiler first. Built without default
115+
# features to isolate the grpc deps from the embedding stack and keep the
116+
# job fast; `cargo test` also runs the in-module unit tests for the
117+
# implemented RPCs and error sanitization.
118+
grpc-build:
119+
runs-on: ubuntu-latest
120+
steps:
121+
- uses: actions/checkout@v4
122+
- name: Install Rust
123+
uses: dtolnay/rust-toolchain@stable
124+
- name: Install protoc
125+
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
126+
- name: Build (grpc, no embeddings)
127+
run: cargo build --no-default-features --features grpc --verbose
128+
- name: Test (grpc, no embeddings)
129+
run: cargo test --no-default-features --features grpc --verbose
130+
111131
# Guard the optional multimodal document-extraction feature (#236): it pulls
112132
# zip + pdf-extract, so make sure it still builds and its tests pass. Built
113133
# without default features to isolate the multimodal deps from the embedding

src/grpc.rs

Lines changed: 193 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,33 @@ pub mod grpc {
2727
}
2828
}
2929

30-
// Helper to run DB operations inside the mutex
30+
// Helper to run DB operations inside the mutex.
31+
//
32+
// Error hygiene (#354): this module is a documented external wire contract,
33+
// so internal error text (rusqlite constraint/column names, file paths)
34+
// must not reach remote clients. Match the HTTP surface (src/web/mod.rs,
35+
// which returns a bare 500 with no detail): log the detail server-side,
36+
// return a generic INTERNAL to the client. Handlers that raise a *typed*
37+
// Status inside the closure (e.g. get_entity's not_found) get it passed
38+
// through unchanged instead of being flattened into INTERNAL.
3139
fn with_db<T>(
3240
server: &MnemeGrpcServer,
3341
f: impl FnOnce(&Database) -> Result<T, Box<dyn std::error::Error>>,
3442
) -> Result<T, Status> {
3543
let db = server.db.lock().map_err(|_| Status::internal("lock poisoned"))?;
36-
f(&db).map_err(|e| Status::internal(e.to_string()))
44+
f(&db).map_err(sanitize_error)
45+
}
46+
47+
/// Map a handler error to the client-facing Status: intentional `Status`
48+
/// values pass through; everything else is logged and genericized.
49+
fn sanitize_error(e: Box<dyn std::error::Error>) -> Status {
50+
match e.downcast::<Status>() {
51+
Ok(status) => *status,
52+
Err(e) => {
53+
eprintln!("mimir grpc: internal error: {e}");
54+
Status::internal("internal error")
55+
}
56+
}
3757
}
3858

3959
#[tonic::async_trait]
@@ -279,6 +299,156 @@ pub mod grpc {
279299
.await?;
280300
Ok(())
281301
}
302+
303+
#[cfg(test)]
304+
mod tests {
305+
use super::mneme_server::Mneme;
306+
use super::*;
307+
use crate::db::Database;
308+
309+
fn test_server() -> (MnemeGrpcServer, String) {
310+
let path = std::env::temp_dir()
311+
.join(format!("mimir-test-grpc-{}.db", uuid::Uuid::new_v4()));
312+
let path_str = path.to_str().unwrap().to_string();
313+
let db = Database::open(&path_str).expect("open test db");
314+
(MnemeGrpcServer::new(Arc::new(Mutex::new(db))), path_str)
315+
}
316+
317+
fn remember_req(key: &str) -> RememberRequest {
318+
RememberRequest {
319+
category: "note".to_string(),
320+
key: key.to_string(),
321+
body_json: "{\"content\":\"hello grpc\"}".to_string(),
322+
status: "active".to_string(),
323+
r#type: "insight".to_string(),
324+
tags: vec!["t1".to_string()],
325+
importance: 1.0,
326+
topic_path: String::new(),
327+
recall_when: vec![],
328+
always_on: false,
329+
certainty: 0.5,
330+
workspace_hash: String::new(),
331+
agent_id: String::new(),
332+
visibility: "workspace".to_string(),
333+
}
334+
}
335+
336+
#[test]
337+
fn sanitize_error_hides_internal_detail_from_clients() {
338+
// #354: raw internal error text (constraint/column names) must not
339+
// reach gRPC clients — generic message out, detail logged only.
340+
let e: Box<dyn std::error::Error> =
341+
"UNIQUE constraint failed: entities.category, entities.key".into();
342+
let status = sanitize_error(e);
343+
assert_eq!(status.code(), tonic::Code::Internal);
344+
assert_eq!(status.message(), "internal error");
345+
}
346+
347+
#[test]
348+
fn sanitize_error_passes_through_typed_statuses() {
349+
let e: Box<dyn std::error::Error> = Box::new(Status::not_found("entity not found"));
350+
let status = sanitize_error(e);
351+
assert_eq!(status.code(), tonic::Code::NotFound);
352+
assert_eq!(status.message(), "entity not found");
353+
}
354+
355+
#[test]
356+
fn entity_to_proto_maps_fields() {
357+
let e = models::Entity {
358+
id: "ent-1".to_string(),
359+
category: "note".to_string(),
360+
key: "k".to_string(),
361+
body_json: "{}".to_string(),
362+
status: "active".to_string(),
363+
entity_type: "insight".to_string(),
364+
tags: vec!["a".to_string()],
365+
decay_score: 0.7,
366+
retrieval_count: 3,
367+
layer: "working".to_string(),
368+
topic_path: "x/y".to_string(),
369+
archived: false,
370+
archive_reason: String::new(),
371+
links: vec![],
372+
verified: true,
373+
source: "grpc".to_string(),
374+
always_on: true,
375+
certainty: 0.9,
376+
workspace_hash: "ws".to_string(),
377+
agent_id: "agent".to_string(),
378+
visibility: "workspace".to_string(),
379+
created_at_unix_ms: 1,
380+
last_accessed_unix_ms: 2,
381+
embedding: None,
382+
};
383+
let p = entity_to_proto(&e);
384+
assert_eq!(p.id, "ent-1");
385+
assert_eq!(p.category, "note");
386+
assert_eq!(p.key, "k");
387+
assert_eq!(p.r#type, "insight");
388+
assert_eq!(p.tags, vec!["a".to_string()]);
389+
assert_eq!(p.decay_score, 0.7);
390+
assert_eq!(p.retrieval_count, 3);
391+
assert!(p.verified);
392+
assert!(p.always_on);
393+
assert_eq!(p.workspace_hash, "ws");
394+
assert_eq!(p.created_at_unix_ms, 1);
395+
assert_eq!(p.last_accessed_unix_ms, 2);
396+
}
397+
398+
#[tokio::test]
399+
async fn remember_then_get_entity_roundtrip() {
400+
let (server, path) = test_server();
401+
let resp = server
402+
.remember(Request::new(remember_req("k1")))
403+
.await
404+
.expect("remember");
405+
let r = resp.into_inner();
406+
assert!(!r.id.is_empty());
407+
assert_eq!(r.category, "note");
408+
assert_eq!(r.key, "k1");
409+
410+
let got = server
411+
.get_entity(Request::new(GetEntityRequest { id: r.id.clone() }))
412+
.await
413+
.expect("get_entity")
414+
.into_inner();
415+
assert_eq!(got.id, r.id);
416+
assert_eq!(got.category, "note");
417+
assert_eq!(got.key, "k1");
418+
let _ = std::fs::remove_file(&path);
419+
}
420+
421+
#[tokio::test]
422+
async fn get_entity_missing_returns_not_found_not_internal() {
423+
// The typed not_found raised inside the with_db closure must
424+
// survive sanitize_error instead of being flattened to INTERNAL.
425+
let (server, path) = test_server();
426+
let err = server
427+
.get_entity(Request::new(GetEntityRequest { id: "does-not-exist".to_string() }))
428+
.await
429+
.expect_err("missing entity should error");
430+
assert_eq!(err.code(), tonic::Code::NotFound);
431+
let _ = std::fs::remove_file(&path);
432+
}
433+
434+
#[tokio::test]
435+
async fn health_and_stats_respond() {
436+
let (server, path) = test_server();
437+
let h = server
438+
.health(Request::new(HealthRequest {}))
439+
.await
440+
.expect("health")
441+
.into_inner();
442+
assert!(h.healthy);
443+
let s = server
444+
.stats(Request::new(StatsRequest {}))
445+
.await
446+
.expect("stats")
447+
.into_inner();
448+
assert_eq!(s.total_entities, 0);
449+
let _ = std::fs::remove_file(&path);
450+
}
451+
}
282452
}
283453

284454
// Non-grpc fallback
@@ -297,4 +467,25 @@ pub mod grpc {
297467
) -> Result<(), Box<dyn std::error::Error>> {
298468
Err("gRPC transport not compiled in. Rebuild with: cargo build --features grpc".into())
299469
}
470+
471+
#[cfg(test)]
472+
mod tests {
473+
use super::*;
474+
475+
#[tokio::test]
476+
async fn stub_serve_returns_actionable_error() {
477+
let path = std::env::temp_dir()
478+
.join(format!("mimir-test-grpc-stub-{}.db", uuid::Uuid::new_v4()));
479+
let path_str = path.to_str().unwrap().to_string();
480+
let db = Database::open(&path_str).expect("open test db");
481+
let err = serve(
482+
Arc::new(Mutex::new(db)),
483+
"127.0.0.1:0".parse().unwrap(),
484+
)
485+
.await
486+
.expect_err("stub must refuse to serve");
487+
assert!(err.to_string().contains("--features grpc"));
488+
let _ = std::fs::remove_file(&path_str);
489+
}
490+
}
300491
}

0 commit comments

Comments
 (0)