#![allow(clippy::await_holding_lock)]
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::Path;
use std::sync::Mutex;
use openpaw_tools::web::web_fetch;
use serde_json::json;
static ENV_LOCK: Mutex<()> = Mutex::new(());
const TEST_ALLOW_LOOPBACK: &str = "OPENPAW_WEB_TEST_ALLOW_LOOPBACK";
fn set_gate() {
std::env::set_var(TEST_ALLOW_LOOPBACK, "1");
}
fn clear_gate() {
std::env::remove_var(TEST_ALLOW_LOOPBACK);
}
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner())
}
fn spawn_mock(responses: Vec<String>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
let addr = listener.local_addr().expect("addr").to_string();
std::thread::spawn(move || {
for resp in responses {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 8192];
let header_end = loop {
let n = stream.read(&mut chunk).unwrap_or(0);
if n == 0 {
break buf.len();
}
buf.extend_from_slice(&chunk[..n]);
if let Some(pos) = find_header_end(&buf) {
break pos;
}
};
let body_len = parse_content_length(&buf[..header_end]);
while buf.len() < header_end + body_len {
let n = stream.read(&mut chunk).unwrap_or(0);
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
}
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
});
addr
}
fn find_header_end(buf: &[u8]) -> Option<usize> {
buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
}
fn parse_content_length(headers: &[u8]) -> usize {
let text = String::from_utf8_lossy(headers);
for line in text.lines() {
if let Some(v) = line.to_ascii_lowercase().strip_prefix("content-length:") {
return v.trim().parse().unwrap_or(0);
}
}
0
}
fn http_html(body: &str) -> String {
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
}
fn http_redirect(location: &str) -> String {
format!(
"HTTP/1.1 301 Moved Permanently\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
}
#[tokio::test]
async fn fetch_html_contract_returns_markdown() {
let _guard = lock_env();
set_gate();
let addr = spawn_mock(vec![http_html(
"<html><body><h1>Paw Docs</h1><p>Body text</p></body></html>",
)]);
let res = web_fetch::run(Path::new("."), json!({"url": format!("http://{addr}/")}))
.await
.unwrap();
clear_gate();
assert!(!res.is_error, "fetch should succeed, got: {}", res.content);
assert!(
res.content.contains("# Paw Docs"),
"missing heading, got: {}",
res.content
);
assert!(
res.content.contains("Body text"),
"missing body text, got: {}",
res.content
);
assert!(
!res.content.contains("<h1>"),
"raw html leaked into output, got: {}",
res.content
);
}
#[tokio::test]
async fn ssrf_rejects_loopback_target() {
let _guard = lock_env();
clear_gate();
let res = web_fetch::run(Path::new("."), json!({"url": "http://127.0.0.1/"}))
.await
.unwrap();
clear_gate();
assert!(
res.is_error,
"loopback target must be an error outcome, got: {}",
res.content
);
assert!(
res.content.contains("refused"),
"expected SSRF refusal message, got: {}",
res.content
);
}
#[tokio::test]
async fn bad_url_is_error_outcome() {
let res = web_fetch::run(Path::new("."), json!({"url": "not a valid url"}))
.await
.unwrap();
assert!(
res.is_error,
"bad URL must be an error outcome, got: {}",
res.content
);
assert!(
res.content.contains("invalid URL"),
"expected invalid URL message, got: {}",
res.content
);
}
#[tokio::test]
async fn redirect_is_followed_to_mock_page() {
let _guard = lock_env();
set_gate();
let addr = spawn_mock(vec![
http_redirect("/final"),
http_html("<html><body><h2>Landed</h2></body></html>"),
]);
let res = web_fetch::run(
Path::new("."),
json!({"url": format!("http://{addr}/start")}),
)
.await
.unwrap();
clear_gate();
assert!(
!res.is_error,
"redirect should be followed to the mock page, got: {}",
res.content
);
assert!(
res.content.contains("## Landed"),
"expected markdown of the redirect target page, got: {}",
res.content
);
}