/// End-to-end tests that bind a real TCP listener and exercise the HTTP layer. /// /// These tests differ from the router-level `oneshot` tests in that they verify /// the full Axum `serve` path: `TcpListener` binding, HTTP framing, and /// response serialization. Each test spins up a server on an ephemeral port, /// issues a real HTTP request via reqwest, then shuts down. mod common; use std::net::SocketAddr; use tokio::net::TcpListener; use tower::ServiceExt; /// Bind a listener on an ephemeral port, spawn the server in the background, /// and return the bound address as a string. /// /// Uses `into_make_service_with_connect_info` so that the governor rate /// limiter can extract `ConnectInfo` from real TCP connections. async fn bind_and_serve() -> String { let app = common::setup_app().await; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await .unwrap(); }); format!("http://{addr}") } /// Bind a listener on an ephemeral port with auth enabled. Returns the base /// URL and tokens for admin, editor, and viewer users. /// /// Tokens are issued in-process before binding so they work against the same /// app instance served over TCP. async fn bind_and_serve_authed() -> (String, String, String, String) { let (app, admin_token, editor_token, viewer_token) = common::setup_app_with_auth().await; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve( listener, app.into_make_service_with_connect_info::(), ) .await .unwrap(); }); ( format!("http://{addr}"), admin_token, editor_token, viewer_token, ) } #[tokio::test] async fn health_endpoint_responds_over_real_tcp() { let base = bind_and_serve().await; let resp = reqwest::get(format!("{base}/api/v1/health")) .await .expect("health request failed"); assert_eq!(resp.status(), 200); let body: serde_json::Value = resp.json().await.expect("body not JSON"); assert!( body["status"].is_string(), "expected health response to contain 'status' field" ); } #[tokio::test] async fn media_list_responds_over_real_tcp() { // setup_app has authentication_disabled=true; verifies the router serves // real TCP traffic, not just in-process oneshot requests. let base = bind_and_serve().await; let resp = reqwest::get(format!("{base}/api/v1/media")) .await .expect("media list request failed"); assert_eq!(resp.status(), 200); } #[tokio::test] async fn unknown_route_returns_404_over_real_tcp() { let base = bind_and_serve().await; let resp = reqwest::get(format!("{base}/api/v1/nonexistent-route")) .await .expect("request failed"); assert_eq!(resp.status(), 404); } #[tokio::test] async fn authenticated_request_accepted_over_real_tcp() { let (base, _, _, viewer_token) = bind_and_serve_authed().await; let client = reqwest::Client::new(); let resp = client .get(format!("{base}/api/v1/health")) .bearer_auth(&viewer_token) .send() .await .expect("authenticated health request failed"); assert_eq!(resp.status(), 200); } #[tokio::test] async fn invalid_token_rejected_over_real_tcp() { let (base, ..) = bind_and_serve_authed().await; let client = reqwest::Client::new(); let resp = client .get(format!("{base}/api/v1/webhooks")) .bearer_auth("not-a-valid-token") .send() .await .expect("request failed"); assert_eq!(resp.status(), 401); } // In-process cross-checks: verify that RBAC and response shapes are // consistent whether accessed via oneshot or real TCP. #[tokio::test] async fn health_response_body_has_status_field() { let app = common::setup_app().await; let resp = app.oneshot(common::get("/api/v1/health")).await.unwrap(); let status = resp.status(); let body = common::response_body(resp).await; assert_eq!(status, 200); assert!(body["status"].is_string(), "expected status field: {body}"); } #[tokio::test] async fn rbac_enforced_for_write_methods() { let (app, _, editor_token, viewer_token) = common::setup_app_with_auth().await; let _ = common::hash_password("unused"); // exercises hash_password // post_json - unauthenticated login attempt with wrong password let resp = app .clone() .oneshot(common::post_json( "/api/v1/auth/login", r#"{"username":"viewer","password":"wrong"}"#, )) .await .unwrap(); assert_eq!(resp.status(), 401); // get_authed - viewer can reach health let resp = app .clone() .oneshot(common::get_authed("/api/v1/health", &viewer_token)) .await .unwrap(); assert_eq!(resp.status(), 200); // post_json_authed - viewer cannot trigger batch enrich (editor route) let resp = app .clone() .oneshot(common::post_json_authed( "/api/v1/jobs/enrich", r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, &viewer_token, )) .await .unwrap(); assert_eq!(resp.status(), 403); // put_json_authed - viewer cannot update sync device (editor route) let resp = app .clone() .oneshot(common::put_json_authed( "/api/v1/sync/devices/00000000-0000-0000-0000-000000000000", r#"{"name":"device"}"#, &viewer_token, )) .await .unwrap(); assert_eq!(resp.status(), 403); // patch_json_authed - viewer cannot update media (editor route) let resp = app .clone() .oneshot(common::patch_json_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000", r#"{"title":"x"}"#, &viewer_token, )) .await .unwrap(); assert_eq!(resp.status(), 403); // delete_authed - viewer cannot delete media (editor route) let resp = app .clone() .oneshot(common::delete_authed( "/api/v1/media/00000000-0000-0000-0000-000000000000", &viewer_token, )) .await .unwrap(); assert_eq!(resp.status(), 403); // test_addr is exercised by all common request builders above via // extensions_mut().insert(test_addr()); verify it round-trips let addr = common::test_addr(); assert_eq!(addr.0.ip().to_string(), "127.0.0.1"); // editor can access editor routes let resp = app .clone() .oneshot(common::post_json_authed( "/api/v1/jobs/enrich", r#"{"media_ids":["00000000-0000-0000-0000-000000000000"]}"#, &editor_token, )) .await .unwrap(); assert_eq!(resp.status(), 200); }