forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
11.8 KiB
1//! End-to-end: discussions over HTTP against a running server. Covers the settings toggle that
2//! shows and hides the tab, creating a discussion, a reply and a nested reply (refused), marking
3//! an answer in Q&A, and the lock: once locked, a plain viewer's comment is refused while the
4//! owner can still write.
5
6mod common;
7use common::{BIN, launch, pick_ports, tool_on_path, write_config};
8use std::path::Path;
9use std::process::Command;
10use std::time::Duration;
11
12use reqwest::header::{LOCATION, SET_COOKIE};
13
14const ALICE_PW: &str = "alice-password-123";
15const BOB_PW: &str = "bob-password-4567";
16
17struct Resp {
18 status: u16,
19 location: Option<String>,
20 set_cookies: Vec<String>,
21 body: String,
22}
23
24struct Http {
25 client: reqwest::Client,
26 base: String,
27}
28
29impl Http {
30 fn new(port: u16) -> Self {
31 let client = reqwest::Client::builder()
32 .redirect(reqwest::redirect::Policy::none())
33 .timeout(Duration::from_secs(60))
34 .build()
35 .expect("http client");
36 Self {
37 client,
38 base: format!("http://127.0.0.1:{port}"),
39 }
40 }
41
42 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
43 let mut req = self.client.get(format!("{}{path}", self.base));
44 if let Some(cookie) = cookie {
45 req = req.header(reqwest::header::COOKIE, cookie);
46 }
47 send(req).await
48 }
49
50 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
51 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
52 if let Some(cookie) = cookie {
53 req = req.header(reqwest::header::COOKIE, cookie);
54 }
55 send(req).await
56 }
57}
58
59async fn send(req: reqwest::RequestBuilder) -> Resp {
60 let res = req.send().await.expect("http request");
61 let status = res.status().as_u16();
62 let location = res
63 .headers()
64 .get(LOCATION)
65 .and_then(|v| v.to_str().ok())
66 .map(str::to_owned);
67 let set_cookies = res
68 .headers()
69 .get_all(SET_COOKIE)
70 .iter()
71 .filter_map(|v| v.to_str().ok().map(str::to_owned))
72 .collect();
73 let body = res.text().await.unwrap_or_default();
74 Resp {
75 status,
76 location,
77 set_cookies,
78 body,
79 }
80}
81
82/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
83fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
84 resp.set_cookies.iter().find_map(|set| {
85 let pair = set.split(';').next()?;
86 let (name, value) = pair.split_once('=')?;
87 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
88 })
89}
90
91/// The value of the first `<input name="...">` on a page.
92fn input_value(body: &str, name: &str) -> String {
93 let doc = scraper::Html::parse_document(body);
94 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
95 doc.select(&selector)
96 .next()
97 .and_then(|el| el.value().attr("value"))
98 .unwrap_or_else(|| panic!("no input named {name} on page"))
99 .to_owned()
100}
101
102fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
103 let out = Command::new(BIN)
104 .arg("--config")
105 .arg(config)
106 .args(["admin", "create-user", username])
107 .args(extra)
108 .env("FORGE_PASSWORD", password)
109 .output()
110 .expect("run admin create-user");
111 assert!(
112 out.status.success(),
113 "create-user {username} failed:\n{}",
114 String::from_utf8_lossy(&out.stderr)
115 );
116}
117
118async fn login(http: &Http, username: &str, password: &str) -> String {
119 let page = http.get("/login", None).await;
120 assert_eq!(page.status, 200, "GET /login");
121 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
122 let precsrf = input_value(&page.body, "precsrf");
123 let res = http
124 .post_form(
125 "/login",
126 &[
127 ("username", username),
128 ("password", password),
129 ("next", "/"),
130 ("precsrf", &precsrf),
131 ],
132 Some(precsrf_cookie.as_str()),
133 )
134 .await;
135 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
136 cookie_named(&res, "forge_session").expect("session cookie after login")
137}
138
139/// Creates a public repo through `/new`.
140async fn create_repo(http: &Http, session: &str, name: &str) {
141 let page = http.get("/new", Some(session)).await;
142 assert_eq!(page.status, 200, "GET /new");
143 let csrf = input_value(&page.body, "csrf");
144 let res = http
145 .post_form(
146 "/new",
147 &[
148 ("csrf", &csrf),
149 ("name", name),
150 ("description", ""),
151 ("visibility", "public"),
152 ("default_branch", "main"),
153 ],
154 Some(session),
155 )
156 .await;
157 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
158 assert_eq!(
159 res.location.as_deref(),
160 Some(format!("/alice/{name}").as_str())
161 );
162}
163
164/// The `#comment-<id>` anchor of a redirect location, as the id.
165fn anchored_id(location: Option<&str>) -> String {
166 location
167 .and_then(|l| l.rsplit_once("#comment-"))
168 .map(|(_, id)| id.to_owned())
169 .expect("redirect anchored on a comment")
170}
171
172#[tokio::test]
173async fn discussions_create_reply_answer_lock_and_toggle() {
174 if !tool_on_path("git") {
175 eprintln!("SKIP discussions_e2e: `git` was not found on PATH");
176 return;
177 }
178
179 let root = tempfile::tempdir().expect("temp dir");
180 let dir = root.path().to_path_buf();
181 let ports = pick_ports();
182 let config = write_config(&dir, ports);
183
184 run_admin(&config, "alice", &["--admin"], ALICE_PW);
185 run_admin(&config, "bob", &[], BOB_PW);
186
187 let (_server, ports) = launch(&dir, ports).await;
188 let http = Http::new(ports.http);
189 let alice = login(&http, "alice", ALICE_PW).await;
190 let bob = login(&http, "bob", BOB_PW).await;
191 create_repo(&http, &alice, "pub").await;
192
193 // Off by default: the tab is absent and the pages do not exist.
194 let repo_page = http.get("/alice/pub", Some(alice.as_str())).await;
195 assert_eq!(repo_page.status, 200);
196 assert!(
197 !repo_page.body.contains("href=\"/alice/pub/discussions\""),
198 "the Discussions tab shows while discussions are off"
199 );
200 assert_eq!(
201 http.get("/alice/pub/discussions", Some(alice.as_str()))
202 .await
203 .status,
204 404,
205 "list while off"
206 );
207
208 // Turn them on from settings, as the owner.
209 let settings = http.get("/alice/pub/settings", Some(alice.as_str())).await;
210 assert_eq!(settings.status, 200, "GET settings");
211 let csrf = input_value(&settings.body, "csrf");
212 let res = http
213 .post_form(
214 "/alice/pub/settings/discussions",
215 &[("csrf", &csrf), ("enabled", "1")],
216 Some(alice.as_str()),
217 )
218 .await;
219 assert_eq!(res.status, 303, "enable: {}", res.body);
220 let repo_page = http.get("/alice/pub", Some(alice.as_str())).await;
221 assert!(
222 repo_page.body.contains("href=\"/alice/pub/discussions\""),
223 "the Discussions tab is missing after turning it on"
224 );
225
226 // Alice opens a Q&A discussion.
227 let form = http
228 .get("/alice/pub/discussions/new", Some(alice.as_str()))
229 .await;
230 assert_eq!(form.status, 200, "GET new discussion");
231 let csrf = input_value(&form.body, "csrf");
232 let res = http
233 .post_form(
234 "/alice/pub/discussions/new",
235 &[
236 ("csrf", &csrf),
237 ("category", "qa"),
238 ("title", "How do I rebase?"),
239 ("body", "Any tips?"),
240 ],
241 Some(alice.as_str()),
242 )
243 .await;
244 assert_eq!(res.status, 303, "create: {}", res.body);
245 assert_eq!(res.location.as_deref(), Some("/alice/pub/discussions/1"));
246 let discussion = "/alice/pub/discussions/1";
247
248 // Bob answers at the top level, then replies to that comment.
249 let page = http.get(discussion, Some(bob.as_str())).await;
250 assert_eq!(page.status, 200, "GET discussion as bob");
251 let csrf_bob = input_value(&page.body, "csrf");
252 let res = http
253 .post_form(
254 &format!("{discussion}/comments"),
255 &[("csrf", &csrf_bob), ("body", "Try git rebase -i.")],
256 Some(bob.as_str()),
257 )
258 .await;
259 assert_eq!(res.status, 303, "top comment: {}", res.body);
260 let top = anchored_id(res.location.as_deref());
261
262 let res = http
263 .post_form(
264 &format!("{discussion}/comments"),
265 &[
266 ("csrf", &csrf_bob),
267 ("parent", &top),
268 ("body", "Thanks, that worked."),
269 ],
270 Some(bob.as_str()),
271 )
272 .await;
273 assert_eq!(res.status, 303, "reply: {}", res.body);
274 let reply = anchored_id(res.location.as_deref());
275
276 // A reply to a reply is refused, and the page says why.
277 let res = http
278 .post_form(
279 &format!("{discussion}/comments"),
280 &[
281 ("csrf", &csrf_bob),
282 ("parent", &reply),
283 ("body", "Too deep"),
284 ],
285 Some(bob.as_str()),
286 )
287 .await;
288 assert_eq!(res.status, 200, "nested reply shows the refusal page");
289 assert!(
290 res.body.contains("Could not save") && res.body.contains("one level"),
291 "nested reply was not refused: {}",
292 res.body
293 );
294
295 // Alice (the author) marks the top comment as the answer.
296 let page = http.get(discussion, Some(alice.as_str())).await;
297 let csrf_alice = input_value(&page.body, "csrf");
298 let res = http
299 .post_form(
300 &format!("{discussion}/answer"),
301 &[("csrf", &csrf_alice), ("comment_id", &top), ("answer", "1")],
302 Some(alice.as_str()),
303 )
304 .await;
305 assert_eq!(res.status, 303, "answer: {}", res.body);
306 let page = http.get(discussion, Some(bob.as_str())).await;
307 assert!(
308 page.body.contains("Answered"),
309 "the answered badge is missing after marking an answer"
310 );
311
312 // Alice locks the conversation. Bob can no longer comment, and cannot lock it either.
313 let page = http.get(discussion, Some(alice.as_str())).await;
314 let csrf_alice = input_value(&page.body, "csrf");
315 let res = http
316 .post_form(
317 &format!("{discussion}/lock"),
318 &[("csrf", &csrf_alice), ("locked", "1")],
319 Some(alice.as_str()),
320 )
321 .await;
322 assert_eq!(res.status, 303, "lock: {}", res.body);
323 let res = http
324 .post_form(
325 &format!("{discussion}/comments"),
326 &[("csrf", &csrf_bob), ("body", "Still here")],
327 Some(bob.as_str()),
328 )
329 .await;
330 assert_eq!(res.status, 403, "bob comments on a locked discussion");
331 let res = http
332 .post_form(
333 &format!("{discussion}/lock"),
334 &[("csrf", &csrf_bob), ("locked", "0")],
335 Some(bob.as_str()),
336 )
337 .await;
338 assert_eq!(res.status, 403, "bob locks a discussion");
339
340 // Turning discussions off hides the tab and the pages.
341 let settings = http.get("/alice/pub/settings", Some(alice.as_str())).await;
342 let csrf = input_value(&settings.body, "csrf");
343 let res = http
344 .post_form(
345 "/alice/pub/settings/discussions",
346 &[("csrf", &csrf), ("enabled", "0")],
347 Some(alice.as_str()),
348 )
349 .await;
350 assert_eq!(res.status, 303, "disable: {}", res.body);
351 let repo_page = http.get("/alice/pub", Some(alice.as_str())).await;
352 assert!(
353 !repo_page.body.contains("href=\"/alice/pub/discussions\""),
354 "the tab stays after turning discussions off"
355 );
356 assert_eq!(
357 http.get(discussion, Some(alice.as_str())).await.status,
358 404,
359 "discussion page while off"
360 );
361}