//! Drives the router in-process, the way the contract describes it. use axum::{ Router, body::Body, http::{Request, StatusCode, header}, }; use http_body_util::BodyExt; use todo_rust::{app, db::Db}; use tower::ServiceExt; const ORIGIN: &str = "http://127.0.0.1:9"; struct Harness { router: Router, _dir: TempDir, } /// A throwaway directory under $TMPDIR, removed on drop. struct TempDir(std::path::PathBuf); impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn harness() -> Harness { let mut name = [0u8; 8]; getrandom::fill(&mut name).unwrap(); let dir = std::env::temp_dir().join(format!("todo-rust-test-{:x}", u64::from_ne_bytes(name))); std::fs::create_dir_all(&dir).unwrap(); let db = Db::open(&dir.join("test.db")).unwrap(); let router = app::router(app::AppState { db, origin: ORIGIN.into(), version: "test" }); Harness { router, _dir: TempDir(dir) } } struct Reply { status: StatusCode, location: Option, cookie: Option, body: String, } impl Harness { async fn send(&self, req: Request) -> Reply { let res = self.router.clone().oneshot(req).await.unwrap(); let header = |name| res.headers().get(name).map(|v: &header::HeaderValue| v.to_str().unwrap().to_owned()); let location = header(header::LOCATION); let cookie = header(header::SET_COOKIE); let status = res.status(); let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); Reply { status, location, cookie, body } } async fn get(&self, path: &str, cookie: Option<&str>) -> Reply { let mut req = Request::get(path); if let Some(c) = cookie { req = req.header(header::COOKIE, c); } self.send(req.body(Body::empty()).unwrap()).await } async fn post(&self, path: &str, cookie: &str, form: &str) -> Reply { let req = Request::post(path) .header(header::COOKIE, cookie) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(form.to_owned())) .unwrap(); self.send(req).await } /// A new visitor: their cookie, as a `Cookie` header value. async fn visitor(&self) -> String { let reply = self.get("/", None).await; reply.cookie.unwrap().split(';').next().unwrap().to_owned() } } fn ids(body: &str) -> Vec { body.split("data-id=\"").skip(1).map(|s| s.split('"').next().unwrap().to_owned()).collect() } #[tokio::test] async fn first_visit_sets_a_list_cookie() { let h = harness(); let reply = h.get("/", None).await; assert_eq!(reply.status, StatusCode::OK); let cookie = reply.cookie.unwrap(); assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Lax") && cookie.contains("Path=/")); assert!(!cookie.contains("Secure"), "plain http should not get Secure"); let token = cookie.split(';').next().unwrap().split('=').nth(1).unwrap(); assert_eq!(token.len(), 32); } #[tokio::test] async fn add_trims_and_redirects_to_the_filter() { let h = harness(); let me = h.visitor().await; let reply = h.post("/todos", &me, "title=++buy+milk++&filter=active").await; assert_eq!(reply.status, StatusCode::SEE_OTHER); assert_eq!(reply.location.as_deref(), Some("/active")); let page = h.get("/", Some(&me)).await.body; assert!(page.contains("")); assert!(page.contains("1 item left")); let reply = h.post("/todos", &me, "title=+++&filter=bogus").await; assert_eq!(reply.location.as_deref(), Some("/")); assert_eq!(ids(&h.get("/", Some(&me)).await.body).len(), 1); } #[tokio::test] async fn titles_are_escaped_and_kept_exactly() { let h = harness(); let me = h.visitor().await; h.post("/todos", &me, "title=%3Cimg+src%3Dx%3E+%26+na%C3%AFve+%F0%9F%9A%80").await; let page = h.get("/", Some(&me)).await.body; assert!(page.contains("")); assert!(!page.contains("new")); h.post(&format!("/todos/{id}/edit"), &me, "title=+&filter=all").await; assert!(ids(&h.get("/", Some(&me)).await.body).is_empty()); } #[tokio::test] async fn another_visitors_id_is_404_and_untouched() { let h = harness(); let me = h.visitor().await; let them = h.visitor().await; h.post("/todos", &me, "title=mine").await; let id = ids(&h.get("/", Some(&me)).await.body)[0].clone(); for action in ["toggle", "edit", "destroy"] { let reply = h.post(&format!("/todos/{id}/{action}"), &them, "title=x&filter=all").await; assert_eq!(reply.status, StatusCode::NOT_FOUND, "{action}"); } assert_eq!(h.post("/todos/nope/toggle", &me, "filter=all").await.status, StatusCode::NOT_FOUND); let page = h.get("/", Some(&me)).await.body; assert!(page.contains("") && !page.contains("class=\"completed\"")); } #[tokio::test] async fn garbled_or_unknown_cookie_gets_a_fresh_list() { let h = harness(); for cookie in ["todo_list=%%%", "todo_list=00000000000000000000000000000000", "other=1"] { let reply = h.get("/", Some(cookie)).await; assert_eq!(reply.status, StatusCode::OK); assert!(reply.cookie.is_some(), "{cookie}"); assert!(ids(&reply.body).is_empty()); } } #[tokio::test] async fn foreign_origin_is_403_and_own_origin_is_fine() { let h = harness(); let me = h.visitor().await; let post = |origin: &'static str| { Request::post("/todos") .header(header::COOKIE, me.clone()) .header(header::ORIGIN, origin) .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("title=x")) .unwrap() }; assert_eq!(h.send(post("https://evil.example")).await.status, StatusCode::FORBIDDEN); assert_eq!(h.send(post("null")).await.status, StatusCode::FORBIDDEN); assert!(ids(&h.get("/", Some(&me)).await.body).is_empty()); assert_eq!(h.send(post(ORIGIN)).await.status, StatusCode::SEE_OTHER); } #[tokio::test] async fn fragment_header_returns_the_list() { let h = harness(); let me = h.visitor().await; let req = Request::post("/todos") .header(header::COOKIE, me) .header("X-Todo-Fragment", "1") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from("title=hello&filter=all")) .unwrap(); let reply = h.send(req).await; assert_eq!(reply.status, StatusCode::OK); assert!(reply.body.starts_with("
")); assert!(reply.body.contains("")); } #[tokio::test] async fn static_files_health_and_version() { let h = harness(); assert_eq!(h.get("/static/base.css", None).await.body, include_str!("../../../shared/base.css")); assert_eq!(h.get("/static/index.css", None).await.body, include_str!("../../../shared/index.css")); assert_eq!(h.get("/healthz", None).await.status, StatusCode::OK); assert_eq!(h.get("/api/version", None).await.body, "test"); assert_eq!(h.get("/nope", None).await.status, StatusCode::NOT_FOUND); }