//! End-to-end checks for the interface language. Anonymous pages follow `Accept-Language`; a //! signed-in user's stored language beats the browser; `` matches the language used. mod common; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; use common::{BIN, Server, describe, launch, pick_ports, write_config}; use forge_core::Store; use reqwest::header::{ACCEPT_LANGUAGE, COOKIE, HeaderMap, SET_COOKIE}; const PW: &str = "alice-password-123"; struct Resp { status: u16, headers: HeaderMap, body: String, } impl Resp { fn set_cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect() } } struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get(&self, path: &str, cookie: Option<&str>, language: Option<&str>) -> Resp { let mut req = self.client.get(format!("{}{path}", self.base)); if let Some(language) = language { req = req.header(ACCEPT_LANGUAGE, language); } self.send(req, cookie).await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { self.send( self.client.post(format!("{}{path}", self.base)).form(form), cookie, ) .await } async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { let req = match cookie { Some(cookie) => req.header(COOKIE, cookie), None => req, }; let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let headers = res.headers().clone(); let body = res.text().await.expect("read body"); Resp { status, headers, body, } } } /// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies().iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded. fn admin(config: &Path, args: &[&str]) { let out = Command::new(BIN) .arg("--config") .arg(config) .arg("admin") .args(args) .env("FORGE_PASSWORD", PW) .output() .expect("run admin command"); assert!( out.status.success(), "admin {args:?} failed:\n{}", describe(&out) ); } async fn login(http: &Http, username: &str) -> String { let page = http.get("/login", None, None).await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", PW), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// A running server plus the temp dir it lives in. The dir lasts as long as the test. struct Env { _root: tempfile::TempDir, _server: Server, http: Http, } /// Creates alice, then runs `seed` against the store before the server starts. async fn start(seed: F) -> Env where F: FnOnce(PathBuf, Store) -> Fut, Fut: std::future::Future, { let root = tempfile::tempdir().expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); admin(&config, &["create-user", "alice"]); let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); seed(dir.clone(), store).await; let (server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); Env { _root: root, _server: server, http, } } #[tokio::test] async fn anonymous_pages_follow_the_browser_language() { let env = start(|_, _| async {}).await; let turkish = env .http .get("/login", None, Some("tr-TR,tr;q=0.9,en;q=0.8")) .await; assert_eq!(turkish.status, 200); assert!( turkish.body.contains(""), "html lang is tr" ); assert!(turkish.body.contains("Giriş yap"), "Turkish button label"); assert!( turkish.body.contains("Kullanıcı adı"), "Turkish username label" ); assert!( !turkish.body.contains("Username"), "no English label left" ); let english = env.http.get("/login", None, Some("en-US,en;q=0.9")).await; assert!( english.body.contains(""), "html lang is en" ); assert!(english.body.contains("Username"), "English label"); // No header, and a language this build does not have: English. let plain = env.http.get("/login", None, None).await; assert!(plain.body.contains("")); let german = env.http.get("/login", None, Some("de-DE")).await; assert!(german.body.contains("")); } #[tokio::test] async fn stored_language_beats_the_browser_for_a_signed_in_user() { let env = start(|_, store| async move { let alice = store .user_by_username("alice") .await .expect("lookup") .expect("alice"); store .set_ui_prefs(&alice.id, "auto", "tr") .await .expect("set language"); }) .await; let cookie = login(&env.http, "alice").await; // The browser asks for English, but alice's stored preference is Turkish. let page = env .http .get("/settings/security", Some(&cookie), Some("en-US,en;q=0.9")) .await; assert_eq!(page.status, 200, "security settings page"); assert!(page.body.contains(""), "html lang is tr"); assert!(page.body.contains("Güvenlik"), "Turkish heading"); assert!(page.body.contains("Parolayı değiştir"), "Turkish button"); } #[tokio::test] async fn releases_page_follows_the_stored_turkish_language() { let env = start(|_, store| async move { let alice = store .user_by_username("alice") .await .expect("lookup") .expect("alice"); store .set_ui_prefs(&alice.id, "auto", "tr") .await .expect("set language"); }) .await; let cookie = login(&env.http, "alice").await; let new_repo = env.http.get("/new", Some(&cookie), None).await; assert_eq!(new_repo.status, 200, "GET /new"); let csrf = input_value(&new_repo.body, "csrf"); let created = env .http .post_form( "/new", &[ ("csrf", &csrf), ("name", "r"), ("description", ""), ("visibility", "public"), ("default_branch", "main"), ], Some(&cookie), ) .await; assert_eq!(created.status, 303, "create repo: {}", created.body); // The browser asks for English, but alice's stored preference is Turkish. let releases = env .http .get("/alice/r/releases", Some(&cookie), Some("en-US,en;q=0.9")) .await; assert_eq!(releases.status, 200, "releases page"); assert!( releases.body.contains("

Sürümler

"), "Turkish heading" ); assert!(releases.body.contains("Yeni sürüm"), "Turkish new button"); assert!( !releases.body.contains("

Releases

"), "no English heading" ); }