forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
20.2 KiB
1//! Black-box test for rename and transfer. It seeds users and repos through the store, starts the
2//! real `forge` binary, and drives the rename/transfer forms, the redirects, and the git clone
3//! URLs of the old names over HTTP.
4//!
5//! Cookies are handled by hand because the session cookie is `__Host-forge_session` with `Secure`,
6//! which a cookie jar would not send over plain http.
7
8mod common;
9use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config};
10use std::fs;
11use std::path::Path;
12use std::process::{Command, Output};
13use std::time::Duration;
14
15use forge_core::{Role, Store, Visibility};
16use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
17
18const PW: &str = "alice-password-123";
19
20struct Resp {
21 status: u16,
22 headers: HeaderMap,
23 body: String,
24}
25
26impl Resp {
27 fn header(&self, name: HeaderName) -> Option<String> {
28 self.headers
29 .get(name)
30 .and_then(|v| v.to_str().ok())
31 .map(str::to_owned)
32 }
33
34 fn set_cookies(&self) -> Vec<String> {
35 self.headers
36 .get_all(SET_COOKIE)
37 .iter()
38 .filter_map(|v| v.to_str().ok().map(str::to_owned))
39 .collect()
40 }
41}
42
43struct Http {
44 client: reqwest::Client,
45 base: String,
46}
47
48impl Http {
49 fn new(port: u16) -> Self {
50 let client = reqwest::Client::builder()
51 .redirect(reqwest::redirect::Policy::none())
52 .timeout(Duration::from_secs(60))
53 .build()
54 .expect("http client");
55 Self {
56 client,
57 base: format!("http://127.0.0.1:{port}"),
58 }
59 }
60
61 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
62 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
63 .await
64 }
65
66 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
67 self.send(
68 self.client.post(format!("{}{path}", self.base)).form(form),
69 cookie,
70 )
71 .await
72 }
73
74 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
75 let req = match cookie {
76 Some(cookie) => req.header(COOKIE, cookie),
77 None => req,
78 };
79 let res = req.send().await.expect("http request");
80 let status = res.status().as_u16();
81 let headers = res.headers().clone();
82 let body = res.text().await.expect("read body");
83 Resp {
84 status,
85 headers,
86 body,
87 }
88 }
89}
90
91/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
92fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
93 resp.set_cookies().iter().find_map(|set| {
94 let pair = set.split(';').next()?;
95 let (name, value) = pair.split_once('=')?;
96 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
97 })
98}
99
100/// The value of the first `<input name="...">` on a page.
101fn input_value(body: &str, name: &str) -> String {
102 let doc = scraper::Html::parse_document(body);
103 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
104 doc.select(&selector)
105 .next()
106 .and_then(|el| el.value().attr("value"))
107 .unwrap_or_else(|| panic!("no input named {name} on page"))
108 .to_owned()
109}
110
111/// The `action` of the first form on a page whose action ends with `suffix`.
112fn form_action(body: &str, suffix: &str) -> String {
113 let doc = scraper::Html::parse_document(body);
114 let selector = scraper::Selector::parse("form").expect("selector");
115 doc.select(&selector)
116 .filter_map(|el| el.value().attr("action"))
117 .find(|action| action.ends_with(suffix))
118 .unwrap_or_else(|| panic!("no form ending in {suffix} on page"))
119 .to_owned()
120}
121
122/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
123fn admin(config: &Path, args: &[&str]) {
124 let out = Command::new(BIN)
125 .arg("--config")
126 .arg(config)
127 .arg("admin")
128 .args(args)
129 .env("FORGE_PASSWORD", PW)
130 .output()
131 .expect("run admin command");
132 assert!(
133 out.status.success(),
134 "admin {args:?} failed:\n{}",
135 describe(&out)
136 );
137}
138
139async fn login(http: &Http, username: &str) -> String {
140 let page = http.get("/login", None).await;
141 assert_eq!(page.status, 200, "GET /login");
142 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
143 let precsrf = input_value(&page.body, "precsrf");
144 let res = http
145 .post_form(
146 "/login",
147 &[
148 ("username", username),
149 ("password", PW),
150 ("next", "/"),
151 ("precsrf", &precsrf),
152 ],
153 Some(&precsrf_cookie),
154 )
155 .await;
156 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
157 cookie_named(&res, "forge_session").expect("session cookie after login")
158}
159
160/// The session's CSRF token, read from a page every signed-in user can open.
161async fn csrf_for(http: &Http, session: &str) -> String {
162 let page = http.get("/settings/tokens", Some(session)).await;
163 assert_eq!(page.status, 200, "GET /settings/tokens");
164 input_value(&page.body, "csrf")
165}
166
167/// Creates an access token through `/settings/tokens` and returns the token shown once.
168async fn create_token(http: &Http, session: &str, name: &str) -> String {
169 let csrf = csrf_for(http, session).await;
170 let res = http
171 .post_form(
172 "/settings/tokens",
173 &[("csrf", &csrf), ("name", name), ("expires", "30")],
174 Some(session),
175 )
176 .await;
177 assert_eq!(res.status, 200, "create token: {}", res.body);
178 let doc = scraper::Html::parse_document(&res.body);
179 let selector = scraper::Selector::parse("pre.code code").expect("selector");
180 let token: String = doc
181 .select(&selector)
182 .next()
183 .expect("token shown after creation")
184 .text()
185 .collect();
186 let token = token.trim().to_owned();
187 assert!(token.starts_with("forge_"), "unexpected token shape");
188 token
189}
190
191/// Creates a public repo for the signed-in user through `/new`. Returns the response.
192async fn create_repo_web(http: &Http, session: &str, name: &str) -> Resp {
193 let page = http.get("/new", Some(session)).await;
194 assert_eq!(page.status, 200, "GET /new");
195 let csrf = input_value(&page.body, "csrf");
196 http.post_form(
197 "/new",
198 &[
199 ("csrf", &csrf),
200 ("name", name),
201 ("description", ""),
202 ("visibility", "public"),
203 ("default_branch", "main"),
204 ],
205 Some(session),
206 )
207 .await
208}
209
210/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
211fn init_bare(data_dir: &Path, repo_id: &str) {
212 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
213 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
214 let out = Command::new("git")
215 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
216 .arg(&path)
217 .output()
218 .expect("run git init");
219 assert!(out.status.success(), "git init --bare: {}", describe(&out));
220}
221
222fn git(cwd: &Path, args: &[&str]) -> Output {
223 Command::new("git")
224 .current_dir(cwd)
225 .args(["-c", "credential.helper="])
226 .args(args)
227 .env("GIT_TERMINAL_PROMPT", "0")
228 .output()
229 .expect("run git")
230}
231
232fn ok(out: &Output, what: &str) {
233 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
234}
235
236fn path_str(path: &Path) -> &str {
237 path.to_str().expect("utf-8 path")
238}
239
240/// Writes a file under `repo` (creating its directory) and commits it as `bob`.
241fn commit_file(repo: &Path, name: &str, content: &str, message: &str) {
242 let file = repo.join(name);
243 fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
244 fs::write(&file, content).expect("write file");
245 ok(&git(repo, &["config", "user.name", "bob"]), "set user.name");
246 ok(
247 &git(repo, &["config", "user.email", "bob@example.com"]),
248 "set user.email",
249 );
250 ok(&git(repo, &["add", name]), "git add");
251 ok(
252 &git(
253 repo,
254 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
255 ),
256 "git commit",
257 );
258}
259
260#[tokio::test]
261async fn repo_rename_end_to_end() {
262 if !tool_on_path("git") {
263 eprintln!("SKIP repo_rename_e2e: `git` was not found on PATH");
264 return;
265 }
266
267 // Keep the path short: the data dir holds a unix socket with a length limit.
268 let root = tempfile::Builder::new()
269 .prefix("frn")
270 .tempdir_in("/tmp")
271 .expect("temp dir");
272 let dir = root.path().to_path_buf();
273 let ports = pick_ports();
274 let config = write_config(&dir, ports);
275
276 // Alice is the instance admin. Bob owns the repos. Carol has Write on `pub` and Dave has Admin
277 // on it. Eve is an unrelated signed-in user.
278 admin(&config, &["create-user", "alice", "--admin"]);
279 for name in ["bob", "carol", "dave", "eve"] {
280 admin(&config, &["create-user", name]);
281 }
282 {
283 let store = Store::open(&dir.join("forge.db"))
284 .await
285 .expect("open store");
286 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
287 let carol = store
288 .user_by_username("carol")
289 .await
290 .unwrap()
291 .expect("carol");
292 let dave = store.user_by_username("dave").await.unwrap().expect("dave");
293 let pub_repo = store
294 .create_repo(&bob.id, "pub", "", Visibility::Public, "main")
295 .await
296 .expect("pub repo");
297 let priv_repo = store
298 .create_repo(&bob.id, "priv", "", Visibility::Private, "main")
299 .await
300 .expect("priv repo");
301 let secret = store
302 .create_repo(&bob.id, "secret", "", Visibility::Private, "main")
303 .await
304 .expect("secret repo");
305 store
306 .set_collaborator(&pub_repo.id, &carol.id, Role::Write)
307 .await
308 .expect("carol write");
309 store
310 .set_collaborator(&pub_repo.id, &dave.id, Role::Admin)
311 .await
312 .expect("dave admin");
313 for repo in [&pub_repo, &priv_repo, &secret] {
314 init_bare(&dir, &repo.id);
315 }
316 }
317
318 let (server, ports) = launch(&dir, ports).await;
319 let http = Http::new(ports.http);
320 let bob = login(&http, "bob").await;
321 let carol = login(&http, "carol").await;
322 let dave = login(&http, "dave").await;
323 let eve = login(&http, "eve").await;
324 let bob_token = create_token(&http, &bob, "rename-e2e").await;
325
326 // Bob pushes a commit with a `src` directory to the public repo, so the tree path can be checked.
327 let bob_url = |name: &str| {
328 format!(
329 "http://bob:{bob_token}@127.0.0.1:{}/bob/{name}.git",
330 ports.http
331 )
332 };
333 let work = dir.join("work");
334 ok(
335 &git(&dir, &["clone", &bob_url("pub"), path_str(&work)]),
336 "clone empty pub",
337 );
338 commit_file(&work, "src/main.rs", "fn main() {}\n", "initial");
339 ok(
340 &git(&work, &["push", "origin", "HEAD:main"]),
341 "push initial",
342 );
343 let tree = http.get("/bob/pub/tree/main/src", None).await;
344 assert_eq!(tree.status, 200, "tree before rename");
345
346 // Bob renames `pub` to `newpub`. The form sends him to the new settings URL.
347 let csrf = csrf_for(&http, &bob).await;
348 let renamed = http
349 .post_form(
350 "/bob/pub/settings/rename",
351 &[("csrf", &csrf), ("name", "newpub")],
352 Some(&bob),
353 )
354 .await;
355 assert_eq!(renamed.status, 303, "rename: {}", renamed.body);
356 assert_eq!(
357 renamed.header(LOCATION).as_deref(),
358 Some("/bob/newpub/settings/rename")
359 );
360 assert_eq!(
361 http.get("/bob/newpub/tree/main/src", None).await.status,
362 200
363 );
364
365 // The old web URL 301s to the same path under the new name, and keeps the query.
366 let old_tree = http.get("/bob/pub/tree/main/src", None).await;
367 assert_eq!(old_tree.status, 301, "old tree URL");
368 assert_eq!(
369 old_tree.header(LOCATION).as_deref(),
370 Some("/bob/newpub/tree/main/src")
371 );
372 let old_query = http.get("/bob/pub?tab=x", None).await;
373 assert_eq!(old_query.status, 301, "old repo URL with query");
374 assert_eq!(
375 old_query.header(LOCATION).as_deref(),
376 Some("/bob/newpub?tab=x")
377 );
378
379 // The old clone URL still fetches and pushes over HTTPS.
380 ok(
381 &git(&work, &["remote", "set-url", "origin", &bob_url("pub")]),
382 "set old remote",
383 );
384 commit_file(&work, "src/lib.rs", "pub fn lib() {}\n", "after rename");
385 ok(
386 &git(&work, &["push", "origin", "HEAD:main"]),
387 "push to old clone URL",
388 );
389 let fresh = dir.join("fresh");
390 ok(
391 &git(&dir, &["clone", &bob_url("pub"), path_str(&fresh)]),
392 "fetch via old clone URL",
393 );
394 assert!(fresh.join("src/lib.rs").exists(), "push via old URL landed");
395 let new_clone = dir.join("new-clone");
396 ok(
397 &git(
398 &dir,
399 &[
400 "clone",
401 &format!(
402 "http://bob:{bob_token}@127.0.0.1:{}/bob/newpub.git",
403 ports.http
404 ),
405 path_str(&new_clone),
406 ],
407 ),
408 "clone via new URL",
409 );
410 assert!(new_clone.join("src/lib.rs").exists());
411
412 // A name taken by another repo, and an invalid name, are rejected and change nothing.
413 let csrf = csrf_for(&http, &bob).await;
414 let taken = http
415 .post_form(
416 "/bob/newpub/settings/rename",
417 &[("csrf", &csrf), ("name", "priv")],
418 Some(&bob),
419 )
420 .await;
421 assert_eq!(taken.status, 200, "conflicting rename re-renders");
422 assert!(taken.body.contains("already exists"), "conflict message");
423 let invalid = http
424 .post_form(
425 "/bob/newpub/settings/rename",
426 &[("csrf", &csrf), ("name", "Bad Name")],
427 Some(&bob),
428 )
429 .await;
430 assert_eq!(invalid.status, 200, "invalid rename re-renders");
431 assert!(invalid.body.contains("only A-Z"), "invalid name message");
432 assert_eq!(
433 http.get("/bob/newpub", None).await.status,
434 200,
435 "name unchanged after rejected renames"
436 );
437
438 // A Write collaborator cannot rename (403) and the name stays.
439 let carol_csrf = csrf_for(&http, &carol).await;
440 let carol_rename = http
441 .post_form(
442 "/bob/newpub/settings/rename",
443 &[("csrf", &carol_csrf), ("name", "hacked")],
444 Some(&carol),
445 )
446 .await;
447 assert_eq!(carol_rename.status, 403, "write collaborator rename");
448 assert_eq!(http.get("/bob/newpub", None).await.status, 200);
449
450 // An Admin collaborator cannot transfer (403). The repo stays with bob.
451 let dave_csrf = csrf_for(&http, &dave).await;
452 let dave_transfer = http
453 .post_form(
454 "/bob/newpub/settings/transfer",
455 &[
456 ("csrf", &dave_csrf),
457 ("new_owner", "dave"),
458 ("confirm", "bob/newpub"),
459 ],
460 Some(&dave),
461 )
462 .await;
463 assert_eq!(dave_transfer.status, 403, "admin collaborator transfer");
464 assert_eq!(http.get("/bob/newpub", None).await.status, 200);
465
466 // Bob transfers the repo to carol. The URL moves to carol, the old one redirects.
467 let csrf = csrf_for(&http, &bob).await;
468 let transfer_wrong = http
469 .post_form(
470 "/bob/newpub/settings/transfer",
471 &[
472 ("csrf", &csrf),
473 ("new_owner", "carol"),
474 ("confirm", "newpub"),
475 ],
476 Some(&bob),
477 )
478 .await;
479 assert_eq!(transfer_wrong.status, 200, "wrong confirmation re-renders");
480 assert!(transfer_wrong.body.contains("exactly to confirm"));
481 let transfer = http
482 .post_form(
483 "/bob/newpub/settings/transfer",
484 &[
485 ("csrf", &csrf),
486 ("new_owner", "carol"),
487 ("confirm", "bob/newpub"),
488 ],
489 Some(&bob),
490 )
491 .await;
492 // The owner only offers the repo: it stays with bob until carol accepts in her inbox.
493 assert_eq!(transfer.status, 303, "transfer offered: {}", transfer.body);
494 assert_eq!(
495 transfer.header(LOCATION).as_deref(),
496 Some("/bob/newpub/transfer")
497 );
498 assert_eq!(
499 http.get("/bob/newpub/tree/main/src", None).await.status,
500 200,
501 "repo unchanged until accepted"
502 );
503 let offered = http.get("/bob/newpub/transfer", Some(&bob)).await;
504 assert!(offered.body.contains("Pending transfer to carol"));
505 let inbox = http.get("/notifications", Some(&carol)).await;
506 let accept_action = form_action(&inbox.body, "/accept");
507 let carol_accept_csrf = csrf_for(&http, &carol).await;
508 let accepted = http
509 .post_form(
510 &accept_action,
511 &[("csrf", &carol_accept_csrf)],
512 Some(&carol),
513 )
514 .await;
515 assert_eq!(accepted.status, 303, "accept: {}", accepted.body);
516 assert_eq!(accepted.header(LOCATION).as_deref(), Some("/carol/newpub"));
517 assert_eq!(
518 http.get("/carol/newpub/tree/main/src", None).await.status,
519 200
520 );
521 let moved = http.get("/bob/newpub/tree/main/src", None).await;
522 assert_eq!(moved.status, 301, "old owner and name");
523 assert_eq!(
524 moved.header(LOCATION).as_deref(),
525 Some("/carol/newpub/tree/main/src")
526 );
527
528 // A chain: bob/pub -> bob/newpub -> carol/newpub. The oldest name still redirects, and its
529 // clone URL still works for an anonymous fetch of this public repo.
530 let chained = http.get("/bob/pub/tree/main/src", None).await;
531 assert_eq!(chained.status, 301, "chained redirect");
532 assert_eq!(
533 chained.header(LOCATION).as_deref(),
534 Some("/carol/newpub/tree/main/src")
535 );
536 let anon_clone = dir.join("anon-clone");
537 ok(
538 &git(
539 &dir,
540 &[
541 "clone",
542 &format!("http://127.0.0.1:{}/bob/pub.git", ports.http),
543 path_str(&anon_clone),
544 ],
545 ),
546 "anonymous clone via chained old name",
547 );
548 assert!(anon_clone.join("src/main.rs").exists());
549
550 // A new repo created with an old name takes over: no redirect, the new repo is served.
551 let takeover = create_repo_web(&http, &bob, "pub").await;
552 assert_eq!(takeover.status, 303, "create pub again: {}", takeover.body);
553 assert_eq!(takeover.header(LOCATION).as_deref(), Some("/bob/pub"));
554 let taken_over = http.get("/bob/pub", None).await;
555 assert_eq!(taken_over.status, 200, "new repo takes over the old name");
556 assert_eq!(
557 http.get("/bob/newpub", None).await.status,
558 301,
559 "the other old name still redirects"
560 );
561
562 // A private repo renamed away: an anonymous or unrelated viewer gets 404, the owner gets 301.
563 let csrf = csrf_for(&http, &bob).await;
564 let secret_rename = http
565 .post_form(
566 "/bob/secret/settings/rename",
567 &[("csrf", &csrf), ("name", "secret2")],
568 Some(&bob),
569 )
570 .await;
571 assert_eq!(secret_rename.status, 303, "rename private repo");
572 assert_eq!(http.get("/bob/secret", None).await.status, 404, "anonymous");
573 assert_eq!(
574 http.get("/bob/secret", Some(&eve)).await.status,
575 404,
576 "unrelated user"
577 );
578 assert_eq!(
579 http.get("/bob/nothere", Some(&eve)).await.status,
580 404,
581 "missing repo looks the same"
582 );
583 let owner_view = http.get("/bob/secret", Some(&bob)).await;
584 assert_eq!(owner_view.status, 301, "owner follows the redirect");
585 assert_eq!(owner_view.header(LOCATION).as_deref(), Some("/bob/secret2"));
586 // Git clients get the same answer for a hidden repo as for a missing one.
587 let hidden_git = http
588 .get("/bob/secret.git/info/refs?service=git-upload-pack", None)
589 .await;
590 let missing_git = http
591 .get("/bob/nothere.git/info/refs?service=git-upload-pack", None)
592 .await;
593 assert_eq!(hidden_git.status, 401, "hidden repo over git");
594 assert_eq!(
595 hidden_git.status, missing_git.status,
596 "hidden matches missing"
597 );
598
599 drop(server);
600}