carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

1//! Drives the router in-process, the way the contract describes it.
2
3use axum::{
4 Router,
5 body::Body,
6 http::{Request, StatusCode, header},
7};
8use http_body_util::BodyExt;
9use todo_rust::{app, db::Db};
10use tower::ServiceExt;
11
12const ORIGIN: &str = "http://127.0.0.1:9";
13
14struct Harness {
15 router: Router,
16 _dir: TempDir,
17}
18
19/// A throwaway directory under $TMPDIR, removed on drop.
20struct TempDir(std::path::PathBuf);
21
22impl Drop for TempDir {
23 fn drop(&mut self) {
24 let _ = std::fs::remove_dir_all(&self.0);
25 }
26}
27
28fn harness() -> Harness {
29 let mut name = [0u8; 8];
30 getrandom::fill(&mut name).unwrap();
31 let dir = std::env::temp_dir().join(format!("todo-rust-test-{:x}", u64::from_ne_bytes(name)));
32 std::fs::create_dir_all(&dir).unwrap();
33 let db = Db::open(&dir.join("test.db")).unwrap();
34 let router = app::router(app::AppState { db, origin: ORIGIN.into(), version: "test" });
35 Harness { router, _dir: TempDir(dir) }
36}
37
38struct Reply {
39 status: StatusCode,
40 location: Option<String>,
41 cookie: Option<String>,
42 body: String,
43}
44
45impl Harness {
46 async fn send(&self, req: Request<Body>) -> Reply {
47 let res = self.router.clone().oneshot(req).await.unwrap();
48 let header = |name| res.headers().get(name).map(|v: &header::HeaderValue| v.to_str().unwrap().to_owned());
49 let location = header(header::LOCATION);
50 let cookie = header(header::SET_COOKIE);
51 let status = res.status();
52 let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
53 Reply { status, location, cookie, body }
54 }
55
56 async fn get(&self, path: &str, cookie: Option<&str>) -> Reply {
57 let mut req = Request::get(path);
58 if let Some(c) = cookie {
59 req = req.header(header::COOKIE, c);
60 }
61 self.send(req.body(Body::empty()).unwrap()).await
62 }
63
64 async fn post(&self, path: &str, cookie: &str, form: &str) -> Reply {
65 let req = Request::post(path)
66 .header(header::COOKIE, cookie)
67 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
68 .body(Body::from(form.to_owned()))
69 .unwrap();
70 self.send(req).await
71 }
72
73 /// A new visitor: their cookie, as a `Cookie` header value.
74 async fn visitor(&self) -> String {
75 let reply = self.get("/", None).await;
76 reply.cookie.unwrap().split(';').next().unwrap().to_owned()
77 }
78}
79
80fn ids(body: &str) -> Vec<String> {
81 body.split("data-id=\"").skip(1).map(|s| s.split('"').next().unwrap().to_owned()).collect()
82}
83
84#[tokio::test]
85async fn first_visit_sets_a_list_cookie() {
86 let h = harness();
87 let reply = h.get("/", None).await;
88 assert_eq!(reply.status, StatusCode::OK);
89 let cookie = reply.cookie.unwrap();
90 assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Lax") && cookie.contains("Path=/"));
91 assert!(!cookie.contains("Secure"), "plain http should not get Secure");
92 let token = cookie.split(';').next().unwrap().split('=').nth(1).unwrap();
93 assert_eq!(token.len(), 32);
94}
95
96#[tokio::test]
97async fn add_trims_and_redirects_to_the_filter() {
98 let h = harness();
99 let me = h.visitor().await;
100 let reply = h.post("/todos", &me, "title=++buy+milk++&filter=active").await;
101 assert_eq!(reply.status, StatusCode::SEE_OTHER);
102 assert_eq!(reply.location.as_deref(), Some("/active"));
103 let page = h.get("/", Some(&me)).await.body;
104 assert!(page.contains("<label>buy milk</label>"));
105 assert!(page.contains("<strong>1</strong> item left"));
106
107 let reply = h.post("/todos", &me, "title=+++&filter=bogus").await;
108 assert_eq!(reply.location.as_deref(), Some("/"));
109 assert_eq!(ids(&h.get("/", Some(&me)).await.body).len(), 1);
110}
111
112#[tokio::test]
113async fn titles_are_escaped_and_kept_exactly() {
114 let h = harness();
115 let me = h.visitor().await;
116 h.post("/todos", &me, "title=%3Cimg+src%3Dx%3E+%26+na%C3%AFve+%F0%9F%9A%80").await;
117 let page = h.get("/", Some(&me)).await.body;
118 assert!(page.contains("<label>&lt;img src=x&gt; &amp; naïve 🚀</label>"));
119 assert!(!page.contains("<img"));
120}
121
122#[tokio::test]
123async fn toggle_toggle_all_and_clear_completed() {
124 let h = harness();
125 let me = h.visitor().await;
126 for t in ["a", "b", "c"] {
127 h.post("/todos", &me, &format!("title={t}")).await;
128 }
129 let id = ids(&h.get("/", Some(&me)).await.body)[0].clone();
130 h.post(&format!("/todos/{id}/toggle"), &me, "filter=all").await;
131 assert_eq!(ids(&h.get("/completed", Some(&me)).await.body), vec![id.clone()]);
132 assert_eq!(ids(&h.get("/active", Some(&me)).await.body).len(), 2);
133
134 h.post("/todos/toggle-all", &me, "filter=all").await;
135 assert_eq!(ids(&h.get("/active", Some(&me)).await.body).len(), 0);
136 h.post("/todos/toggle-all", &me, "filter=all").await;
137 assert_eq!(ids(&h.get("/completed", Some(&me)).await.body).len(), 0);
138
139 h.post(&format!("/todos/{id}/toggle"), &me, "filter=all").await;
140 h.post("/todos/clear-completed", &me, "filter=completed").await;
141 assert_eq!(ids(&h.get("/", Some(&me)).await.body).len(), 2);
142}
143
144#[tokio::test]
145async fn edit_renames_and_empty_title_deletes() {
146 let h = harness();
147 let me = h.visitor().await;
148 h.post("/todos", &me, "title=old").await;
149 let id = ids(&h.get("/", Some(&me)).await.body)[0].clone();
150
151 let page = h.get(&format!("/?edit={id}"), Some(&me)).await.body;
152 assert!(page.contains(&format!("class=\"editing\" data-id=\"{id}\"")));
153 assert!(page.contains(&format!("action=\"/todos/{id}/edit\"")));
154
155 h.post(&format!("/todos/{id}/edit"), &me, "title=+new+&filter=all").await;
156 assert!(h.get("/", Some(&me)).await.body.contains("<label>new</label>"));
157 h.post(&format!("/todos/{id}/edit"), &me, "title=+&filter=all").await;
158 assert!(ids(&h.get("/", Some(&me)).await.body).is_empty());
159}
160
161#[tokio::test]
162async fn another_visitors_id_is_404_and_untouched() {
163 let h = harness();
164 let me = h.visitor().await;
165 let them = h.visitor().await;
166 h.post("/todos", &me, "title=mine").await;
167 let id = ids(&h.get("/", Some(&me)).await.body)[0].clone();
168 for action in ["toggle", "edit", "destroy"] {
169 let reply = h.post(&format!("/todos/{id}/{action}"), &them, "title=x&filter=all").await;
170 assert_eq!(reply.status, StatusCode::NOT_FOUND, "{action}");
171 }
172 assert_eq!(h.post("/todos/nope/toggle", &me, "filter=all").await.status, StatusCode::NOT_FOUND);
173 let page = h.get("/", Some(&me)).await.body;
174 assert!(page.contains("<label>mine</label>") && !page.contains("class=\"completed\""));
175}
176
177#[tokio::test]
178async fn garbled_or_unknown_cookie_gets_a_fresh_list() {
179 let h = harness();
180 for cookie in ["todo_list=%%%", "todo_list=00000000000000000000000000000000", "other=1"] {
181 let reply = h.get("/", Some(cookie)).await;
182 assert_eq!(reply.status, StatusCode::OK);
183 assert!(reply.cookie.is_some(), "{cookie}");
184 assert!(ids(&reply.body).is_empty());
185 }
186}
187
188#[tokio::test]
189async fn foreign_origin_is_403_and_own_origin_is_fine() {
190 let h = harness();
191 let me = h.visitor().await;
192 let post = |origin: &'static str| {
193 Request::post("/todos")
194 .header(header::COOKIE, me.clone())
195 .header(header::ORIGIN, origin)
196 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
197 .body(Body::from("title=x"))
198 .unwrap()
199 };
200 assert_eq!(h.send(post("https://evil.example")).await.status, StatusCode::FORBIDDEN);
201 assert_eq!(h.send(post("null")).await.status, StatusCode::FORBIDDEN);
202 assert!(ids(&h.get("/", Some(&me)).await.body).is_empty());
203 assert_eq!(h.send(post(ORIGIN)).await.status, StatusCode::SEE_OTHER);
204}
205
206#[tokio::test]
207async fn fragment_header_returns_the_list() {
208 let h = harness();
209 let me = h.visitor().await;
210 let req = Request::post("/todos")
211 .header(header::COOKIE, me)
212 .header("X-Todo-Fragment", "1")
213 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
214 .body(Body::from("title=hello&filter=all"))
215 .unwrap();
216 let reply = h.send(req).await;
217 assert_eq!(reply.status, StatusCode::OK);
218 assert!(reply.body.starts_with("<section class=\"main\">"));
219 assert!(reply.body.contains("<label>hello</label>"));
220}
221
222#[tokio::test]
223async fn static_files_health_and_version() {
224 let h = harness();
225 assert_eq!(h.get("/static/base.css", None).await.body, include_str!("../../../shared/base.css"));
226 assert_eq!(h.get("/static/index.css", None).await.body, include_str!("../../../shared/index.css"));
227 assert_eq!(h.get("/healthz", None).await.status, StatusCode::OK);
228 assert_eq!(h.get("/api/version", None).await.body, "test");
229 assert_eq!(h.get("/nope", None).await.status, StatusCode::NOT_FOUND);
230}
231