Krolyxon
Commit 8f664fb

ferreniumtestsbrowse.rs

412 lines · 14.4 KB · Rust

1//! Browsing pages (summary, tree, blob, raw, refs) against real repos.
2
3mod common;
4
5use axum::body::{Body, to_bytes};
6use axum::http::{HeaderMap, Request, StatusCode, header};
7use common::{Rich, bare, rich_repo};
8use ferrenium::config::Config;
9use ferrenium::server;
10use ferrenium::state::{AppState, Overrides};
11use tower::ServiceExt;
12
13struct Site {
14 _root: tempfile::TempDir,
15 app: axum::Router,
16 rich: Rich,
17}
18
19fn site(extra: &str) -> Site {
20 let root = tempfile::tempdir().unwrap();
21 let rich = rich_repo(root.path(), "rich.git");
22 rich_repo(root.path(), "forks/nested.git");
23 bare(root.path(), "empty.git", &[]);
24 let toml = format!(
25 "[repos]\nscan_path = {:?}\n[watch]\nenabled = false\n\
26 [site]\nclone_urls = [\"https://git.example.com/{{name}}\", \"git@example.com:{{name}}\"]\n\
27 [ui]\nmax_blob_kb = 2\n{extra}",
28 root.path().to_str().unwrap()
29 );
30 let mut config = Config::parse(&toml).expect("parse");
31 config.finish().expect("valid");
32 let state = AppState::new(config, Overrides::default());
33 let config = state.config.load_full();
34 state.index.full_scan(&config);
35 Site {
36 app: server::app(state),
37 rich,
38 _root: root,
39 }
40}
41
42struct Res {
43 status: StatusCode,
44 headers: HeaderMap,
45 body: String,
46}
47
48impl Res {
49 fn header(&self, name: header::HeaderName) -> &str {
50 self.headers
51 .get(&name)
52 .map(|v| v.to_str().unwrap())
53 .unwrap_or_default()
54 }
55}
56
57async fn fetch(app: &axum::Router, uri: &str, etag: Option<&str>) -> Res {
58 let mut req = Request::get(uri);
59 if let Some(etag) = etag {
60 req = req.header(header::IF_NONE_MATCH, etag);
61 }
62 let res = app
63 .clone()
64 .oneshot(req.body(Body::empty()).unwrap())
65 .await
66 .unwrap();
67 let status = res.status();
68 let headers = res.headers().clone();
69 let body = to_bytes(res.into_body(), usize::MAX).await.unwrap();
70 Res {
71 status,
72 headers,
73 body: String::from_utf8_lossy(&body).into_owned(),
74 }
75}
76
77async fn ok(app: &axum::Router, uri: &str) -> Res {
78 let res = fetch(app, uri, None).await;
79 assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.body);
80 assert_csp_clean(&res.body);
81 res
82}
83
84/// Pages must work under `script-src 'self'` and no inline styles.
85fn assert_csp_clean(html: &str) {
86 assert!(!html.contains("<script>"), "inline script");
87 assert!(!html.contains("<style"), "inline style");
88 assert!(!html.contains("style=\""), "style attribute");
89}
90
91#[tokio::test]
92async fn summary_shows_tree_readme_and_rail() {
93 let s = site("");
94 let res = ok(&s.app, "/rich").await;
95 let html = &res.body;
96 assert!(
97 html.contains(r#"<h1 class="display-md repo-title">rich</h1>"#),
98 "{html}"
99 );
100 // Root tree: directories first.
101 let docs = html.find(">docs<").expect("docs row");
102 let readme = html.find(">README.md<").expect("README row");
103 assert!(docs < readme);
104 assert!(html.contains(r#"href="/rich/tree/main/src""#));
105 assert!(html.contains(r#"href="/rich/blob/main/README.md""#));
106 // README: rendered, sanitized, relative links rewritten.
107 assert!(
108 html.contains(r#"href="/rich/blob/main/docs/guide.md""#),
109 "{html}"
110 );
111 assert!(html.contains(r#"src="/rich/raw/main/img/logo.png""#));
112 assert!(!html.contains("alert(1)"));
113 // Rail.
114 assert!(
115 html.contains(r#"<dd><a href="/rich/log">2</a></dd>"#),
116 "commit count: {html}"
117 );
118 assert!(html.contains("https://git.example.com/rich"));
119 assert!(html.contains("git@example.com:rich"));
120 assert!(html.contains(">HTTPS<") && html.contains(">SSH<"));
121 assert!(html.contains("add everything"));
122 assert!(html.contains(&format!(r#"href="/rich/commit/{}""#, s.rich.head)));
123 // Nav + picker.
124 assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#));
125 assert!(html.contains(r#"href="/rich/tree/feature/x""#));
126 assert!(html.contains(r#"href="/rich/tree/v1.1""#));
127
128 // Conditional request.
129 let etag = res.header(header::ETAG).to_string();
130 assert!(etag.starts_with("W/\""), "{etag}");
131 assert_eq!(res.header(header::CACHE_CONTROL), "no-cache");
132 let again = fetch(&s.app, "/rich", Some(&etag)).await;
133 assert_eq!(again.status, StatusCode::NOT_MODIFIED);
134 assert!(again.body.is_empty());
135 assert_eq!(again.header(header::ETAG), etag);
136}
137
138#[tokio::test]
139async fn empty_repo_summary() {
140 let s = site("");
141 let res = ok(&s.app, "/empty").await;
142 assert!(res.body.contains("Empty repository"));
143 assert!(res.body.contains("git@example.com:empty"));
144 let tree = fetch(&s.app, "/empty/tree", None).await;
145 assert_eq!(tree.status, StatusCode::NOT_FOUND);
146 assert!(tree.body.contains("[ERROR: REPOSITORY IS EMPTY]"));
147}
148
149#[tokio::test]
150async fn dispatcher_matches_repos_and_revisions() {
151 let s = site("");
152 // Slashy repo name.
153 ok(&s.app, "/forks/nested").await;
154 ok(&s.app, "/forks/nested/tree/main/src").await;
155 // `.git` suffix redirects.
156 let res = fetch(&s.app, "/forks/nested.git", None).await;
157 assert_eq!(res.status, StatusCode::PERMANENT_REDIRECT);
158 assert_eq!(res.header(header::LOCATION), "/forks/nested");
159 // Slashy branch, tags, full and short ids.
160 let res = ok(&s.app, "/rich/tree/feature/x").await;
161 assert!(res.body.contains("only-on-feature.txt"));
162 let res = ok(&s.app, "/rich/blob/feature/x/only-on-feature.txt").await;
163 assert!(res.body.contains("feature"));
164 let res = ok(&s.app, "/rich/tree/v1.1").await;
165 assert!(!res.body.contains("data.bin"), "v1.1 is the first commit");
166 ok(&s.app, "/rich/blob/v1.0/data.bin").await;
167 let full = ok(&s.app, &format!("/rich/tree/{}", s.rich.first)).await;
168 assert_eq!(full.header(header::CACHE_CONTROL), "public, max-age=86400");
169 let short = ok(&s.app, &format!("/rich/tree/{}", &s.rich.feature[..8])).await;
170 assert!(short.body.contains("only-on-feature.txt"));
171 assert_eq!(short.header(header::CACHE_CONTROL), "no-cache");
172
173 for (uri, message) in [
174 ("/nope", "PAGE NOT FOUND"),
175 ("/rich/tree/nope", "UNKNOWN REVISION"),
176 ("/rich/tree/main/nope", "PATH NOT FOUND IN main"),
177 ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"),
178 ("/rich/blame-nope", "PAGE NOT FOUND"),
179 ("/rich/tree/main/../x", "PAGE NOT FOUND"),
180 ("/-/nope", "PAGE NOT FOUND"),
181 ] {
182 let res = fetch(&s.app, uri, None).await;
183 assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
184 assert!(
185 res.body.contains(&format!("[ERROR: {message}]")),
186 "{uri}: {}",
187 res.body
188 );
189 }
190}
191
192#[tokio::test]
193async fn tree_page() {
194 let s = site("");
195 // Submodule: no link, short commit id instead of a size.
196 let res = ok(&s.app, "/rich/tree/main/vendor").await;
197 let row = res.body.split("<li class=\"tree-row\">").nth(2).unwrap();
198 assert!(row.contains(r#"<span class="tree-name">"#), "{row}");
199 assert!(row.contains(&format!("@ {}", &s.rich.first[..7])), "{row}");
200
201 let res = ok(&s.app, "/rich/tree/main/docs").await;
202 let html = &res.body;
203 assert!(
204 html.contains(r#"<a href="/rich/tree/main">rich</a>"#),
205 "{html}"
206 );
207 assert!(html.contains(r#"<span class="crumb-here">docs</span>"#));
208 assert!(html.contains(r#"href="/rich/tree/main">"#)); // ".." row
209 assert!(html.contains("2 entries · Tree "));
210 // Plain-text directory README, escaped.
211 assert!(
212 html.contains("Plain docs readme &lt;b&gt;not html&lt;/b&gt;"),
213 "{html}"
214 );
215 // Picker keeps the path.
216 assert!(html.contains(r#"href="/rich/tree/v1.0/docs""#));
217 // Unicode, spaces and `#` are percent-encoded.
218 let res = ok(&s.app, "/rich/tree/main").await;
219 assert!(
220 res.body.contains("/rich/tree/main/%C3%BCn%C3%AFcode%20dir"),
221 "{}",
222 res.body
223 );
224 let res = ok(&s.app, "/rich/tree/main/%C3%BCn%C3%AFcode%20dir").await;
225 assert!(
226 res.body
227 .contains("/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt")
228 );
229 // ETag round trip.
230 let etag = res.header(header::ETAG).to_string();
231 let again = fetch(
232 &s.app,
233 "/rich/tree/main/%C3%BCn%C3%AFcode%20dir",
234 Some(&etag),
235 )
236 .await;
237 assert_eq!(again.status, StatusCode::NOT_MODIFIED);
238
239 // A file in tree view redirects to the blob view, and vice versa.
240 let res = fetch(&s.app, "/rich/tree/main/src/main.rs", None).await;
241 assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
242 assert_eq!(res.header(header::LOCATION), "/rich/blob/main/src/main.rs");
243 let res = fetch(&s.app, "/rich/blob/main/src", None).await;
244 assert_eq!(res.header(header::LOCATION), "/rich/tree/main/src");
245}
246
247#[tokio::test]
248async fn blob_page_variants() {
249 let s = site("");
250 // Highlighted code with line anchors.
251 let res = ok(&s.app, "/rich/blob/main/src/main.rs").await;
252 let html = &res.body;
253 assert!(html.contains("4 lines · 45 B · Rust"), "{html}");
254 assert!(html.contains(r##"<tr id="L2"><td class="ln"><a href="#L2">2</a></td>"##));
255 assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}");
256 assert!(html.contains("&lt;hi&gt;"));
257 assert!(html.contains(r#"href="/rich/raw/main/src/main.rs""#));
258 assert!(html.contains(r#"href="/rich/raw/main/src/main.rs?download""#));
259 assert!(html.contains(r#"<span class="crumb-here">main.rs</span>"#));
260
261 // Markdown: preview by default, source on request.
262 let res = ok(&s.app, "/rich/blob/main/docs/guide.md").await;
263 assert!(
264 res.body
265 .contains(r#"<article class="markdown blob-markdown">"#)
266 );
267 assert!(
268 res.body.contains(r#"href="/rich/blob/main/README.md""#),
269 "{}",
270 res.body
271 );
272 let res = ok(&s.app, "/rich/blob/main/docs/guide.md?view=source").await;
273 assert!(res.body.contains(r#"id="L1""#));
274 assert!(res.body.contains(r#"aria-current="true">Source"#));
275
276 let res = ok(&s.app, "/rich/blob/main/img/logo.png").await;
277 assert!(
278 res.body
279 .contains(r#"<img src="/rich/raw/main/img/logo.png""#)
280 );
281 let res = ok(&s.app, "/rich/blob/main/data.bin").await;
282 assert!(res.body.contains("[Binary file]"));
283 let res = ok(&s.app, "/rich/blob/main/big.txt").await;
284 assert!(
285 res.body.contains("[Too large to display]"),
286 "max_blob_kb = 2"
287 );
288 let res = ok(&s.app, "/rich/blob/main/link-to-main").await;
289 assert!(
290 res.body
291 .contains(r#"href="/rich/blob/main/src/main.rs">src/main.rs</a>"#)
292 );
293 let res = ok(
294 &s.app,
295 "/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt",
296 )
297 .await;
298 assert!(res.body.contains("fïle #1.txt"));
299 // HTML source is shown as text, never rendered.
300 let res = ok(&s.app, "/rich/blob/main/page.html").await;
301 assert!(res.body.contains("alert") && !res.body.contains("<script>alert"));
302}
303
304#[tokio::test]
305async fn raw_is_sandboxed() {
306 let s = site("");
307 let res = fetch(&s.app, "/rich/raw/main/page.html", None).await;
308 assert_eq!(res.status, StatusCode::OK);
309 assert_eq!(
310 res.header(header::CONTENT_TYPE),
311 "text/plain; charset=utf-8"
312 );
313 assert_eq!(res.header(header::X_CONTENT_TYPE_OPTIONS), "nosniff");
314 assert!(
315 res.header(header::CONTENT_SECURITY_POLICY)
316 .contains("sandbox")
317 );
318 assert_eq!(res.body, "<html><script>alert(1)</script></html>\n");
319
320 let res = fetch(&s.app, "/rich/raw/main/img/logo.png", None).await;
321 assert_eq!(res.header(header::CONTENT_TYPE), "image/png");
322 let res = fetch(&s.app, "/rich/raw/main/data.bin", None).await;
323 assert_eq!(res.header(header::CONTENT_TYPE), "application/octet-stream");
324
325 let res = fetch(&s.app, "/rich/raw/main/page.html?download", None).await;
326 assert_eq!(
327 res.header(header::CONTENT_DISPOSITION),
328 "attachment; filename=\"page.html\"; filename*=UTF-8''page.html"
329 );
330 let res = fetch(
331 &s.app,
332 "/rich/raw/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt?download",
333 None,
334 )
335 .await;
336 assert!(
337 res.header(header::CONTENT_DISPOSITION)
338 .contains("filename*=UTF-8''f%C3%AFle%20%231.txt")
339 );
340
341 // Raw by full id is cacheable; ETag revalidates.
342 let uri = format!("/rich/raw/{}/src/main.rs", s.rich.head);
343 let res = fetch(&s.app, &uri, None).await;
344 assert_eq!(res.header(header::CACHE_CONTROL), "public, max-age=86400");
345 let etag = res.header(header::ETAG).to_string();
346 assert_eq!(
347 fetch(&s.app, &uri, Some(&etag)).await.status,
348 StatusCode::NOT_MODIFIED
349 );
350
351 assert_eq!(
352 fetch(&s.app, "/rich/raw/main/src", None).await.status,
353 StatusCode::NOT_FOUND
354 );
355}
356
357#[tokio::test]
358async fn refs_page() {
359 let s = site("");
360 let res = ok(&s.app, "/rich/refs").await;
361 let html = &res.body;
362 let main = html.find(r#"href="/rich/tree/main""#).expect("main");
363 let feature = html
364 .find(r#"href="/rich/tree/feature/x""#)
365 .expect("feature");
366 assert!(main < feature, "default branch first");
367 assert!(html.contains(r#"<span class="chip">Default</span>"#));
368 assert!(html.contains("Release 1.1"), "annotated tag subject");
369 assert!(html.contains(r#"href="/rich/tree/v1.0""#));
370 assert!(html.contains(r#"<span class="crumb-here">Refs</span>"#));
371}
372
373#[tokio::test]
374async fn base_path_prefixes_repo_links() {
375 let s = site("[server]\nbase_path = \"/git/\"\n");
376 let res = ok(&s.app, "/git/rich").await;
377 assert!(res.body.contains(r#"href="/git/rich/tree/main/src""#));
378 assert!(
379 res.body
380 .contains(r#"href="/git/rich/blob/main/docs/guide.md""#)
381 );
382 let res = fetch(&s.app, "/git/forks/nested.git", None).await;
383 assert_eq!(res.header(header::LOCATION), "/git/forks/nested");
384 ok(&s.app, "/git/rich/blob/main/src/main.rs").await;
385}
386
387#[tokio::test]
388async fn every_referenced_icon_exists() {
389 let s = site("");
390 let res = ok(&s.app, "/rich").await;
391 let sprite_url = res
392 .body
393 .split('"')
394 .find(|s| s.contains("icons/sprite.svg#"))
395 .unwrap()
396 .split('#')
397 .next()
398 .unwrap()
399 .to_string();
400 let sprite = ok(&s.app, &sprite_url).await.body;
401 for id in res
402 .body
403 .split("sprite.svg#")
404 .skip(1)
405 .map(|s| s.split('"').next().unwrap())
406 {
407 assert!(
408 sprite.contains(&format!("id=\"{id}\"")),
409 "missing icon {id}"
410 );
411 }
412}