//! Integration tests against a fake m1ddc so no hardware (or Homebrew) is //! needed. The fake is a shell script the test writes per-case; its argv log //! doubles as the assertion surface for what ddcd actually asked the bus. use std::path::PathBuf; use std::time::Duration; use axum::body::Body; use axum::http::{Method, Request, StatusCode, header}; use http_body_util::BodyExt; use tower::ServiceExt; use ddcd::{Config, GUARD_HEADER, app}; struct Fake { dir: tempfile::TempDir, } impl Fake { /// Writes an executable fake m1ddc with the given body. `$LOG` expands to /// a per-fake log file path the script can append argv to. fn new(script_body: &str) -> Self { let dir = tempfile::tempdir().expect("tempdir"); let fake = Self { dir }; fake.write_tool("m1ddc", script_body); fake } /// Adds another fake tool (pmset, caffeinate) sharing the same call log. fn write_tool(&self, name: &str, script_body: &str) { use std::os::unix::fs::PermissionsExt; let log = self.dir.path().join("calls.log"); let path = self.dir.path().join(name); let script = format!( "#!/bin/bash\nLOG={}\nTOOL={}\n{}\n", log.display(), name, script_body ); std::fs::write(&path, script).expect("write fake"); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod"); } fn path(&self) -> PathBuf { self.dir.path().join("m1ddc") } fn calls(&self) -> Vec { std::fs::read_to_string(self.dir.path().join("calls.log")) .unwrap_or_default() .lines() .map(str::to_string) .collect() } /// Generous per-call timeout: tests run in parallel and bash spawn under /// contention can take hundreds of ms. The timeout PATH is exercised by /// wedged_m1ddc_times_out_instead_of_hanging with its own short config. /// No retries by default - retry behavior has dedicated tests. fn config(&self) -> Config { Config { m1ddc_path: self.path(), pmset_path: self.dir.path().join("pmset"), caffeinate_path: self.dir.path().join("caffeinate"), max_luminance: 100, timeout: Duration::from_secs(3), retry_delays: vec![], } } fn config_with_retries(&self) -> Config { Config { retry_delays: vec![Duration::from_millis(10), Duration::from_millis(10)], ..self.config() } } } /// A well-behaved monitor: luminance 43, max 100, logs every call. fn healthy_fake() -> Fake { Fake::new( r#"echo "$@" >> "$LOG" case "$1 $2" in "get luminance") echo 43 ;; "max luminance") echo 100 ;; "set luminance") ;; *) echo "unknown command" ; exit 1 ;; esac"#, ) } fn request(method: Method, uri: &str, body: Option) -> Request { let mut builder = Request::builder() .method(method) .uri(uri) .header(GUARD_HEADER, "1"); let body = match body { Some(json) => { builder = builder.header(header::CONTENT_TYPE, "application/json"); Body::from(json.to_string()) } None => Body::empty(), }; builder.body(body).unwrap() } async fn json_body(response: axum::response::Response) -> serde_json::Value { let bytes = response.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() } #[tokio::test] async fn requests_without_guard_header_are_refused() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot(Request::get("/health").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn cors_preflight_is_refused() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot( Request::builder() .method(Method::OPTIONS) .uri("/brightness") .header(header::ORIGIN, "https://evil.example") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); } #[tokio::test] async fn browser_originating_requests_are_refused_even_with_header() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot( Request::get("/health") .header(GUARD_HEADER, "1") .header(header::ORIGIN, "http://localhost:8377") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn health_reports_binary_presence() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot(request(Method::GET, "/health", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = json_body(response).await; assert_eq!(body["status"], "ok"); assert_eq!(body["m1ddc_present"], true); } #[tokio::test] async fn get_brightness_reads_and_scales() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = json_body(response).await; assert_eq!(body["raw"], 43); assert_eq!(body["max"], 100); assert!((body["brightness"].as_f64().unwrap() - 0.43).abs() < 1e-9); } #[tokio::test] async fn put_brightness_scales_against_max() { let fake = healthy_fake(); let response = app(fake.config()) .oneshot(request( Method::PUT, "/brightness", Some(serde_json::json!({ "brightness": 0.5 })), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); // Pure write: the panel is never asked for max. assert_eq!(fake.calls(), vec!["set luminance 50"]); } #[tokio::test] async fn put_brightness_rejects_out_of_range() { let fake = healthy_fake(); for bad in [-0.1, 1.1, f64::NAN] { let response = app(fake.config()) .oneshot(request( Method::PUT, "/brightness", Some(serde_json::json!({ "brightness": bad })), )) .await .unwrap(); // NaN fails serde deserialization (400); the rest hit our range check (422). assert!( response.status() == StatusCode::UNPROCESSABLE_ENTITY || response.status() == StatusCode::BAD_REQUEST, "brightness {bad} accepted" ); assert!(fake.calls().is_empty(), "bus touched for invalid input {bad}"); } } #[tokio::test] async fn wedged_m1ddc_times_out_instead_of_hanging() { let fake = Fake::new(r#"sleep 30"#); let config = Config { timeout: Duration::from_millis(500), ..fake.config() }; let started = std::time::Instant::now(); let response = app(config) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); assert!( started.elapsed() < Duration::from_secs(5), "timeout did not bound the call" ); } #[tokio::test] async fn m1ddc_failure_surfaces_as_bad_gateway() { let fake = Fake::new(r#"echo "DDC communication failure"; exit 1"#); let response = app(fake.config()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = json_body(response).await; assert!( body["error"].as_str().unwrap().contains("DDC communication failure"), "m1ddc's stdout error text should surface: {body}" ); } #[tokio::test] async fn display_off_calls_pmset_displaysleepnow() { let fake = healthy_fake(); fake.write_tool("pmset", r#"echo "$TOOL $@" >> "$LOG""#); let response = app(fake.config()) .oneshot(request( Method::PUT, "/display", Some(serde_json::json!({ "on": false })), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(fake.calls(), vec!["pmset displaysleepnow"]); } #[tokio::test] async fn display_on_wakes_via_caffeinate_user_activity() { let fake = healthy_fake(); fake.write_tool("caffeinate", r#"echo "$TOOL $@" >> "$LOG""#); let response = app(fake.config()) .oneshot(request( Method::PUT, "/display", Some(serde_json::json!({ "on": true })), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(fake.calls(), vec!["caffeinate -u -t 2"]); } #[tokio::test] async fn display_power_failure_surfaces() { let fake = healthy_fake(); fake.write_tool("pmset", r#"echo "not permitted"; exit 1"#); let response = app(fake.config()) .oneshot(request( Method::PUT, "/display", Some(serde_json::json!({ "on": false })), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } #[tokio::test] async fn corrupted_negative_read_is_rejected_not_served() { // Field data from the kiosk's LG: "luminance: -51 / max 62". A negative // luminance is impossible - serving it would clamp to brightness 0 and // poison the app's restore level. let fake = Fake::new( r#"echo "$@" >> "$LOG" case "$1 $2" in "max luminance") echo 100 ;; "get luminance") echo -51 ;; esac"#, ); let response = app(fake.config()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = json_body(response).await; assert!(body["error"].as_str().unwrap().contains("implausible")); } #[tokio::test] async fn corrupted_read_recovers_via_retry() { let fake = Fake::new( r#"echo "$@" >> "$LOG" case "$1 $2" in "max luminance") echo 100 ;; "get luminance") if [ ! -f "$LOG.once" ]; then touch "$LOG.once"; echo -51; else echo 43; fi ;; esac"#, ); let response = app(fake.config_with_retries()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = json_body(response).await; assert_eq!(body["raw"], 43); } #[tokio::test] async fn reading_above_max_is_rejected_as_corruption() { let fake = Fake::new( r#"echo "$@" >> "$LOG" case "$1 $2" in "max luminance") echo 100 ;; "get luminance") echo 3200 ;; esac"#, ); let response = app(fake.config()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_GATEWAY); } #[tokio::test] async fn transient_failure_is_retried_and_succeeds() { // Fails the first "get luminance" (the AV service just came back from // panel sleep), succeeds on retry. let fake = Fake::new( r#"echo "$@" >> "$LOG" case "$1 $2" in "max luminance") echo 100 ;; "get luminance") if [ ! -f "$LOG.once" ]; then touch "$LOG.once"; echo "DDC no reply"; exit 1; fi echo 43 ;; esac"#, ); let response = app(fake.config_with_retries()) .oneshot(request(Method::GET, "/brightness", None)) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let calls = fake.calls(); assert_eq!( calls.iter().filter(|l| *l == "get luminance").count(), 2, "expected one retry: {calls:?}" ); } #[tokio::test] async fn concurrent_requests_never_interleave_on_the_bus() { // DDC/I2C is single-master: the mutex must serialize transactions. The // fake records begin/end markers; interleaving shows as nested begins. let fake = Fake::new( r#"echo "begin" >> "$LOG" sleep 0.1 echo "end" >> "$LOG" case "$1 $2" in "get luminance") echo 43 ;; "max luminance") echo 100 ;; esac"#, ); let shared_app = app(fake.config()); let (r1, r2) = tokio::join!( shared_app.clone().oneshot(request(Method::GET, "/brightness", None)), shared_app.clone().oneshot(request(Method::GET, "/brightness", None)), ); assert_eq!(r1.unwrap().status(), StatusCode::OK); assert_eq!(r2.unwrap().status(), StatusCode::OK); let calls = fake.calls(); // One bus transaction per GET now - max is configuration, never read. let begins = calls.iter().filter(|l| *l == "begin").count(); assert_eq!(begins, 2, "{calls:?}"); let mut depth = 0i32; for line in &calls { match line.as_str() { "begin" => { depth += 1; assert_eq!(depth, 1, "interleaved DDC transactions: {calls:?}"); } "end" => depth -= 1, _ => {} } } }