Skip to main content

max / quasi

19.8 KB · 598 lines History Blame Raw
1 //! The decoding and response rules, tested without a host around them.
2 //!
3 //! Everything here would otherwise be tested twice, once through axum and once
4 //! through a Tauri protocol handler, and the two copies would drift. What is
5 //! left in each host's own tests is the part only that host has: its mounting,
6 //! its body reading and its blocking hop.
7
8 use quasi_router::{Action, Class, Node, RegionKind, Response, RouteError, Screen, Slot};
9
10 use super::{DEFAULT_BODY_LIMIT, Refusal, Serves, decode, refuse, respond};
11
12 /// A renderer that says what it was handed, so a test can read it back.
13 struct Spy;
14
15 impl Serves for Spy {
16 fn screen(&self, screen: &Screen) -> String {
17 format!("screen:{}", screen.title)
18 }
19
20 fn fragment(&self, node: &Node) -> String {
21 match node {
22 Node::Text { text, .. } => format!("text:{text}"),
23 Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
24 other => format!("other:{other:?}"),
25 }
26 }
27
28 fn invalidated(&self, region: &str, node: &Node) -> String {
29 format!("[oob:{region}:{}]", self.fragment(node))
30 }
31 }
32
33 /// Decode a request built from its parts.
34 fn read(
35 method: &str,
36 uri: &str,
37 content_type: Option<&str>,
38 body: &str,
39 ) -> Result<super::Incoming, Refusal> {
40 let mut builder = http::Request::builder().method(method).uri(uri);
41 if let Some(kind) = content_type {
42 builder = builder.header(http::header::CONTENT_TYPE, kind);
43 }
44 let request = builder.body(()).unwrap();
45 decode(
46 request.method(),
47 request.uri(),
48 request.headers(),
49 body.as_bytes(),
50 DEFAULT_BODY_LIMIT,
51 )
52 }
53
54 /// A form POST, which is the shape every action arrives in.
55 fn form(uri: &str, body: &str) -> Result<super::Incoming, Refusal> {
56 read("POST", uri, Some("application/x-www-form-urlencoded"), body)
57 }
58
59 /// Everything that arrived, flattened, so an assertion reads like the wire did.
60 fn joined(incoming: &super::Incoming) -> String {
61 incoming
62 .payload
63 .iter()
64 .chain(incoming.carried.iter())
65 .map(|(k, v)| format!("{k}={v}"))
66 .collect::<Vec<_>>()
67 .join(",")
68 }
69
70 /// What was asked, for a `respond` that has to decide whether the answer is a
71 /// place. Built through `decode` rather than by hand, so the URL a push carries
72 /// is the one a real request would have produced.
73 fn asked(method: &str, uri: &str) -> super::Asked {
74 super::Asked::new(&read(method, uri, None, "").unwrap())
75 }
76
77 /// The body of a response, as text.
78 fn text(response: &http::Response<Vec<u8>>) -> String {
79 String::from_utf8(response.body().clone()).unwrap()
80 }
81
82 #[test]
83 fn a_path_arrives_with_no_scheme_or_host_on_it() {
84 // The spike's finding is what makes this one assertion enough for every
85 // platform: wry reverts the Windows workaround before the handler is
86 // called, so a custom-protocol request is `<scheme>://localhost/<path>`
87 // everywhere and `Uri::path` is the whole address either way.
88 let incoming = read("GET", "quasi://localhost/task/7", None, "").unwrap();
89 assert_eq!(incoming.path, "/task/7");
90
91 let hosted = read("GET", "/task/7", None, "").unwrap();
92 assert_eq!(hosted.path, "/task/7");
93 }
94
95 #[test]
96 fn a_query_string_is_percent_decoded() {
97 let incoming = read("GET", "/task/7?note=a%20b&flag=1", None, "").unwrap();
98 assert_eq!(joined(&incoming), "note=a b,flag=1");
99 }
100
101 #[test]
102 fn a_form_body_is_decoded_on_a_post() {
103 let incoming = form("/task/7/edit", "title=new+title").unwrap();
104 assert_eq!(joined(&incoming), "title=new title");
105 }
106
107 #[test]
108 fn a_form_field_and_a_query_argument_of_the_same_name_stay_apart() {
109 // They used to be merged, form first, so this read `from-form` and the
110 // other value was unreachable. That merge is what let a screen's filter and
111 // a write about the same noun mean one name between them. Now the body is
112 // what the control sent and the query is the view it was sent from, and a
113 // handler asks for the one it means.
114 let incoming = form("/task/7/edit?title=from-query", "title=from-form").unwrap();
115 assert_eq!(incoming.payload.get("title"), Some("from-form"));
116 assert_eq!(incoming.carried.get("title"), Some("from-query"));
117 }
118
119 #[test]
120 fn repeated_names_all_survive() {
121 let incoming = form("/tags", "tag=rust&tag=router&tag=quasi").unwrap();
122 assert_eq!(
123 incoming.payload.get_all("tag").collect::<Vec<_>>(),
124 ["rust", "router", "quasi"]
125 );
126 }
127
128 #[test]
129 fn a_get_never_reads_a_body_even_when_one_is_sent() {
130 // A safe verb with a body is either a confused client or a smuggling
131 // attempt, and quasi has no route that would want it either way.
132 let incoming = read(
133 "GET",
134 "/task/7",
135 Some("application/x-www-form-urlencoded"),
136 "title=ignored",
137 )
138 .unwrap();
139 assert!(incoming.payload.is_empty());
140 }
141
142 #[test]
143 fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() {
144 let incoming = read(
145 "POST",
146 "/task/7/edit",
147 Some("multipart/form-data; boundary=xyz"),
148 "--xyz--",
149 )
150 .unwrap();
151 assert!(incoming.payload.is_empty());
152 }
153
154 #[test]
155 fn a_charset_on_the_form_content_type_still_reads_as_a_form() {
156 let incoming = read(
157 "POST",
158 "/task/7/edit",
159 Some("application/x-www-form-urlencoded; charset=utf-8"),
160 "title=ok",
161 )
162 .unwrap();
163 assert_eq!(incoming.payload.get("title"), Some("ok"));
164 }
165
166 #[test]
167 fn a_verb_the_description_layer_lacks_is_refused() {
168 // PATCH is the one left. DELETE and PUT arrived with `61e1b069`, because a
169 // public server's verbs are part of its interface and a description that
170 // cannot name them cannot address it.
171 assert_eq!(read("PATCH", "/task/7", None, ""), Err(Refusal::Method));
172 assert_eq!(read("HEAD", "/task/7", None, ""), Err(Refusal::Method));
173
174 // And the two that arrived decode, rather than being accepted and then
175 // silently read as a POST.
176 assert_eq!(
177 read("DELETE", "/task/7", None, "").unwrap().method.as_str(),
178 "DELETE"
179 );
180 assert_eq!(
181 read("PUT", "/task/7", None, "").unwrap().method.as_str(),
182 "PUT"
183 );
184 }
185
186 #[test]
187 fn an_oversized_form_is_refused_before_it_is_parsed() {
188 let request = http::Request::builder()
189 .method("POST")
190 .uri("/tags")
191 .header(
192 http::header::CONTENT_TYPE,
193 "application/x-www-form-urlencoded",
194 )
195 .body(())
196 .unwrap();
197 let body = "tag=".to_owned() + &"x".repeat(1024);
198 let outcome = decode(
199 request.method(),
200 request.uri(),
201 request.headers(),
202 body.as_bytes(),
203 16,
204 );
205 assert_eq!(outcome, Err(Refusal::TooLarge));
206 }
207
208 #[test]
209 fn a_form_body_that_is_not_utf8_is_refused() {
210 let request = http::Request::builder()
211 .method("POST")
212 .uri("/tags")
213 .header(
214 http::header::CONTENT_TYPE,
215 "application/x-www-form-urlencoded",
216 )
217 .body(())
218 .unwrap();
219 let outcome = decode(
220 request.method(),
221 request.uri(),
222 request.headers(),
223 &[0xff, 0xfe],
224 DEFAULT_BODY_LIMIT,
225 );
226 assert_eq!(outcome, Err(Refusal::Malformed));
227 }
228
229 #[test]
230 fn a_refusal_of_the_verb_says_which_verbs_there_are() {
231 let response = refuse(Refusal::Method);
232 assert_eq!(response.status(), 405);
233 assert_eq!(
234 response.headers().get(http::header::ALLOW).unwrap(),
235 // Exactly what `translate` accepts, or the header promises a verb the
236 // decoder refuses.
237 "GET, POST, DELETE, PUT"
238 );
239 assert!(response.body().is_empty());
240 }
241
242 #[test]
243 fn a_screen_is_served_whole_and_names_no_target() {
244 let screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
245 let response = respond(&Spy, Ok(screen.into()), &asked("GET", "/"));
246 assert_eq!(response.status(), 200);
247 assert_eq!(text(&response), "screen:Home");
248 assert!(response.headers().get(super::htmx::RETARGET).is_none());
249 }
250
251 #[test]
252 fn a_fragment_carries_the_region_it_replaces() {
253 let answer = Response::fragment("detail", Node::text("hello"));
254 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
255 assert_eq!(text(&response), "text:hello");
256 // A slot id becomes a CSS selector, which is what htmx wants.
257 assert_eq!(
258 response.headers().get(super::htmx::RETARGET).unwrap(),
259 "#detail"
260 );
261 }
262
263 #[test]
264 fn a_redirect_names_where_it_goes_and_carries_no_body() {
265 // The finding this closes (`80afd652`): deleting the thing a screen is
266 // about used to answer with a tombstone, because every response was content.
267 let answer = Response::goto(Action::get("/tasks"));
268 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
269 assert_eq!(response.status(), 200);
270 assert!(response.body().is_empty());
271 assert_eq!(
272 response.headers().get(super::htmx::LOCATION).unwrap(),
273 "/tasks"
274 );
275 // Not a fragment, so nothing is being replaced in place.
276 assert!(response.headers().get(super::htmx::RETARGET).is_none());
277 }
278
279 #[test]
280 fn a_redirect_keeps_its_params_rather_than_dropping_the_filter() {
281 // Back to a filtered list is a different place from back to the list.
282 let answer = Response::goto(Action::get("/tasks").with("status", "open"));
283 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
284 assert_eq!(
285 response.headers().get(super::htmx::LOCATION).unwrap(),
286 "/tasks?status=open"
287 );
288 }
289
290 #[test]
291 fn a_param_that_needs_encoding_is_encoded_once() {
292 let answer = Response::goto(Action::get("/tasks").with("q", "a b&c"));
293 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
294 assert_eq!(
295 response.headers().get(super::htmx::LOCATION).unwrap(),
296 "/tasks?q=a+b%26c"
297 );
298 }
299
300 #[test]
301 fn leaving_the_app_is_a_different_header_from_going_somewhere_in_it() {
302 // `844b5ae0`'s opening half. An external address cannot be a swap, because
303 // nothing comes back from it.
304 let answer = Response::goto(Action::external("file:///home/max/notes.pdf"));
305 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
306 assert_eq!(
307 response.headers().get(super::htmx::REDIRECT).unwrap(),
308 "file:///home/max/notes.pdf"
309 );
310 assert!(response.headers().get(super::htmx::LOCATION).is_none());
311 }
312
313 #[test]
314 fn a_notice_rides_beside_the_content_rather_than_replacing_it() {
315 // `a92ecb1e`. The fragment still lands; the message is a header, so it is
316 // not mistaken for the region's new contents.
317 let answer = Response::fragment("detail", Node::text("hello"))
318 .toast(quasi_router::layout::Tone::Success, "Saved");
319 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
320 assert_eq!(text(&response), "text:hello");
321 assert_eq!(
322 response.headers().get(super::htmx::RETARGET).unwrap(),
323 "#detail"
324 );
325 assert_eq!(
326 response.headers().get(super::htmx::TRIGGER).unwrap(),
327 r#"{"quasi:notice":{"kind":"toast","tone":"success","text":"Saved"}}"#
328 );
329 }
330
331 #[test]
332 fn a_notice_survives_a_redirect_which_has_no_body_to_put_one_in() {
333 // The composition the two findings were filed apart from each other and
334 // could not express: a delete both goes elsewhere and says it is gone.
335 let answer =
336 Response::goto(Action::get("/tasks")).toast(quasi_router::layout::Tone::Success, "Deleted");
337 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
338 assert!(response.body().is_empty());
339 assert_eq!(
340 response.headers().get(super::htmx::LOCATION).unwrap(),
341 "/tasks"
342 );
343 assert!(
344 response
345 .headers()
346 .get(super::htmx::TRIGGER)
347 .unwrap()
348 .to_str()
349 .unwrap()
350 .contains(r#""text":"Deleted""#)
351 );
352 }
353
354 #[test]
355 fn a_response_that_says_nothing_sets_no_trigger() {
356 let response = respond(
357 &Spy,
358 Ok(Response::fragment("detail", Node::text("hello"))),
359 &asked("GET", "/"),
360 );
361 assert!(response.headers().get(super::htmx::TRIGGER).is_none());
362 }
363
364 #[test]
365 fn a_banner_and_a_toast_are_told_apart_at_the_boundary() {
366 // They are dismissed differently, so a client that cannot tell them apart
367 // shows a permanent error as a message that vanishes.
368 let banner = respond(
369 &Spy,
370 Ok(Response::fragment("detail", Node::text("x"))
371 .banner(quasi_router::layout::Tone::Danger, "Sync is down")),
372 &asked("GET", "/"),
373 );
374 assert!(
375 banner
376 .headers()
377 .get(super::htmx::TRIGGER)
378 .unwrap()
379 .to_str()
380 .unwrap()
381 .contains(r#""kind":"banner""#)
382 );
383 }
384
385 #[test]
386 fn every_class_becomes_its_status_with_the_notice_as_the_body() {
387 for (error, status) in [
388 (RouteError::denied("not yours"), 403),
389 (RouteError::new(Class::NotFound, "gone"), 404),
390 (RouteError::internal("our fault"), 500),
391 ] {
392 let expected = format!("notice:{:?}:{}", error.tone(), error.message);
393 let response = respond(&Spy, Err(error), &asked("GET", "/"));
394 assert_eq!(response.status(), status);
395 assert_eq!(text(&response), expected);
396 }
397 }
398
399 #[test]
400 fn an_error_body_is_sent_rather_than_left_to_the_status() {
401 // htmx will drop it unless the page carries `htmx::CONFIG_META`, which is
402 // exactly why that constant is not optional.
403 let response = respond(
404 &Spy,
405 Err(RouteError::denied("not yours")),
406 &asked("GET", "/"),
407 );
408 assert!(!response.body().is_empty());
409 }
410
411 #[test]
412 fn the_content_type_is_the_renderers_answer() {
413 struct Json;
414 impl Serves for Json {
415 fn screen(&self, _: &Screen) -> String {
416 "{}".to_owned()
417 }
418 fn fragment(&self, _: &Node) -> String {
419 "{}".to_owned()
420 }
421 fn content_type(&self) -> &'static str {
422 "application/json"
423 }
424 }
425
426 let screen = Screen::sidebar_content("Home");
427 let response = respond(&Json, Ok(screen.into()), &asked("GET", "/"));
428 assert_eq!(
429 response.headers().get(http::header::CONTENT_TYPE).unwrap(),
430 "application/json"
431 );
432 }
433
434 /// The two history headers on a response, for the assertions below.
435 fn history(response: &http::Response<Vec<u8>>) -> (Option<&str>, Option<&str>) {
436 let get = |name| {
437 response
438 .headers()
439 .get(name)
440 .map(|value| value.to_str().unwrap())
441 };
442 (get(super::htmx::PUSH_URL), get(super::htmx::REPLACE_URL))
443 }
444
445 #[test]
446 fn a_read_of_a_screen_is_a_place_and_carries_the_address_it_was_read_from() {
447 // The common case, and it costs a description nothing: the address is the
448 // request's own, so no control had to predict what its answer would be.
449 let screen = Screen::sidebar_content("Projects");
450 let response = respond(
451 &Spy,
452 Ok(screen.into()),
453 &asked("GET", "/projects?sort=name"),
454 );
455
456 // Params included. A filtered list is a different place from the list.
457 assert_eq!(history(&response).0, Some("/projects?sort=name"));
458 assert_eq!(history(&response).1, None);
459 }
460
461 #[test]
462 fn a_write_answering_with_a_screen_is_not_a_place() {
463 // The address is where the form was. Coming back to it should not re-offer
464 // the write's result as a page.
465 let screen = Screen::sidebar_content("Saved");
466 let response = respond(&Spy, Ok(screen.into()), &asked("POST", "/projects/7"));
467 assert_eq!(history(&response), (None, None));
468 }
469
470 #[test]
471 fn a_fragment_is_not_a_place_unless_it_says_so() {
472 let plain = respond(
473 &Spy,
474 Ok(Response::fragment("detail", Node::text("hello"))),
475 &asked("GET", "/projects/7/tab/files"),
476 );
477 assert_eq!(history(&plain), (None, None));
478
479 // The addressable tab panel: the answer is a fragment and a place, and the
480 // address is not the route that was fetched. 32 of the server's panels.
481 let addressed = respond(
482 &Spy,
483 Ok(Response::fragment("tab-content", Node::text("hello")).at("/dashboard#tab-projects")),
484 &asked("GET", "/projects/7/tab/files"),
485 );
486 assert_eq!(history(&addressed).0, Some("/dashboard#tab-projects"));
487 }
488
489 #[test]
490 fn the_override_can_say_no_as_well_as_yes() {
491 let screen = Screen::sidebar_content("Transient");
492
493 // A read of a screen that should not come back on the back button.
494 let suppressed = respond(
495 &Spy,
496 Ok(Response::screen(screen.clone()).in_place()),
497 &asked("GET", "/wizard/step-2"),
498 );
499 assert_eq!(history(&suppressed), (None, None));
500
501 // And a place that takes the current entry's slot rather than adding one.
502 let replaced = respond(
503 &Spy,
504 Ok(Response::screen(screen).replacing("/projects?sort=age")),
505 &asked("GET", "/projects"),
506 );
507 assert_eq!(history(&replaced).1, Some("/projects?sort=age"));
508 assert_eq!(history(&replaced).0, None);
509 }
510
511 #[test]
512 fn a_redirect_sets_neither_because_htmx_already_pushes() {
513 // HX-Location issues the request client-side and htmx pushes for it, and
514 // HX-Redirect is a real navigation. A second answer here would be two
515 // parties deciding one thing.
516 let response = respond(
517 &Spy,
518 Ok(Response::goto(Action::get("/projects"))),
519 &asked("POST", "/projects/7/delete"),
520 );
521 assert_eq!(history(&response), (None, None));
522 }
523
524 #[test]
525 fn a_write_naming_two_slots_carries_both_behind_the_one_it_replaced() {
526 // The row the write was aimed at, and the count above it that also moved.
527 // Both on one answer, in the order the router named them, so the two can
528 // never disagree and no second request is made for a fact it already had.
529 let response = respond(
530 &Spy,
531 Ok(Response::fragment("row-7", Node::text("Done"))
532 .also("task-count", Node::text("4 left"))
533 .also("sidebar-badge", Node::text("4"))),
534 &asked("POST", "/tasks/7/done"),
535 );
536
537 let body = String::from_utf8(response.body().clone()).expect("the spy answers text");
538 assert_eq!(
539 body,
540 "text:Done[oob:task-count:text:4 left][oob:sidebar-badge:text:4]"
541 );
542
543 // The retarget still names one target. A set of invalidations is a
544 // different fact, and conflating them was the rejected option.
545 assert_eq!(
546 response
547 .headers()
548 .get(super::htmx::RETARGET)
549 .map(|value| value.to_str().expect("a slot id is ascii")),
550 Some("#row-7")
551 );
552 }
553
554 #[test]
555 fn a_whole_screen_and_a_redirect_carry_no_out_of_band_copy() {
556 // Not a case being dropped. A screen replaces every slot already, so an
557 // out-of-band copy would put a second element into the document under an
558 // id it now has twice; a redirect has no body at all.
559 let screen = Screen::sidebar_content("Tasks").with(Slot::new("main", RegionKind::Pane));
560 let swapped = respond(
561 &Spy,
562 Ok(Response::screen(screen).also("task-count", Node::text("4 left"))),
563 &asked("GET", "/tasks"),
564 );
565 let body = String::from_utf8(swapped.body().clone()).expect("the spy answers text");
566 assert_eq!(body, "screen:Tasks");
567
568 let sent = respond(
569 &Spy,
570 Ok(Response::goto(Action::get("/tasks")).also("task-count", Node::text("4 left"))),
571 &asked("POST", "/tasks/7/delete"),
572 );
573 assert!(sent.body().is_empty());
574 }
575
576 #[test]
577 fn a_renderer_that_does_not_answer_markup_drops_an_invalidation() {
578 // The default on `Serves::invalidated`, which is the honest answer for a
579 // renderer with no out-of-band channel rather than an oversight: a JSON
580 // client learns what changed from the payload it already parses.
581 struct Json;
582 impl Serves for Json {
583 fn screen(&self, _: &Screen) -> String {
584 "{}".to_owned()
585 }
586 fn fragment(&self, _: &Node) -> String {
587 "{}".to_owned()
588 }
589 }
590
591 let response = respond(
592 &Json,
593 Ok(Response::fragment("row-7", Node::text("Done")).also("count", Node::text("4"))),
594 &asked("POST", "/tasks/7/done"),
595 );
596 assert_eq!(response.body(), b"{}");
597 }
598