synheart-sensor-agent 0.4.0

Privacy-first PC background sensor for behavioral research
Documentation
//! Integration tests for the sensor-agent HTTP server

#[cfg(feature = "server")]
mod server_tests {
    use std::path::PathBuf;
    use std::time::Duration;
    use synheart_sensor_agent::server::{run, ServerConfig};

    fn test_state_dir() -> PathBuf {
        std::env::temp_dir().join("synheart-server-test")
    }

    #[tokio::test]
    async fn test_health_endpoint() {
        let config = ServerConfig::new(0, test_state_dir());

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let client = reqwest::Client::new();
        let response = client
            .get(format!("http://{addr}/health"))
            .send()
            .await
            .expect("Failed to send request");

        assert!(response.status().is_success());

        let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
        assert_eq!(body["status"], "ok");
        assert!(body["version"].as_str().is_some());

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_collect_endpoint_accepts_json() {
        let config = ServerConfig::new(0, test_state_dir());

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let payload = serde_json::json!({
            "session": {
                "session_id": "TEST-123",
                "device_id": "chrome-test",
                "events": [
                    { "timestamp": "2024-01-22T10:00:01Z", "event_type": "typing" }
                ]
            }
        });

        let client = reqwest::Client::new();
        let response = client
            .post(format!("http://{addr}/collect"))
            .header("Content-Type", "application/json")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");

        assert!(response.status().is_success());
        let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
        assert_eq!(body["status"], "ok");

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_collect_rejects_non_object() {
        let config = ServerConfig::new(0, test_state_dir());

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let payload = serde_json::json!({ "session": "not-an-object" });

        let client = reqwest::Client::new();
        let response = client
            .post(format!("http://{addr}/collect"))
            .header("Content-Type", "application/json")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");

        assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_collect_requires_token_when_configured() {
        let config = ServerConfig::new(0, test_state_dir()).with_token(Some("s3cret".to_string()));

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let payload = serde_json::json!({ "session": { "events": [] } });
        let client = reqwest::Client::new();

        // No token -> 401
        let unauthorized = client
            .post(format!("http://{addr}/collect"))
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");
        assert_eq!(unauthorized.status(), reqwest::StatusCode::UNAUTHORIZED);

        // Wrong token -> 401
        let wrong = client
            .post(format!("http://{addr}/collect"))
            .header("Authorization", "Bearer nope")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");
        assert_eq!(wrong.status(), reqwest::StatusCode::UNAUTHORIZED);

        // Correct token -> 200
        let ok = client
            .post(format!("http://{addr}/collect"))
            .header("Authorization", "Bearer s3cret")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");
        assert!(ok.status().is_success());

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_collect_rejects_oversized_body() {
        let config = ServerConfig::new(0, test_state_dir());

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        // A body larger than the 256 KiB limit must be rejected (413), not buffered.
        let big = "a".repeat(512 * 1024);
        let payload = serde_json::json!({ "session": { "blob": big } });

        let client = reqwest::Client::new();
        let response = client
            .post(format!("http://{addr}/collect"))
            .header("Content-Type", "application/json")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");

        assert_eq!(response.status(), reqwest::StatusCode::PAYLOAD_TOO_LARGE);

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_token_same_length_mismatch_is_rejected() {
        // Exercises the constant-time comparison: an equal-length but different
        // token, and a correct-prefix token, must both be rejected.
        let config = ServerConfig::new(0, test_state_dir()).with_token(Some("s3cret".to_string()));

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let payload = serde_json::json!({ "session": { "events": [] } });
        let client = reqwest::Client::new();

        // Same length, different content -> 401
        let same_len = client
            .post(format!("http://{addr}/collect"))
            .header("Authorization", "Bearer s3cseT")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");
        assert_eq!(same_len.status(), reqwest::StatusCode::UNAUTHORIZED);

        // Correct prefix, shorter -> 401
        let prefix = client
            .post(format!("http://{addr}/collect"))
            .header("Authorization", "Bearer s3cre")
            .json(&payload)
            .send()
            .await
            .expect("Failed to send request");
        assert_eq!(prefix.status(), reqwest::StatusCode::UNAUTHORIZED);

        let _ = shutdown_tx.send(());
    }

    #[tokio::test]
    async fn test_cors_headers() {
        let config = ServerConfig::new(0, test_state_dir());

        let (addr, shutdown_tx) = run(config).await.expect("Failed to start server");
        tokio::time::sleep(Duration::from_millis(100)).await;

        let client = reqwest::Client::new();
        let response = client
            .request(reqwest::Method::OPTIONS, format!("http://{addr}/collect"))
            .header("Origin", "http://localhost")
            .header("Access-Control-Request-Method", "POST")
            .send()
            .await
            .expect("Failed to send request");

        assert!(
            response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT,
            "CORS preflight failed: {}",
            response.status()
        );

        let _ = shutdown_tx.send(());
    }
}