//! Integration tests for [`WebResolver`]. //! //! These spin up a tiny mock HTTP server on `127.0.0.1:0` (just like //! the PLC tests do). Because the DID-to-URL builder splits on `:` //! to turn path components into URL segments, encoding `127.0.0.1:PORT` //! in a DID doesn't produce a URL the mock can answer — so the tests //! drive the resolver through its crate-internal `resolve_handle_at_url` //! seam, which takes a URL directly. Production code never calls it; //! tests do, so we can exercise the full HTTP round-trip without TLS. //! //! Each test runs the resolver inside a 2-second timeout so a hung //! connection can't freeze the suite. use at_identity::WebResolver; use reqwest::Client; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; /// Spawn a single-shot mock HTTP server that always responds with /// `status` + `body`, regardless of path. Returns the base URL. async fn mock_server(status: u16, body: &'static [u8]) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { loop { let (mut sock, _) = match listener.accept().await { Ok(p) => p, Err(_) => return, }; tokio::spawn(async move { let mut buf = vec![0u8; 4096]; let _ = sock.read(&mut buf).await; let reason = match status { 200 => "OK", 404 => "Not Found", 500 => "Internal Server Error", _ => "Status", }; let header = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); let _ = sock.write_all(header.as_bytes()).await; if !body.is_empty() { let _ = sock.write_all(body).await; } let _ = sock.shutdown().await; }); } }); format!("http://{addr}") } fn resolver() -> WebResolver { WebResolver { client: Client::builder() .timeout(Duration::from_secs(2)) .build() .unwrap(), scheme: "https".to_string(), } } async fn resolve(r: &WebResolver, base: &str, path: &str) -> anyhow::Result> { let url = format!("{base}{path}"); tokio::time::timeout( Duration::from_secs(2), r.resolve_handle_at_url(&url), ) .await .expect("timed out talking to mock server") } /// Happy path: a DID document with /// `alsoKnownAs: ["at://alice.example.com"]` must yield /// `Some("alice.example.com")`. #[tokio::test] async fn resolve_web_handle_returns_handle_from_alsoKnownAs() { let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://alice.example.com"],"verificationMethod":[]}"#; let base = mock_server(200, body).await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert_eq!(got.as_deref(), Some("alice.example.com")); } /// A 404 from the remote must collapse to `Ok(None)`, never `Err`. #[tokio::test] async fn resolve_web_handle_handles_404() { let base = mock_server(404, b"").await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert!(got.is_none(), "404 must yield None, got {got:?}"); } /// A 2xx with a non-JSON body (think: an HTML error page served by /// a misconfigured reverse proxy) must also collapse to `Ok(None)` /// so the worker doesn't see it as a transient failure. #[tokio::test] async fn resolve_web_handle_handles_invalid_json() { let base = mock_server(200, b"not json").await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert!(got.is_none(), "invalid JSON must yield None, got {got:?}"); } /// A 2xx with valid JSON but no `alsoKnownAs` field must yield /// `Ok(None)` (the document doesn't advertise a handle). #[tokio::test] async fn resolve_web_handle_handles_missing_alsoKnownAs() { let body = br#"{"id":"did:web:example.com","verificationMethod":[]}"#; let base = mock_server(200, body).await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert!(got.is_none(), "missing alsoKnownAs must yield None"); } /// A `alsoKnownAs` array that doesn't contain any `at://` URI must /// yield `Ok(None)`. #[tokio::test] async fn resolve_web_handle_ignores_non_at_uris() { let body = br#"{"id":"did:web:example.com","alsoKnownAs":["https://example.com","mailto:foo"]}"#; let base = mock_server(200, body).await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert!( got.is_none(), "non-at:// entries must not be treated as handles, got {got:?}" ); } /// Multiple `alsoKnownAs` entries: the **first** `at://` wins. This /// matches the PLC client's behavior and the way real-world PDSes /// list their primary handle first. #[tokio::test] async fn resolve_web_handle_picks_first_at_uri() { let body = br#"{"id":"did:web:example.com","alsoKnownAs":["at://first.example.com","at://second.example.com"]}"#; let base = mock_server(200, body).await; let r = resolver(); let got = resolve(&r, &base, "/.well-known/did.json").await.unwrap(); assert_eq!(got.as_deref(), Some("first.example.com")); }