| 1 | |
| 2 | |
| 3 | |
| 4 | mod common; |
| 5 | |
| 6 | use std::path::{Path, PathBuf}; |
| 7 | use std::process::Command; |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use common::{BIN, Server, describe, launch, pick_ports, write_config}; |
| 11 | use forge_core::Store; |
| 12 | use reqwest::header::{ACCEPT_LANGUAGE, COOKIE, HeaderMap, SET_COOKIE}; |
| 13 | |
| 14 | const PW: &str = "alice-password-123"; |
| 15 | |
| 16 | struct Resp { |
| 17 | status: u16, |
| 18 | headers: HeaderMap, |
| 19 | body: String, |
| 20 | } |
| 21 | |
| 22 | impl 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 | |
| 32 | struct Http { |
| 33 | client: reqwest::Client, |
| 34 | base: String, |
| 35 | } |
| 36 | |
| 37 | impl 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 | |
| 84 | fn 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 | |
| 93 | fn 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 | |
| 104 | fn 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 | |
| 120 | async 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 | |
| 142 | struct Env { |
| 143 | _root: tempfile::TempDir, |
| 144 | _server: Server, |
| 145 | http: Http, |
| 146 | } |
| 147 | |
| 148 | |
| 149 | async fn start<F, Fut>(seed: F) -> Env |
| 150 | where |
| 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] |
| 173 | async 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 | |
| 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] |
| 210 | async 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 | |
| 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] |
| 237 | async 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 | |
| 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 | } |