forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
18.1 KiB
1//! Black-box tests for the theme and language preferences: the appearance settings page, the
2//! `data-theme` attribute on `<html>`, and the anonymous footer cookie. Like the other e2e
3//! tests, this starts the real `forge` binary and drives it over HTTP. Cookies are set by hand
4//! because the session cookie is `__Host-` and `Secure`.
5
6mod common;
7use common::{BIN, Server, describe, launch, pick_ports, write_config};
8use std::path::Path;
9use std::process::Command;
10use std::time::Duration;
11
12use reqwest::header::{COOKIE, HeaderMap, HeaderName, SET_COOKIE};
13
14const PW: &str = "alice-password-123";
15
16struct Resp {
17 status: u16,
18 headers: HeaderMap,
19 body: String,
20}
21
22impl Resp {
23 fn header(&self, name: HeaderName) -> Option<String> {
24 self.headers
25 .get(name)
26 .and_then(|v| v.to_str().ok())
27 .map(str::to_owned)
28 }
29
30 fn set_cookies(&self) -> Vec<String> {
31 self.headers
32 .get_all(SET_COOKIE)
33 .iter()
34 .filter_map(|v| v.to_str().ok().map(str::to_owned))
35 .collect()
36 }
37}
38
39struct Http {
40 client: reqwest::Client,
41 base: String,
42}
43
44impl Http {
45 fn new(port: u16) -> Self {
46 let client = reqwest::Client::builder()
47 .redirect(reqwest::redirect::Policy::none())
48 .timeout(Duration::from_secs(60))
49 .build()
50 .expect("http client");
51 Self {
52 client,
53 base: format!("http://127.0.0.1:{port}"),
54 }
55 }
56
57 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
58 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
59 .await
60 }
61
62 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
63 self.send(
64 self.client.post(format!("{}{path}", self.base)).form(form),
65 cookie,
66 )
67 .await
68 }
69
70 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
71 let req = match cookie {
72 Some(cookie) => req.header(COOKIE, cookie),
73 None => req,
74 };
75 let res = req.send().await.expect("http request");
76 let status = res.status().as_u16();
77 let headers = res.headers().clone();
78 let body = res.text().await.expect("read body");
79 Resp {
80 status,
81 headers,
82 body,
83 }
84 }
85}
86
87/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
88fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
89 resp.set_cookies().iter().find_map(|set| {
90 let pair = set.split(';').next()?;
91 let (name, value) = pair.split_once('=')?;
92 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
93 })
94}
95
96/// The value of the first `<input name="...">` on a page.
97fn input_value(body: &str, name: &str) -> String {
98 let doc = scraper::Html::parse_document(body);
99 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
100 doc.select(&selector)
101 .next()
102 .and_then(|el| el.value().attr("value"))
103 .unwrap_or_else(|| panic!("no input named {name} on page"))
104 .to_owned()
105}
106
107/// The `data-theme` attribute of `<html>`, if the page sets one.
108fn html_theme(body: &str) -> Option<String> {
109 let doc = scraper::Html::parse_document(body);
110 doc.root_element().attr("data-theme").map(str::to_owned)
111}
112
113/// The option value that is selected in the `<select name="...">` on a page.
114fn selected_option(body: &str, select: &str) -> Option<String> {
115 let doc = scraper::Html::parse_document(body);
116 let selector = scraper::Selector::parse(&format!("select[name=\"{select}\"] option[selected]"))
117 .expect("selector");
118 doc.select(&selector)
119 .next()
120 .and_then(|el| el.value().attr("value"))
121 .map(str::to_owned)
122}
123
124/// The top-bar theme button: the value it posts and its accessible label.
125fn theme_button(body: &str) -> (String, String) {
126 let doc = scraper::Html::parse_document(body);
127 let form = scraper::Selector::parse("form.theme-form").expect("selector");
128 let form = doc
129 .select(&form)
130 .next()
131 .unwrap_or_else(|| panic!("no theme button on page"));
132 let value = scraper::Selector::parse("input[name=\"theme\"]").expect("selector");
133 let value = form
134 .select(&value)
135 .next()
136 .and_then(|el| el.value().attr("value"))
137 .expect("theme value")
138 .to_owned();
139 let button = scraper::Selector::parse("button").expect("selector");
140 let label = form
141 .select(&button)
142 .next()
143 .and_then(|el| el.value().attr("aria-label"))
144 .expect("aria-label")
145 .to_owned();
146 (value, label)
147}
148
149/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
150fn admin(config: &Path, args: &[&str]) {
151 let out = Command::new(BIN)
152 .arg("--config")
153 .arg(config)
154 .arg("admin")
155 .args(args)
156 .env("FORGE_PASSWORD", PW)
157 .output()
158 .expect("run admin command");
159 assert!(
160 out.status.success(),
161 "admin {args:?} failed:\n{}",
162 describe(&out)
163 );
164}
165
166async fn login(http: &Http, username: &str) -> String {
167 let page = http.get("/login", None).await;
168 assert_eq!(page.status, 200, "GET /login");
169 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
170 let precsrf = input_value(&page.body, "precsrf");
171 let res = http
172 .post_form(
173 "/login",
174 &[
175 ("username", username),
176 ("password", PW),
177 ("next", "/"),
178 ("precsrf", &precsrf),
179 ],
180 Some(&precsrf_cookie),
181 )
182 .await;
183 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
184 cookie_named(&res, "forge_session").expect("session cookie after login")
185}
186
187/// A running server with user `alice`. The temp dir lives as long as the test.
188struct Env {
189 _root: tempfile::TempDir,
190 _server: Server,
191 http: Http,
192}
193
194async fn start() -> Env {
195 let root = tempfile::tempdir().expect("temp dir");
196 let dir = root.path().to_path_buf();
197 let ports = pick_ports();
198 let config = write_config(&dir, ports);
199 admin(&config, &["create-user", "alice"]);
200 let (server, ports) = launch(&dir, ports).await;
201 let http = Http::new(ports.http);
202 Env {
203 _root: root,
204 _server: server,
205 http,
206 }
207}
208
209#[tokio::test]
210async fn appearance_setting_persists_and_renders_the_html_attribute() {
211 let env = start().await;
212 let session = login(&env.http, "alice").await;
213
214 let page = env.http.get("/settings/appearance", Some(&session)).await;
215 assert_eq!(page.status, 200);
216 assert_eq!(
217 selected_option(&page.body, "theme").as_deref(),
218 Some("light")
219 );
220 assert_eq!(
221 html_theme(&page.body).as_deref(),
222 Some("light"),
223 "the default is light, which renders data-theme=light"
224 );
225 let csrf = input_value(&page.body, "csrf");
226
227 let saved = env
228 .http
229 .post_form(
230 "/settings/appearance",
231 &[("csrf", &csrf), ("theme", "dark"), ("language", "tr")],
232 Some(&session),
233 )
234 .await;
235 assert_eq!(saved.status, 303, "save: {}", saved.body);
236 assert_eq!(
237 saved.header(reqwest::header::LOCATION).as_deref(),
238 Some("/settings/appearance?saved=1")
239 );
240
241 let page = env
242 .http
243 .get("/settings/appearance?saved=1", Some(&session))
244 .await;
245 assert_eq!(
246 selected_option(&page.body, "theme").as_deref(),
247 Some("dark")
248 );
249 assert_eq!(
250 selected_option(&page.body, "language").as_deref(),
251 Some("tr")
252 );
253
254 let home = env.http.get("/settings/appearance", Some(&session)).await;
255 assert_eq!(html_theme(&home.body).as_deref(), Some("dark"));
256
257 let page = env.http.get("/settings/appearance", Some(&session)).await;
258 assert_eq!(
259 selected_option(&page.body, "theme").as_deref(),
260 Some("dark")
261 );
262 assert_eq!(
263 selected_option(&page.body, "language").as_deref(),
264 Some("tr")
265 );
266}
267
268#[tokio::test]
269async fn appearance_save_without_a_valid_csrf_token_is_403() {
270 let env = start().await;
271 let session = login(&env.http, "alice").await;
272 let res = env
273 .http
274 .post_form(
275 "/settings/appearance",
276 &[("csrf", "forged"), ("theme", "dark"), ("language", "en")],
277 Some(&session),
278 )
279 .await;
280 assert_eq!(res.status, 403);
281 let page = env.http.get("/settings/appearance", Some(&session)).await;
282 assert_eq!(
283 selected_option(&page.body, "theme").as_deref(),
284 Some("light"),
285 "nothing saved"
286 );
287}
288
289#[tokio::test]
290async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() {
291 let env = start().await;
292 let session = login(&env.http, "alice").await;
293 let page = env.http.get("/settings/appearance", Some(&session)).await;
294 let csrf = input_value(&page.body, "csrf");
295 for form in [
296 [
297 ("csrf", csrf.as_str()),
298 ("theme", "neon"),
299 ("language", "en"),
300 ],
301 [
302 ("csrf", csrf.as_str()),
303 ("theme", "light"),
304 ("language", "de"),
305 ],
306 ] {
307 let res = env
308 .http
309 .post_form("/settings/appearance", &form, Some(&session))
310 .await;
311 assert_ne!(res.status, 303, "bad choice must not redirect as saved");
312 }
313 let page = env.http.get("/settings/appearance", Some(&session)).await;
314 assert_eq!(
315 selected_option(&page.body, "theme").as_deref(),
316 Some("light")
317 );
318 assert_eq!(
319 selected_option(&page.body, "language").as_deref(),
320 Some("auto")
321 );
322}
323
324#[tokio::test]
325async fn anonymous_footer_cookie_sets_the_theme() {
326 let env = start().await;
327 let page = env.http.get("/login", None).await;
328 assert_eq!(
329 html_theme(&page.body).as_deref(),
330 Some("light"),
331 "no cookie defaults to light"
332 );
333 assert!(
334 page.body.contains("action=\"/-/theme\""),
335 "anonymous footer offers the theme form"
336 );
337
338 let set = env
339 .http
340 .post_form("/-/theme", &[("theme", "light")], None)
341 .await;
342 assert_eq!(set.status, 303, "set theme: {}", set.body);
343 let cookie = cookie_named(&set, "forge_theme").expect("forge_theme cookie");
344 assert_eq!(cookie, "forge_theme=light");
345
346 let page = env.http.get("/login", Some(&cookie)).await;
347 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
348}
349
350#[tokio::test]
351async fn anonymous_theme_cookie_ignores_unknown_values() {
352 let env = start().await;
353 let res = env
354 .http
355 .post_form("/-/theme", &[("theme", "neon")], None)
356 .await;
357 assert_ne!(res.status, 303);
358 assert!(cookie_named(&res, "forge_theme").is_none());
359}
360
361#[tokio::test]
362async fn signed_in_preference_wins_over_the_anonymous_cookie() {
363 let env = start().await;
364 let session = login(&env.http, "alice").await;
365 let page = env.http.get("/settings/appearance", Some(&session)).await;
366 assert!(
367 !page.body.contains("class=\"theme-picker\""),
368 "signed-in viewers get no footer theme form"
369 );
370 let cookie = format!("{session}; forge_theme=dark");
371 let page = env.http.get("/settings/appearance", Some(&cookie)).await;
372 assert_eq!(
373 html_theme(&page.body).as_deref(),
374 Some("light"),
375 "a light user ignores the cookie"
376 );
377}
378
379#[tokio::test]
380async fn appearance_page_needs_a_session() {
381 let env = start().await;
382 let res = env.http.get("/settings/appearance", None).await;
383 assert_eq!(res.status, 303);
384 assert!(
385 res.header(reqwest::header::LOCATION)
386 .is_some_and(|l| l.starts_with("/login"))
387 );
388}
389
390#[tokio::test]
391async fn signed_in_toggle_saves_the_preference_and_returns_to_back() {
392 let env = start().await;
393 let session = login(&env.http, "alice").await;
394 let page = env.http.get("/settings/appearance", Some(&session)).await;
395 let csrf = input_value(&page.body, "csrf");
396 let saved = env
397 .http
398 .post_form(
399 "/settings/appearance",
400 &[("csrf", &csrf), ("theme", "auto"), ("language", "tr")],
401 Some(&session),
402 )
403 .await;
404 assert_eq!(saved.status, 303, "save: {}", saved.body);
405
406 let toggle = env
407 .http
408 .post_form(
409 "/-/theme",
410 &[
411 ("csrf", &csrf),
412 ("theme", "dark"),
413 ("back", "/explore?q=rust"),
414 ],
415 Some(&session),
416 )
417 .await;
418 assert_eq!(toggle.status, 303, "toggle: {}", toggle.body);
419 assert_eq!(
420 toggle.header(reqwest::header::LOCATION).as_deref(),
421 Some("/explore?q=rust")
422 );
423 assert!(
424 cookie_named(&toggle, "forge_theme").is_none(),
425 "no cookie for a signed-in user"
426 );
427
428 let page = env.http.get("/settings/appearance", Some(&session)).await;
429 assert_eq!(
430 selected_option(&page.body, "theme").as_deref(),
431 Some("dark")
432 );
433 assert_eq!(
434 selected_option(&page.body, "language").as_deref(),
435 Some("tr"),
436 "language is kept"
437 );
438}
439
440#[tokio::test]
441async fn theme_toggle_with_an_unsafe_back_returns_to_the_root() {
442 let env = start().await;
443 let session = login(&env.http, "alice").await;
444 let page = env.http.get("/settings/appearance", Some(&session)).await;
445 let csrf = input_value(&page.body, "csrf");
446 for back in ["//evil.com", "https://evil.com", "/\\evil"] {
447 let res = env
448 .http
449 .post_form(
450 "/-/theme",
451 &[("csrf", &csrf), ("theme", "light"), ("back", back)],
452 Some(&session),
453 )
454 .await;
455 assert_eq!(res.status, 303, "{back}: {}", res.body);
456 assert_eq!(
457 res.header(reqwest::header::LOCATION).as_deref(),
458 Some("/"),
459 "back {back:?}"
460 );
461 }
462}
463
464#[tokio::test]
465async fn signed_in_theme_toggle_without_a_valid_csrf_is_refused() {
466 let env = start().await;
467 let session = login(&env.http, "alice").await;
468 for form in [
469 vec![("csrf", "forged"), ("theme", "dark")],
470 vec![("theme", "dark")],
471 ] {
472 let res = env.http.post_form("/-/theme", &form, Some(&session)).await;
473 assert_eq!(res.status, 403, "{form:?}: {}", res.body);
474 }
475 let page = env.http.get("/settings/appearance", Some(&session)).await;
476 assert_eq!(
477 selected_option(&page.body, "theme").as_deref(),
478 Some("light"),
479 "nothing saved"
480 );
481}
482
483#[tokio::test]
484async fn anonymous_theme_toggle_sets_the_cookie_and_returns_to_back() {
485 let env = start().await;
486 let set = env
487 .http
488 .post_form("/-/theme", &[("theme", "dark"), ("back", "/login")], None)
489 .await;
490 assert_eq!(set.status, 303, "set theme: {}", set.body);
491 assert_eq!(
492 set.header(reqwest::header::LOCATION).as_deref(),
493 Some("/login")
494 );
495 let cookie = cookie_named(&set, "forge_theme").expect("forge_theme cookie");
496 assert_eq!(cookie, "forge_theme=dark");
497}
498
499#[tokio::test]
500async fn theme_button_cycles_light_dark_auto_light_for_signed_in_users() {
501 let env = start().await;
502 let session = login(&env.http, "alice").await;
503 let back = "/settings/appearance";
504
505 // A new user starts on light, the default, so the button offers dark next.
506 let page = env.http.get(back, Some(&session)).await;
507 let csrf = input_value(&page.body, "csrf");
508 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
509 assert_eq!(
510 theme_button(&page.body),
511 ("dark".into(), "Theme: Light. Switch to Dark".into())
512 );
513
514 let step = env
515 .http
516 .post_form(
517 "/-/theme",
518 &[("csrf", &csrf), ("theme", "dark"), ("back", back)],
519 Some(&session),
520 )
521 .await;
522 assert_eq!(step.status, 303, "light to dark: {}", step.body);
523 let page = env.http.get(back, Some(&session)).await;
524 assert_eq!(html_theme(&page.body).as_deref(), Some("dark"));
525 assert_eq!(
526 theme_button(&page.body),
527 (
528 "auto".into(),
529 "Theme: Dark. Switch to match the system".into()
530 )
531 );
532
533 let step = env
534 .http
535 .post_form(
536 "/-/theme",
537 &[("csrf", &csrf), ("theme", "auto"), ("back", back)],
538 Some(&session),
539 )
540 .await;
541 assert_eq!(step.status, 303, "dark to auto: {}", step.body);
542 let page = env.http.get(back, Some(&session)).await;
543 assert_eq!(
544 selected_option(&page.body, "theme").as_deref(),
545 Some("auto"),
546 "signed-in toggle to auto stores 'auto'"
547 );
548 assert_eq!(html_theme(&page.body), None, "auto renders no data-theme");
549 assert_eq!(
550 theme_button(&page.body),
551 (
552 "light".into(),
553 "Theme: Match the system. Switch to Light".into()
554 )
555 );
556
557 let step = env
558 .http
559 .post_form(
560 "/-/theme",
561 &[("csrf", &csrf), ("theme", "light"), ("back", back)],
562 Some(&session),
563 )
564 .await;
565 assert_eq!(step.status, 303, "auto to light: {}", step.body);
566 let page = env.http.get(back, Some(&session)).await;
567 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
568}
569
570#[tokio::test]
571async fn theme_button_cycles_for_anonymous_visitors_through_the_cookie() {
572 let env = start().await;
573 // No cookie is the default, light, so the button offers dark next.
574 let page = env.http.get("/login", None).await;
575 assert_eq!(
576 theme_button(&page.body),
577 ("dark".into(), "Theme: Light. Switch to Dark".into())
578 );
579
580 let page = env.http.get("/login", Some("forge_theme=light")).await;
581 assert_eq!(
582 theme_button(&page.body),
583 ("dark".into(), "Theme: Light. Switch to Dark".into())
584 );
585 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
586
587 let page = env.http.get("/login", Some("forge_theme=dark")).await;
588 assert_eq!(
589 theme_button(&page.body),
590 (
591 "auto".into(),
592 "Theme: Dark. Switch to match the system".into()
593 )
594 );
595}