Skip to main content

max / makenotwork

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