forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
14.8 KiB
1//! Black-box test for the repo wiki. It seeds users and a repo through the store, starts the real
2//! `forge` binary and drives the wiki pages, the HTTPS clone and push of `<repo>.wiki.git`, the
3//! read-only and stale-edit refusals, and the backup of the wiki.
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::path::Path;
12use std::process::{Command, Output};
13use std::time::Duration;
14
15use forge_core::{Role, Store, Visibility};
16use reqwest::header::{COOKIE, HeaderMap, HeaderName, 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: 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/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
112fn admin(config: &Path, args: &[&str]) {
113 let out = Command::new(BIN)
114 .arg("--config")
115 .arg(config)
116 .arg("admin")
117 .args(args)
118 .env("FORGE_PASSWORD", PW)
119 .output()
120 .expect("run admin command");
121 assert!(
122 out.status.success(),
123 "admin {args:?} failed:\n{}",
124 describe(&out)
125 );
126}
127
128async fn login(http: &Http, username: &str) -> String {
129 let page = http.get("/login", None).await;
130 assert_eq!(page.status, 200, "GET /login");
131 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
132 let precsrf = input_value(&page.body, "precsrf");
133 let res = http
134 .post_form(
135 "/login",
136 &[
137 ("username", username),
138 ("password", PW),
139 ("next", "/"),
140 ("precsrf", &precsrf),
141 ],
142 Some(&precsrf_cookie),
143 )
144 .await;
145 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
146 cookie_named(&res, "forge_session").expect("session cookie after login")
147}
148
149/// The session's CSRF token, read from a page every signed-in user can open.
150async fn csrf_for(http: &Http, session: &str) -> String {
151 let page = http.get("/settings/tokens", Some(session)).await;
152 assert_eq!(page.status, 200, "GET /settings/tokens");
153 input_value(&page.body, "csrf")
154}
155
156/// Creates an access token through `/settings/tokens` and returns the token shown once.
157async fn create_token(http: &Http, session: &str, name: &str) -> String {
158 let csrf = csrf_for(http, session).await;
159 let res = http
160 .post_form(
161 "/settings/tokens",
162 &[("csrf", &csrf), ("name", name), ("expires", "30")],
163 Some(session),
164 )
165 .await;
166 assert_eq!(res.status, 200, "create token: {}", res.body);
167 let doc = scraper::Html::parse_document(&res.body);
168 let selector = scraper::Selector::parse("pre.code code").expect("selector");
169 let token: String = doc
170 .select(&selector)
171 .next()
172 .expect("token shown after creation")
173 .text()
174 .collect();
175 let token = token.trim().to_owned();
176 assert!(token.starts_with("forge_"), "unexpected token shape");
177 token
178}
179
180/// Creates a bare git repo for `repo_id` at its data-dir location, as the create-repo page does.
181fn init_bare(data_dir: &Path, repo_id: &str) {
182 let path = forge_git::repo::repo_path(data_dir, repo_id).expect("repo path");
183 fs::create_dir_all(path.parent().expect("parent")).expect("create repo parent");
184 let out = Command::new("git")
185 .args(["init", "--bare", "--quiet", "--initial-branch=main"])
186 .arg(&path)
187 .output()
188 .expect("run git init");
189 assert!(out.status.success(), "git init --bare: {}", describe(&out));
190}
191
192fn git(cwd: &Path, args: &[&str]) -> Output {
193 Command::new("git")
194 .current_dir(cwd)
195 .args(["-c", "credential.helper="])
196 .args(args)
197 .env("GIT_TERMINAL_PROMPT", "0")
198 .output()
199 .expect("run git")
200}
201
202fn ok(out: &Output, what: &str) {
203 assert!(out.status.success(), "{what} failed:\n{}", describe(out));
204}
205
206fn path_str(path: &Path) -> &str {
207 path.to_str().expect("utf-8 path")
208}
209
210/// Writes a file under `repo` (creating its directory) and commits it as `bob`.
211fn commit_file(repo: &Path, name: &str, content: &str, message: &str) {
212 let file = repo.join(name);
213 fs::create_dir_all(file.parent().expect("parent")).expect("create dir");
214 fs::write(&file, content).expect("write file");
215 ok(&git(repo, &["config", "user.name", "bob"]), "set user.name");
216 ok(
217 &git(repo, &["config", "user.email", "bob@example.com"]),
218 "set user.email",
219 );
220 ok(&git(repo, &["add", name]), "git add");
221 ok(
222 &git(
223 repo,
224 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
225 ),
226 "git commit",
227 );
228}
229
230/// Reads the wiki page `name` as the viewer, returns its body.
231async fn page_body(http: &Http, path: &str, session: Option<&str>) -> Resp {
232 let res = http.get(path, session).await;
233 assert_eq!(res.status, 200, "GET {path}: {}", res.body);
234 res
235}
236
237#[tokio::test]
238async fn wiki_end_to_end() {
239 if !tool_on_path("git") {
240 eprintln!("SKIP wiki_e2e: `git` was not found on PATH");
241 return;
242 }
243
244 // Keep the path short: the data dir holds a unix socket with a length limit.
245 let root = tempfile::Builder::new()
246 .prefix("fwk")
247 .tempdir_in("/tmp")
248 .expect("temp dir");
249 let dir = root.path().to_path_buf();
250 let ports = pick_ports();
251 let config = write_config(&dir, ports);
252
253 // Bob owns a public repo. Carol has Write on it. Dave has only the Read that a public repo gives.
254 admin(&config, &["create-user", "alice", "--admin"]);
255 for name in ["bob", "carol", "dave"] {
256 admin(&config, &["create-user", name]);
257 }
258 {
259 let store = Store::open(&dir.join("forge.db"))
260 .await
261 .expect("open store");
262 let bob = store.user_by_username("bob").await.unwrap().expect("bob");
263 let carol = store
264 .user_by_username("carol")
265 .await
266 .unwrap()
267 .expect("carol");
268 let repo = store
269 .create_repo(&bob.id, "proj", "", Visibility::Public, "main")
270 .await
271 .expect("repo");
272 store
273 .set_collaborator(&repo.id, &carol.id, Role::Write)
274 .await
275 .expect("carol write");
276 init_bare(&dir, &repo.id);
277 }
278
279 let (server, ports) = launch(&dir, ports).await;
280 let http = Http::new(ports.http);
281 let bob = login(&http, "bob").await;
282 let carol = login(&http, "carol").await;
283 let dave = login(&http, "dave").await;
284
285 // The wiki starts empty, and the tab is there.
286 let empty = page_body(&http, "/bob/proj/wiki", Some(&bob)).await;
287 assert!(empty.body.contains("no pages yet"), "{}", empty.body);
288
289 // Bob creates Home on the web. The save redirects to the page.
290 let csrf = input_value(
291 &http.get("/bob/proj/wiki/-/new", Some(&bob)).await.body,
292 "csrf",
293 );
294 let saved = http
295 .post_form(
296 "/bob/proj/wiki/-/new",
297 &[
298 ("csrf", &csrf),
299 ("name", "Home"),
300 ("base", ""),
301 ("content", "# Welcome\n\nSee [[Getting Started]].\n"),
302 ("message", "first page"),
303 ],
304 Some(&bob),
305 )
306 .await;
307 assert_eq!(saved.status, 303, "create Home: {}", saved.body);
308 assert_eq!(
309 saved.header(LOCATION).as_deref(),
310 Some("/bob/proj/wiki/Home")
311 );
312 let home = page_body(&http, "/bob/proj/wiki/Home", Some(&bob)).await;
313 assert!(home.body.contains("Welcome"), "{}", home.body);
314 assert!(
315 home.body.contains("/bob/proj/wiki/Getting-Started"),
316 "wiki link should resolve: {}",
317 home.body
318 );
319
320 // Dave can read the page but not edit it.
321 page_body(&http, "/bob/proj/wiki/Home", Some(&dave)).await;
322 let refused = http.get("/bob/proj/wiki/Home/edit", Some(&dave)).await;
323 assert_eq!(refused.status, 403, "read-only user must not edit");
324
325 // Carol clones the wiki over HTTPS, sees Home, and pushes a change.
326 let carol_token = create_token(&http, &carol, "wiki-e2e").await;
327 let work = dir.join("wiki-work");
328 let url = format!(
329 "http://carol:{carol_token}@127.0.0.1:{}/bob/proj.wiki.git",
330 ports.http
331 );
332 ok(
333 &git(&dir, &["clone", "-q", &url, path_str(&work)]),
334 "clone wiki over HTTPS",
335 );
336 let text = fs::read_to_string(work.join("Home.md")).expect("Home.md in clone");
337 assert!(text.contains("Welcome"), "{text}");
338 commit_file(&work, "Home.md", "# Welcome again\n", "edit from git");
339 ok(
340 &git(&work, &["push", "-q", "origin", "HEAD:main"]),
341 "push wiki change over HTTPS",
342 );
343 let after_push = page_body(&http, "/bob/proj/wiki/Home", Some(&carol)).await;
344 assert!(
345 after_push.body.contains("Welcome again"),
346 "{}",
347 after_push.body
348 );
349 // The push is in the admin audit log (read over HTTP, the server holds the database open).
350 let alice = login(&http, "alice").await;
351 let audit = http
352 .get("/admin/audit?action=wiki_pushed", Some(&alice))
353 .await;
354 assert_eq!(audit.status, 200, "audit page");
355 assert!(
356 audit.body.contains("<code>wiki_pushed</code>"),
357 "the wiki push is audited: {}",
358 audit.body
359 );
360 assert!(audit.body.contains("carol"), "the pusher is named");
361 assert!(audit.body.contains("bob/proj"), "the repo is named");
362
363 // A save that started from the old head is refused with the conflict message, not applied.
364 let stale_csrf = input_value(
365 &http
366 .get("/bob/proj/wiki/Home/edit", Some(&carol))
367 .await
368 .body,
369 "csrf",
370 );
371 let stale = http
372 .post_form(
373 "/bob/proj/wiki/Home/edit",
374 &[
375 ("csrf", &stale_csrf),
376 ("base", "0000000000000000000000000000000000000000"),
377 ("content", "# Lost write\n"),
378 ("message", "stale"),
379 ],
380 Some(&carol),
381 )
382 .await;
383 assert_eq!(stale.status, 200, "{}", stale.body);
384 assert!(
385 stale.body.contains("while you were editing"),
386 "{}",
387 stale.body
388 );
389 let still = page_body(&http, "/bob/proj/wiki/Home", Some(&bob)).await;
390 assert!(still.body.contains("Welcome again"), "{}", still.body);
391
392 // The history lists both edits.
393 let history = page_body(&http, "/bob/proj/wiki/Home/history", Some(&bob)).await;
394 assert!(history.body.contains("first page"), "{}", history.body);
395 assert!(history.body.contains("edit from git"), "{}", history.body);
396
397 // Dave cannot push to the wiki.
398 let dave_token = create_token(&http, &dave, "wiki-e2e").await;
399 let dave_url = format!(
400 "http://dave:{dave_token}@127.0.0.1:{}/bob/proj.wiki.git",
401 ports.http
402 );
403 let dave_work = dir.join("wiki-dave");
404 ok(
405 &git(&dir, &["clone", "-q", &dave_url, path_str(&dave_work)]),
406 "dave clones the public wiki",
407 );
408 commit_file(&dave_work, "Hack.md", "x\n", "not allowed");
409 let denied = git(&dave_work, &["push", "-q", "origin", "HEAD:main"]);
410 assert!(!denied.status.success(), "read-only push must fail");
411
412 // Bob turns the wiki off on the settings page. Carol, who is not an admin, cannot.
413 let settings = page_body(&http, "/bob/proj/settings/wiki", Some(&bob)).await;
414 assert!(
415 settings.body.contains("Enable the wiki"),
416 "{}",
417 settings.body
418 );
419 let forbidden_toggle = http.get("/bob/proj/settings/wiki", Some(&carol)).await;
420 assert_eq!(
421 forbidden_toggle.status, 404,
422 "only admins see wiki settings"
423 );
424 let off = http
425 .post_form(
426 "/bob/proj/settings/wiki",
427 &[("csrf", &input_value(&settings.body, "csrf"))],
428 Some(&bob),
429 )
430 .await;
431 assert_eq!(off.status, 303, "{}", off.body);
432 let hidden = http.get("/bob/proj/wiki", Some(&bob)).await;
433 assert_eq!(hidden.status, 404, "disabled wiki must be hidden");
434 commit_file(&work, "Other.md", "x\n", "after disable");
435 let refused_push = git(&work, &["push", "-q", "origin", "HEAD:main"]);
436 assert!(
437 !refused_push.status.success(),
438 "push to a disabled wiki must fail"
439 );
440
441 drop(server);
442}