//! Black-box tests for the organization pages: members, teams, settings, the creation policy and //! the owner choice on the new repo form. They start the real `forge` binary, seed the org rows //! through the store before the server starts, and then drive the pages over HTTP. Cookies are //! handled by hand, as in `repo_collaborators_e2e.rs`. mod common; use common::{BIN, describe, launch, launch_with, 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::store::orgs::{DefaultRepoRole, OrgMemberRole, OrgSettings}; use forge_core::{Role, Store, Visibility}; use reqwest::header::{COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE}; /// Each test starts its own server, so the tests run one at a time. static SERIAL: std::sync::LazyLock> = std::sync::LazyLock::new(|| tokio::sync::Mutex::new(())); const PW: &str = "alice-password-123"; /// The one-shot notice cookie of the org pages. const ORG_NOTICE: &str = "forge_org_notice"; 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() } /// The value of the selected option of the `` on a page. fn first_option(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("select[name=\"{name}\"] option")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no option in select {name}")) .to_owned() } #[tokio::test] async fn org_member_pushes_over_https_through_a_write_team() { let _guard = SERIAL.lock().await; if !tool_on_path("git") { eprintln!("SKIP orgs_ui_e2e: `git` was not found on PATH"); return; } let root = short_temp_dir(); let dir = root.path().to_path_buf(); let ports = pick_ports(); let config = write_config(&dir, ports); admin(&config, &["create-user", "root", "--admin"]); for name in ["alice", "bob", "dana"] { admin(&config, &["create-user", name]); } { let store = Store::open(&dir.join("forge.db")) .await .expect("open store"); let alice = store .user_by_username("alice") .await .unwrap() .expect("alice"); let bob = store.user_by_username("bob").await.unwrap().expect("bob"); let acme = store.create_org("acme", &alice.id).await.expect("acme"); store .add_org_member(&acme.id, &bob.id, OrgMemberRole::Member) .await .expect("bob joins acme"); store .set_org_settings( &acme.id, OrgSettings { default_repo_role: DefaultRepoRole::None, members_can_create_repos: false, }, ) .await .expect("acme settings"); } let (_server, ports) = launch(&dir, ports).await; let http = Http::new(ports.http); let alice = login(&http, "alice").await; let bob = login(&http, "bob").await; let dana = login(&http, "dana").await; // Alice creates a private repo under acme through the new repo form. let csrf = csrf_for(&http, &alice).await; let res = http .post_form( "/new", &[ ("csrf", &csrf), ("name", "pushme"), ("visibility", "private"), ("default_branch", "main"), ("owner", "acme"), ], Some(&alice), ) .await; assert_eq!(res.status, 303, "create acme/pushme: {}", res.body); assert_eq!(res.header(LOCATION).as_deref(), Some("/acme/pushme")); // A write team takes the repo, and bob joins the team. let res = http .post_form( "/org/acme/teams/new", &[ ("csrf", &csrf), ("name", "writers"), ("role", "write"), ("description", ""), ], Some(&alice), ) .await; assert_eq!(res.status, 303, "create writers: {}", res.body); let team_url = res.header(LOCATION).expect("team location"); let team = http.get(&team_url, Some(&alice)).await; assert_eq!(team.status, 200, "team page"); let repo_id = first_option(&team.body, "repo_id"); let res = http .post_form( &format!("{team_url}/repos/add"), &[("csrf", &csrf), ("repo_id", &repo_id)], Some(&alice), ) .await; assert_eq!(res.status, 303, "link pushme to writers"); let res = http .post_form( &format!("{team_url}/members/add"), &[("csrf", &csrf), ("username", "bob")], Some(&alice), ) .await; assert_eq!(res.status, 303, "bob joins writers"); // Bob pushes over HTTPS with his token. Dana is not in the org, so her clone is refused. let bob_token = create_token(&http, &bob).await; let dana_token = create_token(&http, &dana).await; let bob_url = format!( "http://bob:{bob_token}@127.0.0.1:{}/acme/pushme.git", ports.http ); let dana_url = format!( "http://dana:{dana_token}@127.0.0.1:{}/acme/pushme.git", ports.http ); let refused = git(&dir, &["clone", &dana_url, "dana-work"]); assert!(!refused.status.success(), "dana cannot clone acme/pushme"); let work = dir.join("bob-work"); let work_str = work.to_str().expect("utf8 path"); let clone = git(&dir, &["clone", &bob_url, work_str]); assert!(clone.status.success(), "bob clones: {}", describe(&clone)); commit_file(&work, "hello.txt", "hello over https\n"); let push = git(&work, &["push", "origin", "HEAD:main"]); assert!(push.status.success(), "bob pushes: {}", describe(&push)); }