use std::fs;
use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use mini_static::Server;
use tempfile::TempDir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
#[derive(Clone, Default)]
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
impl SharedBuffer {
fn contents(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn root_with_file() -> TempDir {
let root = TempDir::new().unwrap();
fs::write(root.path().join("test.txt"), b"hello world").unwrap();
root
}
async fn get(port: u16, path: &str) {
let mut stream = TcpStream::connect(format!("127.0.0.1:{port}"))
.await
.unwrap();
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
}
#[tokio::test]
async fn a_served_request_is_logged_with_its_method_path_status_and_size() {
let root = root_with_file();
let log = SharedBuffer::default();
let server = Server::new(root.path())
.unwrap()
.with_request_logging_to(Box::new(log.clone()));
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
get(port, "/test.txt").await;
tokio::time::sleep(Duration::from_millis(50)).await;
let line = log.contents();
assert!(line.starts_with("GET /test.txt 200 11 "), "got: {line:?}");
assert!(
line.trim_end().ends_with("ms"),
"a duration should close the line, got: {line:?}"
);
}
#[tokio::test]
async fn a_miss_is_logged_with_its_404() {
let root = root_with_file();
let log = SharedBuffer::default();
let server = Server::new(root.path())
.unwrap()
.with_request_logging_to(Box::new(log.clone()));
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
get(port, "/nope.txt").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
log.contents().contains("GET /nope.txt 404"),
"got: {:?}",
log.contents()
);
}
#[tokio::test]
async fn the_logged_path_is_the_raw_request_path() {
let root = root_with_file();
let log = SharedBuffer::default();
let server = Server::new(root.path())
.unwrap()
.with_request_logging_to(Box::new(log.clone()));
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
get(port, "/%2E%2E/etc/passwd").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
log.contents().contains("/%2E%2E/etc/passwd"),
"the raw, undecoded path should appear, got: {:?}",
log.contents()
);
}
#[tokio::test]
async fn nothing_is_logged_without_the_builder() {
let root = root_with_file();
let log = SharedBuffer::default();
let server = Server::new(root.path()).unwrap();
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
get(port, "/test.txt").await;
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(log.contents(), "");
}
#[tokio::test]
async fn concurrent_requests_produce_whole_lines() {
let root = root_with_file();
let log = SharedBuffer::default();
let server = Server::new(root.path())
.unwrap()
.with_request_logging_to(Box::new(log.clone()));
let (port, _handle) = server.run_ephemeral().await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let requests: Vec<_> = (0..8)
.map(|_| tokio::spawn(async move { get(port, "/test.txt").await }))
.collect();
for request in requests {
request.await.unwrap();
}
tokio::time::sleep(Duration::from_millis(100)).await;
let contents = log.contents();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 8, "expected 8 lines, got: {contents:?}");
for line in lines {
assert!(
line.starts_with("GET /test.txt 200 11 "),
"line was fragmented: {line:?}"
);
}
}