Skip to main content

max / makenotwork

16.7 KB · 416 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::config::QuasiScreens;
39 use makenotwork::quasi;
40
41 /// Every described screen, as (switch name, address, region it answers into).
42 ///
43 /// Written out rather than derived from `quasi::PATHS`, because the region is
44 /// not on that list and the point of the third column is to assert the answer
45 /// lands somewhere. Kept in step by `every_described_screen_is_pressed` below.
46 ///
47 /// The regions are *read* rather than transcribed wherever the screen publishes
48 /// one. Transcribing them is what broke here on 2026-08-18: the library strip
49 /// was described (`6b24f2df`), the two library panels stopped sharing
50 /// `tab-content` with everything else, and this list still said they did. A
51 /// constant this table can name is a constant this table cannot disagree with.
52 const SCREENS: &[(&str, &str, &str)] = &[
53 (
54 "user_ssh_keys",
55 quasi::ssh_keys::PATH,
56 quasi::ssh_keys::REGION,
57 ),
58 (
59 "library_contacts",
60 quasi::library_contacts::PATH,
61 quasi::library_contacts::REGION,
62 ),
63 // The one screen that publishes no REGION, so this is the only row left
64 // transcribing one. Read it from the screen the day it publishes one.
65 (
66 "buyer_contacts",
67 quasi::buyer_contacts::PATH,
68 "contacts-section",
69 ),
70 (
71 "user_analytics",
72 quasi::user_analytics::PATH,
73 quasi::user_analytics::REGION,
74 ),
75 (
76 "library_communities",
77 quasi::forum_memberships::LIBRARY_PATH,
78 quasi::forum_memberships::LIBRARY_REGION,
79 ),
80 (
81 "user_forums",
82 quasi::forum_memberships::SETTINGS_PATH,
83 quasi::forum_memberships::SETTINGS_REGION,
84 ),
85 ];
86
87 /// One control the description emitted.
88 #[derive(Debug, Clone, PartialEq, Eq)]
89 struct Control {
90 method: &'static str,
91 address: String,
92 }
93
94 /// Pull every control out of a rendered screen.
95 ///
96 /// Attribute-driven, so it sees exactly what a browser would act on and nothing
97 /// a test author remembered to list. `href` is deliberately excluded: an anchor
98 /// that also carries `hx-get` is already counted by the verb, and one that does
99 /// not is either external or a download, neither of which this server answers.
100 fn controls(html: &str) -> Vec<Control> {
101 let mut found = Vec::new();
102 for (attr, method) in [
103 ("hx-get=\"", "GET"),
104 ("hx-post=\"", "POST"),
105 ("hx-put=\"", "PUT"),
106 ("hx-delete=\"", "DELETE"),
107 ] {
108 let mut rest = html;
109 while let Some(at) = rest.find(attr) {
110 rest = &rest[at + attr.len()..];
111 let Some(end) = rest.find('"') else { break };
112 let address = rest[..end].replace("&amp;", "&");
113 rest = &rest[end..];
114 // A form's own action is emitted on the form and again on nothing
115 // else; a repeated address is a repeated control and both get
116 // pressed, which costs one request and keeps the reader honest.
117 found.push(Control { method, address });
118 }
119 }
120 found
121 }
122
123 /// A harness with one screen switched on, signed in as a creator.
124 async fn viewing(screen: &str) -> TestHarness {
125 let mut h = TestHarness::build(BuildOptions {
126 quasi_screens: QuasiScreens::parse(screen),
127 // The forum screens refuse when Multithreaded is unconfigured, which is
128 // deliberate and would otherwise read here as a broken screen. Pointed
129 // at an address nothing answers on purpose: the upstream call then fails
130 // and both screens fall back to an empty list, which is the path a real
131 // outage takes and the one worth pressing.
132 mt_base_url: Some("http://127.0.0.1:9".to_owned()),
133 internal_shared_secret: Some("press-the-buttons".to_owned()),
134 ..Default::default()
135 })
136 .await;
137 h.signup("presser", "presser@example.com", "password123")
138 .await;
139 h.login("presser", "password123").await;
140 h.client.fetch_csrf_token().await;
141 h
142 }
143
144 /// Every screen answers its own address, and says what region it changed.
145 ///
146 /// The retarget header is how a described answer says where it goes, and it is
147 /// the half of decision 7 that a rendering test cannot see: the markup is
148 /// identical whether or not the header is set, and without it htmx swaps the
149 /// answer into whatever element made the request.
150 #[tokio::test]
151 async fn every_described_screen_answers_into_the_region_it_names() {
152 for (screen, path, region) in SCREENS {
153 let mut h = viewing(screen).await;
154 let resp = h.client.htmx_get(path).await;
155
156 assert_eq!(
157 resp.status, 200,
158 "{screen} did not answer {path} (got {})",
159 resp.status
160 );
161 let retarget = resp
162 .headers
163 .get("HX-Retarget")
164 .and_then(|v| v.to_str().ok())
165 .unwrap_or_default();
166 assert_eq!(
167 retarget,
168 format!("#{region}"),
169 "{screen} answered without naming {region} (got {retarget:?})"
170 );
171 }
172 }
173
174 /// A screen that emits no controls is a screen this suite cannot test.
175 ///
176 /// The guard against passing vacuously. Every assertion below iterates over what
177 /// `controls` found, so a rendering that quietly stopped emitting anything would
178 /// make all of them trivially true.
179 #[tokio::test]
180 async fn a_screen_offers_something() {
181 // ssh_keys is the richest: two add forms, a theme picker and, once a key
182 // exists, two destructive controls. If any screen has controls, it does.
183 let mut h = viewing("user_ssh_keys").await;
184 let html = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
185
186 let found = controls(&html);
187 assert!(
188 found.len() >= 3,
189 "the ssh-keys screen emitted {} controls: {found:?}",
190 found.len()
191 );
192 assert!(
193 found.iter().any(|c| c.method == "POST"),
194 "no write among {found:?}"
195 );
196 }
197
198 /// Every control on every screen addresses something the server answers.
199 ///
200 /// Not a route-table assertion: the request is issued. A control whose address
201 /// is registered nowhere answers 404, and one whose route exists under another
202 /// verb answers 405, and both are the failure this catches. 403 is a pass, since
203 /// a control can legitimately address something this particular reader may not
204 /// do, and the CSRF layer is asserted separately in `csrf_coverage`.
205 #[tokio::test]
206 async fn every_described_control_is_answered() {
207 for (screen, path, _) in SCREENS {
208 let mut h = viewing(screen).await;
209 let html = h.client.htmx_get(path).await.text;
210
211 for control in controls(&html) {
212 let resp = match control.method {
213 "GET" => h.client.htmx_get(&control.address).await,
214 "POST" => h.client.htmx_post_form(&control.address, "").await,
215 "PUT" => h.client.htmx_put_form(&control.address, "").await,
216 "DELETE" => h.client.htmx_delete(&control.address).await,
217 other => panic!("unhandled method {other}"),
218 };
219 let code = resp.status.as_u16();
220 assert!(
221 code != 404 && code != 405,
222 "{screen}: [{}] {} answered {code}, so the control renders and \
223 does nothing. This is the S3 failure class.",
224 control.method,
225 control.address
226 );
227 assert!(
228 code < 500,
229 "{screen}: [{}] {} answered {code}",
230 control.method,
231 control.address
232 );
233 }
234 }
235 }
236
237 /// A key on the ssh-keys screen, added the way the screen's own form adds one.
238 ///
239 /// Shared by the test below and by `a_described_delete_answers_with_the_screen_it_is_on`,
240 /// which cannot press a Remove that no row emitted.
241 const SEED_KEY: &str =
242 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2n4ZVGJoGZ8pM5vJXVv0kL3T5V7wQ9dNqR8mY1uH6c";
243
244 /// Put a row on a screen, for the screens that can grow one through their own
245 /// description.
246 ///
247 /// Only `user_ssh_keys` today, because it is the one screen whose row this suite
248 /// can create by posting the form the description itself emitted. The others
249 /// need fixtures that do not exist yet, and the pressed-count guard in
250 /// `a_described_delete_answers_with_the_screen_it_is_on` is what keeps that from
251 /// being silent: it fails if the loop pressed nothing at all, so a screen
252 /// growing a fixture joins the coverage and a screen losing one is noticed.
253 async fn seed_a_deletable_row(h: &mut TestHarness, screen: &str) {
254 if screen != "user_ssh_keys" {
255 return;
256 }
257 let added = h
258 .client
259 .post_form(
260 "/api/users/me/ssh-keys",
261 &format!("public_key={}&label=fw13", urlencoding::encode(SEED_KEY)),
262 )
263 .await;
264 assert_eq!(
265 added.status, 200,
266 "seeding a key failed: {} {}",
267 added.status, added.text
268 );
269 }
270
271 /// Removing a key removes it, and answers with the pane rather than a list.
272 ///
273 /// The defect this file exists for, pinned end to end: seed a key, find the
274 /// control the description emitted for it, press it, and read both what came
275 /// back and what is left in the database.
276 #[tokio::test]
277 async fn pressing_remove_on_a_key_removes_it_and_redraws_the_pane() {
278 let mut h = viewing("user_ssh_keys").await;
279
280 seed_a_deletable_row(&mut h, "user_ssh_keys").await;
281
282 let html = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
283 assert!(html.contains("fw13"), "the key is on the screen: {html}");
284
285 let remove = controls(&html)
286 .into_iter()
287 .find(|c| c.method == "DELETE" && c.address.contains("/keys/"))
288 .expect("the description emitted a Remove control");
289
290 let resp = h.client.htmx_delete(&remove.address).await;
291 assert_eq!(
292 resp.status, 200,
293 "pressing Remove answered {} at {}",
294 resp.status, remove.address
295 );
296
297 // The answer is this screen's own pane, named as such. Before 2026-08-11
298 // the control addressed the API route, whose answer is the Askama list
299 // fragment with no retarget: htmx put that table inside the button.
300 //
301 // Read from the screen rather than transcribed, for the reason SCREENS
302 // gives: this assertion said `#settings-body` until 2026-08-19, when the
303 // settings strip was described and the pane stopped being shared.
304 assert_eq!(
305 resp.headers
306 .get("HX-Retarget")
307 .and_then(|v| v.to_str().ok())
308 .unwrap_or_default(),
309 format!("#{}", quasi::ssh_keys::REGION),
310 "the answer did not name the pane it changed"
311 );
312 assert!(
313 !resp.text.contains("fw13"),
314 "the removed key is still drawn: {}",
315 resp.text
316 );
317 assert!(
318 resp.text.contains("No SSH keys registered."),
319 "the answer is the pane, empty: {}",
320 resp.text
321 );
322
323 // And it is gone for the next reader, not only from this response.
324 let after = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
325 assert!(!after.contains("fw13"), "the key came back: {after}");
326 }
327
328 /// A destructive control answers with a region, whatever screen it is on.
329 ///
330 /// The generalisation of the test above, and the one that will catch the next
331 /// conversion rather than this one. Any described `DELETE` must answer with the
332 /// screen's own region: an answer that names nothing is an answer htmx puts
333 /// inside the pressed button, and an answer that names another screen's region
334 /// swaps the wrong part of the page.
335 ///
336 /// # It has to be given a row first
337 ///
338 /// This pressed nothing at all until 2026-08-13. The reader is freshly signed
339 /// up, so every list on every screen was empty, so no row emitted a Remove and
340 /// the loop below ran zero times on all six screens. It passed throughout,
341 /// which is what a vacuous test does. [`seed_a_deletable_row`] is what gives it
342 /// something to press, and `pressed` is what stops the vacuity coming back:
343 /// a loop that asserts nothing now fails rather than reporting green.
344 #[tokio::test]
345 async fn a_described_delete_answers_with_the_screen_it_is_on() {
346 let mut pressed = 0_usize;
347 for (screen, path, region) in SCREENS {
348 let mut h = viewing(screen).await;
349 seed_a_deletable_row(&mut h, screen).await;
350 let html = h.client.htmx_get(path).await.text;
351
352 for control in controls(&html).into_iter().filter(|c| c.method == "DELETE") {
353 let resp = h.client.htmx_delete(&control.address).await;
354 // A delete of something this seeded account does not have is fine,
355 // and 404 is how that is said. Every other code is the answer being
356 // wrong rather than the fixture being thin, so it is asserted
357 // instead of skipped.
358 //
359 // 200 exactly, not any 2xx. A 204 is the `library_contacts` bug in
360 // the module header: htmx never swaps one, so the row stays put
361 // whatever headers ride along with it. Tolerating the whole 2xx
362 // range here would have let the second of the two bugs this file
363 // was written for pass, since a 204 carrying an `HX-Retarget` it
364 // never acts on satisfies the assertion below.
365 if resp.status == 404 {
366 continue;
367 }
368 assert_eq!(
369 resp.status, 200,
370 "{screen}: DELETE {} answered {} rather than the region",
371 control.address, resp.status
372 );
373 let retarget = resp
374 .headers
375 .get("HX-Retarget")
376 .and_then(|v| v.to_str().ok())
377 .unwrap_or_default();
378 assert_eq!(
379 retarget,
380 format!("#{region}"),
381 "{screen}: DELETE {} succeeded without naming {region}",
382 control.address
383 );
384 pressed += 1;
385 }
386 }
387
388 // The guard the module header claims for controls in general, applied to
389 // the destructive ones: `a_screen_offers_something` sees the screens still
390 // emit controls, and cannot see that none of them is a DELETE.
391 assert!(
392 pressed > 0,
393 "no described DELETE was pressed on any of the {} screens, so this test \
394 asserted nothing. Seed a row for whichever screen lost its fixture.",
395 SCREENS.len()
396 );
397 }
398
399 /// The screen list here covers every screen the description layer can mount.
400 ///
401 /// `quasi::PATHS` is the authority and is already checked against `mounts`, so
402 /// this closes the loop: a screen added there but not here would never be
403 /// pressed, and the suite would keep passing while covering less.
404 #[tokio::test]
405 async fn every_described_screen_is_pressed() {
406 let mut pressed: Vec<&str> = SCREENS.iter().map(|(_, path, _)| *path).collect();
407 pressed.sort_unstable();
408 let mut mountable = quasi::PATHS.to_vec();
409 mountable.sort_unstable();
410
411 assert_eq!(
412 pressed, mountable,
413 "the pressed screens and the mountable ones have diverged"
414 );
415 }
416