mirror of
https://github.com/ruvnet/RuView
synced 2026-07-20 17:03:24 +00:00
test(calibrate-serve): HTTP integration tests for the room/enroll endpoints
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 <ruv@ruv.net>
This commit is contained in:
Generated
+7
-33
@@ -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]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::<CalCommand>(8);
|
||||
let status = Arc::new(RwLock::new(SharedStatus {
|
||||
output_dir: dir.to_string(),
|
||||
..Default::default()
|
||||
}));
|
||||
let window = Arc::new(RwLock::new(VecDeque::<f32>::new()));
|
||||
let enroll = Arc::new(RwLock::new(HashMap::<String, RoomEnroll>::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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user