Krolyxon
Commit 55c1225

ferreniumtestsbrowse.rs

409 lines · 14.3 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!(html.contains("<dd>2</dd>"), "commit count: {html}");
115 assert!(html.contains("https://git.example.com/rich"));
116 assert!(html.contains("git@example.com:rich"));
117 assert!(html.contains(">HTTPS<") && html.contains(">SSH<"));
118 assert!(html.contains("add everything"));
119 assert!(html.contains(&format!(r#"href="/rich/tree/{}""#, s.rich.head)));
120 // Nav + picker.
121 assert!(html.contains(r#"class="label active" href="/rich" aria-current="page">Summary"#));
122 assert!(html.contains(r#"href="/rich/tree/feature/x""#));
123 assert!(html.contains(r#"href="/rich/tree/v1.1""#));
124
125 // Conditional request.
126 let etag = res.header(header::ETAG).to_string();
127 assert!(etag.starts_with("W/\""), "{etag}");
128 assert_eq!(res.header(header::CACHE_CONTROL), "no-cache");
129 let again = fetch(&s.app, "/rich", Some(&etag)).await;
130 assert_eq!(again.status, StatusCode::NOT_MODIFIED);
131 assert!(again.body.is_empty());
132 assert_eq!(again.header(header::ETAG), etag);
133}
134
135#[tokio::test]
136async fn empty_repo_summary() {
137 let s = site("");
138 let res = ok(&s.app, "/empty").await;
139 assert!(res.body.contains("Empty repository"));
140 assert!(res.body.contains("git@example.com:empty"));
141 let tree = fetch(&s.app, "/empty/tree", None).await;
142 assert_eq!(tree.status, StatusCode::NOT_FOUND);
143 assert!(tree.body.contains("[ERROR: REPOSITORY IS EMPTY]"));
144}
145
146#[tokio::test]
147async fn dispatcher_matches_repos_and_revisions() {
148 let s = site("");
149 // Slashy repo name.
150 ok(&s.app, "/forks/nested").await;
151 ok(&s.app, "/forks/nested/tree/main/src").await;
152 // `.git` suffix redirects.
153 let res = fetch(&s.app, "/forks/nested.git", None).await;
154 assert_eq!(res.status, StatusCode::PERMANENT_REDIRECT);
155 assert_eq!(res.header(header::LOCATION), "/forks/nested");
156 // Slashy branch, tags, full and short ids.
157 let res = ok(&s.app, "/rich/tree/feature/x").await;
158 assert!(res.body.contains("only-on-feature.txt"));
159 let res = ok(&s.app, "/rich/blob/feature/x/only-on-feature.txt").await;
160 assert!(res.body.contains("feature"));
161 let res = ok(&s.app, "/rich/tree/v1.1").await;
162 assert!(!res.body.contains("data.bin"), "v1.1 is the first commit");
163 ok(&s.app, "/rich/blob/v1.0/data.bin").await;
164 let full = ok(&s.app, &format!("/rich/tree/{}", s.rich.first)).await;
165 assert_eq!(full.header(header::CACHE_CONTROL), "public, max-age=86400");
166 let short = ok(&s.app, &format!("/rich/tree/{}", &s.rich.feature[..8])).await;
167 assert!(short.body.contains("only-on-feature.txt"));
168 assert_eq!(short.header(header::CACHE_CONTROL), "no-cache");
169
170 for (uri, message) in [
171 ("/nope", "PAGE NOT FOUND"),
172 ("/rich/tree/nope", "UNKNOWN REVISION"),
173 ("/rich/tree/main/nope", "PATH NOT FOUND IN main"),
174 ("/rich/blob/main/vendor/lib", "PATH IS A SUBMODULE"),
175 ("/rich/log", "PAGE NOT FOUND"),
176 ("/rich/tree/main/../x", "PAGE NOT FOUND"),
177 ("/-/nope", "PAGE NOT FOUND"),
178 ] {
179 let res = fetch(&s.app, uri, None).await;
180 assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
181 assert!(
182 res.body.contains(&format!("[ERROR: {message}]")),
183 "{uri}: {}",
184 res.body
185 );
186 }
187}
188
189#[tokio::test]
190async fn tree_page() {
191 let s = site("");
192 // Submodule: no link, short commit id instead of a size.
193 let res = ok(&s.app, "/rich/tree/main/vendor").await;
194 let row = res.body.split("<li class=\"tree-row\">").nth(2).unwrap();
195 assert!(row.contains(r#"<span class="tree-name">"#), "{row}");
196 assert!(row.contains(&format!("@ {}", &s.rich.first[..7])), "{row}");
197
198 let res = ok(&s.app, "/rich/tree/main/docs").await;
199 let html = &res.body;
200 assert!(
201 html.contains(r#"<a href="/rich/tree/main">rich</a>"#),
202 "{html}"
203 );
204 assert!(html.contains(r#"<span class="crumb-here">docs</span>"#));
205 assert!(html.contains(r#"href="/rich/tree/main">"#)); // ".." row
206 assert!(html.contains("2 entries · Tree "));
207 // Plain-text directory README, escaped.
208 assert!(
209 html.contains("Plain docs readme &lt;b&gt;not html&lt;/b&gt;"),
210 "{html}"
211 );
212 // Picker keeps the path.
213 assert!(html.contains(r#"href="/rich/tree/v1.0/docs""#));
214 // Unicode, spaces and `#` are percent-encoded.
215 let res = ok(&s.app, "/rich/tree/main").await;
216 assert!(
217 res.body.contains("/rich/tree/main/%C3%BCn%C3%AFcode%20dir"),
218 "{}",
219 res.body
220 );
221 let res = ok(&s.app, "/rich/tree/main/%C3%BCn%C3%AFcode%20dir").await;
222 assert!(
223 res.body
224 .contains("/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt")
225 );
226 // ETag round trip.
227 let etag = res.header(header::ETAG).to_string();
228 let again = fetch(
229 &s.app,
230 "/rich/tree/main/%C3%BCn%C3%AFcode%20dir",
231 Some(&etag),
232 )
233 .await;
234 assert_eq!(again.status, StatusCode::NOT_MODIFIED);
235
236 // A file in tree view redirects to the blob view, and vice versa.
237 let res = fetch(&s.app, "/rich/tree/main/src/main.rs", None).await;
238 assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
239 assert_eq!(res.header(header::LOCATION), "/rich/blob/main/src/main.rs");
240 let res = fetch(&s.app, "/rich/blob/main/src", None).await;
241 assert_eq!(res.header(header::LOCATION), "/rich/tree/main/src");
242}
243
244#[tokio::test]
245async fn blob_page_variants() {
246 let s = site("");
247 // Highlighted code with line anchors.
248 let res = ok(&s.app, "/rich/blob/main/src/main.rs").await;
249 let html = &res.body;
250 assert!(html.contains("4 lines · 45 B · Rust"), "{html}");
251 assert!(html.contains(r##"<tr id="L2"><td class="ln"><a href="#L2">2</a></td>"##));
252 assert!(html.contains(r#"<span class="hl-k">fn</span>"#), "{html}");
253 assert!(html.contains("&lt;hi&gt;"));
254 assert!(html.contains(r#"href="/rich/raw/main/src/main.rs""#));
255 assert!(html.contains(r#"href="/rich/raw/main/src/main.rs?download""#));
256 assert!(html.contains(r#"<span class="crumb-here">main.rs</span>"#));
257
258 // Markdown: preview by default, source on request.
259 let res = ok(&s.app, "/rich/blob/main/docs/guide.md").await;
260 assert!(
261 res.body
262 .contains(r#"<article class="markdown blob-markdown">"#)
263 );
264 assert!(
265 res.body.contains(r#"href="/rich/blob/main/README.md""#),
266 "{}",
267 res.body
268 );
269 let res = ok(&s.app, "/rich/blob/main/docs/guide.md?view=source").await;
270 assert!(res.body.contains(r#"id="L1""#));
271 assert!(res.body.contains(r#"aria-current="true">Source"#));
272
273 let res = ok(&s.app, "/rich/blob/main/img/logo.png").await;
274 assert!(
275 res.body
276 .contains(r#"<img src="/rich/raw/main/img/logo.png""#)
277 );
278 let res = ok(&s.app, "/rich/blob/main/data.bin").await;
279 assert!(res.body.contains("[Binary file]"));
280 let res = ok(&s.app, "/rich/blob/main/big.txt").await;
281 assert!(
282 res.body.contains("[Too large to display]"),
283 "max_blob_kb = 2"
284 );
285 let res = ok(&s.app, "/rich/blob/main/link-to-main").await;
286 assert!(
287 res.body
288 .contains(r#"href="/rich/blob/main/src/main.rs">src/main.rs</a>"#)
289 );
290 let res = ok(
291 &s.app,
292 "/rich/blob/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt",
293 )
294 .await;
295 assert!(res.body.contains("fïle #1.txt"));
296 // HTML source is shown as text, never rendered.
297 let res = ok(&s.app, "/rich/blob/main/page.html").await;
298 assert!(res.body.contains("alert") && !res.body.contains("<script>alert"));
299}
300
301#[tokio::test]
302async fn raw_is_sandboxed() {
303 let s = site("");
304 let res = fetch(&s.app, "/rich/raw/main/page.html", None).await;
305 assert_eq!(res.status, StatusCode::OK);
306 assert_eq!(
307 res.header(header::CONTENT_TYPE),
308 "text/plain; charset=utf-8"
309 );
310 assert_eq!(res.header(header::X_CONTENT_TYPE_OPTIONS), "nosniff");
311 assert!(
312 res.header(header::CONTENT_SECURITY_POLICY)
313 .contains("sandbox")
314 );
315 assert_eq!(res.body, "<html><script>alert(1)</script></html>\n");
316
317 let res = fetch(&s.app, "/rich/raw/main/img/logo.png", None).await;
318 assert_eq!(res.header(header::CONTENT_TYPE), "image/png");
319 let res = fetch(&s.app, "/rich/raw/main/data.bin", None).await;
320 assert_eq!(res.header(header::CONTENT_TYPE), "application/octet-stream");
321
322 let res = fetch(&s.app, "/rich/raw/main/page.html?download", None).await;
323 assert_eq!(
324 res.header(header::CONTENT_DISPOSITION),
325 "attachment; filename=\"page.html\"; filename*=UTF-8''page.html"
326 );
327 let res = fetch(
328 &s.app,
329 "/rich/raw/main/%C3%BCn%C3%AFcode%20dir/f%C3%AFle%20%231.txt?download",
330 None,
331 )
332 .await;
333 assert!(
334 res.header(header::CONTENT_DISPOSITION)
335 .contains("filename*=UTF-8''f%C3%AFle%20%231.txt")
336 );
337
338 // Raw by full id is cacheable; ETag revalidates.
339 let uri = format!("/rich/raw/{}/src/main.rs", s.rich.head);
340 let res = fetch(&s.app, &uri, None).await;
341 assert_eq!(res.header(header::CACHE_CONTROL), "public, max-age=86400");
342 let etag = res.header(header::ETAG).to_string();
343 assert_eq!(
344 fetch(&s.app, &uri, Some(&etag)).await.status,
345 StatusCode::NOT_MODIFIED
346 );
347
348 assert_eq!(
349 fetch(&s.app, "/rich/raw/main/src", None).await.status,
350 StatusCode::NOT_FOUND
351 );
352}
353
354#[tokio::test]
355async fn refs_page() {
356 let s = site("");
357 let res = ok(&s.app, "/rich/refs").await;
358 let html = &res.body;
359 let main = html.find(r#"href="/rich/tree/main""#).expect("main");
360 let feature = html
361 .find(r#"href="/rich/tree/feature/x""#)
362 .expect("feature");
363 assert!(main < feature, "default branch first");
364 assert!(html.contains(r#"<span class="chip">Default</span>"#));
365 assert!(html.contains("Release 1.1"), "annotated tag subject");
366 assert!(html.contains(r#"href="/rich/tree/v1.0""#));
367 assert!(html.contains(r#"<span class="crumb-here">Refs</span>"#));
368}
369
370#[tokio::test]
371async fn base_path_prefixes_repo_links() {
372 let s = site("[server]\nbase_path = \"/git/\"\n");
373 let res = ok(&s.app, "/git/rich").await;
374 assert!(res.body.contains(r#"href="/git/rich/tree/main/src""#));
375 assert!(
376 res.body
377 .contains(r#"href="/git/rich/blob/main/docs/guide.md""#)
378 );
379 let res = fetch(&s.app, "/git/forks/nested.git", None).await;
380 assert_eq!(res.header(header::LOCATION), "/git/forks/nested");
381 ok(&s.app, "/git/rich/blob/main/src/main.rs").await;
382}
383
384#[tokio::test]
385async fn every_referenced_icon_exists() {
386 let s = site("");
387 let res = ok(&s.app, "/rich").await;
388 let sprite_url = res
389 .body
390 .split('"')
391 .find(|s| s.contains("icons/sprite.svg#"))
392 .unwrap()
393 .split('#')
394 .next()
395 .unwrap()
396 .to_string();
397 let sprite = ok(&s.app, &sprite_url).await.body;
398 for id in res
399 .body
400 .split("sprite.svg#")
401 .skip(1)
402 .map(|s| s.split('"').next().unwrap())
403 {
404 assert!(
405 sprite.contains(&format!("id=\"{id}\"")),
406 "missing icon {id}"
407 );
408 }
409}