|
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("&", "&");
|
|
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 |
+ |
}
|