crates/gunnar-forge/src/testing.rs
//! **A canned-JSON HTTP server on `std::net::TcpListener`, so no test in this
//! crate ever touches a network.**
//!
//! Same pattern as `monetize-cloud-impl/src/testing.rs` and
//! `monetize-pay-impl/src/testing.rs`, which fake UpCloud and Stripe the same
//! way — deliberately the same rather than a new invention, because the pattern
//! has already been argued for once and a second HTTP double in the
//! constellation is a second thing to learn and a second thing to get subtly
//! wrong.
//!
//! # One thing this one adds: **response headers**
//!
//! The monetize doubles only ever needed a status and a body. Every forge in
//! this crate paginates *through headers* — GitHub's `Link`, Forgejo's
//! `X-Total-Count`, GitLab's `X-Next-Page` — and the rate-limit case is a
//! status plus `Retry-After`. A double that could not send a header could not
//! test the half of this crate most likely to be wrong, so [`MockServer::on`]
//! takes them and the writer emits them.
//!
//! # Why a real socket and not a mocked HTTP client
//!
//! Because the thing most likely to be wrong is ureq's behaviour, not ours:
//! how it renders a header, whether it follows a redirect with the
//! `Authorization` header attached, what `ureq::Error::Status` hands back. A
//! trait-object `HttpClient` seam with a fake implementation would test our
//! code against our own assumptions about ureq — which is the shape a measured
//! assertion exists to catch. A loopback socket tests the bytes.
//!
//! # Why every test can assert on what was SENT
//!
//! [`MockServer::recorded`] keeps every request including its headers, which is
//! what `tests/token_never_leaks.rs` uses to prove the negative from both ends:
//! the token appears in exactly one header of the request, and in nothing the
//! crate returns.
use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
/// One request as the server saw it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recorded {
/// `GET`, uppercased.
pub method: String,
/// Path without the query string.
pub path: String,
/// The raw query string, empty if none.
pub query: String,
/// Every request header, in wire order and with wire casing.
pub headers: Vec<(String, String)>,
}
impl Recorded {
/// One request header, case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
/// One query parameter, e.g. `page`.
pub fn param(&self, name: &str) -> Option<&str> {
self.query
.split('&')
.find_map(|kv| kv.strip_prefix(name)?.strip_prefix('='))
}
}
#[derive(Clone, Debug)]
struct Canned {
status: u16,
headers: Vec<(String, String)>,
body: String,
}
#[derive(Default)]
struct State {
routes: HashMap<(String, String), VecDeque<Canned>>,
recorded: Vec<Recorded>,
}
/// A loopback HTTP/1.1 server that replays canned answers. See the module docs.
pub struct MockServer {
addr: SocketAddr,
state: Arc<Mutex<State>>,
stop: Arc<Mutex<bool>>,
thread: Option<JoinHandle<()>>,
}
impl MockServer {
/// Bind `127.0.0.1:0` and start serving.
pub fn start() -> MockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let addr = listener.local_addr().expect("local addr");
let state = Arc::new(Mutex::new(State::default()));
let stop = Arc::new(Mutex::new(false));
let (st, sp) = (Arc::clone(&state), Arc::clone(&stop));
let thread = std::thread::spawn(move || {
for conn in listener.incoming() {
if *sp.lock().unwrap() {
break;
}
if let Ok(stream) = conn {
// A failed read means the client hung up; nothing to answer.
let _ = serve_one(stream, &st);
}
}
});
MockServer {
addr,
state,
stop,
thread: Some(thread),
}
}
/// `http://127.0.0.1:<port>` — hand this to [`crate::Forge`] as its base.
///
/// Plain `http` and not TLS on purpose: a self-signed certificate would
/// mean either a certificate crate in dev-dependencies or turning off
/// verification in ureq, and the second is a switch that must not exist in
/// this crate at all. What is under test is the REST dialect, not rustls.
pub fn url(&self) -> String {
format!("http://{}", self.addr)
}
/// Queue an answer for `(method, path)`, with response headers.
///
/// Queue semantics, as in the monetize doubles: pushing twice for the same
/// route answers the first call with the first entry and every later call
/// with the last one. That is how a pagination test says "page 1, then page
/// 2, then page 3" against one path whose page number is in the *query*.
pub fn on(&self, method: &str, path: &str, status: u16, headers: &[(&str, &str)], body: &str) {
let mut st = self.state.lock().unwrap();
st.routes
.entry((method.to_ascii_uppercase(), path.to_string()))
.or_default()
.push_back(Canned {
status,
headers: headers
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
body: body.to_string(),
});
}
/// [`MockServer::on`] with no response headers.
pub fn on_body(&self, method: &str, path: &str, status: u16, body: &str) {
self.on(method, path, status, &[], body);
}
/// Every request the server saw, in order.
pub fn recorded(&self) -> Vec<Recorded> {
self.state.lock().unwrap().recorded.clone()
}
/// How many requests matched `method` and the bare `path`.
pub fn count(&self, method: &str, path: &str) -> usize {
self.recorded()
.iter()
.filter(|r| r.method.eq_ignore_ascii_case(method) && r.path == path)
.count()
}
}
impl Drop for MockServer {
fn drop(&mut self) {
*self.stop.lock().unwrap() = true;
// Wake the accept loop so it observes the flag.
let _ = TcpStream::connect(self.addr);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn serve_one(stream: TcpStream, state: &Arc<Mutex<State>>) -> std::io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
return Ok(()); // the wake-up connect from Drop
}
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("").to_ascii_uppercase();
let target = parts.next().unwrap_or("/").to_string();
let (path, query) = match target.split_once('?') {
Some((p, q)) => (p.to_string(), q.to_string()),
None => (target.clone(), String::new()),
};
let mut headers = Vec::new();
let mut content_length = 0usize;
loop {
let mut h = String::new();
if reader.read_line(&mut h)? == 0 {
break;
}
let h = h.trim_end_matches(['\r', '\n']);
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
let (k, v) = (k.trim().to_string(), v.trim().to_string());
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
}
headers.push((k, v));
}
}
// Drained but not kept: this crate only ever issues GETs, and a recorded
// request body would be one more place a test could accidentally prove the
// wrong thing about where the token went.
if content_length > 0 {
let mut body = vec![0u8; content_length];
reader.read_exact(&mut body)?;
}
let canned = {
let mut st = state.lock().unwrap();
st.recorded.push(Recorded {
method: method.clone(),
path: path.clone(),
query: query.clone(),
headers,
});
// Full `path?query` first, then the bare path — a test that cares about
// the page number registers the query, one that does not registers the
// path and asserts on `Recorded::param` afterwards.
let full = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let keys = [(method.clone(), full), (method.clone(), path.clone())];
let mut found = None;
for k in keys {
if let Some(q) = st.routes.get_mut(&k) {
found = if q.len() > 1 {
q.pop_front()
} else {
q.front().cloned()
};
if found.is_some() {
break;
}
}
}
found.unwrap_or(Canned {
status: 404,
headers: Vec::new(),
body: format!(r#"{{"message":"no canned answer for {method} {path}"}}"#),
})
};
let mut out = stream;
let reason = match canned.status {
200 => "OK",
204 => "No Content",
301 => "Moved Permanently",
302 => "Found",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
429 => "Too Many Requests",
500 => "Internal Server Error",
502 => "Bad Gateway",
_ => "Status",
};
let mut extra = String::new();
for (k, v) in &canned.headers {
extra.push_str(&format!("{k}: {v}\r\n"));
}
write!(
out,
"HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n{}",
canned.status,
reason,
canned.body.len(),
extra,
canned.body
)?;
out.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replays_headers_and_records_the_request() {
let s = MockServer::start();
s.on(
"GET",
"/x",
200,
&[("Link", "<u?page=2>; rel=\"next\"")],
"[]",
);
let mut c = TcpStream::connect(s.addr).unwrap();
write!(
c,
"GET /x?page=1 HTTP/1.1\r\nHost: h\r\nAuthorization: Bearer t\r\n\r\n"
)
.unwrap();
let mut resp = String::new();
c.read_to_string(&mut resp).unwrap();
assert!(resp.starts_with("HTTP/1.1 200 OK"), "{resp}");
assert!(resp.contains("Link: <u?page=2>; rel=\"next\""), "{resp}");
let r = s.recorded();
assert_eq!(r[0].path, "/x");
assert_eq!(r[0].param("page"), Some("1"));
assert_eq!(r[0].header("authorization"), Some("Bearer t"));
}
#[test]
fn the_queue_pops_then_sticks() {
let s = MockServer::start();
s.on_body("GET", "/y", 200, "first");
s.on_body("GET", "/y", 200, "later");
let call = || {
let mut c = TcpStream::connect(s.addr).unwrap();
write!(c, "GET /y HTTP/1.1\r\nHost: h\r\n\r\n").unwrap();
let mut resp = String::new();
c.read_to_string(&mut resp).unwrap();
resp
};
assert!(call().ends_with("first"));
assert!(call().ends_with("later"));
assert!(call().ends_with("later"));
assert_eq!(s.count("GET", "/y"), 3);
}
}