From 0b35d882449082547db5da70b81d494ca67e0711 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 3 Mar 2023 20:01:04 +0100 Subject: [PATCH 1/4] feat: make html compression configurable --- src/config.rs | 13 +++++++++++++ src/page.rs | 5 +++-- src/storage.rs | 32 ++++++++++++-------------------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/config.rs b/src/config.rs index a4ad846..d6c9cd4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,8 @@ use std::{collections::BTreeMap, ops::Deref, path::Path, sync::Arc}; use regex::Regex; use serde::{Deserialize, Serialize}; +use crate::storage::CompressionAlg; + #[derive(Clone, Default)] pub struct Config { i: Arc, @@ -123,6 +125,17 @@ impl CompressionCfg { pub fn enabled(&self) -> bool { self.gzip_en || self.brotli_en } + + pub fn algs(&self) -> Vec { + let mut res = vec![CompressionAlg::None]; + if self.gzip_en { + res.push(CompressionAlg::Gzip) + } + if self.brotli_en { + res.push(CompressionAlg::Brotli) + } + res + } } #[derive(Debug, Default, Clone, Serialize, Deserialize)] diff --git a/src/page.rs b/src/page.rs index 6c612f8..e9a7afd 100644 --- a/src/page.rs +++ b/src/page.rs @@ -58,8 +58,9 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result { Ok(match file.rd_path { Some(rd_path) => Redirect::moved_permanent(rd_path).into_response(), - None => file - .to_response(request.headers(), ok) + None => talon + .storage + .file_to_response(file, request.headers(), ok) .await? .into_response(), }) diff --git a/src/storage.rs b/src/storage.rs index 3b968a3..9ca8412 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -363,22 +363,21 @@ impl Storage { )), } } -} -impl GotFile { /// Convert the retrieved file to a HTTP response /// /// Adapted from: - pub async fn to_response( - self, + pub async fn file_to_response( + &self, + gf: GotFile, headers: &HeaderMap, ok: bool, ) -> std::result::Result { - let mut file = File::open(&self.file_path)?; + let mut file = File::open(&gf.file_path)?; let metadata = file.metadata()?; // etag and last modified - let etag = headers::ETag::from_str(&format!("\"{}\"", self.hash)).unwrap(); + let etag = headers::ETag::from_str(&format!("\"{}\"", gf.hash)).unwrap(); let mut last_modified_str = String::new(); if ok { @@ -415,7 +414,7 @@ impl GotFile { } } - if self + if gf .mime .as_ref() .map(|m| m.essence_str() == "text/html") @@ -434,28 +433,21 @@ impl GotFile { } // Compress response if possible - let alg = util::parse_accept_encoding( - headers, - &[ - CompressionAlg::Brotli, - CompressionAlg::Gzip, - CompressionAlg::None, - ], - ) - .unwrap_or_default(); + let alg = util::parse_accept_encoding(headers, &self.cfg.compression.algs()) + .unwrap_or_default(); let body = match alg { CompressionAlg::None => Body::from(html), CompressionAlg::Gzip => { let enc = async_compression::tokio::bufread::GzipEncoder::with_quality( tokio::io::BufReader::new(Body::from(html).into_async_read()), - async_compression::Level::Precise(6), + async_compression::Level::Precise(self.cfg.compression.gzip_level.into()), ); Body::from_async_read(enc) } CompressionAlg::Brotli => { let enc = async_compression::tokio::bufread::BrotliEncoder::with_quality( tokio::io::BufReader::new(Body::from(html).into_async_read()), - async_compression::Level::Precise(7), + async_compression::Level::Precise(self.cfg.compression.brotli_level.into()), ); Body::from_async_read(enc) } @@ -520,10 +512,10 @@ impl GotFile { if !last_modified_str.is_empty() { response = response.header(header::LAST_MODIFIED, last_modified_str); } - if let Some(encoding) = self.encoding { + if let Some(encoding) = gf.encoding { response = response.header(header::CONTENT_ENCODING, encoding); } - if let Some(mime) = &self.mime { + if let Some(mime) = &gf.mime { response = response.header(header::CONTENT_TYPE, mime.essence_str()); } if let Some((range, size)) = content_range { From 949159a9eb641a9992410d843d1df421cefdd889 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Fri, 3 Mar 2023 20:13:54 +0100 Subject: [PATCH 2/4] feat: add `x-content-type-options=nosniff` header --- src/server.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/server.rs b/src/server.rs index 8af17f9..c4f4b2b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,7 +2,9 @@ use std::{ops::Deref, path::Path, sync::Arc}; use crate::{api::TalonApi, config::Config, db::Db, page::page, storage::Storage, util}; use path_macro::path; -use poem::{listener::TcpListener, EndpointExt, Route, RouteDomain, Server}; +use poem::{ + http::header, listener::TcpListener, middleware, EndpointExt, Route, RouteDomain, Server, +}; use poem_openapi::OpenApiService; #[derive(Clone)] @@ -71,7 +73,7 @@ impl Talon { "/api/spec", poem::endpoint::make_sync(move |_| spec.clone()), ) - .with(poem::middleware::Cors::new()); + .with(middleware::Cors::new()); let internal_domain = format!( "{}.{}", @@ -83,7 +85,10 @@ impl Talon { .at(&internal_domain, route_internal) .at(&site_domains, page) .at(&self.i.cfg.server.root_domain, page) - .with(poem::middleware::Tracing) + .with(middleware::Tracing) + .with( + middleware::SetHeader::new().overriding(header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ) .data(self.clone()); Server::new(TcpListener::bind(&self.i.cfg.server.address)) From b4529662c00f230fb6235f772cf933e1113016b4 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 4 Mar 2023 02:26:09 +0100 Subject: [PATCH 3/4] tests: add file_to_response tests --- Cargo.lock | 109 +++++++++- Cargo.toml | 3 +- src/config.rs | 8 +- src/lib.rs | 2 +- src/storage.rs | 17 +- tests/fixtures/mod.rs | 108 ++++------ tests/snapshots/tests__config__sparse.snap | 6 +- tests/tests.rs | 223 ++++++++++++++++++++- 8 files changed, 378 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9156ad3..519d51d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad445822218ce64be7a341abfb0b1ea43b5c23aa83902542a4542e78309d8e5e" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4655ae1a7b0cdf149156f780c5bf3f1352bc53cbd9e0a361a7ef7b22947e965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.64" @@ -173,6 +195,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + [[package]] name = "bytes" version = "1.4.0" @@ -648,6 +676,18 @@ dependencies = [ "slab", ] +[[package]] +name = "futures_codec" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce54d63f8b0c75023ed920d46fd71d0cbbb830b0ee012726b5b4f506fb6dea5b" +dependencies = [ + "bytes 0.5.6", + "futures", + "memchr", + "pin-project", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -694,7 +734,7 @@ version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f9f29bc9dda355256b2916cf526ab02ce0aeaaaf2bad60d65ef3f12f11dd0f4" dependencies = [ - "bytes", + "bytes 1.4.0", "fnv", "futures-core", "futures-sink", @@ -721,7 +761,7 @@ checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" dependencies = [ "base64 0.13.1", "bitflags", - "bytes", + "bytes 1.4.0", "headers-core", "http", "httpdate", @@ -786,7 +826,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" dependencies = [ - "bytes", + "bytes 1.4.0", "fnv", "itoa", ] @@ -797,7 +837,7 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" dependencies = [ - "bytes", + "bytes 1.4.0", "http", "pin-project-lite", ] @@ -820,7 +860,7 @@ version = "0.14.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e011372fa0b68db8350aa7a248930ecc7839bf46d8485577d69f117a75f164c" dependencies = [ - "bytes", + "bytes 1.4.0", "futures-channel", "futures-core", "futures-util", @@ -1055,7 +1095,7 @@ version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ed4198ce7a4cbd2a57af78d28c6fbb57d81ac5f1d6ad79ac6c5587419cbdf22" dependencies = [ - "bytes", + "bytes 1.4.0", "encoding_rs", "futures-util", "http", @@ -1200,6 +1240,26 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "pin-project" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef0f924a5ee7ea9cbcea77529dba45f8a9ba9f622419fe3386ca581a3ae9d5a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851c8d0ce9bebe43790dedfc86614c23494ac9f423dd618d3a61fc693eafe61e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -1225,7 +1285,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0608069d4999c3c02d49dff261663f2e73a8f7b00b7cd364fb5e93e419dafa1" dependencies = [ "async-trait", - "bytes", + "bytes 1.4.0", "chrono", "cookie", "futures-util", @@ -1246,6 +1306,7 @@ dependencies = [ "serde_urlencoded", "serde_yaml", "smallvec", + "sse-codec", "tempfile", "thiserror", "time", @@ -1274,7 +1335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1077defedfd8ff15990bb42993970ac75bc46dd8a5b3c0b452ab4e2041b825a4" dependencies = [ "base64 0.21.0", - "bytes", + "bytes 1.4.0", "derive_more", "futures-util", "mime", @@ -1686,6 +1747,18 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dccf47db1b41fa1573ed27ccf5e08e3ca771cb994f776668c5ebda893b248fc" +[[package]] +name = "sse-codec" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a59f811350c44b4a037aabeb72dc6a9591fc22aa95a036db9a96297c58085a" +dependencies = [ + "bytes 0.5.6", + "futures-io", + "futures_codec", + "memchr", +] + [[package]] name = "strsim" version = "0.10.0" @@ -1719,7 +1792,6 @@ dependencies = [ "flate2", "hex", "hex-literal", - "httpdate", "insta", "log", "mime_guess", @@ -1739,6 +1811,7 @@ dependencies = [ "thiserror", "time", "tokio", + "tokio-test", "toml", "tracing-subscriber", "zip", @@ -1853,7 +1926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8e00990ebabbe4c14c08aca901caed183ecd5c09562a12c824bb53d3c3fd3af" dependencies = [ "autocfg", - "bytes", + "bytes 1.4.0", "libc", "memchr", "mio", @@ -1886,14 +1959,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" +dependencies = [ + "async-stream", + "bytes 1.4.0", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5427d89453009325de0d8f342c9490009f76e999cb7672d77e46267448f7e6b2" dependencies = [ - "bytes", + "bytes 1.4.0", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 585b375..543fa6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,6 @@ mime_guess = { version = "2.0.4", default-features = false } compressible = "0.2.0" regex = "1.7.1" log = "0.4.17" -httpdate = "1.0.2" tracing-subscriber = "0.3.16" async-compression = { version = "0.3.15", features = [ "tokio", @@ -47,6 +46,8 @@ async-compression = { version = "0.3.15", features = [ [dev-dependencies] rstest = "0.16.0" +poem = { version = "1.3.55", features = ["test"] } +tokio-test = "0.4.2" temp_testdir = "0.2.3" insta = { version = "1.17.1", features = ["ron"] } hex-literal = "0.3.4" diff --git a/src/config.rs b/src/config.rs index d6c9cd4..4d1288a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,9 +90,9 @@ impl Default for ServerCfg { fn default() -> Self { Self { address: "0.0.0.0:3000".to_owned(), - root_domain: "localhost".to_owned(), + root_domain: "localhost:3000".to_owned(), internal_subdomain: "talon".to_owned(), - internal_url: "http://talon.localhost".to_owned(), + internal_url: "http://talon.localhost:3000".to_owned(), } } } @@ -113,9 +113,9 @@ pub struct CompressionCfg { impl Default for CompressionCfg { fn default() -> Self { Self { - gzip_en: false, + gzip_en: true, gzip_level: 6, - brotli_en: false, + brotli_en: true, brotli_level: 7, } } diff --git a/src/lib.rs b/src/lib.rs index 665b1a8..39358de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,11 @@ pub mod api; pub mod config; pub mod db; pub mod model; +pub mod page; pub mod server; pub mod storage; mod oai; -mod page; mod util; pub use server::{Result, Talon, TalonError}; diff --git a/src/storage.rs b/src/storage.rs index 9ca8412..9a59220 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -9,7 +9,6 @@ use std::{ use flate2::{read::GzDecoder, write::GzEncoder}; use hex::ToHex; -use httpdate::HttpDate; use mime_guess::Mime; use poem::{ error::{ResponseError, StaticFileError}, @@ -52,7 +51,7 @@ impl CompressionAlg { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct GotFile { /// File hash pub hash: String, @@ -378,10 +377,10 @@ impl Storage { // etag and last modified let etag = headers::ETag::from_str(&format!("\"{}\"", gf.hash)).unwrap(); - let mut last_modified_str = String::new(); + let mut last_modified = None; if ok { - // handle if-match and if-(un)modified queries + // handle if-(none)-match and if-(un)modified queries let if_match = headers.typed_get::(); let if_unmodified_since = headers.typed_get::(); let if_none_match = headers.typed_get::(); @@ -410,7 +409,7 @@ impl Storage { } } - last_modified_str = HttpDate::from(modified).to_string(); + last_modified = Some(headers::LastModified::from(modified)); } } @@ -461,8 +460,8 @@ impl Storage { if let Some(encoding) = alg.encoding() { response = response.header(header::CONTENT_ENCODING, encoding) } - if !last_modified_str.is_empty() { - response = response.header(header::LAST_MODIFIED, last_modified_str); + if let Some(last_modified) = last_modified { + response = response.typed_header(last_modified); } Ok(response.body(body)) @@ -509,8 +508,8 @@ impl Storage { } else { response = response.status(StatusCode::NOT_FOUND); } - if !last_modified_str.is_empty() { - response = response.header(header::LAST_MODIFIED, last_modified_str); + if let Some(last_modified) = last_modified { + response = response.typed_header(last_modified); } if let Some(encoding) = gf.encoding { response = response.header(header::CONTENT_ENCODING, encoding); diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 691b13b..817ab42 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -7,12 +7,11 @@ use temp_testdir::TempDir; use time::macros::datetime; use talon::{ - config::{CompressionCfg, Config, ConfigInner}, db::{ model::{Version, Website}, Db, }, - storage::Storage, + Talon, }; pub const SUBDOMAIN_1: &str = "-"; @@ -28,6 +27,18 @@ pub const HASH_1_1_INDEX: [u8; 32] = hex!("3b5f6bad5376897435def176d0fe77e5b9b4f0deafc7491fc27262650744ad68"); pub const HASH_1_1_STYLE: [u8; 32] = hex!("356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026"); +pub const HASH_1_2_INDEX: [u8; 32] = + hex!("a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb"); +pub const HASH_1_2_STYLE: [u8; 32] = + hex!("356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026"); +pub const HASH_2_1_INDEX: [u8; 32] = + hex!("6c5d37546616519e8973be51515b8a90898b4675f7b6d01f2d891edb686408a2"); +pub const HASH_2_1_STYLE: [u8; 32] = + hex!("fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"); +pub const HASH_3_1_INDEX: [u8; 32] = + hex!("94a67cf13d752a9c1875ad999eb2be5a1b0f9746c66bca2631820b8186028811"); +pub const HASH_3_1_STYLE: [u8; 32] = + hex!("ee4fc4911a56e627c047a29ba3085131939d8d487759b9149d42aaab89ce8993"); pub struct DbTest { db: Db, @@ -163,18 +174,10 @@ pub fn db() -> DbTest { db.insert_file(VERSION_1_1, "style.css", &HASH_1_1_STYLE) .unwrap(); - db.insert_file( - VERSION_1_2, - "index.html", - &hex!("a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb"), - ) - .unwrap(); - db.insert_file( - VERSION_1_2, - "assets/style.css", - &hex!("356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026"), - ) - .unwrap(); + db.insert_file(VERSION_1_2, "index.html", &HASH_1_2_INDEX) + .unwrap(); + db.insert_file(VERSION_1_2, "assets/style.css", &HASH_1_2_STYLE) + .unwrap(); db.insert_file( VERSION_1_2, "assets/image.jpg", @@ -194,80 +197,55 @@ pub fn db() -> DbTest { ) .unwrap(); - db.insert_file( - VERSION_2_1, - "index.html", - &hex!("6c5d37546616519e8973be51515b8a90898b4675f7b6d01f2d891edb686408a2"), - ) - .unwrap(); - db.insert_file( - VERSION_2_1, - "gex_style.css", - &hex!("fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"), - ) - .unwrap(); + db.insert_file(VERSION_2_1, "index.html", &HASH_2_1_INDEX) + .unwrap(); + db.insert_file(VERSION_2_1, "gex_style.css", &HASH_2_1_STYLE) + .unwrap(); - db.insert_file( - VERSION_3_1, - "index.html", - &hex!("94a67cf13d752a9c1875ad999eb2be5a1b0f9746c66bca2631820b8186028811"), - ) - .unwrap(); - db.insert_file( - VERSION_3_1, - "rp_style.css", - &hex!("ee4fc4911a56e627c047a29ba3085131939d8d487759b9149d42aaab89ce8993"), - ) - .unwrap(); + db.insert_file(VERSION_3_1, "index.html", &HASH_3_1_INDEX) + .unwrap(); + db.insert_file(VERSION_3_1, "rp_style.css", &HASH_3_1_STYLE) + .unwrap(); DbTest { db, _temp: temp } } -pub struct StorageTest { - store: Storage, - _temp: TempDir, +pub struct TalonTest { + talon: Talon, + pub temp: TempDir, } -impl Deref for StorageTest { - type Target = Storage; +impl Deref for TalonTest { + type Target = Talon; fn deref(&self) -> &Self::Target { - &self.store + &self.talon } } #[fixture] -pub fn store() -> StorageTest { +pub fn tln() -> TalonTest { let temp = temp_testdir::TempDir::default(); - let db_path = path!(temp / "db"); - std::fs::create_dir(&db_path).unwrap(); + let talon = Talon::new(&temp).unwrap(); - let cfg = Config::new(ConfigInner { - compression: CompressionCfg { - gzip_en: true, - brotli_en: true, - ..Default::default() - }, - ..Default::default() - }); + insert_websites(&talon.db); - let db = Db::new(&db_path).unwrap(); - insert_websites(&db); - - let store = Storage::new(temp.to_path_buf(), db, cfg); - - store + talon + .storage .insert_dir(path!("tests" / "testfiles" / "ThetaDev0"), VERSION_1_1) .unwrap(); - store + talon + .storage .insert_dir(path!("tests" / "testfiles" / "ThetaDev1"), VERSION_1_2) .unwrap(); - store + talon + .storage .insert_dir(path!("tests" / "testfiles" / "GenderEx"), VERSION_2_1) .unwrap(); - store + talon + .storage .insert_dir(path!("tests" / "testfiles" / "RustyPipe"), VERSION_3_1) .unwrap(); - StorageTest { store, _temp: temp } + TalonTest { talon, temp } } diff --git a/tests/snapshots/tests__config__sparse.snap b/tests/snapshots/tests__config__sparse.snap index a31db55..f9d11cb 100644 --- a/tests/snapshots/tests__config__sparse.snap +++ b/tests/snapshots/tests__config__sparse.snap @@ -5,14 +5,14 @@ expression: "&cfg" ConfigInner( server: ServerCfg( address: "0.0.0.0:3000", - root_domain: "localhost", + root_domain: "localhost:3000", internal_subdomain: "talon", - internal_url: "http://talon.localhost", + internal_url: "http://talon.localhost:3000", ), compression: CompressionCfg( gzip_en: true, gzip_level: 6, - brotli_en: false, + brotli_en: true, brotli_level: 7, ), keys: { diff --git a/tests/tests.rs b/tests/tests.rs index 22f825e..e845b95 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -181,10 +181,17 @@ mod database { } mod storage { + use std::{str::FromStr, time::SystemTime}; + use hex::ToHex; - use poem::http::{header, HeaderMap}; + use poem::{ + error::StaticFileError, + http::{header, HeaderMap, StatusCode}, + web::headers::{self, HeaderMapExt}, + }; use talon::config::{CompressionCfg, Config, ConfigInner}; - use talon::storage::Storage; + use talon::storage::{GotFile, Storage}; + use time::OffsetDateTime; use super::*; @@ -297,7 +304,7 @@ mod storage { #[case::image("br", VERSION_1_2, "assets/image.jpg", false, "image/jpeg", None)] #[case::subdir("br", VERSION_3_1, "page2", false, "text/html", Some("/page2/"))] fn get_file( - store: StorageTest, + tln: TalonTest, #[case] encoding: &str, #[case] version: u32, #[case] path: &str, @@ -318,7 +325,8 @@ mod storage { None }; - let index_file = store.get_file(version, path, &headers).unwrap(); + let index_file = tln.storage.get_file(version, path, &headers).unwrap(); + dbg!(&index_file); assert!(index_file.file_path.is_file()); assert_eq!( index_file @@ -334,6 +342,213 @@ mod storage { assert_eq!(index_file.mime.unwrap().essence_str(), mime); assert_eq!(index_file.rd_path.as_deref(), rd_path); } + + #[rstest] + #[case::index(&HASH_1_2_INDEX, "text/html", "")] + #[case::index_gz(&HASH_1_2_INDEX, "text/html", "gzip")] + #[case::index_br(&HASH_1_2_INDEX, "text/html", "br")] + #[case::style(&HASH_1_2_STYLE, "text/css", "")] + fn file_to_response( + tln: TalonTest, + #[case] hash: &[u8], + #[case] mime: &str, + #[case] encoding: &str, + ) { + let gf = got_file(&tln, hash, mime); + + let file_date = std::fs::metadata(&gf.file_path) + .unwrap() + .modified() + .unwrap(); + + let mut headers = HeaderMap::new(); + if !encoding.is_empty() { + headers.insert(header::ACCEPT_ENCODING, encoding.parse().unwrap()); + } + + let resp = + tokio_test::block_on(tln.storage.file_to_response(gf.clone(), &headers, true)).unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.header(header::CONTENT_TYPE).unwrap(), mime); + assert_eq!( + resp.header(header::ETAG).unwrap(), + format!("\"{}\"", gf.hash) + ); + + let date = OffsetDateTime::from(SystemTime::from( + resp.headers().typed_get::().unwrap(), + )); + assert!(date - file_date < time::Duration::SECOND); + + // HTML files should get dynamically compressed + if mime == "text/html" && !encoding.is_empty() { + assert_eq!(resp.header(header::CONTENT_ENCODING).unwrap(), encoding); + } else { + assert!(resp.header(header::CONTENT_ENCODING).is_none()) + } + } + + fn got_file(tln: &TalonTest, hash: &[u8], mime: &str) -> GotFile { + let hash = hash.encode_hex::(); + let file_path = path!(tln.temp / "storage" / &hash[..2] / &hash); + GotFile { + hash: hash.clone(), + file_path, + encoding: None, + mime: Some(mime_guess::Mime::from_str(mime).unwrap()), + rd_path: None, + } + } + + fn got_file_html(tln: &TalonTest) -> GotFile { + got_file(tln, &HASH_1_2_INDEX, "text/html") + } + + #[rstest] + fn file_to_response_inject(tln: TalonTest) { + let gf = got_file_html(&tln); + let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &HeaderMap::new(), true)) + .unwrap(); + let body = tokio_test::block_on(resp.into_body().into_string()).unwrap(); + assert!(body.contains("\n")); + } + + #[rstest] + #[case::unmodified(true)] + #[case::modified(true)] + fn file_to_response_if_modified(tln: TalonTest, #[case] modified: bool) { + let gf = got_file_html(&tln); + let mut file_date = std::fs::metadata(&gf.file_path) + .unwrap() + .modified() + .unwrap(); + if modified { + file_date -= std::time::Duration::from_secs(1); + } + + let mut headers = HeaderMap::new(); + headers.typed_insert(headers::IfModifiedSince::from(file_date)); + + let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); + assert_eq!( + resp.status(), + if modified { + StatusCode::OK + } else { + StatusCode::NOT_MODIFIED + } + ); + assert_eq!(resp.into_body().is_empty(), !modified); + } + + #[rstest] + #[case::unmodified(true)] + #[case::modified(true)] + fn file_to_response_if_unmodified(tln: TalonTest, #[case] modified: bool) { + let gf = got_file_html(&tln); + let mut file_date = std::fs::metadata(&gf.file_path) + .unwrap() + .modified() + .unwrap(); + if modified { + file_date -= std::time::Duration::from_secs(1); + } + + let mut headers = HeaderMap::new(); + headers.typed_insert(headers::IfModifiedSince::from(file_date)); + + let res = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)); + + if modified { + let resp = res.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(!resp.into_body().is_empty()); + } else { + assert!(matches!( + res.unwrap_err(), + StaticFileError::PreconditionFailed + )); + } + } + + #[rstest] + #[case::matched(true)] + #[case::no_match(false)] + fn file_to_response_if_match(tln: TalonTest, #[case] matched: bool) { + let gf = got_file_html(&tln); + let etag = format!( + "\"{}\"", + if matched { + gf.hash.clone() + } else { + HASH_2_1_INDEX.encode_hex() + } + ); + + let mut headers = HeaderMap::new(); + headers.typed_insert(headers::IfMatch::from( + headers::ETag::from_str(&etag).unwrap(), + )); + + let res = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)); + + if matched { + let resp = res.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(!resp.into_body().is_empty()); + } else { + assert!(matches!( + res.unwrap_err(), + StaticFileError::PreconditionFailed + )); + } + } + + #[rstest] + #[case::matched(true)] + #[case::no_match(false)] + fn file_to_response_if_none_match(tln: TalonTest, #[case] matched: bool) { + let gf = got_file_html(&tln); + let etag = format!( + "\"{}\"", + if matched { + gf.hash.clone() + } else { + HASH_2_1_INDEX.encode_hex() + } + ); + + let mut headers = HeaderMap::new(); + headers.typed_insert(headers::IfNoneMatch::from( + headers::ETag::from_str(&etag).unwrap(), + )); + + let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); + assert_eq!( + resp.status(), + if matched { + StatusCode::NOT_MODIFIED + } else { + StatusCode::OK + } + ); + assert_eq!(resp.into_body().is_empty(), matched); + } + + #[rstest] + fn file_to_response_range(tln: TalonTest) { + let gf = got_file(&tln, &HASH_2_1_STYLE, "text/css"); + + let mut headers = HeaderMap::new(); + headers.typed_insert(headers::Range::bytes(0..100).unwrap()); + + let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); + assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); + + let body = tokio_test::block_on(resp.into_body().into_bytes()).unwrap(); + assert_eq!(body.len(), 100); + } } mod config { From 9403e36dce29b98573d476f7969fd2b2745405f9 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Sat, 4 Mar 2023 03:09:17 +0100 Subject: [PATCH 4/4] tests: add tests for page endpoint --- src/lib.rs | 2 +- src/page.rs | 35 ++++++------- src/server.rs | 17 +++--- tests/fixtures/mod.rs | 49 ++++++++++++++++-- .../tests__database__delete_website.snap | 5 +- tests/snapshots/tests__database__export.snap | 5 +- .../tests__database__get_websites.snap | 9 ++++ tests/testfiles/archive/spa.tar.gz | Bin 0 -> 21436 bytes tests/tests.rs | 42 ++++++++++++++- 9 files changed, 131 insertions(+), 33 deletions(-) create mode 100644 tests/testfiles/archive/spa.tar.gz diff --git a/src/lib.rs b/src/lib.rs index 39358de..665b1a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,11 @@ pub mod api; pub mod config; pub mod db; pub mod model; -pub mod page; pub mod server; pub mod storage; mod oai; +mod page; mod util; pub use server::{Result, Talon, TalonError}; diff --git a/src/page.rs b/src/page.rs index e9a7afd..10fd756 100644 --- a/src/page.rs +++ b/src/page.rs @@ -36,25 +36,24 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result { let ws = talon.db.get_website(subdomain)?; let vid = ws.latest_version.ok_or(PageError::NoVersion)?; - let (file, ok) = - match talon - .storage - .get_file(vid, request.original_uri().path(), request.headers()) - { - Ok(file) => (file, true), - Err(StorageError::NotFound(f)) => { - let version = talon.db.get_version(subdomain, vid)?; - if let Some(fallback) = &version.fallback { - ( - talon.storage.get_file(vid, fallback, request.headers())?, - version.spa, - ) - } else { - return Err(StorageError::NotFound(f).into()); - } + let (file, ok) = match talon + .storage + .get_file(vid, request.uri().path(), request.headers()) + { + Ok(file) => (file, true), + Err(StorageError::NotFound(f)) => { + let version = talon.db.get_version(subdomain, vid)?; + if let Some(fallback) = &version.fallback { + ( + talon.storage.get_file(vid, fallback, request.headers())?, + version.spa, + ) + } else { + return Err(StorageError::NotFound(f).into()); } - Err(e) => return Err(e.into()), - }; + } + Err(e) => return Err(e.into()), + }; Ok(match file.rd_path { Some(rd_path) => Redirect::moved_permanent(rd_path).into_response(), diff --git a/src/server.rs b/src/server.rs index c4f4b2b..c5c9649 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,7 +3,8 @@ use std::{ops::Deref, path::Path, sync::Arc}; use crate::{api::TalonApi, config::Config, db::Db, page::page, storage::Storage, util}; use path_macro::path; use poem::{ - http::header, listener::TcpListener, middleware, EndpointExt, Route, RouteDomain, Server, + http::header, listener::TcpListener, middleware, Endpoint, EndpointExt, Route, RouteDomain, + Server, }; use poem_openapi::OpenApiService; @@ -56,7 +57,7 @@ impl Talon { }) } - pub async fn launch(&self) -> Result<()> { + pub fn endpoint(&self) -> impl Endpoint { let api_service = OpenApiService::new(TalonApi, "Talon", "0.1.0") .server(format!("{}/api", self.i.cfg.server.internal_url)); let swagger_ui = api_service.swagger_ui(); @@ -81,18 +82,20 @@ impl Talon { ); let site_domains = format!("+.{}", self.i.cfg.server.root_domain); - let route = RouteDomain::new() - .at(&internal_domain, route_internal) - .at(&site_domains, page) + RouteDomain::new() + .at(internal_domain, route_internal) + .at(site_domains, page) .at(&self.i.cfg.server.root_domain, page) .with(middleware::Tracing) .with( middleware::SetHeader::new().overriding(header::X_CONTENT_TYPE_OPTIONS, "nosniff"), ) - .data(self.clone()); + .data(self.clone()) + } + pub async fn launch(&self) -> Result<()> { Server::new(TcpListener::bind(&self.i.cfg.server.address)) - .run(route) + .run(self.endpoint()) .await?; Ok(()) } diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 817ab42..7804f88 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeMap, ops::Deref}; +use std::{collections::BTreeMap, fs::File, ops::Deref}; use hex_literal::hex; use path_macro::path; @@ -17,11 +17,13 @@ use talon::{ pub const SUBDOMAIN_1: &str = "-"; pub const SUBDOMAIN_2: &str = "spotify-gender-ex"; pub const SUBDOMAIN_3: &str = "rustypipe"; +pub const SUBDOMAIN_4: &str = "spa"; pub const VERSION_1_1: u32 = 1; pub const VERSION_1_2: u32 = 2; pub const VERSION_2_1: u32 = 3; pub const VERSION_3_1: u32 = 4; +pub const VERSION_4_1: u32 = 5; pub const HASH_1_1_INDEX: [u8; 32] = hex!("3b5f6bad5376897435def176d0fe77e5b9b4f0deafc7491fc27262650744ad68"); @@ -36,9 +38,16 @@ pub const HASH_2_1_INDEX: [u8; 32] = pub const HASH_2_1_STYLE: [u8; 32] = hex!("fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"); pub const HASH_3_1_INDEX: [u8; 32] = - hex!("94a67cf13d752a9c1875ad999eb2be5a1b0f9746c66bca2631820b8186028811"); + hex!("cc31423924cf1f124750825861ab1ccc675e755921fc2fa111c0a98e8c346a5e"); pub const HASH_3_1_STYLE: [u8; 32] = hex!("ee4fc4911a56e627c047a29ba3085131939d8d487759b9149d42aaab89ce8993"); +pub const HASH_3_1_PAGE2: [u8; 32] = + hex!("be4f409ca0adcb21cdc7130cde63031718406726f889ef97ac8870c90b330a75"); + +pub const HASH_SPA_INDEX: [u8; 32] = + hex!("90d454dd999b52486902e845f748ce7510a1fc8404421c8f44dadccc5b4b8e1d"); +pub const HASH_SPA_FALLBACK: [u8; 32] = + hex!("4ee0d3f7522f620a2a69b39b7443f8fe65029e1324cefaf797b8cad2b223cf7b"); pub struct DbTest { db: Db, @@ -102,6 +111,18 @@ fn insert_websites(db: &Db) { }, ) .unwrap(); + db.insert_website( + SUBDOMAIN_4, + &Website { + name: "SvelteKit SPA".to_owned(), + created_at: datetime!(2023-03-03 22:00 +0), + latest_version: Some(VERSION_4_1), + color: Some(16727552), + visibility: talon::model::Visibility::Hidden, + ..Default::default() + }, + ) + .unwrap(); let mut v1_data = BTreeMap::new(); v1_data.insert("Version".to_owned(), "v0.1.0".to_owned()); @@ -145,7 +166,6 @@ fn insert_websites(db: &Db) { VERSION_2_1, &Version { created_at: datetime!(2023-02-18 16:30 +0), - data: BTreeMap::new(), ..Default::default() }, ) @@ -156,7 +176,19 @@ fn insert_websites(db: &Db) { VERSION_3_1, &Version { created_at: datetime!(2023-02-20 18:30 +0), - data: BTreeMap::new(), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(db.new_version_id().unwrap(), VERSION_4_1); + db.insert_version( + SUBDOMAIN_4, + VERSION_4_1, + &Version { + created_at: datetime!(2023-03-03 22:00 +0), + fallback: Some("200.html".to_owned()), + spa: true, ..Default::default() }, ) @@ -206,6 +238,8 @@ pub fn db() -> DbTest { .unwrap(); db.insert_file(VERSION_3_1, "rp_style.css", &HASH_3_1_STYLE) .unwrap(); + db.insert_file(VERSION_3_1, "page2/index.html", &HASH_3_1_PAGE2) + .unwrap(); DbTest { db, _temp: temp } } @@ -246,6 +280,13 @@ pub fn tln() -> TalonTest { .storage .insert_dir(path!("tests" / "testfiles" / "RustyPipe"), VERSION_3_1) .unwrap(); + talon + .storage + .insert_tgz_archive( + File::open(path!("tests" / "testfiles" / "archive" / "spa.tar.gz")).unwrap(), + VERSION_4_1, + ) + .unwrap(); TalonTest { talon, temp } } diff --git a/tests/snapshots/tests__database__delete_website.snap b/tests/snapshots/tests__database__delete_website.snap index f7de527..6408673 100644 --- a/tests/snapshots/tests__database__delete_website.snap +++ b/tests/snapshots/tests__database__delete_website.snap @@ -3,10 +3,13 @@ source: tests/tests.rs expression: data --- {"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}} +{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":5,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null}} {"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}} {"type":"version","key":"rustypipe:4","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} +{"type":"version","key":"spa:5","value":{"created_at":[2023,62,22,0,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} {"type":"version","key":"spotify-gender-ex:3","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} {"type":"file","key":"3:gex_style.css","value":"fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"} {"type":"file","key":"3:index.html","value":"6c5d37546616519e8973be51515b8a90898b4675f7b6d01f2d891edb686408a2"} -{"type":"file","key":"4:index.html","value":"94a67cf13d752a9c1875ad999eb2be5a1b0f9746c66bca2631820b8186028811"} +{"type":"file","key":"4:index.html","value":"cc31423924cf1f124750825861ab1ccc675e755921fc2fa111c0a98e8c346a5e"} +{"type":"file","key":"4:page2/index.html","value":"be4f409ca0adcb21cdc7130cde63031718406726f889ef97ac8870c90b330a75"} {"type":"file","key":"4:rp_style.css","value":"ee4fc4911a56e627c047a29ba3085131939d8d487759b9149d42aaab89ce8993"} diff --git a/tests/snapshots/tests__database__export.snap b/tests/snapshots/tests__database__export.snap index d78a80b..5b1a231 100644 --- a/tests/snapshots/tests__database__export.snap +++ b/tests/snapshots/tests__database__export.snap @@ -4,10 +4,12 @@ expression: data --- {"type":"website","key":"-","value":{"name":"ThetaDev","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":2,"color":2068974,"visibility":"featured","source_url":null,"source_icon":null}} {"type":"website","key":"rustypipe","value":{"name":"RustyPipe","created_at":[2023,51,18,30,0,0,0,0,0],"latest_version":4,"color":7943647,"visibility":"featured","source_url":"https://code.thetadev.de/ThetaDev/rustypipe","source_icon":"gitea"}} +{"type":"website","key":"spa","value":{"name":"SvelteKit SPA","created_at":[2023,62,22,0,0,0,0,0,0],"latest_version":5,"color":16727552,"visibility":"hidden","source_url":null,"source_icon":null}} {"type":"website","key":"spotify-gender-ex","value":{"name":"Spotify-Gender-Ex","created_at":[2023,49,16,30,0,0,0,0,0],"latest_version":3,"color":1947988,"visibility":"featured","source_url":"https://github.com/Theta-Dev/Spotify-Gender-Ex","source_icon":"github"}} {"type":"version","key":"-:1","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1352014628","Version":"v0.1.0"},"fallback":null,"spa":false}} {"type":"version","key":"-:2","value":{"created_at":[2023,49,16,52,0,0,0,0,0],"data":{"Deployed by":"https://github.com/Theta-Dev/Talon/actions/runs/1354755231","Version":"v0.1.1"},"fallback":null,"spa":false}} {"type":"version","key":"rustypipe:4","value":{"created_at":[2023,51,18,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} +{"type":"version","key":"spa:5","value":{"created_at":[2023,62,22,0,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} {"type":"version","key":"spotify-gender-ex:3","value":{"created_at":[2023,49,16,30,0,0,0,0,0],"data":{},"fallback":null,"spa":false}} {"type":"file","key":"1:index.html","value":"3b5f6bad5376897435def176d0fe77e5b9b4f0deafc7491fc27262650744ad68"} {"type":"file","key":"1:style.css","value":"356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026"} @@ -18,5 +20,6 @@ expression: data {"type":"file","key":"2:index.html","value":"a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb"} {"type":"file","key":"3:gex_style.css","value":"fc825b409a49724af8f5b3c4ad15e175e68095ea746237a7b46152d3f383f541"} {"type":"file","key":"3:index.html","value":"6c5d37546616519e8973be51515b8a90898b4675f7b6d01f2d891edb686408a2"} -{"type":"file","key":"4:index.html","value":"94a67cf13d752a9c1875ad999eb2be5a1b0f9746c66bca2631820b8186028811"} +{"type":"file","key":"4:index.html","value":"cc31423924cf1f124750825861ab1ccc675e755921fc2fa111c0a98e8c346a5e"} +{"type":"file","key":"4:page2/index.html","value":"be4f409ca0adcb21cdc7130cde63031718406726f889ef97ac8870c90b330a75"} {"type":"file","key":"4:rp_style.css","value":"ee4fc4911a56e627c047a29ba3085131939d8d487759b9149d42aaab89ce8993"} diff --git a/tests/snapshots/tests__database__get_websites.snap b/tests/snapshots/tests__database__get_websites.snap index a5c5430..5693d0b 100644 --- a/tests/snapshots/tests__database__get_websites.snap +++ b/tests/snapshots/tests__database__get_websites.snap @@ -21,6 +21,15 @@ expression: websites source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"), source_icon: Some(gitea), )), + ("spa", Website( + name: "SvelteKit SPA", + created_at: (2023, 62, 22, 0, 0, 0, 0, 0, 0), + latest_version: Some(5), + color: Some(16727552), + visibility: hidden, + source_url: None, + source_icon: None, + )), ("spotify-gender-ex", Website( name: "Spotify-Gender-Ex", created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), diff --git a/tests/testfiles/archive/spa.tar.gz b/tests/testfiles/archive/spa.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..c4ad22f645530d8b299e52454b788275d5c2ae73 GIT binary patch literal 21436 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 bme=xHUdwBFEwAPES6}}F1w6t{06+l%r_Htg literal 0 HcmV?d00001 diff --git a/tests/tests.rs b/tests/tests.rs index e845b95..0071098 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -176,7 +176,7 @@ mod database { #[rstest] fn get_file_hashes(db: DbTest) { let hashes = db.get_file_hashes().unwrap(); - assert_eq!(hashes.len(), 10) + assert_eq!(hashes.len(), 11) } } @@ -566,3 +566,43 @@ mod config { insta::assert_ron_snapshot!(name, &cfg); } } + +mod page { + use hex::ToHex; + use poem::{http::header, test::TestClient}; + + use super::*; + + #[rstest] + #[case::index("", "/", &HASH_1_2_INDEX, "text/html")] + #[case::style("", "/assets/style.css", &HASH_1_2_STYLE, "text/css")] + #[case::rustypipe("rustypipe", "/", &HASH_3_1_INDEX, "text/html")] + #[case::rustypipe2("rustypipe", "/page2/index.html", &HASH_3_1_PAGE2, "text/html")] + #[case::spa_index("spa", "/", &HASH_SPA_INDEX, "text/html")] + #[case::spa_fallback("spa", "/user/2", &HASH_SPA_FALLBACK, "text/html")] + fn page( + tln: TalonTest, + #[case] subdomain: &str, + #[case] path: &str, + #[case] hash: &[u8], + #[case] mime: &str, + ) { + let host = if subdomain.is_empty() { + "localhost:3000".to_owned() + } else { + format!("{subdomain}.localhost:3000") + }; + + let resp = tokio_test::block_on( + TestClient::new(tln.endpoint()) + .get(format!("http://{host}{path}")) + .header(header::HOST, host) + .data(tln.clone()) + .send(), + ); + + resp.assert_status_is_ok(); + resp.assert_content_type(mime); + resp.assert_header(header::ETAG, format!("\"{}\"", hash.encode_hex::())) + } +}