use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use hyper::StatusCode;
use mini_serve::{handler, RouteBuilder, ServeError};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
#[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(())
}
}
async fn serve(builder: RouteBuilder<()>) -> (String, oneshot::Sender<()>) {
let app = builder.seal();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(async move {
app.run(listener, async move {
let _ = rx.await;
})
.await
});
tokio::time::sleep(Duration::from_millis(50)).await;
(addr, tx)
}
fn ok_route(builder: RouteBuilder<()>) -> RouteBuilder<()> {
builder.get(
"/health",
handler(|_req, _state| async {
mini_serve::json(StatusCode::OK, &serde_json::json!({"ok": true}))
}),
)
}
#[tokio::test]
async fn a_served_request_is_logged_with_its_method_path_and_status() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(ok_route(
RouteBuilder::stateless().with_request_logging_to(Box::new(log.clone())),
))
.await;
reqwest::get(format!("http://{addr}/health")).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let line = log.contents();
assert!(line.starts_with("GET /health 200 "), "got: {line:?}");
assert!(
line.trim_end().ends_with("ms"),
"a duration should close the line, got: {line:?}"
);
}
#[tokio::test]
async fn nothing_is_logged_without_the_builder() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(ok_route(RouteBuilder::stateless())).await;
reqwest::get(format!("http://{addr}/health")).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(log.contents(), "");
}
#[tokio::test]
async fn a_5xx_reports_its_internal_message_while_the_client_body_stays_sanitized() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(
RouteBuilder::stateless()
.with_request_logging_to(Box::new(log.clone()))
.get(
"/boom",
handler(|_req, _state| async {
Err(ServeError::new(500, "connection pool exhausted for shard 7"))
}),
),
)
.await;
let response = reqwest::get(format!("http://{addr}/boom")).await.unwrap();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = response.text().await.unwrap();
assert!(
!body.contains("shard 7"),
"the client must not see internals: {body}"
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
log.contents().contains("connection pool exhausted for shard 7"),
"the operator must see them, got: {:?}",
log.contents()
);
}
#[tokio::test]
async fn a_4xx_message_is_not_duplicated_into_the_log() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(
RouteBuilder::stateless()
.with_request_logging_to(Box::new(log.clone()))
.get(
"/bad",
handler(|_req, _state| async {
Err(ServeError::new(400, "malformed cursor token"))
}),
),
)
.await;
reqwest::get(format!("http://{addr}/bad")).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
let contents = log.contents();
assert!(
contents.contains("GET /bad 400"),
"the request line should still be logged, got: {contents:?}"
);
assert!(
!contents.contains("malformed cursor token"),
"a 4xx message reaches the client already, got: {contents:?}"
);
}
#[tokio::test]
async fn a_handler_panic_is_reported() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(
RouteBuilder::stateless()
.with_request_logging_to(Box::new(log.clone()))
.get(
"/panic",
handler(|_req, _state| async {
panic!("handler exploded");
}),
),
)
.await;
let _ = reqwest::get(format!("http://{addr}/panic")).await;
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
log.contents().contains("panicked"),
"a handler panic must reach the sink, got: {:?}",
log.contents()
);
}
#[tokio::test]
async fn the_logged_path_is_the_raw_request_path() {
let log = SharedBuffer::default();
let (addr, _shutdown) = serve(ok_route(
RouteBuilder::stateless().with_request_logging_to(Box::new(log.clone())),
))
.await;
let mut stream = tokio::net::TcpStream::connect(&addr).await.unwrap();
stream
.write_all(b"GET /%2E%2E/etc/passwd HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut sink = Vec::new();
let _ = stream.read_to_end(&mut sink).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
log.contents().contains("/%2E%2E/etc/passwd"),
"the raw, undecoded path should appear, got: {:?}",
log.contents()
);
}