use std::{ io::Read, path::{Path, PathBuf}, time::{Duration, SystemTime}, }; use axum_test::{TestRequest, TestResponse, TestServer}; use headers::HeaderMapExt; use http::{header, HeaderName, HeaderValue, StatusCode}; use once_cell::sync::Lazy; use path_macro::path; use rstest::{fixture, rstest}; use artifactview::{App, AppState, Config, ConfigData}; use scraper::{selectable::Selectable, ElementRef, Html, Selector}; use temp_testdir::TempDir; static TESTFILES: Lazy = Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles")); static SITEDIR: Lazy = Lazy::new(|| { let sitedir = path!(*TESTFILES / "sites_data"); if !sitedir.is_dir() { std::process::Command::new(path!(*TESTFILES / "sites" / "make_zip.sh")) .output() .unwrap(); } sitedir }); static CACHEDIR: Lazy = Lazy::new(|| { let td = TempDir::default(); setup_cache_dir(&td); td }); const S1: &str = "codeberg-org--thetadev--artifactview-test--1-1"; const S2: &str = "codeberg-org--thetadev--artifactview-test--1-2"; const S3: &str = "codeberg-org--thetadev--artifactview-test--1-3"; fn setup_cache_dir(dir: &Path) { for entry in std::fs::read_dir(SITEDIR.as_path()).unwrap() { let entry = entry.unwrap(); if entry.file_type().unwrap().is_file() { std::fs::copy(entry.path(), path!(dir / entry.file_name())).unwrap(); } } } struct TestAv { server: TestServer, } #[fixture] fn server() -> TestAv { let router = App::router(AppState::from_cfg( Config::from_data(ConfigData { cache_dir: CACHEDIR.to_path_buf(), no_https: true, real_ip_header: Some("x-forwarded-for".to_string()), ..Default::default() }) .unwrap(), )); let mut server = TestServer::new(router).unwrap(); server.add_header( HeaderName::from_static("x-forwarded-for"), HeaderValue::from_static("127.0.0.1"), ); TestAv { server } } impl TestAv { fn get(&self, subdomain: &str, path: &str) -> TestRequest { self.server.get(path).add_header( header::HOST, if subdomain.is_empty() { HeaderValue::from_static("localhost:3000") } else { HeaderValue::from_str(&format!("{subdomain}.localhost:3000")).unwrap() }, ) } async fn get_html(&self, subdomain: &str, path: &str) -> Html { let resp = self.get(subdomain, path).await; resp.assert_status_ok(); assert_eq!(resp.header(header::CONTENT_TYPE), "text/html"); scraper::Html::parse_document(&resp.text()) } } #[derive(Debug)] struct FileEntry { name: String, size: String, crc32: String, } #[derive(Debug, PartialEq, Eq)] struct Link { text: String, href: String, selected: bool, } fn text_from_elm(elm: &ElementRef<'_>) -> String { elm.text() .fold(String::new(), |acc, s| acc + s) .trim() .to_owned() } fn parse_link(elm: &ElementRef<'_>) -> Link { assert_eq!(elm.value().name(), "a"); Link { text: text_from_elm(elm), href: elm.attr("href").expect("href").to_owned(), selected: elm .value() .has_class("selected", scraper::CaseSensitivity::CaseSensitive), } } #[track_caller] fn assert_cache_immutable(resp: &TestResponse) { assert_eq!( resp.header(header::CACHE_CONTROL), "max-age=31536000,public,immutable" ); } #[rstest] #[tokio::test] async fn homepage(server: TestAv) { let doc = server.get_html("", "/").await; let elm = doc .select(&Selector::parse("title").unwrap()) .next() .unwrap(); assert_eq!(text_from_elm(&elm), "Artifactview"); } #[rstest] #[case(S1, "/example.rs", true, "example.rs", "text/x-rust")] #[case(S1, "/sites/", true, "index.html", "text/html")] #[case(S1, "/foo.txt", false, "", "text/html")] #[case(S1, "/.well-known/test.txt", false, "", "text/html")] // 404 fallback #[case(S2, "/foo.txt", false, "404.html", "text/html")] // SPA #[case(S3, "/", true, "index.html", "text/html")] #[case(S3, "/foo.txt", true, "200.html", "text/html")] #[tokio::test] async fn get_file( server: TestAv, #[case] sd: &str, #[case] path: &str, #[case] found: bool, #[case] exp_path: &str, #[case] mime: &str, ) { let resp = server.get(sd, path).await; assert_eq!(resp.header(header::CONTENT_TYPE), mime); if found { resp.assert_status_ok(); assert_cache_immutable(&resp); } else { resp.assert_status_not_found(); } if !exp_path.is_empty() { let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / exp_path)).unwrap(); assert_eq!(resp.text(), expect); } } #[rstest] #[tokio::test] async fn robots_txt(server: TestAv) { let resp1 = server.get("", "/robots.txt").await; let resp2 = server.get(S1, "/robots.txt").await; assert!(resp1.text().contains("\nUser-agent: *\nDisallow: /\n")); assert!(resp2.text().contains("\nUser-agent: *\nDisallow: /\n")); } #[rstest] #[tokio::test] async fn stylesheet(server: TestAv) { let html = server.get_html(S1, "/README.md?viewer=1").await; for path in html .select(&Selector::parse("link[rel=\"stylesheet\"]").unwrap()) .map(|elm| { elm.attr("href") .expect("href") .strip_prefix("http://localhost:3000") .expect("localhost url") }) { let resp = server.get("", path).await; resp.assert_status_ok(); assert_eq!(resp.header(header::CONTENT_TYPE), "text/css"); assert_cache_immutable(&resp); // Remove running number from stylesheet path let fname = path .strip_prefix('/') .unwrap() .strip_suffix(".css") .unwrap() .trim_end_matches(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']); let expect = std::fs::read_to_string(path!( env!("CARGO_MANIFEST_DIR") / "resources" / format!("{fname}.css") )) .unwrap(); assert_eq!(resp.text(), expect); } } fn parse_listing(doc: &Html) -> Vec { let sel_col = Selector::parse("td").unwrap(); doc.select(&Selector::parse(".file").unwrap()) .map(|elm| { let icn = elm .select(&Selector::parse("svg > use").unwrap()) .next() .expect("icon"); let (k, icn_id) = icn.value().attrs().next().expect("icon id"); assert_eq!(k, "href"); assert!(matches!(icn_id, "#file" | "#folder")); let mut parts = elm.select(&sel_col).map(|elm| text_from_elm(&elm)); let name = parts.next().expect("name"); let folder = icn_id == "#folder"; assert_eq!(name.ends_with('/'), folder); FileEntry { name, crc32: parts.next().expect("crc32"), size: parts.next().expect("size"), } }) .collect() } #[rstest] #[case("/", &[ ".well-known/", "junit/", "sites/", "README.md", "example.rs", "robots.txt" ])] #[case("/?C=N&O=D", &[ "sites/", "junit/", ".well-known/", "robots.txt", "example.rs", "README.md", ])] #[tokio::test] async fn listing(server: TestAv, #[case] path: &str, #[case] expect: &[&str]) { let doc = server.get_html(S1, path).await; let files = parse_listing(&doc); let file_names = files.iter().map(|f| f.name.to_owned()).collect::>(); assert_eq!(file_names, expect); let example_rs = files .iter() .find(|f| f.name == "example.rs") .expect("example.rs"); assert_eq!(example_rs.size, "406 B"); assert_eq!(example_rs.crc32, "2013120c"); } #[rstest] // JUnit #[case("/junit/hello.junit.xml?viewer=1", "junit", &[("junit", "JUnit"), ("code", "Code")])] #[case("/junit/hello.junit.xml?viewer=junit", "junit", &[("junit", "JUnit"), ("code", "Code")])] #[case("/junit/hello.junit.xml?viewer=code", "code", &[("junit", "JUnit"), ("code", "Code")])] #[case("/junit/hello.junit.xml?viewer=md", "", &[])] #[case("/example.rs?viewer=1", "code", &[("code", "Code")])] #[case("/README.md?viewer=1", "md", &[("md", "Markdown"), ("code", "Code")])] #[tokio::test] async fn viewer( server: TestAv, #[case] path: &str, #[case] vid: &str, #[case] exp_viewers: &[(&str, &str)], ) { if vid.is_empty() { // Raw file let resp = server.get(S1, path).await; resp.assert_status_ok(); assert_eq!(resp.header(header::CONTENT_TYPE), "text/xml"); assert_cache_immutable(&resp); } else { let doc = server.get_html(S1, path).await; let viewers = doc .select(&Selector::parse("#viewers a").unwrap()) .map(|elm| parse_link(&elm)) .filter(|ln| ln.text != "Raw") .collect::>(); let viewer_names = viewers .iter() .map(|v| { ( v.href.strip_prefix("?viewer=").expect("link prefix"), v.text.as_str(), ) }) .collect::>(); assert_eq!(viewer_names, exp_viewers); // Selected viewer let mut sel_iter = viewers.iter().filter(|ln| ln.selected); let selected = sel_iter.next().expect("selected"); assert_eq!(selected.href, format!("?viewer={vid}")); assert!(sel_iter.next().is_none()); } } #[rstest] #[tokio::test] async fn if_modified(server: TestAv) { let resp = server.get(S1, "/README.md").await; let lastmod = SystemTime::from( resp.headers() .typed_get::() .expect("last modified"), ); let bef_lastmod = lastmod - Duration::from_secs(1); // if-modified-since let resp = server .get(S1, "/README.md") .add_header( header::IF_MODIFIED_SINCE, HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(), ) .await; resp.assert_status(StatusCode::NOT_MODIFIED); let resp = server .get(S1, "/README.md") .add_header( header::IF_MODIFIED_SINCE, HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(), ) .await; resp.assert_status_ok(); // if-unmodified-since let resp = server .get(S1, "/README.md") .add_header( header::IF_UNMODIFIED_SINCE, HeaderValue::from_str(&httpdate::HttpDate::from(lastmod).to_string()).unwrap(), ) .await; resp.assert_status_ok(); let resp = server .get(S1, "/README.md") .add_header( header::IF_UNMODIFIED_SINCE, HeaderValue::from_str(&httpdate::HttpDate::from(bef_lastmod).to_string()).unwrap(), ) .await; resp.assert_status(StatusCode::PRECONDITION_FAILED); } #[rstest] #[tokio::test] async fn range_request(server: TestAv) { let resp = server .get(S1, "/example.rs") .add_header(header::RANGE, HeaderValue::from_static("bytes=10-99")) .await; resp.assert_status(StatusCode::PARTIAL_CONTENT); assert_cache_immutable(&resp); assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust"); assert_eq!(resp.header(header::CONTENT_LENGTH), "90"); assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 10-99/406"); let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); assert_eq!(resp.text(), &expect[10..100]); } #[rstest] #[tokio::test] async fn range_request_start(server: TestAv) { let resp = server .get(S1, "/example.rs") .add_header(header::RANGE, HeaderValue::from_static("bytes=100-")) .await; resp.assert_status(StatusCode::PARTIAL_CONTENT); assert_cache_immutable(&resp); assert_eq!(resp.header(header::CONTENT_TYPE), "text/x-rust"); assert_eq!(resp.header(header::CONTENT_LENGTH), "306"); assert_eq!(resp.header(header::CONTENT_RANGE), "bytes 100-405/406"); let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); assert_eq!(resp.text(), &expect[100..]); } #[rstest] #[tokio::test] async fn compressed(server: TestAv) { let resp = server .get(S1, "/example.rs") .add_header(header::ACCEPT_ENCODING, HeaderValue::from_static("gzip")) .await; let bts = resp.into_bytes().to_vec(); let mut gz = flate2::read::GzDecoder::new(&bts[..]); let mut buf = String::new(); gz.read_to_string(&mut buf).unwrap(); let expect = std::fs::read_to_string(path!(*TESTFILES / "sites" / "example.rs")).unwrap(); assert_eq!(buf, expect); } #[rstest] #[tokio::test] async fn userscript(server: TestAv) { let resp = server.get("", "/artifactview.user.js").await; resp.assert_status_ok(); let script = resp.text(); assert!(script.starts_with("// ==UserScript==\n")); } #[rstest] #[tokio::test] /// Redirect user to the directory path if index.html or directory listing is served async fn index_redirect(server: TestAv) { let resp = server.get(S1, "/sites").await; resp.assert_status(StatusCode::PERMANENT_REDIRECT); assert_eq!(resp.header(header::LOCATION), "/sites/"); let resp = server.get(S1, "/junit").await; resp.assert_status(StatusCode::PERMANENT_REDIRECT); assert_eq!(resp.header(header::LOCATION), "/junit/"); }