forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
16.9 KiB
1//! Black-box tests for the collaborators settings page. They start the real `forge` binary,
2//! seed users and repos through the store before the server starts, and then drive the page and
3//! its POST forms over HTTP. Cookies are handled by hand (see `repo_settings_e2e.rs`).
4
5mod common;
6use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config};
7use std::fs;
8use std::path::Path;
9use std::process::{Command, Output};
10use std::time::Duration;
11
12use forge_core::{Role, 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]) -> Output {
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 out
124}
125
126async fn login(http: &Http, username: &str) -> String {
127 let page = http.get("/login", None).await;
128 assert_eq!(page.status, 200, "GET /login");
129 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
130 let precsrf = input_value(&page.body, "precsrf");
131 let res = http
132 .post_form(
133 "/login",
134 &[
135 ("username", username),
136 ("password", PW),
137 ("next", "/"),
138 ("precsrf", &precsrf),
139 ],
140 Some(&precsrf_cookie),
141 )
142 .await;
143 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
144 cookie_named(&res, "forge_session").expect("session cookie after login")
145}
146
147/// The session's CSRF token, read from a page every signed-in user can open.
148async fn csrf_for(http: &Http, session: &str) -> String {
149 let page = http.get("/settings/tokens", Some(session)).await;
150 assert_eq!(page.status, 200, "GET /settings/tokens");
151 input_value(&page.body, "csrf")
152}
153
154/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
155fn init_bare(data_dir: &Path, repo_id: &str) {
156 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
157 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
158 let out = Command::new("git")
159 .args(["init", "--bare", "--quiet"])
160 .arg(&path)
161 .output()
162 .expect("run git init");
163 assert!(out.status.success(), "git init --bare: {}", describe(&out));
164}
165
166/// The role the page shows as selected for `username`, or `None` when they have no row.
167async fn shown_role(http: &Http, session: &str, username: &str) -> Option<String> {
168 let page = http
169 .get("/olivia/priv/settings/collaborators", Some(session))
170 .await;
171 assert_eq!(page.status, 200, "GET collaborators page");
172 let doc = scraper::Html::parse_document(&page.body);
173 let selector = scraper::Selector::parse(&format!(
174 "select[aria-label=\"Role for {username}\"] option[selected]"
175 ))
176 .expect("selector");
177 doc.select(&selector)
178 .next()
179 .and_then(|el| el.value().attr("value"))
180 .map(str::to_owned)
181}
182
183/// The one-shot notice cookie from a redirect, joined with the session cookie for the next GET.
184fn with_notice(session: &str, redirect: &Resp) -> String {
185 let notice = cookie_named(redirect, "forge_collab_notice").expect("notice cookie");
186 format!("{session}; {notice}")
187}
188
189#[tokio::test]
190async fn repo_collaborators_end_to_end() {
191 if !tool_on_path("git") {
192 eprintln!("SKIP repo_collaborators_e2e: `git` was not found on PATH");
193 return;
194 }
195
196 // Keep the path short: the data dir holds a unix socket with a length limit.
197 let root = tempfile::Builder::new()
198 .prefix("frc")
199 .tempdir_in("/tmp")
200 .expect("temp dir");
201 let dir = root.path().to_path_buf();
202
203 let ports = pick_ports();
204 let config = write_config(&dir, ports);
205
206 // Olivia owns the private repo. Carol is an Admin collaborator, wendy a Write collaborator, and
207 // bob and dana are plain users who get added through the page.
208 admin(&config, &["create-user", "root", "--admin"]);
209 for name in ["olivia", "bob", "carol", "dana", "wendy"] {
210 admin(&config, &["create-user", name]);
211 }
212
213 let (bob_id, carol_id, dana_id) = {
214 let store = Store::open(&dir.join("forge.db"))
215 .await
216 .expect("open store");
217 let olivia = store
218 .user_by_username("olivia")
219 .await
220 .unwrap()
221 .expect("olivia");
222 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
223 let carol = store
224 .user_by_username("carol")
225 .await
226 .unwrap()
227 .expect("carol");
228 let dana = store.user_by_username("dana").await.unwrap().expect("dana");
229 let wendy = store
230 .user_by_username("wendy")
231 .await
232 .unwrap()
233 .expect("wendy");
234 let private = store
235 .create_repo(&olivia.id, "priv", "", Visibility::Private, "main")
236 .await
237 .expect("private repo");
238 store
239 .set_collaborator(&private.id, &carol.id, Role::Admin)
240 .await
241 .expect("carol collaborator");
242 store
243 .set_collaborator(&private.id, &wendy.id, Role::Write)
244 .await
245 .expect("wendy collaborator");
246 init_bare(&dir, &private.id);
247 (bob.id, carol.id, dana.id)
248 };
249
250 let (_server, ports) = launch(&dir, ports).await;
251 let http = Http::new(ports.http);
252 let olivia = login(&http, "olivia").await;
253 let bob = login(&http, "bob").await;
254 let carol = login(&http, "carol").await;
255 let wendy = login(&http, "wendy").await;
256
257 let page = "/olivia/priv/settings/collaborators";
258 let add = "/olivia/priv/settings/collaborators/add";
259 let role = "/olivia/priv/settings/collaborators/role";
260 let remove = "/olivia/priv/settings/collaborators/remove";
261
262 // Before the add, bob cannot see the private repo or its settings.
263 assert_eq!(http.get(page, None).await.status, 404, "anonymous page");
264 assert_eq!(
265 http.get(page, Some(&bob)).await.status,
266 404,
267 "bob before add"
268 );
269 assert_eq!(http.get("/olivia/priv", Some(&bob)).await.status, 404);
270
271 // The owner sees the page with the role legend.
272 let owner_page = http.get(page, Some(&olivia)).await;
273 assert_eq!(owner_page.status, 200, "owner page");
274 assert!(owner_page.body.contains("view and clone"), "legend missing");
275
276 // The owner adds bob as Write: bob now sees the private repo, and the row shows the role.
277 let csrf = csrf_for(&http, &olivia).await;
278 let res = http
279 .post_form(
280 add,
281 &[("csrf", &csrf), ("username", "bob"), ("role", "write")],
282 Some(&olivia),
283 )
284 .await;
285 assert_eq!(res.status, 303, "add bob: {}", res.body);
286 assert_eq!(res.header(LOCATION).as_deref(), Some(page));
287 assert_eq!(
288 http.get("/olivia/priv", Some(&bob)).await.status,
289 200,
290 "bob sees repo"
291 );
292 assert_eq!(
293 shown_role(&http, &olivia, "bob").await.as_deref(),
294 Some("write")
295 );
296 // A Write collaborator cannot open the settings page.
297 assert_eq!(
298 http.get(page, Some(&bob)).await.status,
299 403,
300 "write on page"
301 );
302
303 // Changing bob's role to Read through the role form.
304 let res = http
305 .post_form(
306 role,
307 &[("csrf", &csrf), ("user_id", &bob_id), ("role", "read")],
308 Some(&olivia),
309 )
310 .await;
311 assert_eq!(res.status, 303, "role change: {}", res.body);
312 assert_eq!(
313 shown_role(&http, &olivia, "bob").await.as_deref(),
314 Some("read")
315 );
316 assert_eq!(
317 http.get("/olivia/priv", Some(&bob)).await.status,
318 200,
319 "read still sees repo"
320 );
321
322 // Re-adding through the add form updates the role instead of duplicating the row.
323 let res = http
324 .post_form(
325 add,
326 &[("csrf", &csrf), ("username", "bob"), ("role", "triage")],
327 Some(&olivia),
328 )
329 .await;
330 assert_eq!(res.status, 303, "re-add bob");
331 assert_eq!(
332 shown_role(&http, &olivia, "bob").await.as_deref(),
333 Some("triage")
334 );
335
336 // Removing bob: the repo is hidden from him again.
337 let res = http
338 .post_form(
339 remove,
340 &[("csrf", &csrf), ("user_id", &bob_id)],
341 Some(&olivia),
342 )
343 .await;
344 assert_eq!(res.status, 303, "remove bob");
345 assert_eq!(
346 http.get("/olivia/priv", Some(&bob)).await.status,
347 404,
348 "bob after remove"
349 );
350 assert_eq!(
351 shown_role(&http, &olivia, "bob").await,
352 None,
353 "bob row after remove"
354 );
355
356 // Notices: an unknown user and the owner both show an error on the next page view.
357 let res = http
358 .post_form(
359 add,
360 &[("csrf", &csrf), ("username", "nobody"), ("role", "read")],
361 Some(&olivia),
362 )
363 .await;
364 assert_eq!(res.status, 303, "unknown user redirects with a notice");
365 let notice_page = http.get(page, Some(&with_notice(&olivia, &res))).await;
366 assert!(
367 notice_page.body.contains("No user has that username."),
368 "unknown user notice missing"
369 );
370 let res = http
371 .post_form(
372 add,
373 &[("csrf", &csrf), ("username", "olivia"), ("role", "read")],
374 Some(&olivia),
375 )
376 .await;
377 let notice_page = http.get(page, Some(&with_notice(&olivia, &res))).await;
378 assert!(
379 notice_page
380 .body
381 .contains("cannot be added as a collaborator"),
382 "owner notice missing"
383 );
384
385 // A bad CSRF token is rejected and changes nothing.
386 let res = http
387 .post_form(
388 add,
389 &[
390 ("csrf", "not-the-token"),
391 ("username", "bob"),
392 ("role", "read"),
393 ],
394 Some(&olivia),
395 )
396 .await;
397 assert_eq!(res.status, 403, "no CSRF rejected");
398 assert_eq!(
399 shown_role(&http, &olivia, "bob").await,
400 None,
401 "CSRF failure added bob"
402 );
403
404 // The Admin collaborator carol can add Read, but cannot grant Admin or change an Admin.
405 let carol_csrf = csrf_for(&http, &carol).await;
406 assert_eq!(http.get(page, Some(&carol)).await.status, 200, "carol page");
407 let res = http
408 .post_form(
409 add,
410 &[
411 ("csrf", &carol_csrf),
412 ("username", "dana"),
413 ("role", "read"),
414 ],
415 Some(&carol),
416 )
417 .await;
418 assert_eq!(res.status, 303, "carol adds dana as read");
419 assert_eq!(
420 shown_role(&http, &carol, "dana").await.as_deref(),
421 Some("read")
422 );
423 let res = http
424 .post_form(
425 add,
426 &[
427 ("csrf", &carol_csrf),
428 ("username", "dana"),
429 ("role", "admin"),
430 ],
431 Some(&carol),
432 )
433 .await;
434 assert_eq!(res.status, 403, "carol cannot grant admin");
435 let res = http
436 .post_form(
437 role,
438 &[
439 ("csrf", &carol_csrf),
440 ("user_id", &dana_id),
441 ("role", "admin"),
442 ],
443 Some(&carol),
444 )
445 .await;
446 assert_eq!(
447 res.status, 403,
448 "carol cannot grant admin through role form"
449 );
450 assert_eq!(
451 shown_role(&http, &carol, "dana").await.as_deref(),
452 Some("read")
453 );
454
455 // The owner grants Admin to dana. Now carol cannot remove or demote an Admin.
456 let res = http
457 .post_form(
458 add,
459 &[("csrf", &csrf), ("username", "dana"), ("role", "admin")],
460 Some(&olivia),
461 )
462 .await;
463 assert_eq!(res.status, 303, "owner grants admin");
464 assert_eq!(
465 shown_role(&http, &olivia, "dana").await.as_deref(),
466 Some("admin")
467 );
468 // Carol cannot remove an Admin, and cannot demote one through the role form.
469 let res = http
470 .post_form(
471 remove,
472 &[("csrf", &carol_csrf), ("user_id", &dana_id)],
473 Some(&carol),
474 )
475 .await;
476 assert_eq!(res.status, 403, "carol cannot remove an admin");
477 assert_eq!(
478 shown_role(&http, &olivia, "dana").await.as_deref(),
479 Some("admin")
480 );
481 let res = http
482 .post_form(
483 role,
484 &[
485 ("csrf", &carol_csrf),
486 ("user_id", &dana_id),
487 ("role", "write"),
488 ],
489 Some(&carol),
490 )
491 .await;
492 assert_eq!(res.status, 403, "carol cannot demote an admin");
493 let res = http
494 .post_form(
495 role,
496 &[
497 ("csrf", &carol_csrf),
498 ("user_id", "not-a-real-id"),
499 ("role", "write"),
500 ],
501 Some(&carol),
502 )
503 .await;
504 assert_eq!(
505 res.status, 303,
506 "non-collaborator role change gives a notice, not a grant"
507 );
508
509 // Wendy (Write) is re-added as Read through the add form.
510 let res = http
511 .post_form(
512 add,
513 &[("csrf", &csrf), ("username", "wendy"), ("role", "read")],
514 Some(&olivia),
515 )
516 .await;
517 assert_eq!(res.status, 303, "re-add wendy");
518 assert_eq!(
519 shown_role(&http, &olivia, "wendy").await.as_deref(),
520 Some("read")
521 );
522 assert_eq!(
523 http.get(page, Some(&wendy)).await.status,
524 403,
525 "read on page"
526 );
527
528 // Carol may remove herself. The redirect goes to the repo home, and she loses the repo.
529 let res = http
530 .post_form(
531 remove,
532 &[("csrf", &carol_csrf), ("user_id", &carol_id)],
533 Some(&carol),
534 )
535 .await;
536 assert_eq!(res.status, 303, "carol self-removal");
537 assert_eq!(res.header(LOCATION).as_deref(), Some("/olivia/priv"));
538 assert_eq!(
539 http.get(page, Some(&carol)).await.status,
540 404,
541 "carol after self-removal"
542 );
543 assert_eq!(http.get("/olivia/priv", Some(&carol)).await.status, 404);
544}