| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | mod common; |
| 9 | use common::{BIN, describe, launch, pick_ports, write_config}; |
| 10 | use std::fs; |
| 11 | use std::path::Path; |
| 12 | use std::process::{Command, Output}; |
| 13 | use std::time::Duration; |
| 14 | |
| 15 | use reqwest::header::{LOCATION, SET_COOKIE}; |
| 16 | |
| 17 | const ALICE_PW: &str = "alice-password-123"; |
| 18 | const FILE_SIZE: usize = 100 * 1024; |
| 19 | |
| 20 | |
| 21 | struct Resp { |
| 22 | status: u16, |
| 23 | location: Option<String>, |
| 24 | set_cookies: Vec<String>, |
| 25 | body: String, |
| 26 | } |
| 27 | |
| 28 | struct Http { |
| 29 | client: reqwest::Client, |
| 30 | base: String, |
| 31 | } |
| 32 | |
| 33 | impl Http { |
| 34 | fn new(port: u16) -> Self { |
| 35 | let client = reqwest::Client::builder() |
| 36 | .redirect(reqwest::redirect::Policy::none()) |
| 37 | .timeout(Duration::from_secs(60)) |
| 38 | .build() |
| 39 | .expect("http client"); |
| 40 | Self { |
| 41 | client, |
| 42 | base: format!("http://127.0.0.1:{port}"), |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { |
| 47 | self.send(self.client.get(format!("{}{path}", self.base)), cookie) |
| 48 | .await |
| 49 | } |
| 50 | |
| 51 | async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { |
| 52 | self.send( |
| 53 | self.client.post(format!("{}{path}", self.base)).form(form), |
| 54 | cookie, |
| 55 | ) |
| 56 | .await |
| 57 | } |
| 58 | |
| 59 | async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { |
| 60 | let req = match cookie { |
| 61 | Some(cookie) => req.header(reqwest::header::COOKIE, cookie), |
| 62 | None => req, |
| 63 | }; |
| 64 | let res = req.send().await.expect("http request"); |
| 65 | let status = res.status().as_u16(); |
| 66 | let location = res |
| 67 | .headers() |
| 68 | .get(LOCATION) |
| 69 | .and_then(|v| v.to_str().ok()) |
| 70 | .map(str::to_owned); |
| 71 | let set_cookies = res |
| 72 | .headers() |
| 73 | .get_all(SET_COOKIE) |
| 74 | .iter() |
| 75 | .filter_map(|v| v.to_str().ok().map(str::to_owned)) |
| 76 | .collect(); |
| 77 | let body = res.text().await.unwrap_or_default(); |
| 78 | Resp { |
| 79 | status, |
| 80 | location, |
| 81 | set_cookies, |
| 82 | body, |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | |
| 88 | fn 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 | |
| 97 | fn 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 | fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { |
| 108 | let out = Command::new(BIN) |
| 109 | .arg("--config") |
| 110 | .arg(config) |
| 111 | .args(["admin", "create-user", username]) |
| 112 | .args(extra) |
| 113 | .env("FORGE_PASSWORD", password) |
| 114 | .output() |
| 115 | .expect("run admin create-user"); |
| 116 | ok(&out, &format!("create-user {username}")); |
| 117 | } |
| 118 | |
| 119 | fn ok(out: &Output, what: &str) { |
| 120 | assert!(out.status.success(), "{what} failed:\n{}", describe(out)); |
| 121 | } |
| 122 | |
| 123 | |
| 124 | fn git(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> Output { |
| 125 | let mut cmd = Command::new("git"); |
| 126 | cmd.current_dir(cwd) |
| 127 | .args(["-c", "credential.helper="]) |
| 128 | .args(args) |
| 129 | .env("GIT_TERMINAL_PROMPT", "0"); |
| 130 | for (key, value) in env { |
| 131 | cmd.env(key, value); |
| 132 | } |
| 133 | cmd.output().expect("run git") |
| 134 | } |
| 135 | |
| 136 | fn path_str(path: &Path) -> &str { |
| 137 | path.to_str().expect("utf-8 path") |
| 138 | } |
| 139 | |
| 140 | fn git_lfs_installed() -> bool { |
| 141 | Command::new("git") |
| 142 | .args(["lfs", "version"]) |
| 143 | .output() |
| 144 | .is_ok_and(|out| out.status.success()) |
| 145 | } |
| 146 | |
| 147 | |
| 148 | fn payload() -> Vec<u8> { |
| 149 | (0..FILE_SIZE) |
| 150 | .map(|i| (i.wrapping_mul(31) % 251) as u8) |
| 151 | .collect() |
| 152 | } |
| 153 | |
| 154 | |
| 155 | async fn login(http: &Http, username: &str, password: &str) -> String { |
| 156 | let page = http.get("/login", None).await; |
| 157 | assert_eq!(page.status, 200, "GET /login"); |
| 158 | let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); |
| 159 | let precsrf = input_value(&page.body, "precsrf"); |
| 160 | let res = http |
| 161 | .post_form( |
| 162 | "/login", |
| 163 | &[ |
| 164 | ("username", username), |
| 165 | ("password", password), |
| 166 | ("next", "/"), |
| 167 | ("precsrf", &precsrf), |
| 168 | ], |
| 169 | Some(&precsrf_cookie), |
| 170 | ) |
| 171 | .await; |
| 172 | assert_eq!(res.status, 303, "login as {username}: {}", res.body); |
| 173 | cookie_named(&res, "forge_session").expect("session cookie after login") |
| 174 | } |
| 175 | |
| 176 | |
| 177 | async fn create_token(http: &Http, session: &str, name: &str) -> String { |
| 178 | let page = http.get("/settings/tokens", Some(session)).await; |
| 179 | assert_eq!(page.status, 200, "GET /settings/tokens"); |
| 180 | let csrf = input_value(&page.body, "csrf"); |
| 181 | let res = http |
| 182 | .post_form( |
| 183 | "/settings/tokens", |
| 184 | &[("csrf", &csrf), ("name", name), ("expires", "30")], |
| 185 | Some(session), |
| 186 | ) |
| 187 | .await; |
| 188 | assert_eq!(res.status, 200, "create token: {}", res.body); |
| 189 | let doc = scraper::Html::parse_document(&res.body); |
| 190 | let selector = scraper::Selector::parse("pre.code code").expect("selector"); |
| 191 | let token: String = doc |
| 192 | .select(&selector) |
| 193 | .next() |
| 194 | .expect("token shown after creation") |
| 195 | .text() |
| 196 | .collect(); |
| 197 | let token = token.trim().to_owned(); |
| 198 | assert!(token.starts_with("forge_"), "unexpected token shape"); |
| 199 | token |
| 200 | } |
| 201 | |
| 202 | |
| 203 | async fn create_repo(http: &Http, session: &str, name: &str, visibility: &str) { |
| 204 | let page = http.get("/new", Some(session)).await; |
| 205 | assert_eq!(page.status, 200, "GET /new"); |
| 206 | let csrf = input_value(&page.body, "csrf"); |
| 207 | let res = http |
| 208 | .post_form( |
| 209 | "/new", |
| 210 | &[ |
| 211 | ("csrf", &csrf), |
| 212 | ("name", name), |
| 213 | ("description", ""), |
| 214 | ("visibility", visibility), |
| 215 | ("default_branch", "main"), |
| 216 | ], |
| 217 | Some(session), |
| 218 | ) |
| 219 | .await; |
| 220 | assert_eq!(res.status, 303, "create repo {name}: {}", res.body); |
| 221 | assert_eq!( |
| 222 | res.location.as_deref(), |
| 223 | Some(format!("/alice/{name}").as_str()) |
| 224 | ); |
| 225 | } |
| 226 | |
| 227 | #[tokio::test] |
| 228 | async fn lfs_end_to_end() { |
| 229 | if !git_lfs_installed() { |
| 230 | eprintln!("skipping: git-lfs not installed"); |
| 231 | return; |
| 232 | } |
| 233 | |
| 234 | |
| 235 | let root = tempfile::Builder::new() |
| 236 | .prefix("flfs") |
| 237 | .tempdir_in("/tmp") |
| 238 | .expect("temp dir"); |
| 239 | let dir = root.path().to_path_buf(); |
| 240 | let ports = pick_ports(); |
| 241 | let config = write_config(&dir, ports); |
| 242 | run_admin(&config, "alice", &["--admin"], ALICE_PW); |
| 243 | |
| 244 | let (_server, ports) = launch(&dir, ports).await; |
| 245 | let http = Http::new(ports.http); |
| 246 | |
| 247 | let session = login(&http, "alice", ALICE_PW).await; |
| 248 | let token = create_token(&http, &session, "lfs-e2e").await; |
| 249 | create_repo(&http, &session, "lfs", "public").await; |
| 250 | |
| 251 | |
| 252 | let global = dir.join("gitconfig"); |
| 253 | let iso = [ |
| 254 | ("HOME", path_str(&dir)), |
| 255 | ("GIT_CONFIG_GLOBAL", path_str(&global)), |
| 256 | ]; |
| 257 | let url = format!( |
| 258 | "http://alice:{token}@127.0.0.1:{}/alice/lfs.git", |
| 259 | ports.http |
| 260 | ); |
| 261 | |
| 262 | |
| 263 | let work = dir.join("work"); |
| 264 | ok( |
| 265 | &git(&dir, &["clone", &url, path_str(&work)], &iso), |
| 266 | "clone of empty repo", |
| 267 | ); |
| 268 | ok( |
| 269 | &git(&work, &["config", "user.name", "alice"], &iso), |
| 270 | "user.name", |
| 271 | ); |
| 272 | ok( |
| 273 | &git(&work, &["config", "user.email", "alice@example.com"], &iso), |
| 274 | "user.email", |
| 275 | ); |
| 276 | ok( |
| 277 | &git(&work, &["lfs", "install", "--local"], &iso), |
| 278 | "git lfs install", |
| 279 | ); |
| 280 | ok( |
| 281 | &git(&work, &["lfs", "track", "*.bin"], &iso), |
| 282 | "git lfs track", |
| 283 | ); |
| 284 | let content = payload(); |
| 285 | fs::write(work.join("data.bin"), &content).expect("write data.bin"); |
| 286 | ok( |
| 287 | &git(&work, &["add", ".gitattributes", "data.bin"], &iso), |
| 288 | "git add", |
| 289 | ); |
| 290 | ok( |
| 291 | &git( |
| 292 | &work, |
| 293 | &[ |
| 294 | "-c", |
| 295 | "commit.gpgsign=false", |
| 296 | "commit", |
| 297 | "-q", |
| 298 | "-m", |
| 299 | "add lfs file", |
| 300 | ], |
| 301 | &iso, |
| 302 | ), |
| 303 | "git commit", |
| 304 | ); |
| 305 | ok( |
| 306 | &git(&work, &["push", "origin", "HEAD:main"], &iso), |
| 307 | "git push", |
| 308 | ); |
| 309 | |
| 310 | |
| 311 | let verify = dir.join("verify"); |
| 312 | let smudge = [iso[0], iso[1], ("GIT_LFS_SKIP_SMUDGE", "0")]; |
| 313 | ok( |
| 314 | &git(&dir, &["clone", &url, path_str(&verify)], &smudge), |
| 315 | "clone for verification", |
| 316 | ); |
| 317 | ok( |
| 318 | &git(&verify, &["lfs", "install", "--local"], &iso), |
| 319 | "git lfs install in verify clone", |
| 320 | ); |
| 321 | ok(&git(&verify, &["lfs", "pull"], &smudge), "git lfs pull"); |
| 322 | let fetched = fs::read(verify.join("data.bin")).expect("read fetched data.bin"); |
| 323 | assert!( |
| 324 | fetched == content, |
| 325 | "downloaded LFS bytes differ from the pushed file ({} vs {} bytes)", |
| 326 | fetched.len(), |
| 327 | content.len() |
| 328 | ); |
| 329 | |
| 330 | |
| 331 | let page = http |
| 332 | .get("/alice/lfs/blob/main/data.bin", Some(&session)) |
| 333 | .await; |
| 334 | assert_eq!(page.status, 200, "GET blob page"); |
| 335 | assert!( |
| 336 | page.body.contains("Stored with Git LFS"), |
| 337 | "blob page does not show the LFS note" |
| 338 | ); |
| 339 | } |