artifactview/tests/tests.rs
ThetaDev 197eeea75b
feat: update PR comment format
- removed eye emoji from title
- moved metadata to subtitle
- add current run date
- use newtab links for all AV links
2024-07-02 01:53:52 +02:00

454 lines
13 KiB
Rust

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<PathBuf> =
Lazy::new(|| path!(env!("CARGO_MANIFEST_DIR") / "tests" / "testfiles"));
static SITEDIR: Lazy<PathBuf> = 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<TempDir> = 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<FileEntry> {
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::<Vec<_>>();
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::<Vec<_>>();
let viewer_names = viewers
.iter()
.map(|v| {
(
v.href.strip_prefix("?viewer=").expect("link prefix"),
v.text.as_str(),
)
})
.collect::<Vec<_>>();
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::<headers::LastModified>()
.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/");
}