| 1 | //! Browsing pages (summary, tree, blob, raw, refs) against real repos. |
| 2 | |
| 3 | mod common; |
| 4 | |
| 5 | use axum::body::{Body, to_bytes}; |
| 6 | use axum::http::{HeaderMap, Request, StatusCode, header}; |
| 7 | use common::{Rich, bare, rich_repo}; |
| 8 | use ferrenium::config::Config; |
| 9 | use ferrenium::server; |
| 10 | use ferrenium::state::{AppState, Overrides}; |
| 11 | use tower::ServiceExt; |
| 12 | |
| 13 | struct Site { |
| 14 | _root: tempfile::TempDir, |
| 15 | app: axum::Router, |
| 16 | rich: Rich, |
| 17 | } |
| 18 | |
| 19 | fn 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 | |
| 42 | struct Res { |
| 43 | status: StatusCode, |
| 44 | headers: HeaderMap, |
| 45 | body: String, |
| 46 | } |
| 47 | |
| 48 | impl 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 | |
| 57 | async 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 | |
| 77 | async 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. |
| 85 | fn 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] |
| 92 | async 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] |
| 139 | async 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] |
| 150 | async 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] |
| 193 | async 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 <b>not html</b>"), |
| 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] |
| 248 | async 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("<hi>")); |
| 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] |
| 305 | async 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] |
| 358 | async 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] |
| 374 | async 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] |
| 388 | async 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 | } |