Krolyxon
Commit 18ca21c

ferreniumtestsextras.rs

445 lines · 14.4 KB · Rust

1//! Blame, archives, the Atom feed, the file finder, language statistics,
2//! robots.txt and HTTPS clones, checked against the git CLI.
3
4mod common;
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use std::sync::Arc;
9
10use axum::body::{Body, to_bytes};
11use axum::http::{HeaderMap, Method, Request, StatusCode, header};
12use common::{Work, bare, git};
13use ferrenium::config::Config;
14use ferrenium::index::languages;
15use ferrenium::server;
16use ferrenium::state::{AppState, Overrides};
17use tower::ServiceExt;
18
19struct Fixture {
20 bare: PathBuf,
21 head: String,
22}
23
24/// Several authors editing one file, a rename, vendored/generated/doc
25/// files, a `.gitattributes` and a tag.
26fn fixture(root: &Path) -> Fixture {
27 let bare = bare(root, "ext.git", &[]);
28 let work = Work::clone_of(&bare);
29 let w = work.path();
30 let write = |name: &str, data: &str| {
31 let path = w.join(name);
32 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
33 std::fs::write(path, data).unwrap();
34 };
35 // A day apart, so blame ages differ.
36 let day = std::cell::Cell::new(0);
37 let commit = |message: &str, author: &str| {
38 git(w, &["add", "-A"]);
39 day.set(day.get() + 1);
40 let date = format!("{} +0000", 1_700_000_000 + day.get() * 86_400);
41 let status = Command::new("git")
42 .args(["commit", "-q", "-m", message, "--author", author])
43 .current_dir(w)
44 .env("GIT_CONFIG_NOSYSTEM", "1")
45 .env("GIT_CONFIG_GLOBAL", "/dev/null")
46 .env("GIT_COMMITTER_NAME", "Ada")
47 .env("GIT_COMMITTER_EMAIL", "ada@example.com")
48 .env("GIT_AUTHOR_DATE", &date)
49 .env("GIT_COMMITTER_DATE", &date)
50 .status()
51 .unwrap();
52 assert!(status.success());
53 };
54 write("src/old.rs", "fn a() {}\nfn b() {}\nfn c() {}\n");
55 write("README.md", "# Ext\n");
56 commit("Start", "Ada <ada@example.com>");
57 write(
58 "src/old.rs",
59 "fn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
60 );
61 commit("Edit b, add d", "Bob <bob@example.com>");
62 git(w, &["mv", "src/old.rs", "src/main.rs"]);
63 commit("Rename", "Ada <ada@example.com>");
64 write(
65 "src/main.rs",
66 "// top\nfn a() {}\nfn b2() {}\nfn c() {}\nfn d() {}\n",
67 );
68 write("run.sh", "#!/bin/sh\necho run\n");
69 write("vendor/lib.js", &"var x = 1;\n".repeat(500));
70 write("docs/conf.py", &"x = 1\n".repeat(500));
71 write("app.min.js", &"var y=2;".repeat(500));
72 write("extra/big.c", &"int x;\n".repeat(500));
73 write(".gitattributes", "extra/** linguist-vendored\n");
74 commit("Add the rest", "Cy <cy@example.com>");
75 git(w, &["tag", "v1.0"]);
76 work.push("main");
77 git(w, &["push", "-q", "origin", "--tags"]);
78 let head = git(w, &["rev-parse", "HEAD"]);
79 Fixture { bare, head }
80}
81
82struct Site {
83 _root: tempfile::TempDir,
84 state: Arc<AppState>,
85 app: axum::Router,
86 f: Fixture,
87}
88
89/// `repos` is extra lines for the `[repos]` table.
90fn site(repos: &str) -> Site {
91 let root = tempfile::tempdir().unwrap();
92 let f = fixture(root.path());
93 let toml = format!(
94 "[repos]\nscan_path = {:?}\n{repos}\n[watch]\nenabled = false\n\
95 [server]\nbase_url = \"https://git.example.com\"\n",
96 root.path().to_str().unwrap()
97 );
98 let mut config = Config::parse(&toml).expect("parse");
99 config.finish().expect("valid");
100 let state = AppState::new(config, Overrides::default());
101 let config = state.config.load_full();
102 state.index.full_scan(&config);
103 Site {
104 app: server::app(state.clone()),
105 state,
106 f,
107 _root: root,
108 }
109}
110
111struct Res {
112 status: StatusCode,
113 headers: HeaderMap,
114 body: Vec<u8>,
115}
116
117impl Res {
118 fn text(&self) -> String {
119 String::from_utf8_lossy(&self.body).into_owned()
120 }
121}
122
123async fn request(app: &axum::Router, method: Method, uri: &str) -> Res {
124 let req = Request::builder()
125 .method(method)
126 .uri(uri)
127 .body(Body::empty())
128 .unwrap();
129 let res = app.clone().oneshot(req).await.unwrap();
130 let status = res.status();
131 let headers = res.headers().clone();
132 let body = to_bytes(res.into_body(), usize::MAX)
133 .await
134 .unwrap()
135 .to_vec();
136 Res {
137 status,
138 headers,
139 body,
140 }
141}
142
143async fn get(app: &axum::Router, uri: &str) -> Res {
144 request(app, Method::GET, uri).await
145}
146
147async fn ok(app: &axum::Router, uri: &str) -> String {
148 let res = get(app, uri).await;
149 assert_eq!(res.status, StatusCode::OK, "{uri}: {}", res.text());
150 res.text()
151}
152
153/// Commit of every line, from the page's hunk heads.
154fn blamed_lines(html: &str) -> Vec<String> {
155 let mut out = Vec::new();
156 for part in html.split(r#"class="blame-meta "#).skip(1) {
157 let rows: usize = part
158 .split(r#"rowspan=""#)
159 .nth(1)
160 .unwrap()
161 .split('"')
162 .next()
163 .unwrap()
164 .parse()
165 .unwrap();
166 let id = part.split("/commit/").nth(1).unwrap()[..40].to_string();
167 out.extend(std::iter::repeat_n(id, rows));
168 }
169 out
170}
171
172#[tokio::test]
173async fn blame_matches_git() {
174 let s = site("");
175 for path in ["src/main.rs", "run.sh", "README.md"] {
176 let html = ok(&s.app, &format!("/ext/blame/main/{path}")).await;
177 let porcelain = git(&s.f.bare, &["blame", "--porcelain", "main", "--", path]);
178 let expected: Vec<String> = porcelain
179 .lines()
180 .filter(|l| {
181 l.len() > 41
182 && l.as_bytes()[40] == b' '
183 && l[..40].bytes().all(|b| b.is_ascii_hexdigit())
184 })
185 .map(|l| l[..40].to_string())
186 .collect();
187 assert_eq!(blamed_lines(&html), expected, "{path}");
188 }
189 let html = ok(&s.app, "/ext/blame/main/src/main.rs").await;
190 // Hunk heads: author, age bar (newest is full strength), links.
191 assert!(html.contains("blame-meta age-8"), "{html}");
192 assert!(html.contains("blame-meta age-0"));
193 assert!(html.contains(">Bob<"));
194 assert!(html.contains("5 lines · 3 commits"), "{html}");
195 assert!(html.contains(r#"href="/ext/blob/main/src/main.rs">File<"#));
196 // The blob page links here; directories redirect; binaries don't blame.
197 let blob = ok(&s.app, "/ext/blob/main/src/main.rs").await;
198 assert!(blob.contains(r#"href="/ext/blame/main/src/main.rs">Blame<"#));
199 let res = get(&s.app, "/ext/blame/main/src").await;
200 assert_eq!(res.status, StatusCode::TEMPORARY_REDIRECT);
201 let res = get(&s.app, "/ext/blame/main/nope").await;
202 assert_eq!(res.status, StatusCode::NOT_FOUND);
203}
204
205fn tar_listing(data: &[u8]) -> String {
206 let mut child = Command::new("tar")
207 .args(["tzf", "-"])
208 .stdin(std::process::Stdio::piped())
209 .stdout(std::process::Stdio::piped())
210 .spawn()
211 .unwrap();
212 use std::io::Write;
213 child.stdin.take().unwrap().write_all(data).unwrap();
214 let out = child.wait_with_output().unwrap();
215 // git also lists directories; only files matter.
216 let mut lines: Vec<&str> = std::str::from_utf8(&out.stdout)
217 .unwrap()
218 .lines()
219 .filter(|l| !l.ends_with('/'))
220 .collect();
221 lines.sort();
222 lines.join("\n")
223}
224
225#[tokio::test]
226async fn archives_match_git_archive() {
227 let s = site("");
228 let res = get(&s.app, "/ext/archive/v1.0.tar.gz").await;
229 assert_eq!(res.status, StatusCode::OK);
230 assert_eq!(res.headers[header::CONTENT_TYPE], "application/gzip");
231 assert!(
232 res.headers[header::CONTENT_DISPOSITION]
233 .to_str()
234 .unwrap()
235 .contains("filename=\"ext-v1.0.tar.gz\"")
236 );
237 assert!(res.headers.get(header::CONTENT_ENCODING).is_none());
238 let ours = tar_listing(&res.body);
239 let theirs = Command::new("git")
240 .args(["archive", "--format=tar.gz", "--prefix=ext-v1.0/", "v1.0"])
241 .current_dir(&s.f.bare)
242 .output()
243 .unwrap()
244 .stdout;
245 assert_eq!(ours, tar_listing(&theirs));
246 assert!(ours.contains("ext-v1.0/src/main.rs"));
247
248 let res = get(&s.app, &format!("/ext/archive/{}.zip", s.f.head)).await;
249 assert_eq!(res.status, StatusCode::OK);
250 assert_eq!(res.headers[header::CONTENT_TYPE], "application/zip");
251 assert_eq!(res.headers[header::CACHE_CONTROL], "public, max-age=86400");
252 assert_eq!(&res.body[..2], b"PK");
253
254 for uri in [
255 "/ext/archive/nope.zip",
256 "/ext/archive/main.rar",
257 "/ext/archive/main",
258 ] {
259 assert_eq!(
260 get(&s.app, uri).await.status,
261 StatusCode::NOT_FOUND,
262 "{uri}"
263 );
264 }
265 let s = site("snapshots = [\"tar.gz\"]");
266 assert_eq!(
267 get(&s.app, "/ext/archive/main.zip").await.status,
268 StatusCode::NOT_FOUND
269 );
270 let refs = ok(&s.app, "/ext/refs").await;
271 assert!(
272 refs.contains(r#"href="/ext/archive/v1.0.tar.gz""#),
273 "{refs}"
274 );
275 assert!(!refs.contains(".zip"));
276}
277
278#[tokio::test]
279async fn atom_feed() {
280 let s = site("");
281 let res = get(&s.app, "/ext/atom").await;
282 assert_eq!(res.status, StatusCode::OK);
283 assert_eq!(
284 res.headers[header::CONTENT_TYPE],
285 "application/atom+xml; charset=utf-8"
286 );
287 let xml = res.text();
288 assert!(xml.starts_with("<?xml"));
289 assert_eq!(xml.matches("<entry>").count(), 4);
290 assert!(xml.contains("<id>https://git.example.com/ext/atom</id>"));
291 assert!(xml.contains(&format!(
292 r#"href="https://git.example.com/ext/commit/{}""#,
293 s.f.head
294 )));
295 assert!(xml.contains("<name>Cy</name>"));
296 assert_eq!(
297 get(&s.app, "/ext/atom?h=nope").await.status,
298 StatusCode::NOT_FOUND
299 );
300 // Advertised on the summary.
301 let html = ok(&s.app, "/ext").await;
302 assert!(html.contains(
303 r#"<link rel="alternate" type="application/atom+xml" title="Commits" href="/ext/atom">"#
304 ));
305}
306
307#[tokio::test]
308async fn find_files() {
309 let s = site("");
310 let html = ok(&s.app, "/ext/find/main?q=MAIN").await;
311 assert!(
312 html.contains(r#"href="/ext/blob/main/src/main.rs""#),
313 "{html}"
314 );
315 assert!(html.contains("1 of 8 files"), "{html}");
316 let html = ok(&s.app, "/ext/find/main?q=zzz").await;
317 assert!(html.contains("[No matches]"));
318 let html = ok(&s.app, "/ext/find/main").await;
319 assert!(html.contains("8 files"));
320 assert!(html.contains(r#"aria-current="page">Find<"#));
321}
322
323#[tokio::test]
324async fn language_statistics() {
325 let s = site("");
326 let repo = s.state.index.load().repos["ext"].clone();
327 let handle = ferrenium::git::handle(&repo.gix);
328 let tree = languages::root_tree(&handle, repo.meta.head.unwrap()).unwrap();
329 let stats = languages::compute(&handle, tree).unwrap();
330 let names: Vec<&str> = stats.languages.iter().map(|(n, _)| *n).collect();
331 // Vendored, documentation, minified and attribute-vendored files, and
332 // prose (README), don't count.
333 assert_eq!(names, ["Rust", "Shell"], "{stats:?}");
334
335 // The summary shows the bar once the warmer has run.
336 let html = ok(&s.app, "/ext").await;
337 assert!(!html.contains("lang-bar"));
338 s.state
339 .cache
340 .languages(tree, || Ok::<_, std::convert::Infallible>(stats))
341 .unwrap();
342 let html = ok(&s.app, "/ext").await;
343 assert!(html.contains(r#"class="lang-bar""#), "{html}");
344 assert_eq!(
345 html.matches(r#"<i class="o0"></i>"#).count()
346 + html.matches(r#"<i class="o1"></i>"#).count(),
347 40
348 );
349}
350
351#[tokio::test]
352async fn robots_and_methods() {
353 let s = site("");
354 let robots = ok(&s.app, "/robots.txt").await;
355 assert!(robots.contains("Disallow: /*/blame/"));
356 assert!(robots.contains("Disallow: /*/archive/"));
357 assert!(robots.contains("Disallow: /*?q="));
358 assert_eq!(
359 request(&s.app, Method::POST, "/ext/tree/main").await.status,
360 StatusCode::METHOD_NOT_ALLOWED
361 );
362 assert_eq!(
363 get(&s.app, "/ext/info/refs?service=git-receive-pack")
364 .await
365 .status,
366 StatusCode::FORBIDDEN
367 );
368 assert_eq!(
369 get(&s.app, "/ext/info/refs").await.status,
370 StatusCode::FORBIDDEN
371 );
372 assert_eq!(
373 request(&s.app, Method::POST, "/ext.git/git-receive-pack")
374 .await
375 .status,
376 StatusCode::FORBIDDEN
377 );
378 assert_eq!(
379 get(&s.app, "/ext.git/tree/main").await.status,
380 StatusCode::NOT_FOUND
381 );
382 let s = site("https_clone = false");
383 assert_eq!(
384 get(&s.app, "/ext/info/refs?service=git-upload-pack")
385 .await
386 .status,
387 StatusCode::NOT_FOUND
388 );
389}
390
391#[tokio::test(flavor = "multi_thread")]
392async fn clone_over_https() {
393 let s = site("");
394 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
395 let addr = listener.local_addr().unwrap();
396 let app = s.app.clone();
397 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
398
399 let dest = tempfile::tempdir().unwrap();
400 let head = s.f.head.clone();
401 let out = tokio::task::spawn_blocking(move || {
402 let mut results = Vec::new();
403 for (url, dir, protocol) in [
404 (format!("http://{addr}/ext.git"), "v2", "2"),
405 (format!("http://{addr}/ext"), "v0", "0"),
406 ] {
407 let target = dest.path().join(dir);
408 let status = Command::new("git")
409 .args([
410 "-c",
411 &format!("protocol.version={protocol}"),
412 "clone",
413 "-q",
414 &url,
415 ])
416 .arg(&target)
417 .env("GIT_CONFIG_NOSYSTEM", "1")
418 .env("GIT_CONFIG_GLOBAL", "/dev/null")
419 .env("GIT_TERMINAL_PROMPT", "0")
420 .status()
421 .unwrap();
422 assert!(status.success(), "clone {url}");
423 results.push(git(&target, &["rev-parse", "HEAD"]));
424 }
425 // Pushing is refused.
426 let work = dest.path().join("v2");
427 std::fs::write(work.join("new.txt"), "x").unwrap();
428 git(&work, &["add", "-A"]);
429 git(&work, &["commit", "-q", "-m", "x"]);
430 let push = Command::new("git")
431 .args(["push", "-q", "origin", "main"])
432 .current_dir(&work)
433 .env("GIT_CONFIG_NOSYSTEM", "1")
434 .env("GIT_CONFIG_GLOBAL", "/dev/null")
435 .env("GIT_TERMINAL_PROMPT", "0")
436 .output()
437 .unwrap();
438 assert!(!push.status.success());
439 assert!(String::from_utf8_lossy(&push.stderr).contains("403"));
440 results
441 })
442 .await
443 .unwrap();
444 assert_eq!(out, vec![head.clone(), head]);
445}