Skip to main content

max / makenotwork

20.2 KB · 523 lines History Blame Raw
1 //! Press the buttons on every described screen.
2 //!
3 //! The debt this pays off. Four conversion batches shipped with tests that
4 //! rendered a screen and asserted the *address* each control carried was a
5 //! registered route. Two controls were still wrong when pressed, and both got
6 //! through because the address was never the thing that was broken:
7 //!
8 //! - `ssh_keys`' Remove addressed `DELETE /api/users/me/ssh-keys/{id}`, which
9 //! answers an htmx request with the whole re-rendered Askama list. With no
10 //! target htmx swapped that table into the button that was pressed.
11 //! - `library_contacts`' Revoke addressed `DELETE /api/contacts/{id}`, which
12 //! answers 204. htmx never swaps a 204, so the revoke landed and the row
13 //! stayed until the reader left the tab and came back.
14 //!
15 //! Both are answers, not addresses, so no amount of route-table assertion would
16 //! have found either. What finds them is issuing the request and reading what
17 //! comes back, which is what everything below does.
18 //!
19 //! # Why this is here and not a sweep check of its own
20 //!
21 //! It runs nightly on astra already: the sweep's `test` check is
22 //! `cargo nextest run --all-features` over `MNW/server`, with postgres and
23 //! `TEST_DATABASE_URL` supplied by `[repo.MNW.env]`, and nextest's JUnit report
24 //! names every failing test individually, so a red cell here carries the test
25 //! name rather than one opaque verdict. A separate check would pay for a second
26 //! full build of the server, which is the most expensive cell in the matrix, to
27 //! buy a column heading.
28 //!
29 //! # The one shape to keep
30 //!
31 //! [`controls`] reads what the *description emitted* rather than a list written
32 //! here by hand. A control that a future conversion adds is pressed by this
33 //! suite the day it is added, without anybody remembering to extend a list, and
34 //! a screen whose controls all disappear fails [`a_screen_offers_something`]
35 //! rather than passing vacuously.
36
37 use crate::harness::{BuildOptions, TestHarness};
38 use makenotwork::quasi;
39
40 /// Every described screen, as (switch name, address, region it answers into).
41 ///
42 /// Written out rather than derived from `quasi::PATHS`, because the region is
43 /// not on that list and the point of the third column is to assert the answer
44 /// lands somewhere. Kept in step by `every_described_screen_is_pressed` below.
45 ///
46 /// The regions are *read* rather than transcribed wherever the screen publishes
47 /// one: a transcribed region goes stale the moment a screen stops sharing
48 /// `tab-content`. A constant this table can name is a constant this table cannot
49 /// disagree with.
50 const SCREENS: &[(&str, &str, &str)] = &[
51 (
52 "user_ssh_keys",
53 quasi::ssh_keys::PATH,
54 quasi::ssh_keys::REGION,
55 ),
56 (
57 "library_contacts",
58 quasi::library_contacts::PATH,
59 quasi::library_contacts::REGION,
60 ),
61 // The one screen that publishes no REGION, so this is the only row left
62 // transcribing one. Read it from the screen the day it publishes one.
63 (
64 "buyer_contacts",
65 quasi::buyer_contacts::PATH,
66 "contacts-section",
67 ),
68 (
69 "payout_summary",
70 quasi::payout_summary::PATH,
71 quasi::payout_summary::REGION,
72 ),
73 (
74 "user_analytics",
75 quasi::user_analytics::PATH,
76 quasi::user_analytics::REGION,
77 ),
78 (
79 "library_communities",
80 quasi::forum_memberships::LIBRARY_PATH,
81 quasi::forum_memberships::LIBRARY_REGION,
82 ),
83 (
84 "user_forums",
85 quasi::forum_memberships::SETTINGS_PATH,
86 quasi::forum_memberships::SETTINGS_REGION,
87 ),
88 ];
89
90 /// One control the description emitted.
91 #[derive(Debug, Clone, PartialEq, Eq)]
92 struct Control {
93 method: &'static str,
94 address: String,
95 }
96
97 /// Pull every control out of a rendered screen.
98 ///
99 /// Attribute-driven, so it sees exactly what a browser would act on and nothing
100 /// a test author remembered to list. `href` is deliberately excluded: an anchor
101 /// that also carries `hx-get` is already counted by the verb, and one that does
102 /// not is either external or a download, neither of which this server answers.
103 fn controls(html: &str) -> Vec<Control> {
104 let mut found = Vec::new();
105 for (attr, method) in [
106 ("hx-get=\"", "GET"),
107 ("hx-post=\"", "POST"),
108 ("hx-put=\"", "PUT"),
109 ("hx-delete=\"", "DELETE"),
110 ] {
111 let mut rest = html;
112 while let Some(at) = rest.find(attr) {
113 rest = &rest[at + attr.len()..];
114 let Some(end) = rest.find('"') else { break };
115 let address = rest[..end].replace("&amp;", "&");
116 rest = &rest[end..];
117 // A form's own action is emitted on the form and again on nothing
118 // else; a repeated address is a repeated control and both get
119 // pressed, which costs one request and keeps the reader honest.
120 found.push(Control { method, address });
121 }
122 }
123 found
124 }
125
126 /// A harness signed in as a creator.
127 ///
128 /// Every described screen serves unconditionally, so there is nothing to turn
129 /// on and every screen in `SCREENS` is reachable from any of these harnesses.
130 async fn viewing() -> TestHarness {
131 let mut h = TestHarness::build(BuildOptions {
132 // The forum screens refuse when Multithreaded is unconfigured, which is
133 // deliberate and would otherwise read here as a broken screen. Pointed
134 // at an address nothing answers on purpose: the upstream call then fails
135 // and both screens fall back to an empty list, which is the path a real
136 // outage takes and the one worth pressing.
137 mt_base_url: Some("http://127.0.0.1:9".to_owned()),
138 internal_shared_secret: Some("press-the-buttons".to_owned()),
139 ..Default::default()
140 })
141 .await;
142 h.signup("presser", "presser@example.com", "password123")
143 .await;
144 h.login("presser", "password123").await;
145 h.client.fetch_csrf_token().await;
146 h
147 }
148
149 /// Every screen answers its own address, and says what region it changed.
150 ///
151 /// The retarget header is how a described answer says where it goes, and it is
152 /// the half of decision 7 that a rendering test cannot see: the markup is
153 /// identical whether or not the header is set, and without it htmx swaps the
154 /// answer into whatever element made the request.
155 #[tokio::test]
156 async fn every_described_screen_answers_into_the_region_it_names() {
157 for (screen, path, region) in SCREENS {
158 let mut h = viewing().await;
159 let resp = h.client.htmx_get(path).await;
160
161 assert_eq!(
162 resp.status, 200,
163 "{screen} did not answer {path} (got {})",
164 resp.status
165 );
166 let retarget = resp
167 .headers
168 .get("HX-Retarget")
169 .and_then(|v| v.to_str().ok())
170 .unwrap_or_default();
171 assert_eq!(
172 retarget,
173 format!("#{region}"),
174 "{screen} answered without naming {region} (got {retarget:?})"
175 );
176 }
177 }
178
179 /// A screen that emits no controls is a screen this suite cannot test.
180 ///
181 /// The guard against passing vacuously. Every assertion below iterates over what
182 /// `controls` found, so a rendering that quietly stopped emitting anything would
183 /// make all of them trivially true.
184 #[tokio::test]
185 async fn a_screen_offers_something() {
186 // ssh_keys is the richest: two add forms, a theme picker and, once a key
187 // exists, two destructive controls. If any screen has controls, it does.
188 let mut h = viewing().await;
189 let html = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
190
191 let found = controls(&html);
192 assert!(
193 found.len() >= 3,
194 "the ssh-keys screen emitted {} controls: {found:?}",
195 found.len()
196 );
197 assert!(
198 found.iter().any(|c| c.method == "POST"),
199 "no write among {found:?}"
200 );
201 }
202
203 /// Every control on every screen addresses something the server answers.
204 ///
205 /// Not a route-table assertion: the request is issued. A control whose address
206 /// is registered nowhere answers 404, and one whose route exists under another
207 /// verb answers 405, and both are the failure this catches. 403 is a pass, since
208 /// a control can legitimately address something this particular reader may not
209 /// do, and the CSRF layer is asserted separately in `csrf_coverage`.
210 #[tokio::test]
211 async fn every_described_control_is_answered() {
212 for (screen, path, _) in SCREENS {
213 let mut h = viewing().await;
214 let html = h.client.htmx_get(path).await.text;
215
216 for control in controls(&html) {
217 let resp = match control.method {
218 "GET" => h.client.htmx_get(&control.address).await,
219 "POST" => h.client.htmx_post_form(&control.address, "").await,
220 "PUT" => h.client.htmx_put_form(&control.address, "").await,
221 "DELETE" => h.client.htmx_delete(&control.address).await,
222 other => panic!("unhandled method {other}"),
223 };
224 let code = resp.status.as_u16();
225 assert!(
226 code != 404 && code != 405,
227 "{screen}: [{}] {} answered {code}, so the control renders and \
228 does nothing. This is the S3 failure class.",
229 control.method,
230 control.address
231 );
232 assert!(
233 code < 500,
234 "{screen}: [{}] {} answered {code}",
235 control.method,
236 control.address
237 );
238 }
239 }
240 }
241
242 /// A key on the ssh-keys screen, added the way the screen's own form adds one.
243 ///
244 /// Shared by the test below and by `a_described_delete_answers_with_the_screen_it_is_on`,
245 /// which cannot press a Remove that no row emitted.
246 const SEED_KEY: &str =
247 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2n4ZVGJoGZ8pM5vJXVv0kL3T5V7wQ9dNqR8mY1uH6c";
248
249 /// Put a row on a screen, for the screens that can grow one through their own
250 /// description.
251 ///
252 /// Only `user_ssh_keys` today, because it is the one screen whose row this suite
253 /// can create by posting the form the description itself emitted. The others
254 /// need fixtures that do not exist yet, and the pressed-count guard in
255 /// `a_described_delete_answers_with_the_screen_it_is_on` is what keeps that from
256 /// being silent: it fails if the loop pressed nothing at all, so a screen
257 /// growing a fixture joins the coverage and a screen losing one is noticed.
258 async fn seed_a_deletable_row(h: &mut TestHarness, screen: &str) {
259 if screen != "user_ssh_keys" {
260 return;
261 }
262 let added = h
263 .client
264 .post_form(
265 "/api/users/me/ssh-keys",
266 &format!("public_key={}&label=fw13", urlencoding::encode(SEED_KEY)),
267 )
268 .await;
269 assert_eq!(
270 added.status, 200,
271 "seeding a key failed: {} {}",
272 added.status, added.text
273 );
274 }
275
276 /// Removing a key removes it, and answers with the pane rather than a list.
277 ///
278 /// The defect this file exists for, pinned end to end: seed a key, find the
279 /// control the description emitted for it, press it, and read both what came
280 /// back and what is left in the database.
281 #[tokio::test]
282 async fn pressing_remove_on_a_key_removes_it_and_redraws_the_pane() {
283 let mut h = viewing().await;
284
285 seed_a_deletable_row(&mut h, "user_ssh_keys").await;
286
287 let html = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
288 assert!(html.contains("fw13"), "the key is on the screen: {html}");
289
290 let remove = controls(&html)
291 .into_iter()
292 .find(|c| c.method == "DELETE" && c.address.contains("/keys/"))
293 .expect("the description emitted a Remove control");
294
295 let resp = h.client.htmx_delete(&remove.address).await;
296 assert_eq!(
297 resp.status, 200,
298 "pressing Remove answered {} at {}",
299 resp.status, remove.address
300 );
301
302 // The answer is this screen's own pane, named as such. Before 2026-08-11
303 // the control addressed the API route, whose answer is the Askama list
304 // fragment with no retarget: htmx put that table inside the button.
305 //
306 // Read from the screen rather than transcribed, for the reason SCREENS
307 // gives: this assertion said `#settings-body` until 2026-08-19, when the
308 // settings strip was described and the pane stopped being shared.
309 assert_eq!(
310 resp.headers
311 .get("HX-Retarget")
312 .and_then(|v| v.to_str().ok())
313 .unwrap_or_default(),
314 format!("#{}", quasi::ssh_keys::REGION),
315 "the answer did not name the pane it changed"
316 );
317 assert!(
318 !resp.text.contains("fw13"),
319 "the removed key is still drawn: {}",
320 resp.text
321 );
322 assert!(
323 resp.text.contains("No SSH keys registered."),
324 "the answer is the pane, empty: {}",
325 resp.text
326 );
327
328 // And it is gone for the next reader, not only from this response.
329 let after = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
330 assert!(!after.contains("fw13"), "the key came back: {after}");
331 }
332
333 /// A destructive control answers with a region, whatever screen it is on.
334 ///
335 /// The generalisation of the test above, and the one that will catch the next
336 /// conversion rather than this one. Any described `DELETE` must answer with the
337 /// screen's own region: an answer that names nothing is an answer htmx puts
338 /// inside the pressed button, and an answer that names another screen's region
339 /// swaps the wrong part of the page.
340 ///
341 /// # It has to be given a row first
342 ///
343 /// The reader is freshly signed up, so every list on every screen is empty and
344 /// no row emits a Remove: the loop below would run zero times and pass
345 /// vacuously. [`seed_a_deletable_row`] is what gives it something to press, and
346 /// `pressed` is what keeps the vacuity out: a loop that asserts nothing fails
347 /// rather than reporting green.
348 #[tokio::test]
349 async fn a_described_delete_answers_with_the_screen_it_is_on() {
350 let mut pressed = 0_usize;
351 for (screen, path, region) in SCREENS {
352 let mut h = viewing().await;
353 seed_a_deletable_row(&mut h, screen).await;
354 let html = h.client.htmx_get(path).await.text;
355
356 for control in controls(&html).into_iter().filter(|c| c.method == "DELETE") {
357 let resp = h.client.htmx_delete(&control.address).await;
358 // A delete of something this seeded account does not have is fine,
359 // and 404 is how that is said. Every other code is the answer being
360 // wrong rather than the fixture being thin, so it is asserted
361 // instead of skipped.
362 //
363 // 200 exactly, not any 2xx. A 204 is the `library_contacts` bug in
364 // the module header: htmx never swaps one, so the row stays put
365 // whatever headers ride along with it. Tolerating the whole 2xx
366 // range here would have let the second of the two bugs this file
367 // was written for pass, since a 204 carrying an `HX-Retarget` it
368 // never acts on satisfies the assertion below.
369 if resp.status == 404 {
370 continue;
371 }
372 assert_eq!(
373 resp.status, 200,
374 "{screen}: DELETE {} answered {} rather than the region",
375 control.address, resp.status
376 );
377 let retarget = resp
378 .headers
379 .get("HX-Retarget")
380 .and_then(|v| v.to_str().ok())
381 .unwrap_or_default();
382 assert_eq!(
383 retarget,
384 format!("#{region}"),
385 "{screen}: DELETE {} succeeded without naming {region}",
386 control.address
387 );
388 pressed += 1;
389 }
390 }
391
392 // The guard the module header claims for controls in general, applied to
393 // the destructive ones: `a_screen_offers_something` sees the screens still
394 // emit controls, and cannot see that none of them is a DELETE.
395 assert!(
396 pressed > 0,
397 "no described DELETE was pressed on any of the {} screens, so this test \
398 asserted nothing. Seed a row for whichever screen lost its fixture.",
399 SCREENS.len()
400 );
401 }
402
403 /// The screen list here covers every screen the description layer can mount.
404 ///
405 /// `quasi::PATHS` is the authority and is already checked against `mounts`, so
406 /// this closes the loop: a screen added there but not here would never be
407 /// pressed, and the suite would keep passing while covering less.
408 #[tokio::test]
409 async fn every_described_screen_is_pressed() {
410 let mut pressed: Vec<&str> = SCREENS.iter().map(|(_, path, _)| *path).collect();
411 pressed.sort_unstable();
412 let mut mountable = quasi::PATHS.to_vec();
413 mountable.sort_unstable();
414
415 assert_eq!(
416 pressed, mountable,
417 "the pressed screens and the mountable ones have diverged"
418 );
419 }
420
421 /// A screen that owns its document answers with one, not with a fragment.
422 ///
423 /// `quasi::DOCUMENT_PATHS` is the other half of the mount table and is not in
424 /// `SCREENS` above, because the two answer differently: a panel names the region
425 /// it changed and this suite reads that off `HX-Retarget`, and a document sets
426 /// no such header. What is worth pressing here instead is that the document is
427 /// whole: the head the shell owns, the header the assembly layer owns, and the
428 /// screen's own body class.
429 #[tokio::test]
430 async fn every_document_screen_serves_a_whole_document() {
431 for path in quasi::DOCUMENT_PATHS {
432 let mut h = viewing().await;
433 let resp = h.client.get(path).await;
434
435 assert_eq!(resp.status, 200, "{path} did not answer");
436 let html = resp.text;
437 assert!(html.starts_with("<!doctype html>"), "{path}: {html}");
438 assert!(html.contains("/static/style.css"), "{path} lost its sheets");
439 assert!(
440 html.contains("role=\"banner\""),
441 "{path} lost the site header"
442 );
443 assert!(
444 html.contains("name=\"csrf-token\""),
445 "{path} lost the token meta the classic scripts read"
446 );
447 assert!(html.contains("skip-to-main"), "{path} lost its skip link");
448 }
449 }
450
451 /// A document screen a reader can type the address of answers the branded 401,
452 /// not the adapter's bare 403.
453 ///
454 /// `pages.rs::unauthorized_page_offers_login_and_signup` is the shipped rule for
455 /// `/feed` in particular; this is the same rule held for every document screen
456 /// that follows it onto the description layer.
457 #[tokio::test]
458 async fn a_signed_out_reader_is_offered_the_way_in() {
459 for path in quasi::DOCUMENT_PATHS {
460 let mut h = TestHarness::new().await;
461 let resp = h.client.get(path).await;
462
463 assert_eq!(
464 resp.status, 401,
465 "{path} refused a stranger with the wrong code"
466 );
467 assert!(
468 resp.text.contains("href=\"/login\""),
469 "{path}: {}",
470 resp.text
471 );
472 assert!(
473 resp.text.contains("href=\"/join\""),
474 "{path}: {}",
475 resp.text
476 );
477 }
478 }
479
480 /// `e0c0d991`. The shortcut is declared once on the shell, so every described
481 /// screen offers it and none of them has to remember to.
482 #[tokio::test]
483 async fn a_described_screen_offers_the_shortcuts_key() {
484 let mut h = viewing().await;
485 let html = h.client.get("/pricing").await.text;
486
487 // quasi's half: a hidden button per binding, firing on the key, aimed at
488 // the overlay container. The renderer wires it; nothing here draws a list.
489 assert!(
490 html.contains("data-chrome"),
491 "the chrome is emitted: {html:.400}"
492 );
493 assert!(
494 html.contains("Keyboard shortcuts"),
495 "the binding is labelled: {html:.400}"
496 );
497 assert!(
498 html.contains("/shortcuts"),
499 "and it reaches the listing: {html:.400}"
500 );
501
502 // And the affordance it replaces is gone.
503 assert!(
504 !html.contains("toggleShortcutsHelp"),
505 "the data-action link was retired: {html:.400}"
506 );
507 }
508
509 /// The listing is ours, and it answers as something drawn over the page rather
510 /// than as a page of its own.
511 #[tokio::test]
512 async fn the_shortcuts_listing_names_every_key_this_site_binds() {
513 let mut h = viewing().await;
514 let html = h.client.htmx_get("/shortcuts").await.text;
515
516 // The described binding, read off the Chrome rather than written twice.
517 assert!(html.contains('?'), "the described key: {html:.400}");
518 // And the three the host still owns.
519 for key in ["Cmd+K", "Esc", "Cmd+S"] {
520 assert!(html.contains(key), "{key} is listed: {html:.400}");
521 }
522 }
523