Skip to main content

max / makenotwork

Press the buttons on every described screen
Author: Max Johnson <me@maxj.phd> · 2026-08-11 19:09 UTC
Signed with PGP, not checked
Commit: 8dff6c10600d4e383fa624e2933ba3ae6dfb2c45
Parent: bcdc370
6 files changed, +344 insertions, -7 deletions
@@ -159,7 +159,7 @@
159 159 # rather than reached through quasi-router's re-export because a described
160 160 # screen names FieldKind and Tone directly; it has to track what quasi-router
161 161 # resolves or the two `layout::` paths are different crates.
162 - makeover-layout = "0.13.0"
162 + makeover-layout = "0.14.0"
163 163 # For the request head the per-viewer state factory reads. axum re-exports it,
164 164 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
165 165 http = "1.3.1"
@@ -236,7 +236,7 @@
236 236 # Emits static/geometry.css (makeover-geometry) and static/layout.css
237 237 # (makeover-webview). The same generator GO and BB run; only the output paths
238 238 # differ, since the server serves its stylesheets rather than bundling them.
239 - makeover-build = "0.19.0"
239 + makeover-build = "0.20.1"
240 240 # Read directly for the embeds' own copy of the spacing layer, which they need
241 241 # because an iframe cannot link a stylesheet. makeover-build does not re-export
242 242 # it, so the pin lives here and has to track the one makeover-build resolves.
@@ -45,7 +45,7 @@
45 45 const REGION: &str = "tab-content";
46 46
47 47 /// The address a revoke calls, relative to this screen's own nest.
48 - const REVOKE: &str = "/revoke/{seller_id}";
48 + const REVOKE: &str = "/revoke/:seller_id";
49 49
50 50 /// The writes this screen serves. Registered under its nest by `super::mount`.
51 51 pub const WRITES: &[(Method, &str, super::Screen)] = &[(Method::Delete, REVOKE, revoke)];
@@ -60,10 +60,10 @@
60 60 const REGION: &str = "settings-body";
61 61
62 62 /// The address removing a key calls, relative to this screen's own nest.
63 - const REMOVE_KEY: &str = "/keys/{id}";
63 + const REMOVE_KEY: &str = "/keys/:id";
64 64
65 65 /// The address revoking a token calls, relative to this screen's own nest.
66 - const REVOKE_TOKEN: &str = "/tokens/{id}";
66 + const REVOKE_TOKEN: &str = "/tokens/:id";
67 67
68 68 /// The writes this screen serves. Registered under its nest by `super::mount`.
69 69 pub const WRITES: &[(Method, &str, super::Screen)] = &[
@@ -292,7 +292,7 @@
292 292 field = field.value(chosen.id.clone());
293 293 }
294 294 Node::Form {
295 - action: Action::post("/api/users/me/console-theme"),
295 + action: Action::put("/api/users/me/console-theme"),
296 296 submit: "Save Theme".into(),
297 297 fields: vec![field],
298 298 }
@@ -447,7 +447,12 @@
447 447 founder_window_open: opts.founder_window_open,
448 448 },
449 449 integrations: IntegrationsConfig {
450 - mt_base_url: None,
450 + // Was hardcoded `None` while `BuildOptions::mt_base_url` fed
451 + // `mt_client` alone, so a test could ask for Multithreaded and
452 + // get a client with no config behind it. Anything reading the
453 + // config (the described forum-memberships screens do) saw it as
454 + // unconfigured and refused. Default is still `None`.
455 + mt_base_url: opts.mt_base_url.clone(),
451 456 wam_url: None,
452 457 internal_shared_secret: opts.internal_shared_secret.clone(),
453 458 cli_service_token: opts.cli_service_token.clone(),
@@ -47,6 +47,7 @@
47 47 mod db_transactions_layer;
48 48 mod db_users_layer;
49 49 mod db_webhook_events;
50 + mod described_screens;
50 51 mod discover;
51 52 mod docs_site;
52 53 mod embeds;
@@ -1,0 +1,331 @@
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 + /// Removing a key removes it, and answers with the pane rather than a list.
214 + ///
215 + /// The defect this file exists for, pinned end to end: seed a key, find the
216 + /// control the description emitted for it, press it, and read both what came
217 + /// back and what is left in the database.
218 + #[tokio::test]
219 + async fn pressing_remove_on_a_key_removes_it_and_redraws_the_pane() {
220 + let mut h = viewing("user_ssh_keys").await;
221 +
222 + // A real key, added the way the screen's own form adds one.
223 + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB2n4ZVGJoGZ8pM5vJXVv0kL3T5V7wQ9dNqR8mY1uH6c";
224 + let added = h
225 + .client
226 + .post_form(
227 + "/api/users/me/ssh-keys",
228 + &format!("public_key={}&label=fw13", urlencoding::encode(key)),
229 + )
230 + .await;
231 + assert!(
232 + added.status.is_success(),
233 + "seeding a key failed: {} {}",
234 + added.status,
235 + added.text
236 + );
237 +
238 + let html = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
239 + assert!(html.contains("fw13"), "the key is on the screen: {html}");
240 +
241 + let remove = controls(&html)
242 + .into_iter()
243 + .find(|c| c.method == "DELETE" && c.address.contains("/keys/"))
244 + .expect("the description emitted a Remove control");
245 +
246 + let resp = h.client.htmx_delete(&remove.address).await;
247 + assert_eq!(
248 + resp.status, 200,
249 + "pressing Remove answered {} at {}",
250 + resp.status, remove.address
251 + );
252 +
253 + // The answer is this screen's own pane, named as such. Before 2026-08-11
254 + // the control addressed the API route, whose answer is the Askama list
255 + // fragment with no retarget: htmx put that table inside the button.
256 + assert_eq!(
257 + resp.headers
258 + .get("HX-Retarget")
259 + .and_then(|v| v.to_str().ok())
260 + .unwrap_or_default(),
261 + "#settings-body",
262 + "the answer did not name the pane it changed"
263 + );
264 + assert!(
265 + !resp.text.contains("fw13"),
266 + "the removed key is still drawn: {}",
267 + resp.text
268 + );
269 + assert!(
270 + resp.text.contains("No SSH keys registered."),
271 + "the answer is the pane, empty: {}",
272 + resp.text
273 + );
274 +
275 + // And it is gone for the next reader, not only from this response.
276 + let after = h.client.htmx_get("/dashboard/tabs/ssh-keys").await.text;
277 + assert!(!after.contains("fw13"), "the key came back: {after}");
278 + }
279 +
280 + /// A destructive control answers with a region, whatever screen it is on.
281 + ///
282 + /// The generalisation of the test above, and the one that will catch the next
283 + /// conversion rather than this one. Any described `DELETE` must answer with the
284 + /// screen's own region: an answer that names nothing is an answer htmx puts
285 + /// inside the pressed button, and an answer that names another screen's region
286 + /// swaps the wrong part of the page.
287 + #[tokio::test]
288 + async fn a_described_delete_answers_with_the_screen_it_is_on() {
289 + for (screen, path, region) in SCREENS {
290 + let mut h = viewing(screen).await;
291 + let html = h.client.htmx_get(path).await.text;
292 +
293 + for control in controls(&html).into_iter().filter(|c| c.method == "DELETE") {
294 + let resp = h.client.htmx_delete(&control.address).await;
295 + if !resp.status.is_success() {
296 + // A delete of something this seeded account does not have is
297 + // fine; what is not fine is a success that says nothing.
298 + continue;
299 + }
300 + let retarget = resp
301 + .headers
302 + .get("HX-Retarget")
303 + .and_then(|v| v.to_str().ok())
304 + .unwrap_or_default();
305 + assert_eq!(
306 + retarget,
307 + format!("#{region}"),
308 + "{screen}: DELETE {} succeeded without naming {region}",
309 + control.address
310 + );
311 + }
312 + }
313 + }
314 +
315 + /// The screen list here covers every screen the description layer can mount.
316 + ///
317 + /// `quasi::PATHS` is the authority and is already checked against `mounts`, so
318 + /// this closes the loop: a screen added there but not here would never be
319 + /// pressed, and the suite would keep passing while covering less.
320 + #[tokio::test]
321 + async fn every_described_screen_is_pressed() {
322 + let mut pressed: Vec<&str> = SCREENS.iter().map(|(_, path, _)| *path).collect();
323 + pressed.sort_unstable();
324 + let mut mountable = quasi::PATHS.to_vec();
325 + mountable.sort_unstable();
326 +
327 + assert_eq!(
328 + pressed, mountable,
329 + "the pressed screens and the mountable ones have diverged"
330 + );
331 + }