forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
15.4 KiB
1//! Black-box tests for stars, the explore page, the starred profile section and the archived
2//! banner. Like the other e2e tests, this starts the real `forge` binary and drives it over
3//! HTTP. Cookies are set by hand because the session cookie is `__Host-` and `Secure`.
4
5mod common;
6use common::{BIN, Server, describe, launch, pick_ports, tool_on_path, write_config};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::time::Duration;
11
12use forge_core::{Store, Visibility};
13use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
14
15const PW: &str = "alice-password-123";
16
17struct Resp {
18 status: u16,
19 headers: HeaderMap,
20 body: String,
21}
22
23impl Resp {
24 fn header(&self, name: HeaderName) -> Option<String> {
25 self.headers
26 .get(name)
27 .and_then(|v| v.to_str().ok())
28 .map(str::to_owned)
29 }
30
31 fn set_cookies(&self) -> Vec<String> {
32 self.headers
33 .get_all(SET_COOKIE)
34 .iter()
35 .filter_map(|v| v.to_str().ok().map(str::to_owned))
36 .collect()
37 }
38}
39
40struct Http {
41 client: reqwest::Client,
42 base: String,
43}
44
45impl Http {
46 fn new(port: u16) -> Self {
47 let client = reqwest::Client::builder()
48 .redirect(reqwest::redirect::Policy::none())
49 .timeout(Duration::from_secs(60))
50 .build()
51 .expect("http client");
52 Self {
53 client,
54 base: format!("http://127.0.0.1:{port}"),
55 }
56 }
57
58 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
59 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
60 .await
61 }
62
63 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
64 self.send(
65 self.client.post(format!("{}{path}", self.base)).form(form),
66 cookie,
67 )
68 .await
69 }
70
71 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
72 let req = match cookie {
73 Some(cookie) => req.header(COOKIE, cookie),
74 None => req,
75 };
76 let res = req.send().await.expect("http request");
77 let status = res.status().as_u16();
78 let headers = res.headers().clone();
79 let body = res.text().await.expect("read body");
80 Resp {
81 status,
82 headers,
83 body,
84 }
85 }
86}
87
88/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
89fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
90 resp.set_cookies().iter().find_map(|set| {
91 let pair = set.split(';').next()?;
92 let (name, value) = pair.split_once('=')?;
93 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
94 })
95}
96
97/// The value of the first `<input name="...">` on a page.
98fn input_value(body: &str, name: &str) -> String {
99 let doc = scraper::Html::parse_document(body);
100 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
101 doc.select(&selector)
102 .next()
103 .and_then(|el| el.value().attr("value"))
104 .unwrap_or_else(|| panic!("no input named {name} on page"))
105 .to_owned()
106}
107
108/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
109fn admin(config: &Path, args: &[&str]) {
110 let out = Command::new(BIN)
111 .arg("--config")
112 .arg(config)
113 .arg("admin")
114 .args(args)
115 .env("FORGE_PASSWORD", PW)
116 .output()
117 .expect("run admin command");
118 assert!(
119 out.status.success(),
120 "admin {args:?} failed:\n{}",
121 describe(&out)
122 );
123}
124
125async fn login(http: &Http, username: &str) -> String {
126 let page = http.get("/login", None).await;
127 assert_eq!(page.status, 200, "GET /login");
128 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
129 let precsrf = input_value(&page.body, "precsrf");
130 let res = http
131 .post_form(
132 "/login",
133 &[
134 ("username", username),
135 ("password", PW),
136 ("next", "/"),
137 ("precsrf", &precsrf),
138 ],
139 Some(&precsrf_cookie),
140 )
141 .await;
142 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
143 cookie_named(&res, "forge_session").expect("session cookie after login")
144}
145
146/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
147fn init_bare(data_dir: &Path, repo_id: &str) {
148 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
149 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
150 let out = Command::new("git")
151 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
152 .arg(&path)
153 .output()
154 .expect("run git init");
155 assert!(out.status.success(), "git init --bare: {}", describe(&out));
156}
157
158/// A running server plus the data it was seeded with. The temp dir lives as long as the test.
159struct Env {
160 _root: tempfile::TempDir,
161 _server: Server,
162 http: Http,
163}
164
165/// Creates the users and config, then runs `seed` against the store before the server starts.
166/// The seed closure receives the data dir (for bare repos) and the open store.
167async fn start<F, Fut>(seed: F) -> Env
168where
169 F: FnOnce(PathBuf, Store) -> Fut,
170 Fut: std::future::Future<Output = ()>,
171{
172 let root = tempfile::Builder::new()
173 .prefix("fst")
174 .tempdir_in("/tmp")
175 .expect("temp dir");
176 let dir = root.path().to_path_buf();
177 let ports = pick_ports();
178 let config = write_config(&dir, ports);
179 admin(&config, &["create-user", "alice"]);
180 admin(&config, &["create-user", "bob"]);
181 let store = Store::open(&dir.join("forge.db"))
182 .await
183 .expect("open store");
184 seed(dir.clone(), store).await;
185 let (server, ports) = launch(&dir, ports).await;
186 let http = Http::new(ports.http);
187 Env {
188 _root: root,
189 _server: server,
190 http,
191 }
192}
193
194/// Alice owns `pub` (public), `priv` (private) and `pct%x` (public, for literal-search checks).
195async fn seed_repos(dir: PathBuf, store: Store) {
196 let alice = store
197 .user_by_username("alice")
198 .await
199 .unwrap()
200 .expect("alice");
201 for (name, vis) in [
202 ("pub", Visibility::Public),
203 ("priv", Visibility::Private),
204 ("pct%x", Visibility::Public),
205 ] {
206 let repo = store
207 .create_repo(&alice.id, name, "", vis, "main")
208 .await
209 .expect("create repo");
210 init_bare(&dir, &repo.id);
211 }
212}
213
214/// Writes one commit with a README to the bare repo's `main`, using plumbing only (no hooks).
215fn commit_main(data_dir: &Path, repo_id: &str) {
216 let id = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
217 let blob = plumbing(&id, &["hash-object", "-w", "--stdin"], b"hello\n");
218 let tree = plumbing(
219 &id,
220 &["mktree"],
221 format!("100644 blob {blob}\tREADME.md\n").as_bytes(),
222 );
223 let commit = plumbing(&id, &["commit-tree", &tree, "-m", "init"], b"");
224 let _ = plumbing(&id, &["update-ref", "refs/heads/main", &commit], b"");
225}
226
227/// Runs git against the bare repo at `path` with fixed identities and returns trimmed stdout.
228fn plumbing(path: &Path, args: &[&str], stdin: &[u8]) -> String {
229 use std::io::Write;
230 let mut child = Command::new("git")
231 .arg("--git-dir")
232 .arg(path)
233 .args(args)
234 .env("GIT_AUTHOR_NAME", "t")
235 .env("GIT_AUTHOR_EMAIL", "t@example.com")
236 .env("GIT_COMMITTER_NAME", "t")
237 .env("GIT_COMMITTER_EMAIL", "t@example.com")
238 .stdin(Stdio::piped())
239 .stdout(Stdio::piped())
240 .stderr(Stdio::piped())
241 .spawn()
242 .expect("run git");
243 child
244 .stdin
245 .take()
246 .expect("stdin")
247 .write_all(stdin)
248 .expect("write stdin");
249 let out = child.wait_with_output().expect("git output");
250 assert!(out.status.success(), "git {args:?}: {}", describe(&out));
251 String::from_utf8_lossy(&out.stdout).trim().to_owned()
252}
253
254/// Text of every `.repo-name` link on a page, in document order.
255fn repo_names(body: &str) -> Vec<String> {
256 let doc = scraper::Html::parse_document(body);
257 let selector = scraper::Selector::parse(".repo-name").expect("selector");
258 doc.select(&selector)
259 .map(|el| el.text().collect::<String>().trim().to_owned())
260 .collect()
261}
262
263/// Sends a star POST with an optional Referer header.
264async fn post_star(
265 http: &Http,
266 path: &str,
267 csrf: &str,
268 cookie: Option<&str>,
269 referer: Option<&str>,
270) -> Resp {
271 let mut req = http
272 .client
273 .post(format!("{}{path}", http.base))
274 .form(&[("csrf", csrf)]);
275 if let Some(referer) = referer {
276 req = req.header(reqwest::header::REFERER, referer);
277 }
278 http.send(req, cookie).await
279}
280
281#[tokio::test]
282async fn star_round_trip_shows_the_count() {
283 if !tool_on_path("git") {
284 eprintln!("SKIP stars_e2e: `git` was not found on PATH");
285 return;
286 }
287 let env = start(seed_repos).await;
288 let bob = login(&env.http, "bob").await;
289
290 let page = env.http.get("/alice/pub", Some(&bob)).await;
291 assert_eq!(page.status, 200, "repo page");
292 assert!(
293 page.body.contains("Star 0"),
294 "star button with zero: {}",
295 page.body
296 );
297 let csrf = input_value(&page.body, "csrf");
298
299 let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await;
300 assert_eq!(res.status, 303, "star: {}", res.body);
301 assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub"));
302 let page = env.http.get("/alice/pub", Some(&bob)).await;
303 assert!(page.body.contains("Unstar 1"), "after star: {}", page.body);
304
305 // Back to the tree page through the Referer, when it is a same-origin path.
306 let referer = format!("{}/alice/pub/tree/main", env.http.base);
307 let res = post_star(
308 &env.http,
309 "/alice/pub/unstar",
310 &csrf,
311 Some(&bob),
312 Some(&referer),
313 )
314 .await;
315 assert_eq!(res.status, 303, "unstar: {}", res.body);
316 assert_eq!(
317 res.header(LOCATION).as_deref(),
318 Some("/alice/pub/tree/main")
319 );
320 let page = env.http.get("/alice/pub", Some(&bob)).await;
321 assert!(page.body.contains("Star 0"), "after unstar: {}", page.body);
322
323 // A Referer on another host is ignored.
324 let foreign = "https://evil.example/alice/pub";
325 let res = post_star(
326 &env.http,
327 "/alice/pub/star",
328 &csrf,
329 Some(&bob),
330 Some(foreign),
331 )
332 .await;
333 assert_eq!(res.header(LOCATION).as_deref(), Some("/alice/pub"));
334}
335
336#[tokio::test]
337async fn anonymous_star_redirects_to_login() {
338 let env = start(seed_repos).await;
339 let res = post_star(&env.http, "/alice/pub/star", "anything", None, None).await;
340 assert_eq!(res.status, 303);
341 let location = res.header(LOCATION).unwrap_or_default();
342 assert!(
343 location.starts_with("/login"),
344 "redirect to login, got {location}"
345 );
346}
347
348#[tokio::test]
349async fn star_without_a_valid_csrf_token_is_403() {
350 let env = start(seed_repos).await;
351 let bob = login(&env.http, "bob").await;
352 let res = post_star(&env.http, "/alice/pub/star", "wrong", Some(&bob), None).await;
353 assert_eq!(res.status, 403);
354 let page = env.http.get("/alice/pub", Some(&bob)).await;
355 assert!(page.body.contains("Star 0"), "nothing was starred");
356}
357
358#[tokio::test]
359async fn star_on_a_private_repo_is_404_for_a_non_reader() {
360 let env = start(seed_repos).await;
361 let bob = login(&env.http, "bob").await;
362 let res = post_star(&env.http, "/alice/priv/star", "anything", Some(&bob), None).await;
363 assert_eq!(res.status, 404);
364}
365
366#[tokio::test]
367async fn explore_lists_public_repos_and_searches_literally() {
368 let env = start(seed_repos).await;
369
370 let page = env.http.get("/explore", None).await;
371 assert_eq!(page.status, 200, "explore");
372 let names = repo_names(&page.body);
373 assert!(names.contains(&"alice/pub".to_owned()), "{names:?}");
374 assert!(names.contains(&"alice/pct%x".to_owned()), "{names:?}");
375 assert!(
376 !names.contains(&"alice/priv".to_owned()),
377 "private hidden: {names:?}"
378 );
379
380 // `%` in the search is a literal percent sign, so only `pct%x` matches.
381 let page = env.http.get("/explore?q=%25", None).await;
382 assert_eq!(repo_names(&page.body), vec!["alice/pct%x".to_owned()]);
383
384 // Sorting by stars puts a starred repo first; the default (name) puts `pct%x` first.
385 let bob = login(&env.http, "bob").await;
386 let page = env.http.get("/alice/pub", Some(&bob)).await;
387 let csrf = input_value(&page.body, "csrf");
388 let res = post_star(&env.http, "/alice/pub/star", &csrf, Some(&bob), None).await;
389 assert_eq!(res.status, 303);
390 let by_name = repo_names(&env.http.get("/explore?sort=name", None).await.body);
391 assert_eq!(
392 by_name,
393 vec!["alice/pct%x".to_owned(), "alice/pub".to_owned()]
394 );
395 let by_stars = repo_names(&env.http.get("/explore?sort=stars", None).await.body);
396 assert_eq!(
397 by_stars,
398 vec!["alice/pub".to_owned(), "alice/pct%x".to_owned()]
399 );
400}
401
402#[tokio::test]
403async fn profile_starred_section_hides_repos_the_viewer_cannot_read() {
404 let env = start(|dir, store| async move {
405 seed_repos(dir, store.clone()).await;
406 let alice = store
407 .user_by_username("alice")
408 .await
409 .unwrap()
410 .expect("alice");
411 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
412 let repos = store.list_repos_by_owner(&alice.id).await.unwrap();
413 let find = |name: &str| repos.iter().find(|r| r.name == name).unwrap().id.clone();
414 // Bob stars a public repo and a private one he cannot read.
415 store.star(&bob.id, &find("pub")).await.unwrap();
416 store.star(&bob.id, &find("priv")).await.unwrap();
417 })
418 .await;
419
420 let anon = env.http.get("/bob", None).await;
421 assert_eq!(anon.status, 200);
422 let starred = |body: &str| {
423 let doc = scraper::Html::parse_document(body);
424 let selector = scraper::Selector::parse(".profile-starred .repo-name").expect("selector");
425 doc.select(&selector)
426 .map(|el| el.text().collect::<String>().trim().to_owned())
427 .collect::<Vec<_>>()
428 };
429 assert_eq!(starred(&anon.body), vec!["alice/pub".to_owned()]);
430
431 let alice = login(&env.http, "alice").await;
432 let owner = env.http.get("/bob", Some(&alice)).await;
433 let mut names = starred(&owner.body);
434 names.sort();
435 assert_eq!(names, vec!["alice/priv".to_owned(), "alice/pub".to_owned()]);
436}
437
438#[tokio::test]
439async fn archived_banner_shows_on_tree_and_issues_pages() {
440 let env = start(|dir, store| async move {
441 seed_repos(dir.clone(), store.clone()).await;
442 let repo = store
443 .repo_by_owner_and_name("alice", "pub")
444 .await
445 .unwrap()
446 .expect("pub");
447 commit_main(&dir, &repo.id);
448 store.set_repo_archived(&repo.id, true).await.unwrap();
449 })
450 .await;
451
452 let banner = "This repository is archived. It is read-only.";
453 let tree = env.http.get("/alice/pub/tree/main", None).await;
454 assert_eq!(tree.status, 200, "tree page");
455 assert!(tree.body.contains(banner), "banner on tree");
456 let issues = env.http.get("/alice/pub/issues", None).await;
457 assert_eq!(issues.status, 200, "issues page");
458 assert!(issues.body.contains(banner), "banner on issues");
459}