//! Black-box tests for the collaborators settings page. They start the real `forge` binary, //! seed users and repos through the store before the server starts, and then drive the page and //! its POST forms over HTTP. Cookies are handled by hand (see `repo_settings_e2e.rs`). mod common; use common::{BIN, describe, launch, pick_ports, tool_on_path, write_config}; use std::fs; use std::path::Path; use std::process::{Command, Output}; use std::time::Duration; use forge_core::{Role, Store, Visibility}; use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; const PW: &str = "alice-password-123"; struct Resp { status: u16, headers: HeaderMap, body: String, } impl Resp { fn header(&self, name: HeaderName) -> Option { self.headers .get(name) .and_then(|v| v.to_str().ok()) .map(str::to_owned) } fn set_cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect() } } struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get(&self, path: &str, cookie: Option<&str>) -> Resp { self.send(self.client.get(format!("{}{path}", self.base)), cookie) .await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { self.send( self.client.post(format!("{}{path}", self.base)).form(form), cookie, ) .await } async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp { let req = match cookie { Some(cookie) => req.header(COOKIE, cookie), None => req, }; let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let headers = res.headers().clone(); let body = res.text().await.expect("read body"); Resp { status, headers, body, } } } /// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies().iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded. fn admin(config: &Path, args: &[&str]) -> Output { let out = Command::new(BIN) .arg("--config") .arg(config) .arg("admin") .args(args) .env("FORGE_PASSWORD", PW) .output() .expect("run admin command"); assert!( out.status.success(), "admin {args:?} failed:\n{}", describe(&out) ); out } async fn login(http: &Http, username: &str) -> String { let page = http.get("/login", None).await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", PW), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } /// The session's CSRF token, read from a page every signed-in user can open. async fn csrf_for(http: &Http, session: &str) -> String { let page = http.get("/settings/tokens", Some(session)).await; assert_eq!(page.status, 200, "GET /settings/tokens"); input_value(&page.body, "csrf") } /// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does. fn init_bare(data_dir: &Path, repo_id: &str) { let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path"); fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent"); let out = Command::new("git") .args(["init", "--bare", "--quiet"]) .arg(&path) .output() .expect("run git init"); assert!(out.status.success(), "git init --bare: {}", describe(&out)); } /// The role the page shows as selected for `username`, or `None` when they have no row. async fn shown_role(http: &Http, session: &str, username: &str) -> Option { let page = http .get("/olivia/priv/settings/collaborators", Some(session)) .await; assert_eq!(page.status, 200, "GET collaborators page"); let doc = scraper::Html::parse_document(&page.body); let selector = scraper::Selector::parse(&format!( "select[aria-label=\"Role for {username}\"] option[selected]" )) .expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .map(str::to_owned) } /// The one-shot notice cookie from a redirect, joined with the session cookie for the next GET. fn with_notice(session: &str, redirect: &Resp) -> String { let notice = cookie_named(redirect, "forge_collab_notice").expect("notice cookie"); format!("{session}; {notice}") } #[tokio::test] async fn repo_collaborators_end_to_end() { if !tool_on_path("git") { eprintln!("SKIP repo_collaborators_e2e: `git` was not found on PATH"); return; } // Keep the path short: the data dir holds a unix socket with a length limit. let root = tempfile::Builder::new() .prefix("frc") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); // Olivia owns the private repo. Carol is an Admin collaborator, wendy a Write collaborator, and // bob and dana are plain users who get added through the page. admin(&config, &["create-user", "root", "--admin"]); for name in ["olivia", "bob", "carol", "dana", "wendy"] { admin(&config, &["create-user", name]); } let (bob_id, carol_id, dana_id) = { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let olivia = store .user_by_username("olivia") .await .unwrap() .expect("olivia"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let carol = store .user_by_username("carol") .await .unwrap() .expect("carol"); let dana = store.user_by_username("dana").await.unwrap().expect("dana"); let wendy = store .user_by_username("wendy") .await .unwrap() .expect("wendy"); let private = store .create_repo(&olivia.id, "priv", "", Visibility::Private, "main") .await .expect("private repo"); store .set_collaborator(&private.id, &carol.id, Role::Admin) .await .expect("carol collaborator"); store .set_collaborator(&private.id, &wendy.id, Role::Write) .await .expect("wendy collaborator"); init_bare(&dir, &private.id); (bob.id, carol.id, dana.id) }; let (_server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let olivia = login(&http, "olivia").await; let bob = login(&http, "bob").await; let carol = login(&http, "carol").await; let wendy = login(&http, "wendy").await; let page = "/olivia/priv/settings/collaborators"; let add = "/olivia/priv/settings/collaborators/add"; let role = "/olivia/priv/settings/collaborators/role"; let remove = "/olivia/priv/settings/collaborators/remove"; // Before the add, bob cannot see the private repo or its settings. assert_eq!(http.get(page, None).await.status, 404, "anonymous page"); assert_eq!( http.get(page, Some(&bob)).await.status, 404, "bob before add" ); assert_eq!(http.get("/olivia/priv", Some(&bob)).await.status, 404); // The owner sees the page with the role legend. let owner_page = http.get(page, Some(&olivia)).await; assert_eq!(owner_page.status, 200, "owner page"); assert!(owner_page.body.contains("view and clone"), "legend missing"); // The owner adds bob as Write: bob now sees the private repo, and the row shows the role. let csrf = csrf_for(&http, &olivia).await; let res = http .post_form( add, &[("csrf", &csrf), ("username", "bob"), ("role", "write")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "add bob: {}", res.body); assert_eq!(res.header(LOCATION).as_deref(), Some(page)); assert_eq!( http.get("/olivia/priv", Some(&bob)).await.status, 200, "bob sees repo" ); assert_eq!( shown_role(&http, &olivia, "bob").await.as_deref(), Some("write") ); // A Write collaborator cannot open the settings page. assert_eq!( http.get(page, Some(&bob)).await.status, 403, "write on page" ); // Changing bob's role to Read through the role form. let res = http .post_form( role, &[("csrf", &csrf), ("user_id", &bob_id), ("role", "read")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "role change: {}", res.body); assert_eq!( shown_role(&http, &olivia, "bob").await.as_deref(), Some("read") ); assert_eq!( http.get("/olivia/priv", Some(&bob)).await.status, 200, "read still sees repo" ); // Re-adding through the add form updates the role instead of duplicating the row. let res = http .post_form( add, &[("csrf", &csrf), ("username", "bob"), ("role", "triage")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "re-add bob"); assert_eq!( shown_role(&http, &olivia, "bob").await.as_deref(), Some("triage") ); // Removing bob: the repo is hidden from him again. let res = http .post_form( remove, &[("csrf", &csrf), ("user_id", &bob_id)], Some(&olivia), ) .await; assert_eq!(res.status, 303, "remove bob"); assert_eq!( http.get("/olivia/priv", Some(&bob)).await.status, 404, "bob after remove" ); assert_eq!( shown_role(&http, &olivia, "bob").await, None, "bob row after remove" ); // Notices: an unknown user and the owner both show an error on the next page view. let res = http .post_form( add, &[("csrf", &csrf), ("username", "nobody"), ("role", "read")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "unknown user redirects with a notice"); let notice_page = http.get(page, Some(&with_notice(&olivia, &res))).await; assert!( notice_page.body.contains("No user has that username."), "unknown user notice missing" ); let res = http .post_form( add, &[("csrf", &csrf), ("username", "olivia"), ("role", "read")], Some(&olivia), ) .await; let notice_page = http.get(page, Some(&with_notice(&olivia, &res))).await; assert!( notice_page .body .contains("cannot be added as a collaborator"), "owner notice missing" ); // A bad CSRF token is rejected and changes nothing. let res = http .post_form( add, &[ ("csrf", "not-the-token"), ("username", "bob"), ("role", "read"), ], Some(&olivia), ) .await; assert_eq!(res.status, 403, "no CSRF rejected"); assert_eq!( shown_role(&http, &olivia, "bob").await, None, "CSRF failure added bob" ); // The Admin collaborator carol can add Read, but cannot grant Admin or change an Admin. let carol_csrf = csrf_for(&http, &carol).await; assert_eq!(http.get(page, Some(&carol)).await.status, 200, "carol page"); let res = http .post_form( add, &[ ("csrf", &carol_csrf), ("username", "dana"), ("role", "read"), ], Some(&carol), ) .await; assert_eq!(res.status, 303, "carol adds dana as read"); assert_eq!( shown_role(&http, &carol, "dana").await.as_deref(), Some("read") ); let res = http .post_form( add, &[ ("csrf", &carol_csrf), ("username", "dana"), ("role", "admin"), ], Some(&carol), ) .await; assert_eq!(res.status, 403, "carol cannot grant admin"); let res = http .post_form( role, &[ ("csrf", &carol_csrf), ("user_id", &dana_id), ("role", "admin"), ], Some(&carol), ) .await; assert_eq!( res.status, 403, "carol cannot grant admin through role form" ); assert_eq!( shown_role(&http, &carol, "dana").await.as_deref(), Some("read") ); // The owner grants Admin to dana. Now carol cannot remove or demote an Admin. let res = http .post_form( add, &[("csrf", &csrf), ("username", "dana"), ("role", "admin")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "owner grants admin"); assert_eq!( shown_role(&http, &olivia, "dana").await.as_deref(), Some("admin") ); // Carol cannot remove an Admin, and cannot demote one through the role form. let res = http .post_form( remove, &[("csrf", &carol_csrf), ("user_id", &dana_id)], Some(&carol), ) .await; assert_eq!(res.status, 403, "carol cannot remove an admin"); assert_eq!( shown_role(&http, &olivia, "dana").await.as_deref(), Some("admin") ); let res = http .post_form( role, &[ ("csrf", &carol_csrf), ("user_id", &dana_id), ("role", "write"), ], Some(&carol), ) .await; assert_eq!(res.status, 403, "carol cannot demote an admin"); let res = http .post_form( role, &[ ("csrf", &carol_csrf), ("user_id", "not-a-real-id"), ("role", "write"), ], Some(&carol), ) .await; assert_eq!( res.status, 303, "non-collaborator role change gives a notice, not a grant" ); // Wendy (Write) is re-added as Read through the add form. let res = http .post_form( add, &[("csrf", &csrf), ("username", "wendy"), ("role", "read")], Some(&olivia), ) .await; assert_eq!(res.status, 303, "re-add wendy"); assert_eq!( shown_role(&http, &olivia, "wendy").await.as_deref(), Some("read") ); assert_eq!( http.get(page, Some(&wendy)).await.status, 403, "read on page" ); // Carol may remove herself. The redirect goes to the repo home, and she loses the repo. let res = http .post_form( remove, &[("csrf", &carol_csrf), ("user_id", &carol_id)], Some(&carol), ) .await; assert_eq!(res.status, 303, "carol self-removal"); assert_eq!(res.header(LOCATION).as_deref(), Some("/olivia/priv")); assert_eq!( http.get(page, Some(&carol)).await.status, 404, "carol after self-removal" ); assert_eq!(http.get("/olivia/priv", Some(&carol)).await.status, 404); }