Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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);
    }
}