forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.7 KiB
1//! Black-box tests for snippets. Like the other e2e tests, this starts the real `forge` binary and
2//! drives it over HTTP. Cookies are set by hand because the session cookie is `__Host-` and
3//! `Secure`. Files are fetched from the user-content listener, as a browser would.
4
5mod common;
6use common::{BIN, Server, describe, launch, pick_ports, write_config};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use std::time::Duration;
10
11use reqwest::header::{CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, LOCATION, SET_COOKIE};
12
13const PW: &str = "alice-password-123";
14
15struct Resp {
16 status: u16,
17 headers: HeaderMap,
18 body: String,
19}
20
21impl Resp {
22 fn header(&self, name: HeaderName) -> Option<String> {
23 self.headers
24 .get(name)
25 .and_then(|v| v.to_str().ok())
26 .map(str::to_owned)
27 }
28
29 fn set_cookies(&self) -> Vec<String> {
30 self.headers
31 .get_all(SET_COOKIE)
32 .iter()
33 .filter_map(|v| v.to_str().ok().map(str::to_owned))
34 .collect()
35 }
36}
37
38struct Http {
39 client: reqwest::Client,
40 base: String,
41}
42
43impl Http {
44 fn new(base: String) -> Self {
45 let client = reqwest::Client::builder()
46 .redirect(reqwest::redirect::Policy::none())
47 .timeout(Duration::from_secs(60))
48 .build()
49 .expect("http client");
50 Self { client, base }
51 }
52
53 async fn get(&self, path: &str, cookie: Option<&str>) -> Resp {
54 self.send(self.client.get(format!("{}{path}", self.base)), cookie)
55 .await
56 }
57
58 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
59 self.send(
60 self.client.post(format!("{}{path}", self.base)).form(form),
61 cookie,
62 )
63 .await
64 }
65
66 async fn send(&self, req: reqwest::RequestBuilder, cookie: Option<&str>) -> Resp {
67 let req = match cookie {
68 Some(cookie) => req.header(COOKIE, cookie),
69 None => req,
70 };
71 let res = req.send().await.expect("http request");
72 let status = res.status().as_u16();
73 let headers = res.headers().clone();
74 let body = res.text().await.expect("read body");
75 Resp {
76 status,
77 headers,
78 body,
79 }
80 }
81}
82
83/// `name=value` for the cookie whose name ends with `suffix`, ignoring cleared cookies.
84fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
85 resp.set_cookies().iter().find_map(|set| {
86 let pair = set.split(';').next()?;
87 let (name, value) = pair.split_once('=')?;
88 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
89 })
90}
91
92/// The value of the first `<input name="...">` on a page.
93fn input_value(body: &str, name: &str) -> String {
94 let doc = scraper::Html::parse_document(body);
95 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
96 doc.select(&selector)
97 .next()
98 .and_then(|el| el.value().attr("value"))
99 .unwrap_or_else(|| panic!("no input named {name} on page"))
100 .to_owned()
101}
102
103/// The path and query of an absolute URL, so it can be requested through a base-bound client.
104fn path_of(url: &str) -> String {
105 let rest = url.split_once("//").map_or(url, |(_, r)| r);
106 match rest.find('/') {
107 Some(i) => rest[i..].to_owned(),
108 None => "/".to_owned(),
109 }
110}
111
112/// The href of the first link whose text is `text`.
113fn link_href(body: &str, text: &str) -> String {
114 let doc = scraper::Html::parse_document(body);
115 let selector = scraper::Selector::parse("a").expect("selector");
116 doc.select(&selector)
117 .find(|a| a.text().collect::<String>().trim() == text)
118 .and_then(|a| a.value().attr("href"))
119 .unwrap_or_else(|| panic!("no link {text:?} on page"))
120 .to_owned()
121}
122
123/// Runs an admin subcommand with the password in FORGE_PASSWORD and asserts it succeeded.
124fn admin(config: &Path, args: &[&str]) {
125 let out = Command::new(BIN)
126 .arg("--config")
127 .arg(config)
128 .arg("admin")
129 .args(args)
130 .env("FORGE_PASSWORD", PW)
131 .output()
132 .expect("run admin command");
133 assert!(
134 out.status.success(),
135 "admin {args:?} failed:\n{}",
136 describe(&out)
137 );
138}
139
140async fn login(http: &Http, username: &str) -> String {
141 let page = http.get("/login", None).await;
142 assert_eq!(page.status, 200, "GET /login");
143 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
144 let precsrf = input_value(&page.body, "precsrf");
145 let res = http
146 .post_form(
147 "/login",
148 &[
149 ("username", username),
150 ("password", PW),
151 ("next", "/"),
152 ("precsrf", &precsrf),
153 ],
154 Some(&precsrf_cookie),
155 )
156 .await;
157 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
158 cookie_named(&res, "forge_session").expect("session cookie after login")
159}
160
161/// A running server. The temp dir lives as long as the test.
162struct Env {
163 _root: tempfile::TempDir,
164 _server: Server,
165 app: Http,
166 content: Http,
167}
168
169async fn start() -> Env {
170 let root = tempfile::tempdir().expect("temp dir");
171 let dir: PathBuf = root.path().to_path_buf();
172 let ports = pick_ports();
173 let config = write_config(&dir, ports);
174 admin(&config, &["create-user", "alice"]);
175 admin(&config, &["create-user", "bob"]);
176 let (server, ports) = launch(&dir, ports).await;
177 Env {
178 _root: root,
179 _server: server,
180 app: Http::new(format!("http://127.0.0.1:{}", ports.http)),
181 content: Http::new(format!("http://127.0.0.1:{}", ports.content)),
182 }
183}
184
185/// Creates a snippet as `cookie`'s user and returns its id.
186async fn create(
187 http: &Http,
188 cookie: &str,
189 title: &str,
190 visibility: &str,
191 files: &[(&str, &str)],
192) -> String {
193 let page = http.get("/snippets/new", Some(cookie)).await;
194 assert_eq!(page.status, 200, "GET /snippets/new");
195 let csrf = input_value(&page.body, "csrf");
196 let mut form: Vec<(String, String)> = vec![
197 ("csrf".into(), csrf),
198 ("title".into(), title.into()),
199 ("description".into(), String::new()),
200 ("visibility".into(), visibility.into()),
201 ];
202 for (i, (name, content)) in files.iter().enumerate() {
203 form.push((format!("name_{i}"), (*name).into()));
204 form.push((format!("content_{i}"), (*content).into()));
205 }
206 let borrowed: Vec<(&str, &str)> = form.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
207 let res = http
208 .post_form("/snippets/new", &borrowed, Some(cookie))
209 .await;
210 assert_eq!(res.status, 303, "create snippet: {}", res.body);
211 let location = res.header(LOCATION).expect("location");
212 location
213 .strip_prefix("/snippets/")
214 .expect("redirect to the snippet")
215 .to_owned()
216}
217
218#[tokio::test]
219async fn create_edit_and_revisions() {
220 let env = start().await;
221 let alice = login(&env.app, "alice").await;
222 let id = create(
223 &env.app,
224 &alice,
225 "Greeting",
226 "public",
227 &[("hello.txt", "hello\n")],
228 )
229 .await;
230
231 let view = env.app.get(&format!("/snippets/{id}"), None).await;
232 assert_eq!(view.status, 200);
233 assert!(view.body.contains("hello"), "file contents are shown");
234
235 let edit_path = format!("/snippets/{id}/edit");
236 let page = env.app.get(&edit_path, Some(&alice)).await;
237 assert_eq!(page.status, 200, "GET edit");
238 let csrf = input_value(&page.body, "csrf");
239 let saved = env
240 .app
241 .post_form(
242 &edit_path,
243 &[
244 ("csrf", &csrf),
245 ("title", "Greeting v2"),
246 ("description", "two files now"),
247 ("visibility", "public"),
248 ("name_0", "hello.txt"),
249 ("content_0", "hello v2\n"),
250 ("name_1", "notes.md"),
251 ("content_1", "# Notes\n\nSome **bold** text.\n"),
252 ],
253 Some(&alice),
254 )
255 .await;
256 assert_eq!(saved.status, 303, "save edit: {}", saved.body);
257
258 let view = env.app.get(&format!("/snippets/{id}"), None).await;
259 assert!(view.body.contains("Greeting v2"));
260 assert!(view.body.contains("<strong>"), "markdown file is rendered");
261 assert!(
262 view.body.contains("<strong>bold</strong>"),
263 "markdown bold renders"
264 );
265
266 let history = env
267 .app
268 .get(&format!("/snippets/{id}/revisions"), Some(&alice))
269 .await;
270 assert_eq!(history.status, 200);
271 assert!(
272 history.body.contains("Revision 2"),
273 "second revision listed"
274 );
275 assert!(history.body.contains("Revision 1"), "first revision listed");
276}
277
278#[tokio::test]
279async fn private_snippet_is_hidden_from_others() {
280 let env = start().await;
281 let alice = login(&env.app, "alice").await;
282 let bob = login(&env.app, "bob").await;
283 let id = create(&env.app, &alice, "Secret", "private", &[("s.txt", "shh\n")]).await;
284
285 let owner = env.app.get(&format!("/snippets/{id}"), Some(&alice)).await;
286 assert_eq!(owner.status, 200, "owner can read");
287 assert_eq!(
288 env.app
289 .get(&format!("/snippets/{id}"), Some(&bob))
290 .await
291 .status,
292 404
293 );
294 assert_eq!(
295 env.app.get(&format!("/snippets/{id}"), None).await.status,
296 404
297 );
298
299 let list = env.app.get("/snippets", Some(&bob)).await;
300 assert!(
301 !list.body.contains("Secret"),
302 "private snippet is not listed for bob"
303 );
304
305 // Bob cannot edit or delete it either.
306 let page = env
307 .app
308 .get(&format!("/snippets/{id}/edit"), Some(&bob))
309 .await;
310 assert_eq!(page.status, 404, "bob cannot open the editor");
311 let bob_page = env.app.get("/snippets/new", Some(&bob)).await;
312 let bob_csrf = input_value(&bob_page.body, "csrf");
313 let res = env
314 .app
315 .post_form(
316 &format!("/snippets/{id}/delete"),
317 &[("csrf", &bob_csrf)],
318 Some(&bob),
319 )
320 .await;
321 assert_eq!(res.status, 404, "bob cannot delete");
322 assert_eq!(
323 env.app
324 .get(&format!("/snippets/{id}"), Some(&alice))
325 .await
326 .status,
327 200
328 );
329}
330
331#[tokio::test]
332async fn raw_files_use_the_user_content_origin_and_private_tokens() {
333 let env = start().await;
334 let alice = login(&env.app, "alice").await;
335 let public = create(&env.app, &alice, "Pub", "public", &[("notes.md", "# Hi\n")]).await;
336 let private = create(
337 &env.app,
338 &alice,
339 "Priv",
340 "private",
341 &[("data.json", "{\"a\":1}\n")],
342 )
343 .await;
344
345 let page = env.app.get(&format!("/snippets/{public}"), None).await;
346 let raw = link_href(&page.body, "Raw");
347 let res = env.content.get(&path_of(&raw), None).await;
348 assert_eq!(res.status, 200, "public raw: {raw}");
349 assert!(
350 res.header(CONTENT_TYPE)
351 .unwrap_or_default()
352 .starts_with("text/markdown"),
353 "markdown content type"
354 );
355 assert_eq!(res.body, "# Hi\n");
356
357 // Private raw links carry a signed token, and the bare path is refused.
358 let page = env
359 .app
360 .get(&format!("/snippets/{private}"), Some(&alice))
361 .await;
362 let raw = link_href(&page.body, "Raw");
363 assert!(raw.contains("token="), "private raw link is signed: {raw}");
364 let res = env.content.get(&path_of(&raw), None).await;
365 assert_eq!(res.status, 200, "private raw with token");
366 assert!(
367 res.header(CONTENT_TYPE)
368 .unwrap_or_default()
369 .starts_with("application/json")
370 );
371 let bare = raw.split('?').next().unwrap_or_default().to_owned();
372 assert_eq!(
373 env.content.get(&path_of(&bare), None).await.status,
374 404,
375 "no token, no file"
376 );
377
378 let missing = format!("/-/snippets/{public}/nope.txt");
379 assert_eq!(env.content.get(&missing, None).await.status, 404);
380}
381
382#[tokio::test]
383async fn unlisted_is_by_link_only_and_delete_removes_it() {
384 let env = start().await;
385 let alice = login(&env.app, "alice").await;
386 let id = create(
387 &env.app,
388 &alice,
389 "Link only",
390 "unlisted",
391 &[("a.txt", "x\n")],
392 )
393 .await;
394
395 let list = env.app.get("/snippets", None).await;
396 assert!(!list.body.contains("Link only"), "unlisted is not listed");
397 assert_eq!(
398 env.app.get(&format!("/snippets/{id}"), None).await.status,
399 200
400 );
401
402 let page = env.app.get(&format!("/snippets/{id}"), Some(&alice)).await;
403 let csrf = input_value(&page.body, "csrf");
404 let res = env
405 .app
406 .post_form(
407 &format!("/snippets/{id}/delete"),
408 &[("csrf", &csrf)],
409 Some(&alice),
410 )
411 .await;
412 assert_eq!(res.status, 303, "delete: {}", res.body);
413 assert_eq!(
414 env.app.get(&format!("/snippets/{id}"), None).await.status,
415 404
416 );
417}
418
419#[tokio::test]
420async fn bad_input_redisplays_the_form_with_an_error() {
421 let env = start().await;
422 let alice = login(&env.app, "alice").await;
423 let page = env.app.get("/snippets/new", Some(&alice)).await;
424 let csrf = input_value(&page.body, "csrf");
425 let res = env
426 .app
427 .post_form(
428 "/snippets/new",
429 &[
430 ("csrf", &csrf),
431 ("title", "Bad"),
432 ("visibility", "public"),
433 ("name_0", "a/b.txt"),
434 ("content_0", "x"),
435 ],
436 Some(&alice),
437 )
438 .await;
439 assert_eq!(res.status, 200, "invalid input is not a redirect");
440 assert!(
441 res.body.contains("may only use"),
442 "error shown: {}",
443 res.body
444 );
445 assert!(
446 res.body.contains("a/b.txt") || res.body.contains("Bad"),
447 "input kept"
448 );
449}