forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
8.7 KiB
1//! End-to-end checks for the interface language. Anonymous pages follow `Accept-Language`; a
2//! signed-in user's stored language beats the browser; `<html lang>` matches the language used.
3
4mod common;
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::time::Duration;
9
10use common::{BIN, Server, describe, launch, pick_ports, write_config};
11use forge_core::Store;
12use reqwest::header::{ACCEPT_LANGUAGE, COOKIE, HeaderMap, 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 set_cookies(&self) -> Vec<String> {
24 self.headers
25 .get_all(SET_COOKIE)
26 .iter()
27 .filter_map(|v| v.to_str().ok().map(str::to_owned))
28 .collect()
29 }
30}
31
32struct Http {
33 client: reqwest::Client,
34 base: String,
35}
36
37impl Http {
38 fn new(port: u16) -> Self {
39 let client = reqwest::Client::builder()
40 .redirect(reqwest::redirect::Policy::none())
41 .timeout(Duration::from_secs(60))
42 .build()
43 .expect("http client");
44 Self {
45 client,
46 base: format!("http://127.0.0.1:{port}"),
47 }
48 }
49
50 async fn get(&self, path: &str, cookie: Option<&str>, language: Option<&str>) -> Resp {
51 let mut req = self.client.get(format!("{}{path}", self.base));
52 if let Some(language) = language {
53 req = req.header(ACCEPT_LANGUAGE, language);
54 }
55 self.send(req, cookie).await
56 }
57
58 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
59 self.send(
60 self.client.post(format!("{}{path}", self.base)).form(form),
61 cookie,
62 )
63 .await
64 }
65
66 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
67 let req = match cookie {
68 Some(cookie) => req.header(COOKIE, cookie),
69 None => req,
70 };
71 let res = req.send().await.expect("http request");
72 let status = res.status().as_u16();
73 let headers = res.headers().clone();
74 let body = res.text().await.expect("read body");
75 Resp {
76 status,
77 headers,
78 body,
79 }
80 }
81}
82
83/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
84fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
85 resp.set_cookies().iter().find_map(|set| {
86 let pair = set.split(';').next()?;
87 let (name, value) = pair.split_once('=')?;
88 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
89 })
90}
91
92/// The value of the first `<input name="...">` on a page.
93fn input_value(body: &str, name: &str) -> String {
94 let doc = scraper::Html::parse_document(body);
95 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
96 doc.select(&selector)
97 .next()
98 .and_then(|el| el.value().attr("value"))
99 .unwrap_or_else(|| panic!("no input named {name} on page"))
100 .to_owned()
101}
102
103/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
104fn admin(config: &Path, args: &[&str]) {
105 let out = Command::new(BIN)
106 .arg("--config")
107 .arg(config)
108 .arg("admin")
109 .args(args)
110 .env("FORGE_PASSWORD", PW)
111 .output()
112 .expect("run admin command");
113 assert!(
114 out.status.success(),
115 "admin {args:?} failed:\n{}",
116 describe(&out)
117 );
118}
119
120async fn login(http: &Http, username: &str) -> String {
121 let page = http.get("/login", None, None).await;
122 assert_eq!(page.status, 200, "GET /login");
123 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
124 let precsrf = input_value(&page.body, "precsrf");
125 let res = http
126 .post_form(
127 "/login",
128 &[
129 ("username", username),
130 ("password", PW),
131 ("next", "/"),
132 ("precsrf", &precsrf),
133 ],
134 Some(&precsrf_cookie),
135 )
136 .await;
137 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
138 cookie_named(&res, "forge_session").expect("session cookie after login")
139}
140
141/// A running server plus the temp dir it lives in. The dir lasts as long as the test.
142struct Env {
143 _root: tempfile::TempDir,
144 _server: Server,
145 http: Http,
146}
147
148/// Creates alice, then runs `seed` against the store before the server starts.
149async fn start<F, Fut>(seed: F) -> Env
150where
151 F: FnOnce(PathBuf, Store) -> Fut,
152 Fut: std::future::Future<Output = ()>,
153{
154 let root = tempfile::tempdir().expect("temp dir");
155 let dir = root.path().to_path_buf();
156 let ports = pick_ports();
157 let config = write_config(&dir, ports);
158 admin(&config, &["create-user", "alice"]);
159 let store = Store::open(&dir.join("forge.db"))
160 .await
161 .expect("open store");
162 seed(dir.clone(), store).await;
163 let (server, ports) = launch(&dir, ports).await;
164 let http = Http::new(ports.http);
165 Env {
166 _root: root,
167 _server: server,
168 http,
169 }
170}
171
172#[tokio::test]
173async fn anonymous_pages_follow_the_browser_language() {
174 let env = start(|_, _| async {}).await;
175
176 let turkish = env
177 .http
178 .get("/login", None, Some("tr-TR,tr;q=0.9,en;q=0.8"))
179 .await;
180 assert_eq!(turkish.status, 200);
181 assert!(
182 turkish.body.contains("<html lang=\"tr\""),
183 "html lang is tr"
184 );
185 assert!(turkish.body.contains("Giriş yap"), "Turkish button label");
186 assert!(
187 turkish.body.contains("Kullanıcı adı"),
188 "Turkish username label"
189 );
190 assert!(
191 !turkish.body.contains("Username</span>"),
192 "no English label left"
193 );
194
195 let english = env.http.get("/login", None, Some("en-US,en;q=0.9")).await;
196 assert!(
197 english.body.contains("<html lang=\"en\""),
198 "html lang is en"
199 );
200 assert!(english.body.contains("Username"), "English label");
201
202 // No header, and a language this build does not have: English.
203 let plain = env.http.get("/login", None, None).await;
204 assert!(plain.body.contains("<html lang=\"en\""));
205 let german = env.http.get("/login", None, Some("de-DE")).await;
206 assert!(german.body.contains("<html lang=\"en\""));
207}
208
209#[tokio::test]
210async fn stored_language_beats_the_browser_for_a_signed_in_user() {
211 let env = start(|_, store| async move {
212 let alice = store
213 .user_by_username("alice")
214 .await
215 .expect("lookup")
216 .expect("alice");
217 store
218 .set_ui_prefs(&alice.id, "auto", "tr")
219 .await
220 .expect("set language");
221 })
222 .await;
223 let cookie = login(&env.http, "alice").await;
224
225 // The browser asks for English, but alice's stored preference is Turkish.
226 let page = env
227 .http
228 .get("/settings/security", Some(&cookie), Some("en-US,en;q=0.9"))
229 .await;
230 assert_eq!(page.status, 200, "security settings page");
231 assert!(page.body.contains("<html lang=\"tr\""), "html lang is tr");
232 assert!(page.body.contains("Güvenlik"), "Turkish heading");
233 assert!(page.body.contains("Parolayı değiştir"), "Turkish button");
234}
235
236#[tokio::test]
237async fn releases_page_follows_the_stored_turkish_language() {
238 let env = start(|_, store| async move {
239 let alice = store
240 .user_by_username("alice")
241 .await
242 .expect("lookup")
243 .expect("alice");
244 store
245 .set_ui_prefs(&alice.id, "auto", "tr")
246 .await
247 .expect("set language");
248 })
249 .await;
250 let cookie = login(&env.http, "alice").await;
251
252 let new_repo = env.http.get("/new", Some(&cookie), None).await;
253 assert_eq!(new_repo.status, 200, "GET /new");
254 let csrf = input_value(&new_repo.body, "csrf");
255 let created = env
256 .http
257 .post_form(
258 "/new",
259 &[
260 ("csrf", &csrf),
261 ("name", "r"),
262 ("description", ""),
263 ("visibility", "public"),
264 ("default_branch", "main"),
265 ],
266 Some(&cookie),
267 )
268 .await;
269 assert_eq!(created.status, 303, "create repo: {}", created.body);
270
271 // The browser asks for English, but alice's stored preference is Turkish.
272 let releases = env
273 .http
274 .get("/alice/r/releases", Some(&cookie), Some("en-US,en;q=0.9"))
275 .await;
276 assert_eq!(releases.status, 200, "releases page");
277 assert!(
278 releases.body.contains("<h2>Sürümler</h2>"),
279 "Turkish heading"
280 );
281 assert!(releases.body.contains("Yeni sürüm"), "Turkish new button");
282 assert!(
283 !releases.body.contains("<h2>Releases</h2>"),
284 "no English heading"
285 );
286}