ThetaDev
197eeea75b
- removed eye emoji from title - moved metadata to subtitle - add current run date - use newtab links for all AV links
454 lines
13 KiB
Rust
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/");
|
|
}
|