//! End-to-end coverage for the `/img-proxy` external-image proxy (M-UX1). //! //! These drive the REAL handler with the production SSRF-safe HTTP client wired //! (`link_preview_http`), so they exercise routing -> the login gate -> client //! selection -> `fetch_image` -> `validate_url` refusal, not just the unit //! `validate_url` string check. //! //! Scope note: `validate_url` rejects literal private/reserved targets and bad //! ports/schemes *before* any socket is opened, so these cases are fully //! hermetic (no network, deterministic). The connect-time `SsrfSafeResolver` //! layer, which catches a public *hostname* that resolves to a private address, //! including DNS rebinding, cannot be exercised hermetically: it needs real //! DNS, and any loopback mock server is itself refused as private. That layer //! stays covered by its own resolver logic plus the `validate_url` unit tests. use crate::harness::{HarnessOptions, TestHarness}; use axum::http::StatusCode; async fn http_preview_harness() -> TestHarness { TestHarness::with_options(HarnessOptions { link_preview_http: true, ..Default::default() }) .await } #[tokio::test] async fn img_proxy_requires_login() { let mut h = http_preview_harness().await; // Auth is checked before any fetch, so even a well-formed target is rejected. let resp = h.client.get("/img-proxy?u=http://example.com/a.png").await; assert_eq!( resp.status, StatusCode::UNAUTHORIZED, "anonymous /img-proxy must be 401, got {}", resp.status ); } #[tokio::test] async fn img_proxy_refuses_cloud_metadata_ip() { let mut h = http_preview_harness().await; h.login_as("proxyuser_meta").await; let resp = h .client .get("/img-proxy?u=http://169.254.169.254/latest/meta-data/") .await; assert_eq!( resp.status, StatusCode::BAD_GATEWAY, "link-local cloud-metadata IP must be refused (502), got {}", resp.status ); } #[tokio::test] async fn img_proxy_refuses_loopback() { let mut h = http_preview_harness().await; h.login_as("proxyuser_loop").await; let resp = h.client.get("/img-proxy?u=http://127.0.0.1:1/x.png").await; assert_eq!( resp.status, StatusCode::BAD_GATEWAY, "loopback target must be refused (502), got {}", resp.status ); } #[tokio::test] async fn img_proxy_refuses_rfc1918_private_ip() { let mut h = http_preview_harness().await; h.login_as("proxyuser_priv").await; let resp = h.client.get("/img-proxy?u=http://10.0.0.5/x.png").await; assert_eq!( resp.status, StatusCode::BAD_GATEWAY, "RFC1918 private IP must be refused (502), got {}", resp.status ); } #[tokio::test] async fn img_proxy_refuses_non_web_port() { let mut h = http_preview_harness().await; h.login_as("proxyuser_port").await; // Public IP, but a non-web port (Redis), the proxy must not be usable as a // request-forwarder to arbitrary services. let resp = h.client.get("/img-proxy?u=http://1.1.1.1:6379/x").await; assert_eq!( resp.status, StatusCode::BAD_GATEWAY, "non-web port must be refused (502), got {}", resp.status ); } #[tokio::test] async fn img_proxy_refuses_non_http_scheme() { let mut h = http_preview_harness().await; h.login_as("proxyuser_scheme").await; let resp = h.client.get("/img-proxy?u=ftp://example.com/x.png").await; assert_eq!( resp.status, StatusCode::BAD_GATEWAY, "non-http scheme must be refused (502), got {}", resp.status ); }