From 77cb80d78cc19a2b8298da6ee887667ebb42a623 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 9 Jun 2026 14:34:16 -0400 Subject: [PATCH] test(calibrate-serve): HTTP integration tests for the room/enroll endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor the router into build_router() (shared by execute + tests) and add tower-oneshot integration tests (no network/ingest needed): - health + descriptor → 200 - POST /room/train persists the bank; GET /room/state → 200; train with no anchors/enrollment → 400 - path-traversal: /room/state?bank=../../etc/passwd → 404 (sanitized, never reads outside output_dir) - enroll/status empty; /enroll/anchor with an unknown label → 400 CI regression coverage for the endpoints added this session. 18 CLI tests pass. Co-Authored-By: claude-flow --- v2/Cargo.lock | 40 +----- v2/crates/wifi-densepose-cli/Cargo.toml | 1 + .../wifi-densepose-cli/src/calibrate_api.rs | 122 ++++++++++++++++-- 3 files changed, 116 insertions(+), 47 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 0c75d899..e24248fc 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -10847,6 +10847,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tower 0.4.13", "tower-http", "tracing", "tracing-subscriber", @@ -10911,10 +10912,10 @@ dependencies = [ "criterion", "wifi-densepose-bfld", "wifi-densepose-core", - "wifi-densepose-geo 0.1.0", + "wifi-densepose-geo", "wifi-densepose-ruvector", "wifi-densepose-signal", - "wifi-densepose-worldgraph 0.3.0", + "wifi-densepose-worldgraph", ] [[package]] @@ -10929,20 +10930,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "wifi-densepose-geo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092ea59d81e7be76d6d9c2d81628c1dbe768fd77591f0e82dd3c80e2963ff04a" -dependencies = [ - "anyhow", - "chrono", - "reqwest 0.12.28", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "wifi-densepose-hardware" version = "0.3.0" @@ -11204,37 +11191,24 @@ dependencies = [ [[package]] name = "wifi-densepose-worldgraph" -version = "0.3.0" +version = "0.3.1" dependencies = [ "petgraph", "serde", "serde_json", "thiserror 2.0.18", - "wifi-densepose-geo 0.1.0", -] - -[[package]] -name = "wifi-densepose-worldgraph" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ad8df7b323061ed7afae1917dac7eedfbd24a463a668a55a16cde79df067e2" -dependencies = [ - "petgraph", - "serde", - "serde_json", - "thiserror 2.0.18", - "wifi-densepose-geo 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wifi-densepose-geo", ] [[package]] name = "wifi-densepose-worldmodel" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde", "serde_json", "thiserror 2.0.18", "tokio", - "wifi-densepose-worldgraph 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)", + "wifi-densepose-worldgraph", ] [[package]] diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index 5f84d54d..521f00bb 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -72,3 +72,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } assert_cmd = "2.0" predicates = "3.0" tempfile = "3.9" +tower = { workspace = true } diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index 7c7d67c4..a8a57180 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -285,6 +285,25 @@ async fn require_bearer( // Public entry point // --------------------------------------------------------------------------- +/// Build the API router (without the optional auth layer). Shared by `execute` +/// and the integration tests. +fn build_router(state: ApiState) -> Router { + Router::new() + .route("/", get(descriptor)) + .route("/api/v1/calibration/health", get(health)) + .route("/api/v1/calibration/start", post(start)) + .route("/api/v1/calibration/status", get(status_handler)) + .route("/api/v1/calibration/stop", post(stop)) + .route("/api/v1/calibration/result", get(result)) + .route("/api/v1/calibration/baselines", get(baselines)) + .route("/api/v1/room/state", get(room_state)) + .route("/api/v1/room/train", post(train_room)) + .route("/api/v1/enroll/anchor", post(enroll_anchor)) + .route("/api/v1/enroll/status", get(enroll_status)) + .layer(CorsLayer::permissive()) + .with_state(state) +} + /// Run the calibration HTTP API server (blocks until Ctrl-C). pub async fn execute(args: CalibrateServeArgs) -> Result<()> { std::fs::create_dir_all(&args.output_dir) @@ -320,20 +339,7 @@ pub async fn execute(args: CalibrateServeArgs) -> Result<()> { } let state = ApiState { cmd_tx, status, window, fs_hz: 15.0, enroll }; - let mut app = Router::new() - .route("/", get(descriptor)) - .route("/api/v1/calibration/health", get(health)) - .route("/api/v1/calibration/start", post(start)) - .route("/api/v1/calibration/status", get(status_handler)) - .route("/api/v1/calibration/stop", post(stop)) - .route("/api/v1/calibration/result", get(result)) - .route("/api/v1/calibration/baselines", get(baselines)) - .route("/api/v1/room/state", get(room_state)) - .route("/api/v1/room/train", post(train_room)) - .route("/api/v1/enroll/anchor", post(enroll_anchor)) - .route("/api/v1/enroll/status", get(enroll_status)) - .layer(CorsLayer::permissive()) - .with_state(state); + let mut app = build_router(state); // Optional bearer auth — required before any non-loopback exposure. if let Some(token) = args.token.clone() { @@ -1004,4 +1010,92 @@ mod tests { assert_eq!(sanitize_room_id("..\\..\\win"), "win"); assert!(!sanitize_room_id("a/b/c").contains('/')); } + + // ---- HTTP integration tests (router via tower oneshot, no network/ingest) ---- + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; // for `oneshot` + + fn test_state(dir: &str) -> ApiState { + let (cmd_tx, _rx) = mpsc::channel::(8); + let status = Arc::new(RwLock::new(SharedStatus { + output_dir: dir.to_string(), + ..Default::default() + })); + let window = Arc::new(RwLock::new(VecDeque::::new())); + let enroll = Arc::new(RwLock::new(HashMap::::new())); + // Tested handlers never use cmd_tx; drop the receiver. + drop(_rx); + ApiState { cmd_tx, status, window, fs_hz: 15.0, enroll } + } + + async fn req(app: Router, method: &str, uri: &str, body: Option<&str>) -> StatusCode { + let b = body.map(|s| Body::from(s.to_string())).unwrap_or_else(Body::empty); + let r = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json") + .body(b) + .unwrap(); + app.oneshot(r).await.unwrap().status() + } + + #[tokio::test] + async fn health_and_descriptor_ok() { + let dir = tempfile::tempdir().unwrap(); + let app = build_router(test_state(dir.path().to_str().unwrap())); + assert_eq!(req(app.clone(), "GET", "/", None).await, StatusCode::OK); + assert_eq!(req(app, "GET", "/api/v1/calibration/health", None).await, StatusCode::OK); + } + + #[tokio::test] + async fn train_then_state_and_traversal_defense() { + let dir = tempfile::tempdir().unwrap(); + let state = test_state(dir.path().to_str().unwrap()); + // Fill the live window with a 0.3 Hz breathing sine. + { + let mut w = state.window.write().await; + for i in 0..200 { + w.push_back((2.0 * std::f32::consts::PI * 0.3 * i as f32 / 15.0).sin()); + } + } + let app = build_router(state); + + // POST /room/train with two anchors → bank persisted as t.json. + let body = r#"{"room_id":"t","baseline_id":"b","anchors":[ + {"room_id":"t","label":"empty","features":{"mean":1.0,"variance":1.0,"motion":0.1,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}}, + {"room_id":"t","label":"stand_still","features":{"mean":1.0,"variance":10.0,"motion":0.2,"breathing_score":0.0,"breathing_hz":0.0,"heart_score":0.0,"heart_hz":0.0}} + ]}"#; + assert_eq!(req(app.clone(), "POST", "/api/v1/room/train", Some(body)).await, StatusCode::OK); + assert!(dir.path().join("t.json").exists(), "bank file written"); + + // GET /room/state?bank=t → 200 (trained bank loaded, window present). + assert_eq!(req(app.clone(), "GET", "/api/v1/room/state?bank=t", None).await, StatusCode::OK); + + // Path-traversal: ?bank=../../etc/passwd is sanitized → NOT_FOUND, never reads outside dir. + assert_eq!( + req(app.clone(), "GET", "/api/v1/room/state?bank=../../etc/passwd", None).await, + StatusCode::NOT_FOUND + ); + + // Train with no anchors and nothing enrolled → 400. + assert_eq!( + req(app, "POST", "/api/v1/room/train", Some(r#"{"room_id":"none","baseline_id":"b","anchors":[]}"#)).await, + StatusCode::BAD_REQUEST + ); + } + + #[tokio::test] + async fn enroll_status_empty_and_bad_label() { + let dir = tempfile::tempdir().unwrap(); + let app = build_router(test_state(dir.path().to_str().unwrap())); + // No enrollment yet → 200 with next=empty. + assert_eq!(req(app.clone(), "GET", "/api/v1/enroll/status?room=x", None).await, StatusCode::OK); + // Unknown anchor label → 400. + assert_eq!( + req(app, "POST", "/api/v1/enroll/anchor", Some(r#"{"room_id":"x","baseline":"b","label":"nope"}"#)).await, + StatusCode::BAD_REQUEST + ); + } }