forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
17.8 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("auto")
219 );
220 assert_eq!(html_theme(&page.body), None, "auto renders no data-theme");
221 let csrf = input_value(&page.body, "csrf");
222
223 let saved = env
224 .http
225 .post_form(
226 "/settings/appearance",
227 &[("csrf", &csrf), ("theme", "dark"), ("language", "tr")],
228 Some(&session),
229 )
230 .await;
231 assert_eq!(saved.status, 303, "save: {}", saved.body);
232 assert_eq!(
233 saved.header(reqwest::header::LOCATION).as_deref(),
234 Some("/settings/appearance?saved=1")
235 );
236
237 let page = env
238 .http
239 .get("/settings/appearance?saved=1", Some(&session))
240 .await;
241 assert_eq!(
242 selected_option(&page.body, "theme").as_deref(),
243 Some("dark")
244 );
245 assert_eq!(
246 selected_option(&page.body, "language").as_deref(),
247 Some("tr")
248 );
249
250 let home = env.http.get("/settings/appearance", Some(&session)).await;
251 assert_eq!(html_theme(&home.body).as_deref(), Some("dark"));
252
253 let page = env.http.get("/settings/appearance", Some(&session)).await;
254 assert_eq!(
255 selected_option(&page.body, "theme").as_deref(),
256 Some("dark")
257 );
258 assert_eq!(
259 selected_option(&page.body, "language").as_deref(),
260 Some("tr")
261 );
262}
263
264#[tokio::test]
265async fn appearance_save_without_a_valid_csrf_token_is_403() {
266 let env = start().await;
267 let session = login(&env.http, "alice").await;
268 let res = env
269 .http
270 .post_form(
271 "/settings/appearance",
272 &[("csrf", "forged"), ("theme", "dark"), ("language", "en")],
273 Some(&session),
274 )
275 .await;
276 assert_eq!(res.status, 403);
277 let page = env.http.get("/settings/appearance", Some(&session)).await;
278 assert_eq!(
279 selected_option(&page.body, "theme").as_deref(),
280 Some("auto"),
281 "nothing saved"
282 );
283}
284
285#[tokio::test]
286async fn unoffered_theme_or_language_is_rejected_and_nothing_is_saved() {
287 let env = start().await;
288 let session = login(&env.http, "alice").await;
289 let page = env.http.get("/settings/appearance", Some(&session)).await;
290 let csrf = input_value(&page.body, "csrf");
291 for form in [
292 [
293 ("csrf", csrf.as_str()),
294 ("theme", "neon"),
295 ("language", "en"),
296 ],
297 [
298 ("csrf", csrf.as_str()),
299 ("theme", "light"),
300 ("language", "de"),
301 ],
302 ] {
303 let res = env
304 .http
305 .post_form("/settings/appearance", &form, Some(&session))
306 .await;
307 assert_ne!(res.status, 303, "bad choice must not redirect as saved");
308 }
309 let page = env.http.get("/settings/appearance", Some(&session)).await;
310 assert_eq!(
311 selected_option(&page.body, "theme").as_deref(),
312 Some("auto")
313 );
314 assert_eq!(
315 selected_option(&page.body, "language").as_deref(),
316 Some("auto")
317 );
318}
319
320#[tokio::test]
321async fn anonymous_footer_cookie_sets_the_theme() {
322 let env = start().await;
323 let page = env.http.get("/login", None).await;
324 assert_eq!(html_theme(&page.body), None, "no cookie, no data-theme");
325 assert!(
326 page.body.contains("action=\"/-/theme\""),
327 "anonymous footer offers the theme form"
328 );
329
330 let set = env
331 .http
332 .post_form("/-/theme", &[("theme", "light")], None)
333 .await;
334 assert_eq!(set.status, 303, "set theme: {}", set.body);
335 let cookie = cookie_named(&set, "forge_theme").expect("forge_theme cookie");
336 assert_eq!(cookie, "forge_theme=light");
337
338 let page = env.http.get("/login", Some(&cookie)).await;
339 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
340}
341
342#[tokio::test]
343async fn anonymous_theme_cookie_ignores_unknown_values() {
344 let env = start().await;
345 let res = env
346 .http
347 .post_form("/-/theme", &[("theme", "neon")], None)
348 .await;
349 assert_ne!(res.status, 303);
350 assert!(cookie_named(&res, "forge_theme").is_none());
351}
352
353#[tokio::test]
354async fn signed_in_preference_wins_over_the_anonymous_cookie() {
355 let env = start().await;
356 let session = login(&env.http, "alice").await;
357 let page = env.http.get("/settings/appearance", Some(&session)).await;
358 assert!(
359 !page.body.contains("class=\"theme-picker\""),
360 "signed-in viewers get no footer theme form"
361 );
362 let cookie = format!("{session}; forge_theme=dark");
363 let page = env.http.get("/settings/appearance", Some(&cookie)).await;
364 assert_eq!(html_theme(&page.body), None, "auto user ignores the cookie");
365}
366
367#[tokio::test]
368async fn appearance_page_needs_a_session() {
369 let env = start().await;
370 let res = env.http.get("/settings/appearance", None).await;
371 assert_eq!(res.status, 303);
372 assert!(
373 res.header(reqwest::header::LOCATION)
374 .is_some_and(|l| l.starts_with("/login"))
375 );
376}
377
378#[tokio::test]
379async fn signed_in_toggle_saves_the_preference_and_returns_to_back() {
380 let env = start().await;
381 let session = login(&env.http, "alice").await;
382 let page = env.http.get("/settings/appearance", Some(&session)).await;
383 let csrf = input_value(&page.body, "csrf");
384 let saved = env
385 .http
386 .post_form(
387 "/settings/appearance",
388 &[("csrf", &csrf), ("theme", "auto"), ("language", "tr")],
389 Some(&session),
390 )
391 .await;
392 assert_eq!(saved.status, 303, "save: {}", saved.body);
393
394 let toggle = env
395 .http
396 .post_form(
397 "/-/theme",
398 &[
399 ("csrf", &csrf),
400 ("theme", "dark"),
401 ("back", "/explore?q=rust"),
402 ],
403 Some(&session),
404 )
405 .await;
406 assert_eq!(toggle.status, 303, "toggle: {}", toggle.body);
407 assert_eq!(
408 toggle.header(reqwest::header::LOCATION).as_deref(),
409 Some("/explore?q=rust")
410 );
411 assert!(
412 cookie_named(&toggle, "forge_theme").is_none(),
413 "no cookie for a signed-in user"
414 );
415
416 let page = env.http.get("/settings/appearance", Some(&session)).await;
417 assert_eq!(
418 selected_option(&page.body, "theme").as_deref(),
419 Some("dark")
420 );
421 assert_eq!(
422 selected_option(&page.body, "language").as_deref(),
423 Some("tr"),
424 "language is kept"
425 );
426}
427
428#[tokio::test]
429async fn theme_toggle_with_an_unsafe_back_returns_to_the_root() {
430 let env = start().await;
431 let session = login(&env.http, "alice").await;
432 let page = env.http.get("/settings/appearance", Some(&session)).await;
433 let csrf = input_value(&page.body, "csrf");
434 for back in ["//evil.com", "https://evil.com", "/\\evil"] {
435 let res = env
436 .http
437 .post_form(
438 "/-/theme",
439 &[("csrf", &csrf), ("theme", "light"), ("back", back)],
440 Some(&session),
441 )
442 .await;
443 assert_eq!(res.status, 303, "{back}: {}", res.body);
444 assert_eq!(
445 res.header(reqwest::header::LOCATION).as_deref(),
446 Some("/"),
447 "back {back:?}"
448 );
449 }
450}
451
452#[tokio::test]
453async fn signed_in_theme_toggle_without_a_valid_csrf_is_refused() {
454 let env = start().await;
455 let session = login(&env.http, "alice").await;
456 for form in [
457 vec![("csrf", "forged"), ("theme", "dark")],
458 vec![("theme", "dark")],
459 ] {
460 let res = env.http.post_form("/-/theme", &form, Some(&session)).await;
461 assert_eq!(res.status, 403, "{form:?}: {}", res.body);
462 }
463 let page = env.http.get("/settings/appearance", Some(&session)).await;
464 assert_eq!(
465 selected_option(&page.body, "theme").as_deref(),
466 Some("auto"),
467 "nothing saved"
468 );
469}
470
471#[tokio::test]
472async fn anonymous_theme_toggle_sets_the_cookie_and_returns_to_back() {
473 let env = start().await;
474 let set = env
475 .http
476 .post_form("/-/theme", &[("theme", "dark"), ("back", "/login")], None)
477 .await;
478 assert_eq!(set.status, 303, "set theme: {}", set.body);
479 assert_eq!(
480 set.header(reqwest::header::LOCATION).as_deref(),
481 Some("/login")
482 );
483 let cookie = cookie_named(&set, "forge_theme").expect("forge_theme cookie");
484 assert_eq!(cookie, "forge_theme=dark");
485}
486
487#[tokio::test]
488async fn theme_button_cycles_auto_light_dark_for_signed_in_users() {
489 let env = start().await;
490 let session = login(&env.http, "alice").await;
491 let back = "/settings/appearance";
492
493 let page = env.http.get(back, Some(&session)).await;
494 let csrf = input_value(&page.body, "csrf");
495 assert_eq!(
496 theme_button(&page.body),
497 ("light".into(), "Theme: Match the system. Switch to Light".into())
498 );
499
500 let step = env
501 .http
502 .post_form(
503 "/-/theme",
504 &[("csrf", &csrf), ("theme", "light"), ("back", back)],
505 Some(&session),
506 )
507 .await;
508 assert_eq!(step.status, 303, "auto to light: {}", step.body);
509 let page = env.http.get(back, Some(&session)).await;
510 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
511 assert_eq!(
512 theme_button(&page.body),
513 ("dark".into(), "Theme: Light. Switch to Dark".into())
514 );
515
516 let step = env
517 .http
518 .post_form(
519 "/-/theme",
520 &[("csrf", &csrf), ("theme", "dark"), ("back", back)],
521 Some(&session),
522 )
523 .await;
524 assert_eq!(step.status, 303, "light to dark: {}", step.body);
525 let page = env.http.get(back, Some(&session)).await;
526 assert_eq!(html_theme(&page.body).as_deref(), Some("dark"));
527 assert_eq!(
528 theme_button(&page.body),
529 (
530 "auto".into(),
531 "Theme: Dark. Switch to match the system".into()
532 )
533 );
534
535 let step = env
536 .http
537 .post_form(
538 "/-/theme",
539 &[("csrf", &csrf), ("theme", "auto"), ("back", back)],
540 Some(&session),
541 )
542 .await;
543 assert_eq!(step.status, 303, "dark to auto: {}", step.body);
544 let page = env.http.get(back, Some(&session)).await;
545 assert_eq!(
546 selected_option(&page.body, "theme").as_deref(),
547 Some("auto"),
548 "signed-in toggle to auto stores 'auto'"
549 );
550 assert_eq!(html_theme(&page.body), None, "auto renders no data-theme");
551 assert_eq!(
552 theme_button(&page.body),
553 ("light".into(), "Theme: Match the system. Switch to Light".into())
554 );
555}
556
557#[tokio::test]
558async fn theme_button_cycles_for_anonymous_visitors_through_the_cookie() {
559 let env = start().await;
560 let page = env.http.get("/login", None).await;
561 assert_eq!(
562 theme_button(&page.body),
563 ("light".into(), "Theme: Match the system. Switch to Light".into())
564 );
565
566 let page = env.http.get("/login", Some("forge_theme=light")).await;
567 assert_eq!(
568 theme_button(&page.body),
569 ("dark".into(), "Theme: Light. Switch to Dark".into())
570 );
571 assert_eq!(html_theme(&page.body).as_deref(), Some("light"));
572
573 let page = env.http.get("/login", Some("forge_theme=dark")).await;
574 assert_eq!(
575 theme_button(&page.body),
576 ("auto".into(), "Theme: Dark. Switch to match the system".into())
577 );
578}