use gfeh_core::{Disposition, NodeKind, NodeRef, ObjectStore, OpCtx};
use gfeh_http::{Exposed, Exposures, HttpView, StaticExposures};
use gfeh_store::MemStore;
use std::process::Command;
use std::sync::Arc;
fn curl_image() -> String {
std::env::var("GFEH_CURL_IMAGE")
.unwrap_or_else(|_| "docker.io/curlimages/curl:latest".to_string())
}
struct Curl {
status: String,
headers: String,
body: String,
}
fn curl(args: &[&str]) -> Curl {
let output = Command::new("podman")
.args(["run", "--rm", "--network=host"])
.arg(curl_image())
.args([
"--silent",
"--show-error",
"-D",
"-",
"--write-out",
"\n%{http_code}",
])
.args(args)
.output()
.expect("podman runs");
assert!(
output.status.success(),
"curl {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let combined = String::from_utf8_lossy(&output.stdout).to_string();
let (rest, status) = combined
.rsplit_once('\n')
.expect("curl writes the status last");
let (headers, body) = match rest.rsplit_once("\r\n\r\n") {
Some((headers, body)) => (headers.to_string(), body.to_string()),
None => (rest.to_string(), String::new()),
};
Curl {
status: status.to_string(),
headers,
body,
}
}
impl Curl {
fn header(&self, name: &str) -> Option<String> {
self.headers.lines().find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case(name)
.then(|| value.trim().to_string())
})
}
}
async fn fixture(name: &str, body: &[u8], token: &str) -> HttpView {
let store = Arc::new(MemStore::new());
let exposures = Arc::new(StaticExposures::new());
let cx = OpCtx::system("third-party");
let mut handle = store
.create(
&cx,
&store.root(),
name,
NodeKind::File,
Disposition::CreateNew,
)
.await
.expect("create");
if !body.is_empty() {
handle
.write_at(0, bytes::Bytes::copy_from_slice(body))
.await
.expect("write");
}
let meta = handle.close().await.expect("close");
exposures.publish(
token,
Exposed {
node: NodeRef::Id(store.partition(), meta.id),
filename: None,
enabled: true,
},
);
HttpView::builder(
Arc::clone(&store) as Arc<dyn ObjectStore>,
Arc::clone(&exposures) as Arc<dyn Exposures>,
)
.start()
.await
.expect("bind")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_downloads_a_published_file() {
let view = fixture("report.pdf", b"%PDF-1.7 body", "tok").await;
let response = curl(&[&view.url_for("tok")]);
assert_eq!(response.status, "200", "{}", response.headers);
assert_eq!(
response.body, "%PDF-1.7 body",
"the wrong bytes were served"
);
assert_eq!(
response.header("content-length").as_deref(),
Some("13"),
"a wrong Content-Length truncates or hangs a download: {}",
response.headers
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_reads_an_http_date_from_last_modified() {
let view = fixture("report.pdf", b"body", "tok").await;
let response = curl(&["--head", &view.url_for("tok")]);
let modified = response
.header("last-modified")
.unwrap_or_else(|| panic!("no Last-Modified: {}", response.headers));
assert!(
modified.ends_with(" GMT"),
"Last-Modified does not end in GMT: {modified}"
);
assert!(
modified.contains(", "),
"Last-Modified has no day name: {modified}"
);
assert!(
!modified.contains('-'),
"Last-Modified looks like ISO 8601: {modified}"
);
let conditional = curl(&[
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
"--time-cond",
&modified,
&view.url_for("tok"),
]);
assert_eq!(
conditional.status, "304",
"an If-Modified-Since carrying the object's own date must not re-send it: {}",
conditional.headers
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_resumes_with_a_range_request() {
let view = fixture("data.bin", b"0123456789", "tok").await;
let response = curl(&["--range", "3-6", &view.url_for("tok")]);
assert_eq!(response.status, "206", "{}", response.headers);
assert_eq!(response.body, "3456", "the served range is wrong");
assert_eq!(
response.header("content-range").as_deref(),
Some("bytes 3-6/10"),
"{}",
response.headers
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_is_told_the_length_when_its_range_is_unsatisfiable() {
let view = fixture("data.bin", b"short", "tok").await;
let response = curl(&["--range", "500-600", &view.url_for("tok")]);
assert_eq!(response.status, "416", "{}", response.headers);
assert_eq!(
response.header("content-range").as_deref(),
Some("bytes */5"),
"{}",
response.headers
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_revalidates_with_the_etag_it_was_given() {
let view = fixture("report.pdf", b"body", "tok").await;
let first = curl(&[&view.url_for("tok")]);
let etag = first
.header("etag")
.unwrap_or_else(|| panic!("no ETag: {}", first.headers));
let second = curl(&[
"--header",
&format!("If-None-Match: {etag}"),
&view.url_for("tok"),
]);
assert_eq!(second.status, "304", "{}", second.headers);
assert!(
second.body.is_empty(),
"a 304 carried a body: {:?}",
second.body
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_saves_the_file_under_the_name_it_was_told() {
let view = fixture("report.pdf", b"body", "tok").await;
let out = Command::new("podman")
.args(["run", "--rm", "--network=host", "--entrypoint", "sh"])
.arg(curl_image())
.args([
"-c",
&format!(
"cd /tmp && curl --silent --show-error --remote-name --remote-header-name {} \
&& ls",
view.url_for("tok")
),
])
.output()
.expect("podman runs");
assert!(
out.status.success(),
"curl failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("report.pdf"),
"the client did not name the download from Content-Disposition: {}",
String::from_utf8_lossy(&out.stdout)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_gets_a_404_for_a_token_that_names_nothing() {
let view = fixture("report.pdf", b"body", "tok").await;
let response = curl(&[&format!("{}/f/not-a-token", view.base_url())]);
assert_eq!(response.status, "404", "{}", response.headers);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_real_client_finds_no_surface_but_the_token() {
let view = fixture("report.pdf", b"body", "tok").await;
for path in ["/", "/f/", "/report.pdf", "/health"] {
let response = curl(&[
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
&format!("{}{path}", view.base_url()),
]);
assert!(
!response.status.starts_with('2'),
"{path} was served with {}",
response.status
);
}
}