forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.2 KiB
1//! Black-box test for repo transfer acceptance. It seeds users, repos and an org through the store,
2//! starts the real `forge` binary, and drives the offer, decline, cancel and org-target flows over
3//! HTTP. The target of a private repo can act only from the inbox, which this test relies on.
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::process::Command;
12use std::time::Duration;
13
14use forge_core::store::orgs::OrgMemberRole;
15use forge_core::{Store, Visibility};
16use reqwest::header::{COOKIE, HeaderMap, 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: reqwest::header::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`. Panics when none.
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: &std::path::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/// Posts the transfer form on the settings page as `session`. Returns the response.
168async fn offer(http: &Http, session: &str, owner: &str, repo: &str, to: &str) -> Resp {
169 let csrf = csrf_for(http, session).await;
170 http.post_form(
171 &format!("/{owner}/{repo}/settings/transfer"),
172 &[
173 ("csrf", &csrf),
174 ("new_owner", to),
175 ("confirm", &format!("{owner}/{repo}")),
176 ],
177 Some(session),
178 )
179 .await
180}
181
182/// POSTs one of the transfer actions named by `suffix` from `page`, as `session`.
183async fn act_from(http: &Http, session: &str, page: &str, suffix: &str) -> Resp {
184 let body = http.get(page, Some(session)).await.body;
185 let action = form_action(&body, suffix);
186 let csrf = csrf_for(http, session).await;
187 http.post_form(&action, &[("csrf", &csrf)], Some(session))
188 .await
189}
190
191#[tokio::test]
192async fn repo_transfer_offers_declines_cancels_and_org_targets() {
193 if !tool_on_path("git") {
194 eprintln!("SKIP repo_transfer_e2e: `git` was not found on PATH");
195 return;
196 }
197
198 // Keep the path short: the data dir holds a unix socket with a length limit.
199 let root = tempfile::Builder::new()
200 .prefix("frt")
201 .tempdir_in("/tmp")
202 .expect("temp dir");
203 let dir = root.path().to_path_buf();
204 let ports = pick_ports();
205 let config = write_config(&dir, ports);
206
207 // Bob owns `priv` (private) and `pub`. Carol is an outsider who cannot read `priv`. Acme is an
208 // org owned by bob, with dave as a second owner and eve as a plain member.
209 admin(&config, &["create-user", "alice", "--admin"]);
210 for name in ["bob", "carol", "dave", "eve"] {
211 admin(&config, &["create-user", name]);
212 }
213 {
214 let store = Store::open(&dir.join("forge.db"))
215 .await
216 .expect("open store");
217 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
218 let dave = store.user_by_username("dave").await.unwrap().expect("dave");
219 let eve = store.user_by_username("eve").await.unwrap().expect("eve");
220 let priv_repo = store
221 .create_repo(&bob.id, "priv", "", Visibility::Private, "main")
222 .await
223 .expect("priv repo");
224 let pub_repo = store
225 .create_repo(&bob.id, "pub", "", Visibility::Public, "main")
226 .await
227 .expect("pub repo");
228 let acme = store.create_org("acme", &bob.id).await.expect("acme");
229 store
230 .add_org_member(&acme.id, &dave.id, OrgMemberRole::Owner)
231 .await
232 .expect("dave owner");
233 store
234 .add_org_member(&acme.id, &eve.id, OrgMemberRole::Member)
235 .await
236 .expect("eve member");
237 for repo in [&priv_repo, &pub_repo] {
238 let path = forge_git::repo::repo_path(&dir, &repo.id).expect("repo path");
239 fs::create_dir_all(path.parent().expect("parent")).expect("create parent");
240 let out = Command::new("git")
241 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
242 .arg(&path)
243 .output()
244 .expect("run git init");
245 assert!(out.status.success(), "git init: {}", describe(&out));
246 }
247 }
248
249 let (_server, ports) = launch(&dir, ports).await;
250 let http = Http::new(ports.http);
251 let bob = login(&http, "bob").await;
252 let carol = login(&http, "carol").await;
253 let dave = login(&http, "dave").await;
254 let eve = login(&http, "eve").await;
255
256 // Offer, then decline as carol from the inbox. The repo stays with bob.
257 let offered = offer(&http, &bob, "bob", "priv", "carol").await;
258 assert_eq!(offered.status, 303, "offer: {}", offered.body);
259 assert_eq!(
260 offered.header(LOCATION).as_deref(),
261 Some("/bob/priv/transfer")
262 );
263 let status = http.get("/bob/priv/transfer", Some(&bob)).await;
264 assert!(status.body.contains("Pending transfer to carol"));
265 // Carol cannot read the private repo, yet the inbox offers her the actions.
266 assert_eq!(http.get("/bob/priv", Some(&carol)).await.status, 404);
267 let declined = act_from(&http, &carol, "/notifications", "/decline").await;
268 assert_eq!(declined.status, 303, "decline: {}", declined.body);
269 assert_eq!(declined.header(LOCATION).as_deref(), Some("/notifications"));
270 let after = http.get("/bob/priv/transfer", Some(&bob)).await;
271 assert!(after.body.contains("No transfer is pending"));
272 assert_eq!(http.get("/bob/priv", Some(&bob)).await.status, 200);
273
274 // Offer again, then the initiator cancels it.
275 assert_eq!(offer(&http, &bob, "bob", "priv", "carol").await.status, 303);
276 let cancelled = act_from(&http, &bob, "/bob/priv/transfer", "/cancel").await;
277 assert_eq!(cancelled.status, 303, "cancel: {}", cancelled.body);
278 assert_eq!(
279 cancelled.header(LOCATION).as_deref(),
280 Some("/bob/priv/transfer")
281 );
282 let after = http.get("/bob/priv/transfer", Some(&bob)).await;
283 assert!(after.body.contains("No transfer is pending"));
284
285 // A transfer to an org: plain members get no accept form, owners do.
286 let to_org = offer(&http, &bob, "bob", "pub", "acme").await;
287 assert_eq!(to_org.status, 303, "offer to org: {}", to_org.body);
288 let eve_inbox = http.get("/notifications", Some(&eve)).await;
289 assert!(
290 !eve_inbox.body.contains("/accept"),
291 "member sees no org offer"
292 );
293 let outsider = offer(&http, &carol, "bob", "pub", "carol").await;
294 assert_eq!(outsider.status, 403, "non-admin outsider cannot offer");
295 let accepted = act_from(&http, &dave, "/notifications", "/accept").await;
296 assert_eq!(accepted.status, 303, "accept: {}", accepted.body);
297 assert_eq!(accepted.header(LOCATION).as_deref(), Some("/acme/pub"));
298 assert_eq!(http.get("/acme/pub", Some(&bob)).await.status, 200);
299 let old = http.get("/bob/pub", Some(&bob)).await;
300 assert_eq!(old.status, 301, "old owner and name redirect");
301}