use bytes::Bytes;
use gfeh_core::{Disposition, MetaPatch, NodeKind, NodeRef, ObjectStore, OpCtx};
use gfeh_http::{Exposed, HttpView, StaticExposures};
use gfeh_store::MemStore;
use reqwest::StatusCode;
use reqwest::header;
use std::sync::Arc;
async fn fixture(body: &'static [u8]) -> (HttpView, StaticExposures, Arc<MemStore>, NodeRef) {
let store = Arc::new(MemStore::new());
let cx = OpCtx::system("test");
let mut handle = store
.create(
&cx,
&store.root(),
"report.pdf",
NodeKind::File,
Disposition::CreateNew,
)
.await
.expect("create");
handle
.write_at(0, Bytes::from_static(body))
.await
.expect("write");
let meta = handle.close().await.expect("close");
let node = NodeRef::Id(store.partition(), meta.id);
let exposures = StaticExposures::new().with(
"tok",
Exposed {
node: node.clone(),
filename: None,
enabled: true,
},
);
let view = HttpView::builder(
Arc::clone(&store) as Arc<dyn ObjectStore>,
Arc::new(exposures.clone()),
)
.start()
.await
.expect("bind");
(view, exposures, store, node)
}
fn header_of(response: &reqwest::Response, name: header::HeaderName) -> String {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string()
}
#[tokio::test]
async fn a_published_file_is_served_whole() {
let (view, ..) = fixture(b"0123456789").await;
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header_of(&response, header::CONTENT_LENGTH), "10");
assert_eq!(header_of(&response, header::ACCEPT_RANGES), "bytes");
assert!(
header_of(&response, header::CONTENT_DISPOSITION).contains("report.pdf"),
"the filename must reach the browser"
);
assert_eq!(response.text().await.expect("body"), "0123456789");
}
#[tokio::test]
async fn last_modified_is_an_http_date_rather_than_iso_8601() {
let (view, ..) = fixture(b"body").await;
let response = reqwest::get(view.url_for("tok")).await.expect("request");
let modified = header_of(&response, header::LAST_MODIFIED);
assert!(modified.ends_with(" GMT"), "{modified}");
assert!(!modified.contains('Z'), "{modified}");
assert!(!modified.contains('-'), "{modified}");
}
#[tokio::test]
async fn an_explicit_range_yields_partial_content() {
let (view, ..) = fixture(b"0123456789").await;
let response = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::RANGE, "bytes=2-5")
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes 2-5/10");
assert_eq!(header_of(&response, header::CONTENT_LENGTH), "4");
assert_eq!(response.text().await.expect("body"), "2345");
}
#[tokio::test]
async fn a_suffix_range_serves_the_end_of_the_object() {
let (view, ..) = fixture(b"0123456789").await;
let response = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::RANGE, "bytes=-3")
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes 7-9/10");
assert_eq!(response.text().await.expect("body"), "789");
}
#[tokio::test]
async fn an_open_ended_range_runs_to_the_end() {
let (view, ..) = fixture(b"0123456789").await;
let response = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::RANGE, "bytes=7-")
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(response.text().await.expect("body"), "789");
}
#[tokio::test]
async fn an_unsatisfiable_range_says_how_long_the_object_is() {
let (view, ..) = fixture(b"short").await;
let response = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::RANGE, "bytes=500-600")
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(header_of(&response, header::CONTENT_RANGE), "bytes */5");
}
#[tokio::test]
async fn a_conditional_request_is_answered_without_the_body() {
let (view, ..) = fixture(b"cache me").await;
let first = reqwest::get(view.url_for("tok")).await.expect("request");
let etag = header_of(&first, header::ETAG);
assert!(etag.starts_with('"') && etag.ends_with('"'), "{etag}");
let second = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::IF_NONE_MATCH, &etag)
.send()
.await
.expect("request");
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(header_of(&second, header::ETAG), etag);
assert!(second.bytes().await.expect("body").is_empty());
}
#[tokio::test]
async fn a_stale_validator_gets_the_object() {
let (view, ..) = fixture(b"cache me").await;
let response = reqwest::Client::new()
.get(view.url_for("tok"))
.header(header::IF_NONE_MATCH, "\"not-the-current-tag\"")
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.text().await.expect("body"), "cache me");
}
#[tokio::test]
async fn a_head_request_carries_the_headers_and_no_body() {
let (view, ..) = fixture(b"0123456789").await;
let response = reqwest::Client::new()
.head(view.url_for("tok"))
.send()
.await
.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header_of(&response, header::CONTENT_LENGTH), "10");
assert!(response.bytes().await.expect("body").is_empty());
}
#[tokio::test]
async fn the_content_type_comes_from_the_object() {
let (view, _, store, node) = fixture(b"{}").await;
store
.set_meta(
&OpCtx::system("test"),
&node,
MetaPatch {
mime: Some("application/json".into()),
..MetaPatch::default()
},
)
.await
.expect("set mime");
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(
header_of(&response, header::CONTENT_TYPE),
"application/json"
);
}
#[tokio::test]
async fn an_object_with_no_declared_type_is_served_as_octet_stream() {
let (view, ..) = fixture(b"<script>alert(1)</script>").await;
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(
header_of(&response, header::CONTENT_TYPE),
"application/octet-stream"
);
}
#[tokio::test]
async fn an_unknown_token_and_a_revoked_one_are_indistinguishable() {
let (view, exposures, ..) = fixture(b"secret").await;
let unknown = reqwest::get(view.url_for("no-such-token"))
.await
.expect("request");
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
exposures.set_enabled("tok", false);
let revoked = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(revoked.status(), StatusCode::NOT_FOUND);
exposures.set_enabled("tok", true);
let restored = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(restored.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_link_survives_a_rename_of_the_file_it_names() {
let (view, _, store, node) = fixture(b"stable").await;
store
.rename(
&OpCtx::system("test"),
&node,
&store.root(),
"renamed.pdf",
false,
)
.await
.expect("rename");
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert!(
header_of(&response, header::CONTENT_DISPOSITION).contains("renamed.pdf"),
"the advertised filename should follow the rename"
);
}
#[tokio::test]
async fn a_hostile_filename_cannot_inject_a_header() {
let store = Arc::new(MemStore::new());
let cx = OpCtx::system("test");
let handle = store
.create(
&cx,
&store.root(),
"a.txt",
NodeKind::File,
Disposition::CreateNew,
)
.await
.expect("create");
let meta = handle.close().await.expect("close");
let exposures = StaticExposures::new().with(
"tok",
Exposed {
node: NodeRef::Id(store.partition(), meta.id),
filename: Some("evil\"\r\nX-Injected: yes".into()),
enabled: true,
},
);
let view = HttpView::builder(store, Arc::new(exposures))
.start()
.await
.expect("bind");
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert!(
response.headers().get("x-injected").is_none(),
"header injected"
);
let disposition = header_of(&response, header::CONTENT_DISPOSITION);
assert_eq!(disposition, "inline; filename=\"evilX-Injected: yes\"");
}
#[tokio::test]
async fn an_empty_file_is_served_as_an_empty_body_rather_than_an_error() {
let (view, ..) = fixture(b"").await;
let response = reqwest::get(view.url_for("tok")).await.expect("request");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(header_of(&response, header::CONTENT_LENGTH), "0");
assert!(response.bytes().await.expect("body").is_empty());
}
#[tokio::test]
async fn a_large_object_streams_rather_than_buffering() {
let payload = vec![b'x'; 4 * 1024 * 1024];
let store = Arc::new(MemStore::new());
let cx = OpCtx::system("test");
let mut handle = store
.create(
&cx,
&store.root(),
"big.bin",
NodeKind::File,
Disposition::CreateNew,
)
.await
.expect("create");
handle
.write_at(0, Bytes::from(payload.clone()))
.await
.expect("write");
let meta = handle.close().await.expect("close");
let exposures = StaticExposures::new().with(
"tok",
Exposed {
node: NodeRef::Id(store.partition(), meta.id),
filename: None,
enabled: true,
},
);
let view = HttpView::builder(store, Arc::new(exposures))
.start()
.await
.expect("bind");
let body = reqwest::get(view.url_for("tok"))
.await
.expect("request")
.bytes()
.await
.expect("body");
assert_eq!(body.len(), payload.len());
assert_eq!(&body[..], &payload[..]);
}