Skip to main content

max / quasi

35.2 KB · 1013 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::{Accepted, 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 suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String {
17 format!(
18 "suggestions:{field}:{}",
19 options
20 .iter()
21 .map(|choice| choice.value.as_str())
22 .collect::<Vec<_>>()
23 .join(",")
24 )
25 }
26 fn screen(&self, screen: &Screen) -> String {
27 format!("screen:{}", screen.title)
28 }
29
30 fn fragment(&self, node: &Node) -> String {
31 match node {
32 Node::Text { text, .. } => format!("text:{text}"),
33 Node::Notice { tone, text, .. } => format!("notice:{tone:?}:{text}"),
34 Node::StandIn { state, message, .. } => format!("standin:{state:?}:{message}"),
35 other => format!("other:{other:?}"),
36 }
37 }
38
39 fn invalidated(&self, region: &str, node: &Node) -> String {
40 format!("[oob:{region}:{}]", self.fragment(node))
41 }
42 }
43
44 /// Decode a request built from its parts.
45 fn read(
46 method: &str,
47 uri: &str,
48 content_type: Option<&str>,
49 body: &str,
50 ) -> Result<super::Incoming, Refusal> {
51 let mut builder = http::Request::builder().method(method).uri(uri);
52 if let Some(kind) = content_type {
53 builder = builder.header(http::header::CONTENT_TYPE, kind);
54 }
55 let request = builder.body(()).unwrap();
56 decode(
57 request.method(),
58 request.uri(),
59 request.headers(),
60 body.as_bytes(),
61 DEFAULT_BODY_LIMIT,
62 )
63 }
64
65 /// A form POST, which is the shape every action arrives in.
66 fn form(uri: &str, body: &str) -> Result<super::Incoming, Refusal> {
67 read("POST", uri, Some("application/x-www-form-urlencoded"), body)
68 }
69
70 /// Everything that arrived, flattened, so an assertion reads like the wire did.
71 fn joined(incoming: &super::Incoming) -> String {
72 incoming
73 .payload
74 .iter()
75 .chain(incoming.carried.iter())
76 .map(|(k, v)| format!("{k}={v}"))
77 .collect::<Vec<_>>()
78 .join(",")
79 }
80
81 /// What was asked, for a `respond` that has to decide whether the answer is a
82 /// place. Built through `decode` rather than by hand, so the URL a push carries
83 /// is the one a real request would have produced.
84 fn asked(method: &str, uri: &str) -> super::Asked {
85 super::Asked::new(&read(method, uri, None, "").unwrap())
86 }
87
88 /// The same, as htmx would have made it.
89 ///
90 /// Built through `decode` off a real header for `asked`'s reason: whether a
91 /// request is an XHR is read off the wire, and a test that set the flag by hand
92 /// would pass whether or not `decode` ever looked.
93 fn asked_by_htmx(method: &str, uri: &str) -> super::Asked {
94 let request = http::Request::builder()
95 .method(method)
96 .uri(uri)
97 .header(super::htmx::REQUEST, "true")
98 .body(())
99 .unwrap();
100 let incoming = decode(
101 request.method(),
102 request.uri(),
103 request.headers(),
104 b"",
105 DEFAULT_BODY_LIMIT,
106 )
107 .unwrap();
108 assert!(incoming.htmx, "the header was not read");
109 super::Asked::new(&incoming)
110 }
111
112 /// The body of a response, as text.
113 fn text(response: &http::Response<Vec<u8>>) -> String {
114 String::from_utf8(response.body().clone()).unwrap()
115 }
116
117 #[test]
118 fn a_path_arrives_with_no_scheme_or_host_on_it() {
119 // The spike's finding is what makes this one assertion enough for every
120 // platform: wry reverts the Windows workaround before the handler is
121 // called, so a custom-protocol request is `<scheme>://localhost/<path>`
122 // everywhere and `Uri::path` is the whole address either way.
123 let incoming = read("GET", "quasi://localhost/task/7", None, "").unwrap();
124 assert_eq!(incoming.path, "/task/7");
125
126 let hosted = read("GET", "/task/7", None, "").unwrap();
127 assert_eq!(hosted.path, "/task/7");
128 }
129
130 #[test]
131 fn a_query_string_is_percent_decoded() {
132 let incoming = read("GET", "/task/7?note=a%20b&flag=1", None, "").unwrap();
133 assert_eq!(joined(&incoming), "note=a b,flag=1");
134 }
135
136 #[test]
137 fn a_form_body_is_decoded_on_a_post() {
138 let incoming = form("/task/7/edit", "title=new+title").unwrap();
139 assert_eq!(joined(&incoming), "title=new title");
140 }
141
142 #[test]
143 fn a_form_field_and_a_query_argument_of_the_same_name_stay_apart() {
144 // They used to be merged, form first, so this read `from-form` and the
145 // other value was unreachable. That merge is what let a screen's filter and
146 // a write about the same noun mean one name between them. Now the body is
147 // what the control sent and the query is the view it was sent from, and a
148 // handler asks for the one it means.
149 let incoming = form("/task/7/edit?title=from-query", "title=from-form").unwrap();
150 assert_eq!(incoming.payload.get("title"), Some("from-form"));
151 assert_eq!(incoming.carried.get("title"), Some("from-query"));
152 }
153
154 #[test]
155 fn repeated_names_all_survive() {
156 let incoming = form("/tags", "tag=rust&tag=router&tag=quasi").unwrap();
157 assert_eq!(
158 incoming.payload.get_all("tag").collect::<Vec<_>>(),
159 ["rust", "router", "quasi"]
160 );
161 }
162
163 #[test]
164 fn a_get_never_reads_a_body_even_when_one_is_sent() {
165 // A safe verb with a body is either a confused client or a smuggling
166 // attempt, and quasi has no route that would want it either way.
167 let incoming = read(
168 "GET",
169 "/task/7",
170 Some("application/x-www-form-urlencoded"),
171 "title=ignored",
172 )
173 .unwrap();
174 assert!(incoming.payload.is_empty());
175 }
176
177 #[test]
178 fn a_body_that_is_not_a_form_is_ignored_rather_than_guessed_at() {
179 let incoming = read(
180 "POST",
181 "/task/7/edit",
182 Some("multipart/form-data; boundary=xyz"),
183 "--xyz--",
184 )
185 .unwrap();
186 assert!(incoming.payload.is_empty());
187 }
188
189 #[test]
190 fn a_charset_on_the_form_content_type_still_reads_as_a_form() {
191 let incoming = read(
192 "POST",
193 "/task/7/edit",
194 Some("application/x-www-form-urlencoded; charset=utf-8"),
195 "title=ok",
196 )
197 .unwrap();
198 assert_eq!(incoming.payload.get("title"), Some("ok"));
199 }
200
201 #[test]
202 fn a_verb_the_description_layer_lacks_is_refused() {
203 // PATCH is the one left. DELETE and PUT arrived with `61e1b069`, because a
204 // public server's verbs are part of its interface and a description that
205 // cannot name them cannot address it.
206 assert_eq!(read("PATCH", "/task/7", None, ""), Err(Refusal::Method));
207 assert_eq!(read("HEAD", "/task/7", None, ""), Err(Refusal::Method));
208
209 // And the two that arrived decode, rather than being accepted and then
210 // silently read as a POST.
211 assert_eq!(
212 read("DELETE", "/task/7", None, "").unwrap().method.as_str(),
213 "DELETE"
214 );
215 assert_eq!(
216 read("PUT", "/task/7", None, "").unwrap().method.as_str(),
217 "PUT"
218 );
219 }
220
221 #[test]
222 fn an_oversized_form_is_refused_before_it_is_parsed() {
223 let request = http::Request::builder()
224 .method("POST")
225 .uri("/tags")
226 .header(
227 http::header::CONTENT_TYPE,
228 "application/x-www-form-urlencoded",
229 )
230 .body(())
231 .unwrap();
232 let body = "tag=".to_owned() + &"x".repeat(1024);
233 let outcome = decode(
234 request.method(),
235 request.uri(),
236 request.headers(),
237 body.as_bytes(),
238 16,
239 );
240 assert_eq!(outcome, Err(Refusal::TooLarge));
241 }
242
243 #[test]
244 fn a_form_body_that_is_not_utf8_is_refused() {
245 let request = http::Request::builder()
246 .method("POST")
247 .uri("/tags")
248 .header(
249 http::header::CONTENT_TYPE,
250 "application/x-www-form-urlencoded",
251 )
252 .body(())
253 .unwrap();
254 let outcome = decode(
255 request.method(),
256 request.uri(),
257 request.headers(),
258 &[0xff, 0xfe],
259 DEFAULT_BODY_LIMIT,
260 );
261 assert_eq!(outcome, Err(Refusal::Malformed));
262 }
263
264 #[test]
265 fn a_refusal_of_the_verb_says_which_verbs_there_are() {
266 let response = refuse(Refusal::Method);
267 assert_eq!(response.status(), 405);
268 assert_eq!(
269 response.headers().get(http::header::ALLOW).unwrap(),
270 // Exactly what `translate` accepts, or the header promises a verb the
271 // decoder refuses.
272 "GET, POST, DELETE, PUT"
273 );
274 assert!(response.body().is_empty());
275 }
276
277 #[test]
278 fn a_screen_is_served_whole_and_names_no_target() {
279 let screen = Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
280 let response = respond(&Spy, Ok(screen.into()), &asked("GET", "/"));
281 assert_eq!(response.status(), 200);
282 assert_eq!(text(&response), "screen:Home");
283 assert!(response.headers().get(super::htmx::RETARGET).is_none());
284 }
285
286 #[test]
287 fn a_fragment_carries_the_region_it_replaces() {
288 let answer = Response::fragment("detail", Node::text("hello"));
289 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
290 assert_eq!(text(&response), "text:hello");
291 // A slot id becomes a CSS selector, which is what htmx wants.
292 assert_eq!(
293 response.headers().get(super::htmx::RETARGET).unwrap(),
294 "#detail"
295 );
296 }
297
298 #[test]
299 fn a_redirect_names_where_it_goes_and_carries_no_body() {
300 // The finding this closes (`80afd652`): deleting the thing a screen is
301 // about used to answer with a tombstone, because every response was content.
302 let answer = Response::goto(Action::get("/tasks"));
303 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
304 assert_eq!(response.status(), 200);
305 assert!(response.body().is_empty());
306 assert_eq!(
307 response.headers().get(super::htmx::LOCATION).unwrap(),
308 "/tasks"
309 );
310 // Not a fragment, so nothing is being replaced in place.
311 assert!(response.headers().get(super::htmx::RETARGET).is_none());
312 }
313
314 #[test]
315 fn a_redirect_keeps_its_params_rather_than_dropping_the_filter() {
316 // Back to a filtered list is a different place from back to the list.
317 let answer = Response::goto(Action::get("/tasks").with("status", "open"));
318 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
319 assert_eq!(
320 response.headers().get(super::htmx::LOCATION).unwrap(),
321 "/tasks?status=open"
322 );
323 }
324
325 #[test]
326 fn a_param_that_needs_encoding_is_encoded_once() {
327 let answer = Response::goto(Action::get("/tasks").with("q", "a b&c"));
328 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
329 assert_eq!(
330 response.headers().get(super::htmx::LOCATION).unwrap(),
331 "/tasks?q=a+b%26c"
332 );
333 }
334
335 #[test]
336 fn leaving_the_app_is_a_different_header_from_going_somewhere_in_it() {
337 // `844b5ae0`'s opening half. An external address cannot be a swap, because
338 // nothing comes back from it.
339 let answer = Response::goto(Action::external("file:///home/max/notes.pdf"));
340 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
341 assert_eq!(
342 response.headers().get(super::htmx::REDIRECT).unwrap(),
343 "file:///home/max/notes.pdf"
344 );
345 assert!(response.headers().get(super::htmx::LOCATION).is_none());
346 }
347
348 #[test]
349 fn a_notice_rides_beside_the_content_rather_than_replacing_it() {
350 // `a92ecb1e`. The fragment still lands; the message is a header, so it is
351 // not mistaken for the region's new contents.
352 let answer = Response::fragment("detail", Node::text("hello"))
353 .toast(quasi_router::layout::Tone::Success, "Saved");
354 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
355 assert_eq!(text(&response), "text:hello");
356 assert_eq!(
357 response.headers().get(super::htmx::RETARGET).unwrap(),
358 "#detail"
359 );
360 assert_eq!(
361 response.headers().get(super::htmx::TRIGGER).unwrap(),
362 r#"{"quasi:notice":{"kind":"toast","tone":"success","text":"Saved"}}"#
363 );
364 }
365
366 #[test]
367 fn a_notice_survives_a_redirect_which_has_no_body_to_put_one_in() {
368 // The composition the two findings were filed apart from each other and
369 // could not express: a delete both goes elsewhere and says it is gone.
370 let answer =
371 Response::goto(Action::get("/tasks")).toast(quasi_router::layout::Tone::Success, "Deleted");
372 let response = respond(&Spy, Ok(answer), &asked("GET", "/"));
373 assert!(response.body().is_empty());
374 assert_eq!(
375 response.headers().get(super::htmx::LOCATION).unwrap(),
376 "/tasks"
377 );
378 assert!(
379 response
380 .headers()
381 .get(super::htmx::TRIGGER)
382 .unwrap()
383 .to_str()
384 .unwrap()
385 .contains(r#""text":"Deleted""#)
386 );
387 }
388
389 #[test]
390 fn a_response_that_says_nothing_sets_no_trigger() {
391 let response = respond(
392 &Spy,
393 Ok(Response::fragment("detail", Node::text("hello"))),
394 &asked("GET", "/"),
395 );
396 assert!(response.headers().get(super::htmx::TRIGGER).is_none());
397 }
398
399 #[test]
400 fn a_banner_and_a_toast_are_told_apart_at_the_boundary() {
401 // They are dismissed differently, so a client that cannot tell them apart
402 // shows a permanent error as a message that vanishes.
403 let banner = respond(
404 &Spy,
405 Ok(Response::fragment("detail", Node::text("x"))
406 .banner(quasi_router::layout::Tone::Danger, "Sync is down")),
407 &asked("GET", "/"),
408 );
409 assert!(
410 banner
411 .headers()
412 .get(super::htmx::TRIGGER)
413 .unwrap()
414 .to_str()
415 .unwrap()
416 .contains(r#""kind":"banner""#)
417 );
418 }
419
420 #[test]
421 fn every_class_becomes_its_status_with_the_notice_as_the_body() {
422 for (error, status) in [
423 (RouteError::denied("not yours"), 403),
424 (RouteError::new(Class::NotFound, "gone"), 404),
425 (RouteError::internal("our fault"), 500),
426 ] {
427 let expected = format!("notice:{:?}:{}", error.tone(), error.message);
428 let response = respond(&Spy, Err(error), &asked("GET", "/"));
429 assert_eq!(response.status(), status);
430 assert_eq!(text(&response), expected);
431 }
432 }
433
434 #[test]
435 fn an_error_body_is_sent_rather_than_left_to_the_status() {
436 // htmx 4 swaps every status but 204 and 304, so the body is what the
437 // reader sees for a denial. Under 2.x it was dropped unless the page
438 // carried a `responseHandling` config, which is the constant that left
439 // `htmx.rs` when the emitter moved to 4.
440 let response = respond(
441 &Spy,
442 Err(RouteError::denied("not yours")),
443 &asked("GET", "/"),
444 );
445 assert!(!response.body().is_empty());
446 }
447
448 #[test]
449 fn the_content_type_is_the_renderers_answer() {
450 struct Json;
451 impl Serves for Json {
452 fn suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String {
453 format!("suggestions:{field}:{}", options.len())
454 }
455 fn screen(&self, _: &Screen) -> String {
456 "{}".to_owned()
457 }
458 fn fragment(&self, _: &Node) -> String {
459 "{}".to_owned()
460 }
461 fn content_type(&self) -> &'static str {
462 "application/json"
463 }
464 }
465
466 let screen = Screen::sidebar_content("Home");
467 let response = respond(&Json, Ok(screen.into()), &asked("GET", "/"));
468 assert_eq!(
469 response.headers().get(http::header::CONTENT_TYPE).unwrap(),
470 "application/json"
471 );
472 }
473
474 /// The two history headers on a response, for the assertions below.
475 fn history(response: &http::Response<Vec<u8>>) -> (Option<&str>, Option<&str>) {
476 let get = |name| {
477 response
478 .headers()
479 .get(name)
480 .map(|value| value.to_str().unwrap())
481 };
482 (get(super::htmx::PUSH_URL), get(super::htmx::REPLACE_URL))
483 }
484
485 #[test]
486 fn a_read_of_a_screen_is_a_place_and_carries_the_address_it_was_read_from() {
487 // The common case, and it costs a description nothing: the address is the
488 // request's own, so no control had to predict what its answer would be.
489 let screen = Screen::sidebar_content("Projects");
490 let response = respond(
491 &Spy,
492 Ok(screen.into()),
493 &asked("GET", "/projects?sort=name"),
494 );
495
496 // Params included. A filtered list is a different place from the list.
497 assert_eq!(history(&response).0, Some("/projects?sort=name"));
498 assert_eq!(history(&response).1, None);
499 }
500
501 #[test]
502 fn a_write_answering_with_a_screen_is_not_a_place() {
503 // The address is where the form was. Coming back to it should not re-offer
504 // the write's result as a page.
505 let screen = Screen::sidebar_content("Saved");
506 let response = respond(&Spy, Ok(screen.into()), &asked("POST", "/projects/7"));
507 assert_eq!(history(&response), (None, None));
508 }
509
510 #[test]
511 fn a_fragment_is_not_a_place_unless_it_says_so() {
512 let plain = respond(
513 &Spy,
514 Ok(Response::fragment("detail", Node::text("hello"))),
515 &asked("GET", "/projects/7/tab/files"),
516 );
517 assert_eq!(history(&plain), (None, None));
518
519 // The addressable tab panel: the answer is a fragment and a place, and the
520 // address is not the route that was fetched. 32 of the server's panels.
521 let addressed = respond(
522 &Spy,
523 Ok(Response::fragment("tab-content", Node::text("hello")).at("/dashboard#tab-projects")),
524 &asked("GET", "/projects/7/tab/files"),
525 );
526 assert_eq!(history(&addressed).0, Some("/dashboard#tab-projects"));
527 }
528
529 #[test]
530 fn the_override_can_say_no_as_well_as_yes() {
531 let screen = Screen::sidebar_content("Transient");
532
533 // A read of a screen that should not come back on the back button.
534 let suppressed = respond(
535 &Spy,
536 Ok(Response::screen(screen.clone()).in_place()),
537 &asked("GET", "/wizard/step-2"),
538 );
539 assert_eq!(history(&suppressed), (None, None));
540
541 // And a place that takes the current entry's slot rather than adding one.
542 let replaced = respond(
543 &Spy,
544 Ok(Response::screen(screen).replacing("/projects?sort=age")),
545 &asked("GET", "/projects"),
546 );
547 assert_eq!(history(&replaced).1, Some("/projects?sort=age"));
548 assert_eq!(history(&replaced).0, None);
549 }
550
551 #[test]
552 fn a_redirect_sets_neither_because_htmx_already_pushes() {
553 // HX-Location issues the request client-side and htmx pushes for it, and
554 // HX-Redirect is a real navigation. A second answer here would be two
555 // parties deciding one thing.
556 let response = respond(
557 &Spy,
558 Ok(Response::goto(Action::get("/projects"))),
559 &asked("POST", "/projects/7/delete"),
560 );
561 assert_eq!(history(&response), (None, None));
562 }
563
564 #[test]
565 fn a_write_naming_two_slots_carries_both_behind_the_one_it_replaced() {
566 // The row the write was aimed at, and the count above it that also moved.
567 // Both on one answer, in the order the router named them, so the two can
568 // never disagree and no second request is made for a fact it already had.
569 let response = respond(
570 &Spy,
571 Ok(Response::fragment("row-7", Node::text("Done"))
572 .also("task-count", Node::text("4 left"))
573 .also("sidebar-badge", Node::text("4"))),
574 &asked("POST", "/tasks/7/done"),
575 );
576
577 let body = String::from_utf8(response.body().clone()).expect("the spy answers text");
578 assert_eq!(
579 body,
580 "text:Done[oob:task-count:text:4 left][oob:sidebar-badge:text:4]"
581 );
582
583 // The retarget still names one target. A set of invalidations is a
584 // different fact, and conflating them was the rejected option.
585 assert_eq!(
586 response
587 .headers()
588 .get(super::htmx::RETARGET)
589 .map(|value| value.to_str().expect("a slot id is ascii")),
590 Some("#row-7")
591 );
592 }
593
594 #[test]
595 fn a_whole_screen_and_a_redirect_carry_no_out_of_band_copy() {
596 // Not a case being dropped. A screen replaces every slot already, so an
597 // out-of-band copy would put a second element into the document under an
598 // id it now has twice; a redirect has no body at all.
599 let screen = Screen::sidebar_content("Tasks").with(Slot::new("main", RegionKind::Pane));
600 let swapped = respond(
601 &Spy,
602 Ok(Response::screen(screen).also("task-count", Node::text("4 left"))),
603 &asked("GET", "/tasks"),
604 );
605 let body = String::from_utf8(swapped.body().clone()).expect("the spy answers text");
606 assert_eq!(body, "screen:Tasks");
607
608 let sent = respond(
609 &Spy,
610 Ok(Response::goto(Action::get("/tasks")).also("task-count", Node::text("4 left"))),
611 &asked("POST", "/tasks/7/delete"),
612 );
613 assert!(sent.body().is_empty());
614 }
615
616 #[test]
617 fn a_renderer_that_does_not_answer_markup_drops_an_invalidation() {
618 // The default on `Serves::invalidated`, which is the honest answer for a
619 // renderer with no out-of-band channel rather than an oversight: a JSON
620 // client learns what changed from the payload it already parses.
621 struct Json;
622 impl Serves for Json {
623 fn suggestions(&self, field: &str, options: &[quasi_router::Candidate]) -> String {
624 format!("suggestions:{field}:{}", options.len())
625 }
626 fn screen(&self, _: &Screen) -> String {
627 "{}".to_owned()
628 }
629 fn fragment(&self, _: &Node) -> String {
630 "{}".to_owned()
631 }
632 }
633
634 let response = respond(
635 &Json,
636 Ok(Response::fragment("row-7", Node::text("Done")).also("count", Node::text("4"))),
637 &asked("POST", "/tasks/7/done"),
638 );
639 assert_eq!(response.body(), b"{}");
640 }
641
642 /// A file answer is the bytes and the header, and no renderer is consulted on
643 /// the way: the `Spy` says what it was handed, and it was handed nothing.
644 #[test]
645 fn a_binary_file_is_refused_to_htmx_rather_than_answered_corrupt() {
646 // `3bdf1a75`. htmx leaves `responseType` unset, so the browser decodes the
647 // body as UTF-8 before `DOWNLOAD_JS` sees it: a zip answered this way
648 // downloads with U+FFFD where its bytes were, and looks exactly like one
649 // that worked. Loud beats corrupt.
650 let response = respond(
651 &Spy,
652 Ok(Response::file(
653 "backup.zip",
654 Accepted::media_type("application/zip"),
655 vec![0x50, 0x4b, 0x03, 0x04, 0xff],
656 )),
657 &asked_by_htmx("POST", "/data/backup"),
658 );
659
660 assert_eq!(response.status(), 501);
661 assert!(
662 response
663 .headers()
664 .get(http::header::CONTENT_DISPOSITION)
665 .is_none()
666 );
667 // The refusal names the media type and both ways out, because "cannot"
668 // with no next step is a dead end.
669 let said = text(&response);
670 assert!(said.contains("backup.zip"), "{said}");
671 assert!(said.contains("application/zip"), "{said}");
672 assert!(said.contains("plain link"), "{said}");
673 }
674
675 #[test]
676 fn the_same_file_over_a_plain_navigation_is_answered() {
677 // The no-script path is unaffected and always was: the browser navigates,
678 // reads the same header and saves the bytes as they arrived. The guard is
679 // about a control this renderer emits, because every such control is an
680 // XHR.
681 let bytes = vec![0x50, 0x4b, 0x03, 0x04, 0xff];
682 let response = respond(
683 &Spy,
684 Ok(Response::file(
685 "backup.zip",
686 Accepted::media_type("application/zip"),
687 bytes.clone(),
688 )),
689 &asked("GET", "/data/backup"),
690 );
691
692 assert_eq!(response.status(), 200);
693 assert_eq!(response.body(), &bytes);
694 }
695
696 #[test]
697 fn every_export_in_the_tree_still_downloads_over_htmx() {
698 // The measured sites, and the reason the guard is safe to add: goingson's
699 // three exports are the only described files a webview host answers, and
700 // all three are text.
701 for kind in ["application/json", "text/csv", "text/calendar"] {
702 let response = respond(
703 &Spy,
704 Ok(Response::file(
705 "export",
706 Accepted::media_type(kind),
707 b"x".to_vec(),
708 )),
709 &asked_by_htmx("POST", "/data/export"),
710 );
711 assert_eq!(response.status(), 200, "{kind} was refused");
712 }
713
714 // A charset does not change what the type is, and a structured suffix is
715 // text by RFC 6839's construction rather than by a list anyone maintains.
716 for kind in [
717 "text/csv; charset=utf-8",
718 "application/geo+json",
719 "image/svg+xml",
720 ] {
721 let response = respond(
722 &Spy,
723 Ok(Response::file(
724 "export",
725 Accepted::media_type(kind),
726 b"x".to_vec(),
727 )),
728 &asked_by_htmx("POST", "/data/export"),
729 );
730 assert_eq!(response.status(), 200, "{kind} was refused");
731 }
732 }
733
734 #[test]
735 fn a_kind_that_is_not_a_media_type_cannot_be_shown_to_be_text() {
736 // A `Suffix` is a name and a `Family` is a filter, and this crate keeps no
737 // suffix table on purpose. Both answer "cannot say", which is read as "not
738 // safe": a wrong guess here is a corrupt file rather than a wrong header.
739 for kind in [
740 Accepted::suffix(".csv"),
741 Accepted::family(quasi_router::layout::Family::Image),
742 ] {
743 let response = respond(
744 &Spy,
745 Ok(Response::file("export", kind, b"x".to_vec())),
746 &asked_by_htmx("POST", "/data/export"),
747 );
748 assert_eq!(response.status(), 501);
749 assert!(text(&response).contains("no media type"));
750 }
751 }
752
753 #[test]
754 fn a_file_answer_is_the_bytes_under_an_attachment_header() {
755 let response = respond(
756 &Spy,
757 Ok(Response::file(
758 "goingson-export.json",
759 Accepted::media_type("application/json"),
760 br#"{"tasks":[]}"#.to_vec(),
761 )),
762 &asked("POST", "/data/export/json"),
763 );
764
765 assert_eq!(response.status(), 200);
766 assert_eq!(response.body(), br#"{"tasks":[]}"#);
767 assert_eq!(
768 response
769 .headers()
770 .get(http::header::CONTENT_TYPE)
771 .and_then(|value| value.to_str().ok()),
772 Some("application/json")
773 );
774 let disposition = response
775 .headers()
776 .get(http::header::CONTENT_DISPOSITION)
777 .and_then(|value| value.to_str().ok())
778 .expect("a file answer says it is an attachment");
779 assert!(disposition.starts_with("attachment; "));
780 assert!(disposition.contains(r#"filename="goingson-export.json""#));
781 assert!(disposition.contains("filename*=UTF-8''goingson-export.json"));
782 }
783
784 /// A kind that is not a media type is not turned into one. `.csv` says what to
785 /// call the file and says nothing about what is in it, and guessing would mean
786 /// this crate keeping a suffix table the description layer deliberately lacks.
787 #[test]
788 fn a_kind_that_is_not_a_media_type_sends_bytes() {
789 for kind in [
790 Accepted::suffix(".csv"),
791 Accepted::family(quasi_router::layout::Family::Image),
792 ] {
793 let response = respond(
794 &Spy,
795 Ok(Response::file("export", kind, b"a,b\n".to_vec())),
796 &asked("POST", "/data/export/csv"),
797 );
798 assert_eq!(
799 response
800 .headers()
801 .get(http::header::CONTENT_TYPE)
802 .and_then(|value| value.to_str().ok()),
803 Some("application/octet-stream")
804 );
805 }
806 }
807
808 /// A name is user input by the time it reaches here, and neither half of the
809 /// header may carry what it says. A separator would be a path on the host that
810 /// writes it and a newline would be a second header.
811 #[test]
812 fn a_hostile_file_name_reaches_neither_form_of_the_header() {
813 let response = respond(
814 &Spy,
815 Ok(Response::file(
816 "../../.ssh/authorized_keys\r\nX-Evil: 1",
817 Accepted::media_type("text/plain"),
818 b"ssh-rsa".to_vec(),
819 )),
820 &asked("POST", "/data/export"),
821 );
822
823 let disposition = response
824 .headers()
825 .get(http::header::CONTENT_DISPOSITION)
826 .and_then(|value| value.to_str().ok())
827 .expect("the header is still built");
828 assert!(!disposition.contains(".."));
829 assert!(!disposition.contains('/'));
830 assert!(!disposition.contains('\r'));
831 assert!(!disposition.contains('\n'));
832 assert_eq!(response.headers().get("x-evil"), None);
833 }
834
835 /// A file is not a place. A write that hands over a download leaves the address
836 /// bar where it was, the same as every other write.
837 #[test]
838 fn a_file_answer_pushes_no_address() {
839 let response = respond(
840 &Spy,
841 Ok(Response::file(
842 "a.json",
843 Accepted::media_type("application/json"),
844 b"{}".to_vec(),
845 )),
846 &asked("GET", "/data/export/json"),
847 );
848 assert_eq!(response.headers().get(crate::htmx::PUSH_URL), None);
849 assert_eq!(response.headers().get(crate::htmx::RETARGET), None);
850 }
851
852 /// The notice rides along with a file the way it rides along with the other
853 /// four outcomes, because it is a field on the response rather than a member of
854 /// the outcome.
855 #[test]
856 fn a_file_answer_can_still_say_something() {
857 let response = respond(
858 &Spy,
859 Ok(Response::file(
860 "a.json",
861 Accepted::media_type("application/json"),
862 b"{}".to_vec(),
863 )
864 .toast(quasi_router::layout::Tone::Success, "exported")),
865 &asked("POST", "/data/export/json"),
866 );
867 assert!(response.headers().contains_key(crate::htmx::TRIGGER));
868 }
869
870 #[test]
871 fn an_ask_for_a_place_is_refused_where_the_reader_can_see_it() {
872 // `ec92f9cb`. This host has no picker, and the rule for a host that cannot
873 // perform an outcome is that it says so rather than answering 200 with
874 // nothing done.
875 let answer = Response::locate(quasi_router::Locating::folder(
876 "Import folder",
877 Action::post("/import/from"),
878 "folder",
879 ));
880 let response = respond(&Spy, Ok(answer), &asked("POST", "/import/open"));
881 assert_eq!(response.status(), 501);
882 assert_eq!(
883 text(&response),
884 "notice:Danger:this host cannot choose a folder: Import folder"
885 );
886 }
887
888 #[test]
889 fn the_save_shape_is_refused_by_its_own_name() {
890 // `7fda7ae3`. A browser has a download and does not have a destination it
891 // can hand back, so the save shape is refused like the rest, and named:
892 // "cannot choose a place" would leave a reader guessing which of the two
893 // file dialogs the app meant.
894 let answer = Response::locate(quasi_router::Locating::new(
895 quasi_router::Sought::Save {
896 name: "drums.afcl".into(),
897 accept: vec![Accepted::suffix(".afcl")],
898 },
899 "Export classifier",
900 Action::post("/classifier/export"),
901 "path",
902 ));
903 let response = respond(&Spy, Ok(answer), &asked("POST", "/classifier/open"));
904 assert_eq!(response.status(), 501);
905 assert_eq!(
906 text(&response),
907 "notice:Danger:this host cannot choose where to save: Export classifier"
908 );
909 }
910
911 /// An anchored answer is retargeted at the container the anchor names, not at
912 /// the app's one overlay container -- and the swap style is left alone, which
913 /// `htmx::RESWAP`'s docs say is not this adapter's to set.
914 #[test]
915 fn an_anchored_answer_is_aimed_at_the_container_its_anchor_names() {
916 /// The overlay half of `Spy`, which answers targets for both members so a
917 /// test can tell one from the other.
918 struct Popovers;
919
920 impl Serves for Popovers {
921 fn screen(&self, screen: &Screen) -> String {
922 format!("screen:{}", screen.title)
923 }
924 fn fragment(&self, _node: &Node) -> String {
925 String::new()
926 }
927 fn suggestions(&self, _field: &str, _options: &[quasi_router::Candidate]) -> String {
928 String::new()
929 }
930 fn overlay_target(&self) -> Option<&str> {
931 Some("app-overlay")
932 }
933 fn anchored(&self, screen: &Screen) -> String {
934 format!("anchored:{}", screen.title)
935 }
936 fn anchored_target(&self, anchor: &quasi_router::Anchor) -> Option<String> {
937 match anchor {
938 quasi_router::Anchor::Region(id) | quasi_router::Anchor::Control(id) => {
939 Some(format!("{id}-anchored"))
940 }
941 quasi_router::Anchor::Selection => Some("selection-anchored".to_owned()),
942 }
943 }
944 }
945
946 let menu = Screen::sidebar_content("Menu").with(Slot::new("menu", RegionKind::Pane));
947 let answer = Response::anchored(menu, quasi_router::Anchor::Region("browser".into()));
948 let response = respond(&Popovers, Ok(answer), &asked("GET", "/menu"));
949
950 assert_eq!(response.status(), 200);
951 assert_eq!(
952 response.headers().get(super::htmx::RETARGET).unwrap(),
953 "#browser-anchored"
954 );
955 // Not the app's overlay container, which is the whole difference between
956 // the two outcomes on this host.
957 assert_eq!(response.body(), b"anchored:Menu");
958 // The swap style travels with the element, so nothing here overrides it.
959 assert!(response.headers().get(super::htmx::RESWAP).is_none());
960 }
961
962 /// A renderer with no popover container answers `None` and the fragment lands
963 /// where it was aimed. That is the bargain `Suggestions` and `Over` already
964 /// strike, and the default `Serves` impl is what makes it the quiet path.
965 #[test]
966 fn a_renderer_with_no_popover_container_sends_the_menu_unaimed() {
967 let menu = Screen::sidebar_content("Menu");
968 let answer = Response::anchored(menu, quasi_router::Anchor::Selection);
969 let response = respond(&Spy, Ok(answer), &asked("GET", "/menu"));
970
971 assert_eq!(response.status(), 200);
972 assert!(response.headers().get(super::htmx::RETARGET).is_none());
973 // And `anchored` fell through to `overlay`, which fell through to the
974 // screen: the menu is drawn, unlayered, rather than dropped.
975 assert_eq!(response.body(), b"screen:Menu");
976 }
977
978 /// Handing work off answers the region it will fill, aimed the way a fragment
979 /// naming that region is aimed. What lands is a pending stand-in, because
980 /// `aria-busy` is on the region element and an innerHTML swap does not reach
981 /// it -- so on this host the wait has to be a node.
982 #[test]
983 fn started_aims_a_pending_standin_at_the_region_the_work_will_fill() {
984 let answer = Response::started("backups", "Creating backup…");
985 let response = respond(&Spy, Ok(answer), &asked("POST", "/backups"));
986
987 assert_eq!(response.status(), 200);
988 assert_eq!(
989 response.headers().get(super::htmx::RETARGET).unwrap(),
990 "#backups"
991 );
992 assert_eq!(text(&response), "standin:Pending:Creating backup…");
993 // Nothing here tells the client to poll. The region carries its own
994 // `hx-trigger` from `Slot::live`, and a cadence the description declined to
995 // name is not this adapter's to invent.
996 assert!(response.headers().get(super::htmx::TRIGGER).is_none());
997 // And the swap style travels with the element, as everywhere else.
998 assert!(response.headers().get(super::htmx::RESWAP).is_none());
999 }
1000
1001 /// The address half: starting work is not a place. The reader stays on the
1002 /// screen that is now waiting, which is the whole point of being able to say
1003 /// this rather than redirecting.
1004 #[test]
1005 fn started_pushes_no_url_and_sends_the_reader_nowhere() {
1006 let answer = Response::started("backups", "Creating backup…");
1007 assert_eq!(answer.target(), Some("backups"));
1008 assert!(answer.destination().is_none());
1009
1010 let response = respond(&Spy, Ok(answer), &asked("POST", "/backups"));
1011 assert!(response.headers().get(super::htmx::LOCATION).is_none());
1012 }
1013