#![cfg(all(feature = "macros", feature = "assets"))]
use kynos::{
Router,
http::{Method, StatusCode, header},
router::{assets::Asset, group::Group},
};
#[path = "support/mod.rs"]
mod support;
use support::{get, send};
kynos::assets! {
struct Fixture;
dir = "tests/assets",
exclude = [".map"],
}
fn served() -> Router<()> {
Router::<()>::new().group(Group::new("/static").mount(Fixture::assets()))
}
#[test]
fn the_set_holds_what_the_directory_held_minus_what_was_excluded() {
let paths: Vec<&str> = Fixture::ASSETS.iter().map(Asset::path).collect();
assert_eq!(paths, ["css/app.css", "docs/index.html", "index.html"]);
assert_eq!(Fixture::COUNT, 3);
assert_eq!(
Fixture::TOTAL_BYTES,
Fixture::ASSETS
.iter()
.map(|asset| {
asset.bytes().len()
+ asset
.encodings()
.iter()
.map(|encoded| encoded.bytes().len())
.sum::<usize>()
})
.sum::<usize>()
);
}
#[test]
fn the_set_is_in_a_stable_order() {
let mut sorted: Vec<&str> = Fixture::ASSETS.iter().map(Asset::path).collect();
let listed = sorted.clone();
sorted.sort_unstable();
assert_eq!(listed, sorted);
}
#[tokio::test]
async fn a_file_is_served_with_the_media_type_its_name_implies() {
let service = served().build(()).expect("a describable router");
let reply = get(&service, "/static/css/app.css").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(
reply.field(header::CONTENT_TYPE.as_str()).as_deref(),
Some("text/css; charset=utf-8")
);
assert_eq!(reply.text(), "body { margin: 0 }\n");
}
#[tokio::test]
async fn an_index_is_served_at_both_urls_it_answers_for() {
let service = served().build(()).expect("a describable router");
for path in ["/static/index.html", "/static/"] {
let reply = get(&service, path).call().await;
assert_eq!(reply.status, StatusCode::OK, "{path}");
assert!(reply.text().contains("<title>Kynos</title>"), "{path}");
}
for path in ["/static/docs/index.html", "/static/docs/"] {
assert!(
get(&service, path).call().await.text().contains("Docs"),
"{path}"
);
}
}
#[tokio::test]
async fn an_excluded_file_is_not_served() {
let service = served().build(()).expect("a describable router");
assert_eq!(
get(&service, "/static/app.css.map").call().await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn a_file_answers_only_the_method_it_declares() {
let service = served().build(()).expect("a describable router");
let reply = send(&service, Method::POST, "/static/css/app.css")
.call()
.await;
assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(reply.field(header::ALLOW.as_str()).as_deref(), Some("GET"));
}
#[tokio::test]
async fn a_client_holding_the_current_tag_receives_no_body() {
let service = served().build(()).expect("a describable router");
let first = get(&service, "/static/css/app.css").call().await;
let etag = first.field(header::ETAG.as_str()).expect("an entity tag");
let second = get(&service, "/static/css/app.css")
.header("if-none-match", &etag)
.call()
.await;
assert_eq!(second.status, StatusCode::NOT_MODIFIED);
assert!(second.body.is_empty());
assert_eq!(second.field(header::ETAG.as_str()).as_deref(), Some(&*etag));
}
#[tokio::test]
async fn a_client_holding_a_stale_tag_receives_the_file() {
let service = served().build(()).expect("a describable router");
let reply = get(&service, "/static/css/app.css")
.header("if-none-match", "\"something-else\"")
.call()
.await;
assert_eq!(reply.status, StatusCode::OK);
assert!(!reply.body.is_empty());
}
#[tokio::test]
async fn a_wildcard_precondition_matches_anything_served() {
let service = served().build(()).expect("a describable router");
assert_eq!(
get(&service, "/static/css/app.css")
.header("if-none-match", "*")
.call()
.await
.status,
StatusCode::NOT_MODIFIED
);
}
#[tokio::test]
async fn the_precondition_comparison_is_weak() {
let service = served().build(()).expect("a describable router");
let etag = get(&service, "/static/css/app.css")
.call()
.await
.field(header::ETAG.as_str())
.expect("an entity tag");
assert_eq!(
get(&service, "/static/css/app.css")
.header("if-none-match", &format!("W/{etag}"))
.call()
.await
.status,
StatusCode::NOT_MODIFIED
);
}
#[tokio::test]
async fn a_list_of_tags_matches_on_any_member() {
let service = served().build(()).expect("a describable router");
let etag = get(&service, "/static/css/app.css")
.call()
.await
.field(header::ETAG.as_str())
.expect("an entity tag");
assert_eq!(
get(&service, "/static/css/app.css")
.header("if-none-match", &format!("\"other\", {etag}"))
.call()
.await
.status,
StatusCode::NOT_MODIFIED
);
}
const STYLESHEET: &str = "body { margin: 0 }\n";
#[tokio::test]
async fn a_file_served_whole_advertises_that_it_ranges() {
let service = served().build(()).expect("a describable router");
let reply = get(&service, "/static/css/app.css").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(
reply.field(header::ACCEPT_RANGES.as_str()).as_deref(),
Some("bytes")
);
assert_eq!(reply.field(header::CONTENT_RANGE.as_str()), None);
assert_eq!(reply.text(), STYLESHEET);
}
#[tokio::test]
async fn a_range_request_receives_the_bytes_it_named() {
let service = served().build(()).expect("a describable router");
for (field, first, last) in [
("bytes=5-10", 5, 10),
("bytes=-4", 15, 18),
("bytes=12-", 12, 18),
("bytes=17-99", 17, 18),
] {
let reply = get(&service, "/static/css/app.css")
.header("range", field)
.call()
.await;
assert_eq!(reply.status, StatusCode::PARTIAL_CONTENT, "{field}");
assert_eq!(
reply.field(header::CONTENT_RANGE.as_str()).as_deref(),
Some(format!("bytes {first}-{last}/19").as_str()),
"{field}"
);
assert_eq!(reply.text(), &STYLESHEET[first..=last], "{field}");
assert!(reply.field(header::ETAG.as_str()).is_some(), "{field}");
assert_eq!(
reply.field(header::CONTENT_TYPE.as_str()).as_deref(),
Some("text/css; charset=utf-8"),
"{field}"
);
}
}
#[tokio::test]
async fn an_unsatisfiable_range_states_the_complete_length() {
let service = served().build(()).expect("a describable router");
let reply = get(&service, "/static/css/app.css")
.header("range", "bytes=100-200")
.call()
.await;
assert_eq!(reply.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
reply.field(header::CONTENT_RANGE.as_str()).as_deref(),
Some("bytes */19")
);
}
#[tokio::test]
async fn an_unusable_range_field_is_ignored_rather_than_refused() {
let service = served().build(()).expect("a describable router");
for field in ["items=0-1", "bytes=nonsense", "bytes=5-1"] {
let reply = get(&service, "/static/css/app.css")
.header("range", field)
.call()
.await;
assert_eq!(reply.status, StatusCode::OK, "{field}");
assert_eq!(reply.text(), STYLESHEET, "{field}");
}
}
#[tokio::test]
async fn a_matched_precondition_beats_a_range() {
let service = served().build(()).expect("a describable router");
let etag = get(&service, "/static/css/app.css")
.call()
.await
.field(header::ETAG.as_str())
.expect("an entity tag");
let reply = get(&service, "/static/css/app.css")
.header("if-none-match", &etag)
.header("range", "bytes=0-3")
.call()
.await;
assert_eq!(reply.status, StatusCode::NOT_MODIFIED);
assert!(reply.body.is_empty());
}
#[tokio::test]
async fn an_if_range_naming_this_representation_honours_the_range() {
let service = served().build(()).expect("a describable router");
let etag = get(&service, "/static/css/app.css")
.call()
.await
.field(header::ETAG.as_str())
.expect("an entity tag");
assert!(!etag.starts_with("W/"), "an embedded tag is strong: {etag}");
let reply = get(&service, "/static/css/app.css")
.header("if-range", &etag)
.header("range", "bytes=0-3")
.call()
.await;
assert_eq!(reply.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(reply.text(), &STYLESHEET[0..=3]);
}
#[tokio::test]
async fn an_if_range_that_does_not_hold_sends_the_whole_file() {
let service = served().build(()).expect("a describable router");
let etag = get(&service, "/static/css/app.css")
.call()
.await
.field(header::ETAG.as_str())
.expect("an entity tag");
for condition in [
"\"something-else\"".to_owned(),
format!("W/{etag}"),
"Wed, 15 Nov 1995 04:58:08 GMT".to_owned(),
] {
let reply = get(&service, "/static/css/app.css")
.header("if-range", &condition)
.header("range", "bytes=0-3")
.call()
.await;
assert_eq!(reply.status, StatusCode::OK, "{condition}");
assert_eq!(reply.text(), STYLESHEET, "{condition}");
assert_eq!(
reply.field(header::CONTENT_RANGE.as_str()),
None,
"{condition}"
);
}
}
#[test]
fn every_file_is_a_described_operation() {
let document = served().openapi().expect("a describable router");
let mut keys: Vec<&str> = document.paths.items.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"/static/",
"/static/css/app.css",
"/static/docs/",
"/static/docs/index.html",
"/static/index.html",
]
);
assert!(
document.is_authoritative(),
"an embedded set waives nothing, so the document is authoritative"
);
}
#[test]
fn each_operation_declares_every_status_a_file_can_answer_with() {
let document = served().openapi().expect("a describable router");
let operation = document.paths.items["/static/css/app.css"]
.get
.as_ref()
.expect("a GET");
let mut statuses: Vec<&str> = operation
.responses
.responses
.keys()
.map(String::as_str)
.collect();
statuses.sort_unstable();
assert_eq!(statuses, ["200", "206", "304", "416"]);
let ok = operation.responses.responses["200"]
.as_item()
.expect("an inline 200");
assert!(ok.content.contains_key("text/css; charset=utf-8"));
assert!(ok.headers.contains_key("ETag"));
assert!(ok.headers.contains_key("Cache-Control"));
assert!(ok.headers.contains_key("Accept-Ranges"));
let partial = operation.responses.responses["206"]
.as_item()
.expect("an inline 206");
assert!(partial.content.contains_key("text/css; charset=utf-8"));
assert!(partial.headers.contains_key("ETag"));
assert!(partial.headers.contains_key("Cache-Control"));
assert!(partial.headers.contains_key("Accept-Ranges"));
assert!(partial.headers.contains_key("Content-Range"));
let not_modified = operation.responses.responses["304"]
.as_item()
.expect("an inline 304");
assert!(not_modified.headers.contains_key("ETag"));
assert!(not_modified.headers.contains_key("Vary"));
assert!(!not_modified.headers.contains_key("Content-Encoding"));
assert!(!not_modified.headers.contains_key("Accept-Ranges"));
let unsatisfiable = operation.responses.responses["416"]
.as_item()
.expect("an inline 416");
assert!(unsatisfiable.headers.contains_key("Content-Range"));
}
#[test]
fn each_operation_declares_the_fields_it_reads() {
let document = served().openapi().expect("a describable router");
let operation = document.paths.items["/static/css/app.css"]
.get
.as_ref()
.expect("a GET");
let mut names: Vec<&str> = operation
.parameters
.iter()
.filter_map(|parameter| parameter.as_item())
.map(|parameter| parameter.name.as_str())
.collect();
names.sort_unstable();
assert_eq!(
names,
["Accept-Encoding", "If-None-Match", "If-Range", "Range"]
);
let plain = document.paths.items["/static/docs/index.html"]
.get
.as_ref()
.expect("a GET");
let mut plain_names: Vec<&str> = plain
.parameters
.iter()
.filter_map(|parameter| parameter.as_item())
.map(|parameter| parameter.name.as_str())
.collect();
plain_names.sort_unstable();
assert_eq!(plain_names, ["If-None-Match", "If-Range", "Range"]);
}
#[test]
fn every_operation_has_an_identifier_of_its_own() {
let document = served().openapi().expect("a describable router");
let mut ids: Vec<&str> = document
.paths
.items
.values()
.filter_map(|item| item.get.as_ref())
.filter_map(|operation| operation.operation_id.as_deref())
.collect();
let before = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), before, "two operations share an identifier");
assert_eq!(
ids,
[
"asset_css_app_css",
"asset_docs",
"asset_docs_index_html",
"asset_index",
"asset_index_html",
]
);
}
#[cfg(feature = "assets-fs")]
mod directory {
use kynos::{
Router,
http::StatusCode,
openapi::{OpaqueReason, OpaqueRoute},
router::assets::fs::Directory,
};
use super::support::get;
fn served() -> Router<()> {
Router::<()>::new().assets_directory("/files", Directory::new("tests/assets"))
}
#[test]
fn a_prefix_carrying_a_variable_is_reported_rather_than_asserted() {
let router =
Router::<()>::new().assets_directory("/files/{tenant}", Directory::new("tests/assets"));
let violations = router.validate().expect("validation itself succeeds");
assert!(
!violations.is_empty(),
"a prefix with a variable must be reported"
);
assert!(router.build(()).is_err());
}
#[tokio::test]
async fn a_file_is_served_from_disk() {
let service = served().build(()).expect("a buildable router");
let reply = get(&service, "/files/css/app.css").call().await;
assert_eq!(reply.status, StatusCode::OK);
assert_eq!(reply.text(), "body { margin: 0 }\n");
assert_eq!(
reply
.field(kynos::http::header::CONTENT_TYPE.as_str())
.as_deref(),
Some("text/css; charset=utf-8")
);
}
#[tokio::test]
async fn a_file_the_embedded_set_excluded_is_still_on_disk() {
let service = served().build(()).expect("a buildable router");
assert_eq!(
get(&service, "/files/app.css.map").call().await.status,
StatusCode::OK
);
}
#[tokio::test]
async fn a_directory_serves_its_index() {
let service = served().build(()).expect("a buildable router");
assert!(
get(&service, "/files/docs/")
.call()
.await
.text()
.contains("Docs")
);
}
#[tokio::test]
async fn a_request_climbing_out_of_the_root_is_refused() {
let service = served().build(()).expect("a buildable router");
for path in [
"/files/../Cargo.toml",
"/files/css/../../Cargo.toml",
"/files/%2e%2e/Cargo.toml",
] {
let reply = get(&service, path).call().await;
assert_eq!(reply.status, StatusCode::NOT_FOUND, "{path}");
assert!(reply.body.is_empty(), "{path}");
}
}
#[tokio::test]
async fn a_file_that_is_not_there_is_not_found() {
let service = served().build(()).expect("a buildable router");
assert_eq!(
get(&service, "/files/nothing.css").call().await.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn a_client_holding_the_current_tag_receives_no_body() {
let service = served().build(()).expect("a buildable router");
let etag = get(&service, "/files/css/app.css")
.call()
.await
.field(kynos::http::header::ETAG.as_str())
.expect("an entity tag");
assert!(etag.starts_with("W/"), "{etag}");
let second = get(&service, "/files/css/app.css")
.header("if-none-match", &etag)
.call()
.await;
assert_eq!(second.status, StatusCode::NOT_MODIFIED);
assert!(second.body.is_empty());
}
#[tokio::test]
async fn a_served_file_answers_the_part_it_was_asked_for() {
let service = served().build(()).expect("a buildable router");
let reply = get(&service, "/files/css/app.css")
.header("range", "bytes=5-10")
.call()
.await;
assert_eq!(reply.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(
reply
.field(kynos::http::header::CONTENT_RANGE.as_str())
.as_deref(),
Some("bytes 5-10/19")
);
assert_eq!(reply.text(), &super::STYLESHEET[5..=10]);
assert_eq!(
reply
.field(kynos::http::header::ACCEPT_RANGES.as_str())
.as_deref(),
Some("bytes")
);
}
#[tokio::test]
async fn an_unsatisfiable_range_is_refused_before_the_file_is_read() {
let service = served().build(()).expect("a buildable router");
let reply = get(&service, "/files/css/app.css")
.header("range", "bytes=100-200")
.call()
.await;
assert_eq!(reply.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(
reply
.field(kynos::http::header::CONTENT_RANGE.as_str())
.as_deref(),
Some("bytes */19")
);
}
#[tokio::test]
async fn a_weak_validator_never_satisfies_an_if_range() {
let service = served().build(()).expect("a buildable router");
let etag = get(&service, "/files/css/app.css")
.call()
.await
.field(kynos::http::header::ETAG.as_str())
.expect("an entity tag");
assert!(etag.starts_with("W/"), "{etag}");
let conditional = get(&service, "/files/css/app.css")
.header("if-range", &etag)
.header("range", "bytes=0-3")
.call()
.await;
assert_eq!(conditional.status, StatusCode::OK);
assert_eq!(conditional.text(), super::STYLESHEET);
let unconditional = get(&service, "/files/css/app.css")
.header("range", "bytes=0-3")
.call()
.await;
assert_eq!(unconditional.status, StatusCode::PARTIAL_CONTENT);
}
#[test]
fn a_served_directory_is_recorded_rather_than_described() {
let document = served().openapi().expect("a describable router");
assert!(
document.paths.items.is_empty(),
"a directory took a `paths` key, which is a claim about paths it does not honour"
);
let recorded = OpaqueRoute::all(&document).expect("a readable record");
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].pattern, "/files/{*path}");
assert_eq!(recorded[0].prefix.as_deref(), Some("/files"));
assert_eq!(recorded[0].reason, OpaqueReason::StaticAssets);
assert!(
recorded[0]
.note
.as_deref()
.is_some_and(|note| note.contains("membership is not fixed")),
"{:?}",
recorded[0].note
);
assert!(!document.is_authoritative());
}
#[test]
fn the_waiver_names_itself() {
assert_eq!(served().unchecked_reasons(), [OpaqueReason::StaticAssets]);
}
}
#[tokio::test]
async fn a_stored_coding_is_served_with_its_own_validator() {
let service = served().build(()).expect("a servable router");
let identity = get(&service, "/static/css/app.css").call().await;
let encoded = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.call()
.await;
assert_eq!(encoded.status, StatusCode::OK);
assert_eq!(encoded.field("content-encoding").as_deref(), Some("br"));
assert_eq!(encoded.body, &include_bytes!("assets/css/app.css.br")[..]);
let identity_tag = identity.field("etag").expect("a tagged 200");
let encoded_tag = encoded.field("etag").expect("a tagged 200");
assert_ne!(identity_tag, encoded_tag);
assert_eq!(identity.field("content-encoding"), None);
assert_eq!(identity.body, &include_bytes!("assets/css/app.css")[..]);
}
#[tokio::test]
async fn vary_is_sent_by_the_files_that_negotiate() {
let service = served().build(()).expect("a servable router");
let negotiable = get(&service, "/static/css/app.css").call().await;
assert_eq!(
negotiable.field("vary").as_deref(),
Some("accept-encoding"),
"a file with stored codings must not be cached without its key"
);
let fixed = get(&service, "/static/docs/index.html").call().await;
assert_eq!(fixed.field("vary"), None);
}
#[tokio::test]
async fn negotiation_picks_what_the_client_asked_for() {
let service = served().build(()).expect("a servable router");
let coding = |accept: Option<&str>| {
let service = &service;
let accept = accept.map(str::to_owned);
async move {
let mut pending = get(service, "/static/css/app.css");
if let Some(accept) = &accept {
pending = pending.header("accept-encoding", accept);
}
pending.call().await.field("content-encoding")
}
};
assert_eq!(coding(Some("gzip")).await.as_deref(), Some("gzip"));
assert_eq!(coding(Some("br")).await.as_deref(), Some("br"));
assert_eq!(coding(Some("gzip, br")).await.as_deref(), Some("br"));
assert_eq!(coding(Some("zstd")).await, None);
assert_eq!(coding(None).await, None);
assert_eq!(coding(Some("br;q=0.9")).await, None);
assert_eq!(
coding(Some("br;q=0.9, identity;q=0")).await.as_deref(),
Some("br")
);
}
#[tokio::test]
async fn a_range_is_calculated_over_the_representation_that_was_sent() {
let service = served().build(()).expect("a servable router");
let stored = &include_bytes!("assets/css/app.css.br")[..];
let whole = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.call()
.await;
let tag = whole.field("etag").expect("a tagged 200");
let part = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.header("range", "bytes=0-3")
.header("if-range", &tag)
.call()
.await;
assert_eq!(part.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(part.field("content-encoding").as_deref(), Some("br"));
assert_eq!(part.body, &stored[..4]);
assert_eq!(
part.field("content-range").as_deref(),
Some(format!("bytes 0-3/{}", stored.len()).as_str())
);
let rest = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.header("range", &format!("bytes=4-{}", stored.len() - 1))
.header("if-range", &tag)
.call()
.await;
let mut spliced = part.body.to_vec();
spliced.extend_from_slice(&rest.body);
assert_eq!(spliced, stored);
}
#[tokio::test]
async fn a_resume_across_two_representations_is_refused() {
let service = served().build(()).expect("a servable router");
let identity_tag = get(&service, "/static/css/app.css")
.call()
.await
.field("etag")
.expect("a tagged 200");
let resumed = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.header("range", "bytes=0-3")
.header("if-range", &identity_tag)
.call()
.await;
assert_eq!(resumed.status, StatusCode::OK);
assert_eq!(resumed.field("content-encoding").as_deref(), Some("br"));
assert_eq!(resumed.body, &include_bytes!("assets/css/app.css.br")[..]);
}
#[tokio::test]
async fn a_conditional_request_is_answered_per_representation() {
let service = served().build(()).expect("a servable router");
let encoded_tag = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.call()
.await
.field("etag")
.expect("a tagged 200");
let matched = get(&service, "/static/css/app.css")
.header("accept-encoding", "br")
.header("if-none-match", &encoded_tag)
.call()
.await;
assert_eq!(matched.status, StatusCode::NOT_MODIFIED);
assert_eq!(matched.field("etag").as_deref(), Some(encoded_tag.as_str()));
assert_eq!(
matched.field("vary").as_deref(),
Some("accept-encoding"),
"section 15.4.5 requires the `Vary` the 200 would have carried"
);
assert_eq!(
matched.field("content-encoding"),
None,
"a 304 carrying representation metadata section 15.4.5 does not list"
);
let crossed = get(&service, "/static/css/app.css")
.header("if-none-match", &encoded_tag)
.call()
.await;
assert_eq!(crossed.status, StatusCode::OK);
assert_eq!(crossed.field("content-encoding"), None);
}