use super::*;
use actix_web::{http::StatusCode, test};
use std::io::Read;
fn cfg() -> ServerConfig {
ServerConfig::default()
}
#[actix_web::test]
async fn zstd_endpoint_returns_zstd_encoded_json() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/zstd").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"zstd"
);
let body = test::read_body(resp).await;
let decoded = zstd::decode_all(&body[..]).expect("body should be valid zstd");
let v: serde_json::Value =
serde_json::from_slice(&decoded).expect("decoded body should be JSON");
assert_eq!(v.get("zstd").and_then(|x| x.as_bool()), Some(true));
assert_eq!(v.get("method").and_then(|x| x.as_str()), Some("GET"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn query_method_anything_echoes_method_and_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(query_method())
.uri("/anything")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"hello":"world"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("method").and_then(|x| x.as_str()), Some("QUERY"));
assert_eq!(v["json"]["hello"].as_str(), Some("world"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn query_method_anything_with_param_records_path() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(query_method())
.uri("/anything/foo/bar")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("method").and_then(|x| x.as_str()), Some("QUERY"));
assert_eq!(v["args"]["anything"].as_str(), Some("foo/bar"));
}
#[actix_web::test]
async fn query_method_echo_mirrors_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(query_method())
.uri("/echo")
.set_payload("ping")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, "ping");
}
#[actix_web::test]
async fn options_endpoint_echoes_request_with_allow_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(actix_web::http::Method::OPTIONS)
.uri("/options?k=v")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let allow = resp
.headers()
.get("allow")
.and_then(|v| v.to_str().ok())
.expect("Allow header on OPTIONS success");
assert!(allow.contains("OPTIONS"), "Allow: {allow}");
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["args"]["k"].as_str(), Some("v"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn trace_endpoint_echoes_method() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(actix_web::http::Method::TRACE)
.uri("/trace")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("method").and_then(|x| x.as_str()), Some("TRACE"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn query_endpoint_parses_url_args_and_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(query_method())
.uri("/query?lang=rust")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("select=stars")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["args"]["lang"].as_str(), Some("rust"));
assert_eq!(v["form"]["select"].as_str(), Some("stars"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn dedicated_method_endpoints_405_on_wrong_method() {
let app = test::init_service(create_app(cfg())).await;
for (uri, allowed) in [
("/options", "OPTIONS"),
("/trace", "TRACE"),
("/query", "QUERY"),
] {
let req = test::TestRequest::get().uri(uri).to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED, "GET {uri}");
let allow = resp
.headers()
.get("allow")
.and_then(|v| v.to_str().ok())
.expect("Allow header on 405");
assert!(allow.contains(allowed), "Allow: {allow}");
}
}
#[actix_web::test]
async fn duplicate_request_headers_are_joined() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/headers")
.append_header(("x-multi", "Foo"))
.append_header(("x-multi", "Bar"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v["headers"]["x-multi"].as_str(),
Some("Foo, Bar"),
"duplicate header values must be joined per RFC 7230"
);
}
#[actix_web::test]
async fn status_injects_repeatable_response_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/status/429?header=Retry-After:60&header=X-RateLimit-Remaining:0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
assert_eq!(
resp.headers().get("retry-after").unwrap().to_str().unwrap(),
"60"
);
assert_eq!(
resp.headers()
.get("x-ratelimit-remaining")
.unwrap()
.to_str()
.unwrap(),
"0"
);
}
#[actix_web::test]
async fn multipart_parses_json_fields_and_captures_file_content_type() {
let boundary = "----httpcan-test";
let body = format!(
"--{b}\r\n\
Content-Disposition: form-data; name=\"obj\"\r\n\
Content-Type: application/json\r\n\r\n\
{{\"k\":\"v\"}}\r\n\
--{b}\r\n\
Content-Disposition: form-data; name=\"f\"; filename=\"a.txt\"\r\n\
Content-Type: text/plain\r\n\r\n\
hello\r\n\
--{b}--\r\n",
b = boundary
);
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/post")
.insert_header((
"content-type",
format!("multipart/form-data; boundary={boundary}"),
))
.set_payload(body)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["form"]["obj"]["k"].as_str(), Some("v"));
assert_eq!(v["files"]["f"]["content_type"].as_str(), Some("text/plain"));
assert_eq!(v["files"]["f"]["filename"].as_str(), Some("a.txt"));
assert_eq!(v["files"]["f"]["content"].as_str(), Some("hello"));
}
#[actix_web::test]
async fn healthz_returns_ok() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/healthz").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["status"].as_str(), Some("ok"));
}
#[actix_web::test]
async fn tags_endpoint_returns_object_and_404_for_missing() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/tags").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON object");
assert!(v.is_object());
let req = test::TestRequest::get()
.uri("/tags/__definitely_not_set__")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn tag_key_strips_redundant_httpcan_prefix() {
use crate::handlers::observability::tag_key;
assert_eq!(tag_key("VERSION"), "VERSION");
assert_eq!(tag_key("FOO_BAR"), "FOO_BAR");
assert_eq!(tag_key("HTTPCAN_VERSION"), "VERSION");
assert_eq!(tag_key("HTTPCAN_FOO_BAR"), "FOO_BAR");
assert_eq!(tag_key("HTTPCANISH"), "HTTPCANISH");
assert_eq!(tag_key(""), "");
}
#[actix_web::test]
async fn response_carries_version_and_server_timing_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/get").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.headers().contains_key("x-httpcan-version"));
assert!(
resp.headers()
.get("server-timing")
.unwrap()
.to_str()
.unwrap()
.starts_with("app;dur="),
"Server-Timing must start with app;dur="
);
}
#[actix_web::test]
async fn method_endpoint_echoes_arbitrary_method() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/method").to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("GET"));
for name in ["BREW", "ANY_CUSTOM_METHOD"] {
let custom = actix_web::http::Method::from_bytes(name.as_bytes()).unwrap();
let req = test::TestRequest::default()
.method(custom)
.uri("/method")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK, "method {name}");
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some(name));
}
}
#[actix_web::test]
async fn head_endpoint_echoes_request_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri("/head")
.insert_header(("X-Test", "hello"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
test::read_body(resp).await.is_empty(),
"HEAD response must have an empty body"
);
}
#[actix_web::test]
async fn head_requests_match_get_routes() {
let app = test::init_service(create_app(cfg())).await;
for uri in [
"/get",
"/echo",
"/anything",
"/headers",
"/ip",
"/json",
"/xml",
"/uuid",
"/base64/aGVsbG8=",
"/encoding/utf8",
"/robots.txt",
"/sitemap.xml",
"/healthz",
"/tags",
"/sse",
"/ndjson",
] {
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri(uri)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"HEAD {uri} must serve the GET route"
);
}
}
#[actix_web::test]
async fn head_redirects_and_auth_keep_semantics() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri("/redirect/2")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers().get("location").and_then(|v| v.to_str().ok()),
Some("/relative-redirect/1")
);
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri("/bearer")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(resp.headers().contains_key("www-authenticate"));
}
#[actix_web::test]
async fn head_on_non_get_endpoints_is_405() {
let app = test::init_service(create_app(cfg())).await;
for uri in [
"/post", "/put", "/patch", "/delete", "/options", "/trace", "/query", "/base64",
] {
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri(uri)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
StatusCode::METHOD_NOT_ALLOWED,
"HEAD {uri} must be 405, not 404"
);
}
let req = test::TestRequest::default()
.method(actix_web::http::Method::HEAD)
.uri("/post")
.to_request();
let resp = test::call_service(&app, req).await;
let allow = resp
.headers()
.get("allow")
.and_then(|v| v.to_str().ok())
.expect("Allow header on 405");
assert!(allow.contains("POST"), "Allow: {allow}");
let req = test::TestRequest::post().uri("/get").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
let allow = resp
.headers()
.get("allow")
.and_then(|v| v.to_str().ok())
.expect("Allow header on 405");
assert!(
allow.contains("GET") && allow.contains("HEAD"),
"Allow: {allow}"
);
}
#[actix_web::test]
async fn base64_post_decodes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/base64")
.set_payload("aGVsbG8=") .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, "hello");
}
#[actix_web::test]
async fn compression_post_returns_encoded_body() {
let app = test::init_service(create_app(cfg())).await;
let payload = "the quick brown fox";
let req = test::TestRequest::post()
.uri("/zstd")
.set_payload(payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"zstd"
);
let decoded = zstd::decode_all(&test::read_body(resp).await[..]).expect("valid zstd");
assert_eq!(decoded, payload.as_bytes());
}
#[actix_web::test]
async fn iso_8859_1_endpoint_returns_latin1() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/encoding/iso-8859-1")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=iso-8859-1"
);
assert!(
test::read_body(resp).await.contains(&0xe9),
"body must contain a Latin-1 byte"
);
}
#[actix_web::test]
async fn basic_auth_accepts_post() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/basic-auth/user/passwd")
.insert_header(("Authorization", "Basic dXNlcjpwYXNzd2Q=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn etag_weak_match_and_multisegment_route() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/etag/abc")
.insert_header(("If-None-Match", "W/\"abc\""))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
let req = test::TestRequest::get()
.uri("/etag/W/%22abc%22")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn cookies_set_supports_attributes() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cookies/set?token=secret&httponly=true&samesite=Lax")
.to_request();
let resp = test::call_service(&app, req).await;
let set_cookie = resp
.headers()
.get("set-cookie")
.unwrap()
.to_str()
.unwrap()
.to_lowercase();
assert!(set_cookie.contains("token=secret"));
assert!(set_cookie.contains("httponly"));
assert!(set_cookie.contains("samesite=lax"));
}
#[actix_web::test]
async fn status_ignores_trailing_path() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/status/200/extra/path")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn response_headers_custom_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/response-headers?body=hello&X-Test=bar")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("x-test").unwrap().to_str().unwrap(),
"bar"
);
assert_eq!(test::read_body(resp).await, "hello");
}
#[actix_web::test]
async fn base64_returns_binary_as_octet_stream() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/base64/8A%3D%3D")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/octet-stream"
);
assert_eq!(test::read_body(resp).await, vec![0xf0]);
}
#[actix_web::test]
async fn drip_chunked_streams_unknown_length_body() {
use actix_web::body::{BodySize, MessageBody};
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/drip?numbytes=5&duration=0&delay=0&chunked=true")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
matches!(resp.response().body().size(), BodySize::Stream),
"chunked response must be an unknown-length stream body"
);
assert_eq!(test::read_body(resp).await, "*****");
let req = test::TestRequest::get()
.uri("/drip?numbytes=5&duration=0&delay=0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
matches!(resp.response().body().size(), BodySize::Sized(5)),
"non-chunked response must be a sized body of numbytes"
);
assert_eq!(test::read_body(resp).await, "*****");
}
#[actix_web::test]
async fn digest_auth_sha_512_256_succeeds() {
let app = test::init_service(create_app(cfg())).await;
let uri = "/digest-auth/auth/user/passwd/SHA-512-256";
let req = test::TestRequest::get().uri(uri).to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("www-authenticate")
.unwrap()
.to_str()
.unwrap()
.to_string();
let field = |k: &str| -> String {
let pat = format!("{k}=\"");
let start = challenge.find(&pat).unwrap() + pat.len();
let end = challenge[start..].find('"').unwrap();
challenge[start..start + end].to_string()
};
let realm = field("realm");
let nonce = field("nonce");
let opaque = field("opaque");
let hex = |data: &[u8]| -> String {
crate::handlers::auth::digest_hash_hex("SHA-512-256", data).unwrap()
};
let ha1 = hex(format!("user:{realm}:passwd").as_bytes());
let ha2 = hex(format!("GET:{uri}").as_bytes());
let nc = "00000001";
let cnonce = "0a4f113b";
let response = hex(format!("{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}").as_bytes());
let auth = format!(
r#"Digest username="user", realm="{realm}", nonce="{nonce}", uri="{uri}", qop=auth, nc={nc}, cnonce="{cnonce}", response="{response}", opaque="{opaque}", algorithm=SHA-512-256"#
);
let req = test::TestRequest::get()
.uri(uri)
.insert_header(("Authorization", auth))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(
v.get("algorithm").and_then(|x| x.as_str()),
Some("SHA-512-256")
);
}
#[actix_web::test]
async fn digest_auth_omits_qop_rfc2069() {
let app = test::init_service(create_app(cfg())).await;
let uri = "/digest-auth/none/user/passwd";
let req = test::TestRequest::get().uri(uri).to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("www-authenticate")
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(
!challenge.contains("qop="),
"RFC 2069 challenge must omit qop, got: {challenge}"
);
let field = |k: &str| -> String {
let pat = format!("{k}=\"");
let start = challenge.find(&pat).unwrap() + pat.len();
let end = challenge[start..].find('"').unwrap();
challenge[start..start + end].to_string()
};
let realm = field("realm");
let nonce = field("nonce");
let opaque = field("opaque");
let ha1 = format!("{:x}", md5::compute(format!("user:{realm}:passwd")));
let ha2 = format!("{:x}", md5::compute(format!("GET:{uri}")));
let response = format!("{:x}", md5::compute(format!("{ha1}:{nonce}:{ha2}")));
let auth = format!(
r#"Digest username="user", realm="{realm}", nonce="{nonce}", uri="{uri}", response="{response}", opaque="{opaque}""#
);
let req = test::TestRequest::get()
.uri(uri)
.insert_header(("Authorization", auth))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
}
#[actix_web::test]
async fn cors_exposes_www_authenticate() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/bearer")
.insert_header(("Origin", "https://example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(resp.headers().get("www-authenticate").is_some());
let expose = resp
.headers()
.get("access-control-expose-headers")
.expect("expose-headers present on cross-origin response")
.to_str()
.unwrap();
assert!(
expose.to_lowercase().contains("www-authenticate"),
"WWW-Authenticate must be listed as exposed: {expose}"
);
}
#[actix_web::test]
async fn bytes_returns_requested_bytes_under_limit() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/bytes/16?seed=1")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/octet-stream"
);
assert_eq!(test::read_body(resp).await.len(), 16);
}
#[actix_web::test]
async fn bytes_rejects_over_limit_with_404() {
let limit = crate::config::DEFAULT_MAX_BYTES;
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri(&format!("/bytes/{}", limit + 1))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body_bytes = test::read_body(resp).await;
let body = std::str::from_utf8(&body_bytes).unwrap();
assert!(
body.contains(&format!("(0, {}]", limit)),
"error must state the range, got: {body}"
);
}
#[actix_web::test]
async fn stream_bytes_rejects_over_limit_with_404() {
let limit = crate::config::DEFAULT_MAX_BYTES;
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri(&format!("/stream-bytes/{}", limit + 1))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn bytes_honors_custom_max_bytes() {
let custom = ServerConfig::default().max_bytes(10);
let app = test::init_service(create_app(custom)).await;
let req = test::TestRequest::get().uri("/bytes/20").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let req = test::TestRequest::get().uri("/bytes/5?seed=2").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await.len(), 5);
}
#[actix_web::test]
async fn echo_get_args_headers_origin_url() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/get?foo=bar")
.insert_header(("x-echo-core", "sentinel"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["args"]["foo"].as_str(), Some("bar"));
assert_eq!(v["headers"]["x-echo-core"].as_str(), Some("sentinel"));
let origin = v["origin"].as_str().expect("origin is a string");
assert!(!origin.is_empty(), "origin must not be empty");
let url = v["url"].as_str().expect("url is a string");
assert!(url.contains("/get"), "url should reference /get: {url}");
assert!(url.contains("foo=bar"), "url should carry the query: {url}");
assert!(v.get("method").is_none(), "/get must not expose method");
assert!(v.get("data").is_none(), "/get must not expose data");
}
#[actix_web::test]
async fn echo_post_json_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/post")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"k":"v"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v["json"]["k"].as_str(),
Some("v"),
"json.k should be parsed"
);
assert_eq!(v["data"].as_str(), Some(r#"{"k":"v"}"#));
assert!(v.get("url").is_some());
let origin = v["origin"].as_str().expect("origin is a string");
assert!(!origin.is_empty());
assert!(
v.get("method").is_none(),
"/post must not expose method (HttpMethodsRequestInfo)"
);
}
#[actix_web::test]
async fn echo_post_form_urlencoded() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/post")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("a=1&b=2")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["form"]["a"].as_str(), Some("1"));
assert_eq!(v["form"]["b"].as_str(), Some("2"));
assert_eq!(v["data"].as_str(), Some(""));
assert!(v["json"].is_null(), "form body must not populate json");
}
#[actix_web::test]
async fn echo_put_echoes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::put()
.uri("/put")
.set_payload("put-payload")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["data"].as_str(), Some("put-payload"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn echo_patch_echoes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::patch()
.uri("/patch")
.set_payload("patch-payload")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["data"].as_str(), Some("patch-payload"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn echo_delete_echoes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::delete()
.uri("/delete")
.set_payload("delete-payload")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["data"].as_str(), Some("delete-payload"));
assert!(v.get("url").is_some());
}
#[actix_web::test]
async fn echo_anything_get_method() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/anything").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("GET"));
assert!(v.get("url").is_some());
let origin = v["origin"].as_str().expect("origin is a string");
assert!(!origin.is_empty());
}
#[actix_web::test]
async fn echo_anything_post_echoes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/anything")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"k":"v"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("POST"));
assert_eq!(v["json"]["k"].as_str(), Some("v"));
assert_eq!(v["data"].as_str(), Some(r#"{"k":"v"}"#));
}
#[actix_web::test]
async fn echo_anything_path_param_multisegment() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/anything/foo/bar")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["args"]["anything"].as_str(), Some("foo/bar"));
assert_eq!(v["method"].as_str(), Some("GET"));
}
#[actix_web::test]
async fn echo_get_returns_empty_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/echo").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
assert!(body.is_empty(), "GET /echo body must be empty");
}
#[actix_web::test]
async fn echo_post_body_verbatim() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/echo")
.set_payload("hello")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
assert_eq!(&body[..], b"hello", "POST /echo must mirror body bytes");
}
#[actix_web::test]
async fn echo_put_patch_delete_body_verbatim() {
let app = test::init_service(create_app(cfg())).await;
let cases: &[(Method, &str)] = &[
(Method::PUT, "put-echo"),
(Method::PATCH, "patch-echo"),
(Method::DELETE, "delete-echo"),
];
for (method, payload) in cases {
let req = test::TestRequest::default()
.method(method.clone())
.uri("/echo")
.set_payload(*payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK, "status for {method:?}");
let body = test::read_body(resp).await;
assert_eq!(
&body[..],
payload.as_bytes(),
"{method:?} /echo must mirror body bytes verbatim"
);
}
}
#[actix_web::test]
async fn body_post_verbatim_and_echo_alias_equivalent() {
let app = test::init_service(create_app(cfg())).await;
for uri in ["/body", "/echo"] {
let req = test::TestRequest::post()
.uri(uri)
.insert_header(("Content-Type", "text/plain"))
.set_payload("mirror me")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK, "POST {uri}");
let body = test::read_body(resp).await;
assert_eq!(&body[..], b"mirror me", "POST {uri} must mirror body bytes");
}
}
#[actix_web::test]
async fn fmt_json_returns_slideshow() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/json").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/json"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert!(
v.get("slideshow").is_some(),
"body must contain slideshow key"
);
assert_eq!(v["slideshow"]["author"].as_str(), Some("Yours Truly"));
assert!(v["slideshow"]["slides"].is_array(), "slides is an array");
assert_eq!(v["slideshow"]["title"].as_str(), Some("Sample Slide Show"));
}
#[actix_web::test]
async fn fmt_xml_body_and_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/xml").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/xml"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(text.contains("<slideshow"), "body contains <slideshow");
assert!(text.contains("Sample Slide Show"));
}
#[actix_web::test]
async fn fmt_html_body_and_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/html").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=utf-8"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(text.contains("<html"), "body contains <html");
assert!(text.contains("Moby-Dick"));
}
#[actix_web::test]
async fn fmt_robots_txt_body_and_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/robots.txt").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/plain"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(text.contains("Disallow"), "body contains Disallow");
assert!(text.contains("User-agent: *"));
assert!(text.contains("/deny"));
}
#[actix_web::test]
async fn fmt_deny_body_and_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/deny").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/plain"
);
let body = test::read_body(resp).await;
assert_eq!(&body[..], b"YOU SHOULDN'T BE HERE");
}
#[actix_web::test]
async fn fmt_robots_txt_advertises_sitemap() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/robots.txt")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(
text.contains("Sitemap: http://example.com/sitemap.xml"),
"robots.txt must advertise the sitemap with an absolute URL: {text}"
);
}
#[actix_web::test]
async fn fmt_robots_txt_honors_canonical_scheme() {
let app = test::init_service(create_app(
cfg().canonical_scheme(config::SchemeOverride::Https),
))
.await;
let req = test::TestRequest::get()
.uri("/robots.txt")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(
text.contains("Sitemap: https://example.com/sitemap.xml"),
"canonical_scheme(Https) must force an https sitemap URL even for a plain-http test request: {text}"
);
}
#[actix_web::test]
async fn fmt_robots_txt_declares_content_signals() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/robots.txt")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(
text.contains("Content-Signal: ai-train=yes, search=yes, ai-input=yes"),
"robots.txt must declare Content-Signal preferences: {text}"
);
let ua = text.find("User-agent: *").unwrap();
let cs = text.find("Content-Signal:").unwrap();
let sm = text.find("Sitemap:").unwrap();
assert!(
ua < cs,
"Content-Signal must follow the User-agent group header"
);
assert!(
cs < sm,
"Content-Signal must be inside the User-agent group, before the groupless Sitemap directive"
);
}
#[actix_web::test]
async fn fmt_sitemap_xml_body_and_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/sitemap.xml")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/xml"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(
text.contains("<urlset"),
"body is a sitemap <urlset>: {text}"
);
assert!(
text.contains("http://www.sitemaps.org/schemas/sitemap/0.9"),
"body declares the sitemaps namespace"
);
assert!(text.contains("<url>"), "body contains a <url> entry");
assert!(text.contains("<loc>"), "body contains a <loc> entry");
assert!(
text.contains("<loc>http://example.com/</loc>"),
"loc is an absolute URL pointing at the homepage: {text}"
);
}
#[actix_web::test]
async fn fmt_sitemap_xml_honors_canonical_scheme() {
let app = test::init_service(create_app(
cfg().canonical_scheme(config::SchemeOverride::Https),
))
.await;
let req = test::TestRequest::get()
.uri("/sitemap.xml")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(
text.contains("<loc>https://example.com/</loc>"),
"canonical_scheme(Https) must force an https loc even for a plain-http test request: {text}"
);
}
#[actix_web::test]
async fn fmt_utf8_content_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/encoding/utf8").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/html; charset=utf-8"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
assert!(text.contains("<html"), "body contains <html");
assert!(text.contains("UTF-8") || text.contains("utf-8") || text.contains("Unicode"));
}
#[actix_web::test]
async fn fmt_gzip_get_decodes() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/gzip").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"gzip"
);
let body = test::read_body(resp).await;
let mut decoded = Vec::new();
std::io::Read::read_to_end(&mut flate2::read::GzDecoder::new(&body[..]), &mut decoded)
.expect("gzip decode");
let v: serde_json::Value = serde_json::from_slice(&decoded).expect("decompressed body is JSON");
assert_eq!(v["method"].as_str(), Some("GET"));
assert_eq!(v["gzipped"].as_bool(), Some(true));
}
#[actix_web::test]
async fn fmt_deflate_get_decodes() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/deflate").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"deflate"
);
let body = test::read_body(resp).await;
let mut decoded = Vec::new();
std::io::Read::read_to_end(
&mut flate2::read::DeflateDecoder::new(&body[..]),
&mut decoded,
)
.expect("raw deflate decode");
let v: serde_json::Value = serde_json::from_slice(&decoded).expect("decompressed body is JSON");
assert_eq!(v["method"].as_str(), Some("GET"));
assert_eq!(v["deflated"].as_bool(), Some(true));
}
#[actix_web::test]
async fn fmt_brotli_get_decodes() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/brotli").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"br"
);
let body = test::read_body(resp).await;
let mut decoded = Vec::new();
std::io::Read::read_to_end(
&mut brotli::Decompressor::new(&body[..], 4096),
&mut decoded,
)
.expect("brotli decode");
let v: serde_json::Value = serde_json::from_slice(&decoded).expect("decompressed body is JSON");
assert_eq!(v["method"].as_str(), Some("GET"));
assert_eq!(v["brotli"].as_bool(), Some(true));
}
#[actix_web::test]
async fn redirect_one_targets_get() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/redirect/1").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "/get");
}
#[actix_web::test]
async fn redirect_two_decrements_to_relative_redirect_one() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/redirect/2").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "/relative-redirect/1");
}
#[actix_web::test]
async fn redirect_zero_is_bad_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/redirect/0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("location").is_none());
}
#[actix_web::test]
async fn relative_redirect_one_is_relative_get() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/relative-redirect/1")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert!(location.starts_with('/'));
assert!(!location.starts_with("http"));
assert_eq!(location, "/get");
}
#[actix_web::test]
async fn relative_redirect_two_decrements() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/relative-redirect/2")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "/relative-redirect/1");
}
#[actix_web::test]
async fn relative_redirect_zero_is_bad_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/relative-redirect/0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("location").is_none());
}
#[actix_web::test]
async fn absolute_redirect_one_is_absolute_get() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/absolute-redirect/1")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert!(
location.starts_with("http://") || location.starts_with("https://"),
"expected absolute URL, got {location}"
);
assert!(location.ends_with("/get"));
}
#[actix_web::test]
async fn absolute_redirect_two_decrements_absolute() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/absolute-redirect/2")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert!(
location.starts_with("http://") || location.starts_with("https://"),
"expected absolute URL, got {location}"
);
assert!(location.ends_with("/absolute-redirect/1"));
}
#[actix_web::test]
async fn absolute_redirect_zero_is_bad_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/absolute-redirect/0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("location").is_none());
}
#[actix_web::test]
async fn redirect_to_get_with_query_url() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/redirect-to?url=https%3A%2F%2Fexample.com")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "https://example.com");
}
#[actix_web::test]
async fn redirect_to_get_missing_url_is_bad_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/redirect-to").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("location").is_none());
}
#[actix_web::test]
async fn redirect_to_post_form_url() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/redirect-to")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("url=https%3A%2F%2Fexample.org")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "https://example.org");
}
#[actix_web::test]
async fn redirect_to_post_json_url() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/redirect-to")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"url":"https://json.example"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = resp
.headers()
.get("location")
.expect("location header present")
.to_str()
.unwrap();
assert_eq!(location, "https://json.example");
}
#[actix_web::test]
async fn redirect_to_post_missing_url_is_bad_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/redirect-to")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("location").is_none());
}
#[actix_web::test]
async fn sse_simple_count_frames() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/sse?count=3&delay=0&format=simple")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
assert_eq!(text.matches("data: {").count(), 3);
assert_eq!(text.matches("data: ").count(), 5);
assert!(text.contains("event: end"));
assert!(text.contains("data: [DONE]"));
assert!(text.contains("Hello from HTTPCan SSE! Event 1/3"));
assert!(text.contains("Hello from HTTPCan SSE! Event 3/3"));
}
#[actix_web::test]
async fn sse_path_count() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/sse/2?delay=0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
assert_eq!(text.matches("data: {").count(), 2);
assert_eq!(text.matches("data: ").count(), 4);
assert!(text.contains("data: [DONE]"));
assert!(text.contains("Hello from HTTPCan SSE! Event 1/2"));
assert!(text.contains("Hello from HTTPCan SSE! Event 2/2"));
}
#[actix_web::test]
async fn sse_openai_format() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/sse?count=2&delay=0&format=openai")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
assert_eq!(text.matches("\"choices\"").count(), 3);
assert!(text.contains("\"object\":\"chat.completion.chunk\""));
assert!(text.contains("\"model\":\"httpcan-sse\""));
assert!(text.contains("\"role\":\"assistant\""));
assert!(text.contains("\"finish_reason\":\"stop\""));
assert!(text.contains("data: [DONE]"));
assert!(text.contains("event: message"));
}
#[actix_web::test]
async fn ndjson_simple_count() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/ndjson?count=3&delay=0&format=simple")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/x-ndjson"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
let objects: Vec<&str> = text.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(
objects.len(),
3,
"exactly 3 NDJSON objects; body was:\n{text}"
);
for (i, line) in objects.iter().enumerate() {
let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON object");
assert_eq!(v["event"].as_u64(), Some((i + 1) as u64));
assert!(v["message"]
.as_str()
.unwrap()
.contains("Hello from HTTPCan NDJSON!"));
assert!(v.get("timestamp").is_some());
}
}
#[actix_web::test]
async fn ndjson_path_count() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/ndjson/2?delay=0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/x-ndjson"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
let objects: Vec<&str> = text.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(
objects.len(),
2,
"exactly 2 NDJSON objects; body was:\n{text}"
);
let first: serde_json::Value = serde_json::from_str(objects[0]).expect("valid JSON");
assert_eq!(first["event"].as_u64(), Some(1));
}
#[actix_web::test]
async fn ndjson_ollama_format() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/ndjson?count=1&delay=0&format=ollama")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/x-ndjson"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf8 body");
let objects: Vec<&str> = text.split('\n').filter(|l| !l.is_empty()).collect();
assert_eq!(objects.len(), 1);
let v: serde_json::Value = serde_json::from_str(objects[0]).expect("valid JSON object");
assert_eq!(v["model"].as_str(), Some("llama2"));
assert!(v["response"].is_string());
assert_eq!(v["done"].as_bool(), Some(true));
assert!(v.get("created_at").is_some());
assert!(v.get("context").is_some());
assert!(v.get("eval_count").is_some());
assert!(v.get("total_duration").is_some());
}
#[actix_web::test]
async fn auth_basic_correct_creds() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/basic-auth/user/passwd")
.insert_header(("Authorization", "Basic dXNlcjpwYXNzd2Q=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(v.get("user").and_then(|x| x.as_str()), Some("user"));
}
#[actix_web::test]
async fn auth_basic_no_creds() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/basic-auth/user/passwd")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let www = resp
.headers()
.get("WWW-Authenticate")
.expect("WWW-Authenticate present");
assert!(www.to_str().unwrap().starts_with("Basic realm="));
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn auth_basic_wrong_creds() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/basic-auth/user/passwd")
.insert_header(("Authorization", "Basic d3Jvbmc6d3Jvbmc=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert!(resp.headers().contains_key("WWW-Authenticate"));
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn auth_basic_user_only_empty_password() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/basic-auth/user")
.insert_header(("Authorization", "Basic dXNlcjo=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(v.get("user").and_then(|x| x.as_str()), Some("user"));
}
#[actix_web::test]
async fn auth_hidden_basic_correct_creds() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/hidden-basic-auth/user/passwd")
.insert_header(("Authorization", "Basic dXNlcjpwYXNzd2Q=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(v.get("user").and_then(|x| x.as_str()), Some("user"));
}
#[actix_web::test]
async fn auth_hidden_basic_no_creds_is_404() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/hidden-basic-auth/user/passwd")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(resp.headers().get("WWW-Authenticate").is_none());
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn auth_hidden_basic_wrong_creds_is_404() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/hidden-basic-auth/user/passwd")
.insert_header(("Authorization", "Basic d3Jvbmc6d3Jvbmc=")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(resp.headers().get("WWW-Authenticate").is_none());
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn auth_hidden_basic_user_only_succeeds_and_hides_failure() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/hidden-basic-auth/user")
.insert_header(("Authorization", "Basic dXNlcjo="))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
let req = test::TestRequest::get()
.uri("/hidden-basic-auth/user")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(resp.headers().get("WWW-Authenticate").is_none());
}
#[actix_web::test]
async fn auth_bearer_valid_token() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/bearer")
.insert_header(("Authorization", "Bearer my-secret-token"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(
v.get("token").and_then(|x| x.as_str()),
Some("my-secret-token")
);
}
#[actix_web::test]
async fn auth_bearer_no_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/bearer").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let www = resp
.headers()
.get("WWW-Authenticate")
.expect("WWW-Authenticate present");
assert_eq!(www.to_str().unwrap(), "Bearer");
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn auth_bearer_invalid_scheme() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/bearer")
.insert_header(("Authorization", "Basic dXNlcjpwYXNzd2Q="))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap(),
"Bearer"
);
}
#[actix_web::test]
async fn auth_jwt_valid_decodes_claims() {
let token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.\
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/jwt-bearer")
.insert_header(("Authorization", format!("Bearer {}", token)))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v.get("authenticated").and_then(|x| x.as_bool()), Some(true));
assert_eq!(
v.get("token")
.and_then(|t| t.get("raw"))
.and_then(|x| x.as_str()),
Some(token)
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("header"))
.and_then(|h| h.get("alg"))
.and_then(|x| x.as_str()),
Some("HS256")
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("header"))
.and_then(|h| h.get("typ"))
.and_then(|x| x.as_str()),
Some("JWT")
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("payload"))
.and_then(|p| p.get("sub"))
.and_then(|x| x.as_str()),
Some("1234567890")
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("payload"))
.and_then(|p| p.get("name"))
.and_then(|x| x.as_str()),
Some("John Doe")
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("payload"))
.and_then(|p| p.get("iat"))
.and_then(|x| x.as_i64()),
Some(1516239022)
);
assert!(
v.get("token")
.and_then(|t| t.get("payloadFormatted"))
.and_then(|p| p.get("iat"))
.map(|x| x.is_string())
.unwrap_or(false),
"payloadFormatted.iat must be a formatted timestamp string"
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("validationStatus"))
.and_then(|s| s.get("structure"))
.and_then(|x| x.as_str()),
Some("valid")
);
assert_eq!(
v.get("token")
.and_then(|t| t.get("validationStatus"))
.and_then(|s| s.get("expiration"))
.and_then(|x| x.as_str()),
Some("not_present")
);
}
#[actix_web::test]
async fn auth_jwt_malformed_is_401() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/jwt-bearer")
.insert_header(("Authorization", "Bearer not-a-real-jwt")) .to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap(),
"Bearer"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
assert!(v.get("error").is_some());
}
#[actix_web::test]
async fn auth_jwt_missing_header_is_401() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/jwt-bearer").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap(),
"Bearer"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
assert_eq!(
v.get("error").and_then(|x| x.as_str()),
Some("Missing Authorization header")
);
}
#[actix_web::test]
async fn auth_jwt_wrong_scheme_is_401() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/jwt-bearer")
.insert_header(("Authorization", "Basic dXNlcjpwYXNzd2Q="))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(
resp.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap(),
"Bearer"
);
}
#[actix_web::test]
async fn auth_digest_challenge_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/digest-auth/auth/user/passwd")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("WWW-Authenticate")
.expect("WWW-Authenticate present")
.to_str()
.unwrap()
.to_string();
assert!(
challenge.starts_with("Digest "),
"challenge must be Digest scheme: {}",
challenge
);
assert!(
challenge.contains("realm=\"httpcan@"),
"realm must be prefixed with httpcan@: {}",
challenge
);
assert!(
challenge.contains("nonce=\""),
"missing nonce: {}",
challenge
);
assert!(
challenge.contains("opaque=\""),
"missing opaque: {}",
challenge
);
assert!(
challenge.contains("qop=\"auth\""),
"missing/incorrect qop directive: {}",
challenge
);
assert!(
challenge.contains("algorithm=\"MD5\""),
"missing/incorrect algorithm: {}",
challenge
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(
v.get("authenticated").and_then(|x| x.as_bool()),
Some(false)
);
}
#[actix_web::test]
async fn cookie_get_parses_multiple_pairs() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cookies")
.insert_header(("Cookie", "k1=v1; k2=v2"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert!(v.get("cookies").is_some(), "response has cookies object");
assert_eq!(v["cookies"]["k1"].as_str(), Some("v1"));
assert_eq!(v["cookies"]["k2"].as_str(), Some("v2"));
}
#[actix_web::test]
async fn cookie_get_empty_when_no_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/cookies").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert!(v["cookies"].is_object(), "cookies is a JSON object");
assert!(v["cookies"].as_object().unwrap().is_empty());
}
#[actix_web::test]
async fn cookie_set_named_redirects_and_sets_cookie() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cookies/set/foo/bar")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let set_cookie = resp
.headers()
.get("set-cookie")
.expect("Set-Cookie header present")
.to_str()
.unwrap();
assert!(
set_cookie.contains("foo=bar"),
"Set-Cookie sets foo=bar: {set_cookie}"
);
assert!(
set_cookie.contains("Path=/"),
"cookie scoped to Path=/: {set_cookie}"
);
assert_eq!(
resp.headers().get("location").unwrap().to_str().unwrap(),
"/cookies",
"redirects to /cookies"
);
}
#[actix_web::test]
async fn cookie_delete_redirects_and_clears_cookie() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cookies/delete?foo=")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let set_cookie = resp
.headers()
.get("set-cookie")
.expect("Set-Cookie header present")
.to_str()
.unwrap();
assert!(
set_cookie.contains("foo=;"),
"Set-Cookie clears foo with empty value: {set_cookie}"
);
assert!(
set_cookie.contains("Max-Age=0"),
"Set-Cookie uses Max-Age=0 to expire: {set_cookie}"
);
assert_eq!(
resp.headers().get("location").unwrap().to_str().unwrap(),
"/cookies",
"redirects to /cookies"
);
}
#[actix_web::test]
async fn dyn_uuid_returns_valid_v4_uuid() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/uuid").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let uuid_str = v["uuid"].as_str().expect("uuid field is a string");
assert_eq!(uuid_str.len(), 36, "uuid must be 36 chars");
assert_eq!(
uuid_str.chars().filter(|c| *c == '-').count(),
4,
"uuid must contain 4 dashes"
);
assert!(
uuid_str.chars().all(|c| c.is_ascii_hexdigit() || c == '-'),
"uuid must be hex+dashes only"
);
assert_eq!(
uuid_str.as_bytes()[14],
b'4',
"uuid version nibble must be 4"
);
let variant = uuid_str.as_bytes()[19] as char;
assert!(
matches!(variant, '8' | '9' | 'a' | 'b'),
"uuid variant nibble must be one of 8/9/a/b, got {variant}"
);
}
#[actix_web::test]
async fn dyn_stream_ndjson_objects() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/stream/3").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("application/json"),
"stream content-type should be application/json, got {ct}"
);
let body = test::read_body(resp).await;
let text = std::str::from_utf8(&body).expect("utf8 body");
let objects: Vec<&str> = text.lines().collect();
assert_eq!(objects.len(), 3, "expected 3 ndjson objects");
for (idx, line) in objects.iter().enumerate() {
let obj: serde_json::Value = serde_json::from_str(line).expect("each line is JSON");
assert_eq!(obj["id"].as_u64(), Some(idx as u64), "sequential id");
assert!(obj.get("url").is_some(), "url field present");
assert!(obj.get("args").is_some(), "args field present");
assert!(obj.get("headers").is_some(), "headers field present");
assert!(obj.get("origin").is_some(), "origin field present");
}
}
#[actix_web::test]
async fn dyn_range_full_content_200() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/range/1024").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("application/octet-stream"),
"range content-type should be application/octet-stream, got {ct}"
);
assert_eq!(
resp.headers()
.get("accept-ranges")
.and_then(|v| v.to_str().ok()),
Some("bytes")
);
let body = test::read_body(resp).await;
assert_eq!(body.len(), 1024, "full range body must be 1024 bytes");
assert_eq!(body[0], b'a');
assert_eq!(body[25], b'z');
assert_eq!(body[26], b'a');
}
#[actix_web::test]
async fn dyn_range_partial_206() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/range/1024")
.insert_header(("Range", "bytes=10-19"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
assert_eq!(
resp.headers()
.get("content-range")
.and_then(|v| v.to_str().ok()),
Some("bytes 10-19/1024")
);
assert_eq!(
resp.headers()
.get("content-length")
.and_then(|v| v.to_str().ok()),
Some("10")
);
let body = test::read_body(resp).await;
assert_eq!(body.len(), 10, "partial body must be 10 bytes");
for (offset, byte) in body.iter().enumerate() {
let i = 10 + offset;
assert_eq!(*byte, b'a' + (i % 26) as u8, "byte at position {i}");
}
}
#[actix_web::test]
async fn dyn_links_redirect_302() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/links/2").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
resp.headers().get("location").and_then(|v| v.to_str().ok()),
Some("/links/2/0")
);
}
#[actix_web::test]
async fn dyn_links_page_html_entries() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/links/2/0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("text/html"),
"links content-type should be text/html, got {ct}"
);
let body = test::read_body(resp).await;
let html = std::str::from_utf8(&body).expect("utf8 html");
assert!(html.contains("<!DOCTYPE html>"), "html doctype");
assert!(html.contains("0<br>"), "offset entry rendered as text");
assert!(
html.contains("<a href='/links/2/1'>1</a><br>"),
"non-offset entry rendered as anchor"
);
let anchor_count = html.matches("<a ").count();
assert_eq!(anchor_count, 1, "only the non-offset entry is a link");
}
#[actix_web::test]
async fn dyn_delay_get_echoes_request() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/delay/0?x=1")
.insert_header(("x-test-echo", "yes"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("GET"));
assert!(
v["url"].as_str().unwrap_or("").contains("/delay/0"),
"url echoed: {}",
v["url"]
);
assert_eq!(v["args"]["x"].as_str(), Some("1"), "query args echoed");
assert!(v.get("headers").is_some(), "headers echoed");
assert!(v.get("origin").is_some(), "origin echoed");
assert_eq!(v["data"].as_str(), Some(""), "no body data for GET");
}
#[actix_web::test]
async fn dyn_delay_post_echoes_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/delay/0")
.insert_header(("content-type", "text/plain"))
.set_payload("hello-dyn")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("POST"));
assert_eq!(v["data"].as_str(), Some("hello-dyn"), "body echoed in data");
assert!(
v["url"].as_str().unwrap_or("").contains("/delay/0"),
"url echoed"
);
}
#[actix_web::test]
async fn img_png_returns_valid_png() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/image/png").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"image/png"
);
let body = test::read_body(resp).await;
assert!(!body.is_empty(), "png body must not be empty");
assert!(
body.starts_with(&[0x89, b'P', b'N', b'G']),
"png body must start with PNG magic bytes, got: {:?}",
&body[..body.len().min(8)]
);
}
#[actix_web::test]
async fn img_jpeg_returns_valid_jpeg() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/image/jpeg").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"image/jpeg"
);
let body = test::read_body(resp).await;
assert!(!body.is_empty(), "jpeg body must not be empty");
assert!(
body.starts_with(&[0xff, 0xd8, 0xff]),
"jpeg body must start with JPEG magic bytes, got: {:?}",
&body[..body.len().min(4)]
);
}
#[actix_web::test]
async fn img_webp_returns_valid_webp() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/image/webp").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"image/webp"
);
let body = test::read_body(resp).await;
assert!(body.len() > 12, "webp body must be at least 13 bytes");
assert_eq!(&body[..4], b"RIFF", "webp body must start with RIFF marker");
assert_eq!(
&body[8..12],
b"WEBP",
"webp body must carry WEBP fourcc at offset 8..12"
);
}
#[actix_web::test]
async fn img_svg_returns_valid_svg() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/image/svg").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"image/svg+xml"
);
let body = test::read_body(resp).await;
assert!(!body.is_empty(), "svg body must not be empty");
let text = std::str::from_utf8(&body).expect("svg body must be valid UTF-8");
assert!(
text.contains("<svg"),
"svg body must contain the <svg element"
);
}
#[actix_web::test]
async fn image_accept_png_negotiated() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/image")
.insert_header(("Accept", "image/png"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"image/png"
);
let body = test::read_body(resp).await;
assert!(
body.starts_with(&[0x89, b'P', b'N', b'G']),
"negotiated png body must start with PNG magic bytes"
);
}
#[actix_web::test]
async fn image_accept_wildcard_random_image_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/image")
.insert_header(("Accept", "*/*"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header present")
.to_str()
.unwrap()
.to_owned();
assert!(
ct.starts_with("image/"),
"default /image content-type must be an image type, got: {ct}"
);
}
#[actix_web::test]
async fn image_no_accept_header_random_image_type() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/image").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header present")
.to_str()
.unwrap()
.to_owned();
assert!(
ct.starts_with("image/"),
"default /image content-type must be an image type, got: {ct}"
);
}
#[actix_web::test]
async fn inspect_user_agent() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/user-agent")
.insert_header(("User-Agent", "TestAgent/1.0"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["user-agent"].as_str(), Some("TestAgent/1.0"));
}
#[actix_web::test]
async fn inspect_ip_origin_present() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/ip").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let origin = v["origin"]
.as_str()
.unwrap_or_else(|| panic!("origin not a string: {:?}", v["origin"]));
assert!(!origin.is_empty(), "origin should be a non-empty string");
}
#[actix_web::test]
async fn inspect_headers_custom_and_standard() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/headers")
.insert_header(("X-Foo", "bar"))
.insert_header(("host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let headers = v["headers"]
.as_object()
.expect("headers should be an object");
assert_eq!(headers.get("x-foo").and_then(|h| h.as_str()), Some("bar"));
assert_eq!(
headers.get("host").and_then(|h| h.as_str()),
Some("example.com")
);
}
#[actix_web::test]
async fn cache_no_conditional_returns_200_with_cache_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/cache").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let last_modified = resp
.headers()
.get("last-modified")
.expect("Last-Modified header present");
assert!(!last_modified.is_empty());
let etag = resp.headers().get("etag").expect("ETag header present");
assert!(!etag.is_empty());
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("GET"));
assert!(v["headers"].is_object(), "headers object present");
assert!(v["url"].is_string(), "url field present");
assert!(v["origin"].is_string(), "origin field present");
}
#[actix_web::test]
async fn cache_if_modified_since_returns_304() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cache")
.insert_header(("If-Modified-Since", "Wed, 21 Oct 2015 07:28:00 GMT"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("etag"));
}
#[actix_web::test]
async fn cache_if_none_match_returns_304() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/cache")
.insert_header(("If-None-Match", "\"abc123\""))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
assert!(resp.headers().contains_key("last-modified"));
assert!(resp.headers().contains_key("etag"));
}
#[actix_web::test]
async fn cache_control_max_age_60() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/cache/60").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap(),
"public, max-age=60"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
assert_eq!(v["method"].as_str(), Some("GET"));
assert!(v["headers"].is_object());
}
#[actix_web::test]
async fn cache_control_max_age_0() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/cache/0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("cache-control")
.unwrap()
.to_str()
.unwrap(),
"public, max-age=0"
);
}
#[actix_web::test]
async fn mw_exclude_header_removes_named_header() {
let app = test::init_service(create_app(
ServerConfig::default().exclude_header("x-secret"),
))
.await;
let req = test::TestRequest::get()
.uri("/headers")
.insert_header(("X-Secret", "hidden"))
.insert_header(("X-Other", "v"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let headers = v
.get("headers")
.and_then(|h| h.as_object())
.expect("headers object");
assert!(
headers.get("x-secret").is_none(),
"excluded x-secret must not appear in headers"
);
assert_eq!(
headers.get("x-other").and_then(|v| v.as_str()),
Some("v"),
"non-excluded x-other must be present"
);
}
#[actix_web::test]
async fn mw_exclude_header_wildcard_suffix() {
let app = test::init_service(create_app(
ServerConfig::default().exclude_header("x-prod-*"),
))
.await;
let req = test::TestRequest::get()
.uri("/get")
.insert_header(("X-Prod-Token", "t"))
.insert_header(("X-Dev-Token", "d"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let headers = v
.get("headers")
.and_then(|h| h.as_object())
.expect("headers object");
assert!(
headers.get("x-prod-token").is_none(),
"wildcard-matched x-prod-token must not appear in headers"
);
assert_eq!(
headers.get("x-dev-token").and_then(|v| v.as_str()),
Some("d"),
"x-dev-token is outside the wildcard prefix and must remain"
);
}
#[actix_web::test]
async fn root_always_serves_html() {
let app = test::init_service(create_app(cfg())).await;
for accept in ["text/html", "application/json", "*/*"] {
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Accept", accept))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("text/html"),
"expected text/html content-type for Accept: {accept}, got {ct}"
);
let body = test::read_body(resp).await;
let body_str = std::str::from_utf8(&body).expect("utf-8 html body");
assert!(
body_str.to_lowercase().contains("<html"),
"html body must contain an <html> tag"
);
}
}
#[actix_web::test]
async fn root_serves_html_without_accept_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(ct.starts_with("text/html"));
}
#[actix_web::test]
async fn root_serves_markdown_for_markdown_accept() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Host", "example.com"))
.insert_header(("Accept", "text/markdown"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("text/markdown"),
"expected text/markdown content-type, got {ct}"
);
let body = test::read_body(resp).await;
let md = std::str::from_utf8(&body).expect("utf-8 markdown body");
assert!(md.contains("# HTTPCan"), "markdown has the page title");
assert!(md.contains("## Endpoints"), "markdown lists endpoints");
assert!(
md.contains("| Method | Path |"),
"markdown renders an endpoint table"
);
assert!(
!md.contains("{base}"),
"markdown must substitute the origin into curl examples: {md}"
);
assert!(
md.contains("http://example.com/get"),
"markdown curl examples target the request origin: {md}"
);
assert!(
!md.to_lowercase().contains("<html"),
"markdown response must not contain HTML"
);
}
#[actix_web::test]
async fn root_serves_markdown_when_listed_alongside_other_types() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Accept", "text/markdown, text/html"))
.to_request();
let resp = test::call_service(&app, req).await;
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("text/markdown"),
"Accept listing text/markdown should yield Markdown, got {ct}"
);
}
#[actix_web::test]
async fn root_markdown_includes_token_count_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Accept", "text/markdown"))
.to_request();
let resp = test::call_service(&app, req).await;
let tokens = resp
.headers()
.get("x-markdown-tokens")
.expect("x-markdown-tokens header")
.to_str()
.unwrap()
.parse::<usize>()
.expect("token count is an integer");
assert!(tokens > 0, "token count should be positive, got {tokens}");
}
#[actix_web::test]
async fn root_homepage_has_discovery_link_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let links: Vec<String> = resp
.headers()
.get_all("link")
.map(|h| h.to_str().unwrap().to_string())
.collect();
assert!(!links.is_empty(), "homepage must carry Link headers");
let joined = links.join(", ");
assert!(
joined.contains(r#"</.well-known/api-catalog>; rel="api-catalog""#),
"missing api-catalog link: {joined}"
);
assert!(
joined.contains(r#"</openapi.json>; rel="service-desc""#),
"missing service-desc link: {joined}"
);
assert!(
joined.contains(r#"</>; rel="service-doc""#),
"missing service-doc link: {joined}"
);
assert!(
joined.contains(r#"</openapi.json>; rel="describedby""#),
"missing describedby link: {joined}"
);
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Accept", "text/markdown"))
.to_request();
let resp = test::call_service(&app, req).await;
let links: Vec<String> = resp
.headers()
.get_all("link")
.map(|h| h.to_str().unwrap().to_string())
.collect();
let joined = links.join(", ");
assert!(
joined.contains(r#"</.well-known/api-catalog>; rel="api-catalog""#),
"markdown homepage must also carry discovery links: {joined}"
);
}
#[actix_web::test]
async fn root_homepage_lists_endpoints_and_links_to_openapi() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
let body = test::read_body(resp).await;
let body_str = std::str::from_utf8(&body).expect("utf-8 html body");
assert!(body_str.contains("/get"), "homepage should list /get");
assert!(body_str.contains("/sse"), "homepage should list /sse");
assert!(
body_str.contains("/openapi.json"),
"homepage should link to the OpenAPI spec"
);
}
#[actix_web::test]
async fn root_canonical_honors_override_while_examples_mirror_request() {
let app = test::init_service(create_app(
cfg().canonical_scheme(config::SchemeOverride::Https),
))
.await;
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
let body = test::read_body(resp).await;
let body_str = std::str::from_utf8(&body).expect("utf-8 html body");
assert!(
body_str.contains(r#"<link rel="canonical" href="https://example.com/">"#),
"canonical link must honor canonical_scheme(Https): {body_str}"
);
assert!(
body_str.contains("http://example.com/get"),
"copy-curl examples must mirror the actual (plain-http) request, not the override: {body_str}"
);
assert!(
!body_str.contains("https://example.com/get"),
"copy-curl examples must not be forced to https by canonical_scheme: {body_str}"
);
}
#[actix_web::test]
async fn root_homepage_has_copy_curl_buttons() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
let body = test::read_body(resp).await;
let body_str = std::str::from_utf8(&body).expect("utf-8 html body");
assert!(
body_str.contains(r#"class="copy-btn""#),
"homepage should render a copy-curl button for each endpoint"
);
assert!(
body_str.contains(r#"data-curl="curl http://example.com/uuid""#),
"copy button for /uuid should target the request's own origin"
);
assert!(
body_str.contains("navigator.clipboard"),
"homepage should include the copy-to-clipboard script"
);
}
#[actix_web::test]
async fn openapi_json_returns_valid_spec() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("application/json"),
"expected application/json content-type, got {ct}"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let version = v
.get("openapi")
.and_then(|o| o.as_str())
.expect("openapi version string");
assert!(
version.starts_with("3."),
"openapi version should be 3.x, got {version}"
);
let paths = v
.get("paths")
.and_then(|p| p.as_object())
.expect("paths object");
assert!(
paths.contains_key("/get"),
"paths must include /get, got keys: {:?}",
paths.keys().collect::<Vec<_>>()
);
}
#[actix_web::test]
async fn openapi_tags_match_homepage_categories_for_mock_surfaces() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let tag_names: Vec<&str> = v["tags"]
.as_array()
.expect("tags array")
.iter()
.map(|t| t["name"].as_str().expect("tag name"))
.collect();
assert!(
tag_names.contains(&"AI API Mock"),
"tags must contain 'AI API Mock' (homepage category title), got {tag_names:?}"
);
assert!(
tag_names.contains(&"OAuth 2.0 Mock"),
"tags must contain 'OAuth 2.0 Mock' (homepage category title), got {tag_names:?}"
);
let expected: &[(&str, &str)] = &[
("/llm", "AI API Mock"),
("/llm/v1/chat/completions", "AI API Mock"),
("/llm/v1/messages", "AI API Mock"),
("/llm/v1/responses", "AI API Mock"),
("/llm/v1/completions", "AI API Mock"),
("/llm/v1/models", "AI API Mock"),
("/llm/v1/models/{model}", "AI API Mock"),
("/oauth2", "OAuth 2.0 Mock"),
("/oauth2/authorize", "OAuth 2.0 Mock"),
("/oauth2/token", "OAuth 2.0 Mock"),
("/oauth2/userinfo", "OAuth 2.0 Mock"),
];
for (path, tag) in expected {
let ops = v["paths"][path]
.as_object()
.unwrap_or_else(|| panic!("path {path} missing from spec"));
for (method, op) in ops {
assert_eq!(
op["tags"][0].as_str(),
Some(*tag),
"{method} {path} must be tagged {tag}"
);
}
}
for absent in [
"/llm/chat/completions",
"/llm/responses",
"/llm/completions",
"/.well-known/api-catalog",
"/.well-known/oauth-authorization-server",
] {
assert!(
v["paths"].get(absent).is_none(),
"{absent} is not an interface and must not appear in the spec"
);
}
for method in ["get", "post"] {
assert_eq!(
v["paths"]["/oauth2/userinfo"][method]["security"],
serde_json::json!([{"bearerAuth": []}]),
"userinfo {method} enforces Bearer auth and must declare it"
);
}
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(
body.contains("AI API Mock") && body.contains("OAuth 2.0 Mock"),
"homepage must render the canonical category titles"
);
assert!(
!body.contains("/.well-known/api-catalog")
&& !body.contains("/.well-known/oauth-authorization-server"),
"homepage endpoint list must not present well-known discovery documents as endpoints"
);
}
#[actix_web::test]
async fn api_catalog_returns_rfc9727_linkset() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/.well-known/api-catalog")
.insert_header(("Host", "example.com"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.expect("content-type header")
.to_str()
.unwrap();
assert!(
ct.starts_with("application/linkset+json"),
"expected application/linkset+json content-type, got {ct}"
);
assert!(
ct.contains("rfc9727"),
"content-type should carry the RFC 9727 profile parameter, got {ct}"
);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("JSON body");
let linkset = v
.get("linkset")
.and_then(|l| l.as_array())
.expect("linkset array");
assert!(!linkset.is_empty(), "linkset must list at least one API");
let entry = &linkset[0];
assert_eq!(
entry.get("anchor").and_then(|a| a.as_str()),
Some("http://example.com/")
);
let desc = entry
.get("service-desc")
.and_then(|s| s.as_array())
.expect("service-desc array");
assert_eq!(
desc[0].get("href").and_then(|h| h.as_str()),
Some("http://example.com/openapi.json")
);
assert_eq!(
desc[0].get("type").and_then(|t| t.as_str()),
Some("application/json")
);
let doc = entry
.get("service-doc")
.and_then(|s| s.as_array())
.expect("service-doc array");
assert_eq!(
doc[0].get("href").and_then(|h| h.as_str()),
Some("http://example.com/")
);
assert_eq!(
doc[0].get("type").and_then(|t| t.as_str()),
Some("text/html")
);
let status = entry
.get("status")
.and_then(|s| s.as_array())
.expect("status array");
assert_eq!(
status[0].get("href").and_then(|h| h.as_str()),
Some("http://example.com/healthz")
);
assert_eq!(
status[0].get("type").and_then(|t| t.as_str()),
Some("application/json")
);
}
#[actix_web::test]
async fn sse_count_delay_path_variant() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/sse/2/0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let body = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf-8");
let data_lines = body.lines().filter(|l| l.starts_with("data:")).count();
assert_eq!(
data_lines, 4,
"expected 4 data: lines (2 events + end + DONE): {body}"
);
}
#[actix_web::test]
async fn ndjson_count_delay_path_variant() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/ndjson/2/0").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(resp).await.to_vec()).expect("utf-8");
let objs: Vec<&str> = body.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(objs.len(), 2, "expected exactly 2 NDJSON objects: {body}");
for o in &objs {
serde_json::from_str::<serde_json::Value>(o).expect("each line is valid JSON");
}
}
#[actix_web::test]
async fn status_comma_separated_picks_a_listed_code() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/status/200,418").to_request();
let resp = test::call_service(&app, req).await;
let code = resp.status().as_u16();
assert!(
matches!(code, 200 | 418),
"must pick one of the listed codes, got {code}"
);
}
#[actix_web::test]
async fn status_post_echoes_request_body() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/status/200")
.set_payload("hello-body")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, "hello-body");
}
#[actix_web::test]
async fn status_options_returns_cors_preflight() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::default()
.method(actix_web::http::Method::OPTIONS)
.uri("/status/200")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let allow = resp
.headers()
.get("access-control-allow-methods")
.expect("CORS allow-methods header")
.to_str()
.unwrap();
assert!(
allow.contains("GET") && allow.contains("POST"),
"preflight must list allowed methods: {allow}"
);
assert!(resp.headers().contains_key("access-control-max-age"));
}
#[actix_web::test]
async fn response_headers_post_sets_headers() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/response-headers?X-Post-Test=bar")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("x-post-test").unwrap().to_str().unwrap(),
"bar"
);
}
#[actix_web::test]
async fn digest_auth_full_five_segment_sets_stale_after_cookie() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/digest-auth/auth/user/passwd/MD5/never")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("www-authenticate")
.expect("Digest challenge")
.to_str()
.unwrap();
assert!(
challenge.starts_with("Digest"),
"challenge must be Digest scheme: {challenge}"
);
let set_cookie = resp
.headers()
.get_all("set-cookie")
.map(|v| v.to_str().unwrap_or(""))
.collect::<Vec<_>>()
.join("; ");
assert!(
set_cookie.contains("stale_after=never"),
"5-segment route must set stale_after cookie: {set_cookie}"
);
}
#[actix_web::test]
async fn compress_post_gzip_encodes_body() {
let payload = "compress-me-gzip";
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/gzip")
.set_payload(payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"gzip"
);
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(&test::read_body(resp).await[..])
.read_to_end(&mut decoded)
.expect("valid gzip");
assert_eq!(decoded, payload.as_bytes());
}
#[actix_web::test]
async fn compress_post_deflate_encodes_body() {
let payload = "compress-me-deflate";
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/deflate")
.set_payload(payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"deflate"
);
let mut decoded = Vec::new();
flate2::read::DeflateDecoder::new(&test::read_body(resp).await[..])
.read_to_end(&mut decoded)
.expect("valid raw deflate");
assert_eq!(decoded, payload.as_bytes());
}
#[actix_web::test]
async fn compress_post_brotli_encodes_body() {
let payload = "compress-me-brotli";
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/brotli")
.set_payload(payload)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-encoding")
.unwrap()
.to_str()
.unwrap(),
"br"
);
let mut decoded = Vec::new();
brotli::Decompressor::new(&test::read_body(resp).await[..], 4096)
.read_to_end(&mut decoded)
.expect("valid brotli");
assert_eq!(decoded, payload.as_bytes());
}
fn temp_static_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"httpcan-test-static-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("create temp static dir");
dir
}
fn cfg_with_static(dir: &std::path::Path) -> ServerConfig {
ServerConfig {
static_dir: Some(dir.to_path_buf()),
..ServerConfig::default()
}
}
fn write_static(dir: &std::path::Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).expect("write override file");
}
#[actix_web::test]
async fn openapi_json_served_from_embedded_spec_when_no_override() {
let dir = temp_static_dir("openapi-embedded");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("application/json"));
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON spec");
assert!(
v["openapi"].as_str().unwrap().starts_with("3."),
"embedded spec should be OpenAPI 3.x"
);
assert!(
v["paths"].get("/get").is_some(),
"embedded spec should document /get"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn openapi_disk_override_wins_over_embedded() {
let dir = temp_static_dir("openapi-override");
write_static(
&dir,
"openapi.json",
r#"{"info":{"title":"MyCustomCan"},"paths":{}}"#,
);
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
assert_eq!(v["info"]["title"].as_str(), Some("MyCustomCan"));
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn openapi_no_current_server_serves_base_servers_verbatim() {
let dir = temp_static_dir("openapi-ncs-embedded");
let cfg = ServerConfig {
add_current_server: false,
static_dir: Some(dir.clone()),
..ServerConfig::default()
};
let app = test::init_service(create_app(cfg)).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
assert_eq!(v["servers"].as_array().map(Vec::len), Some(1));
assert_eq!(v["servers"][0]["url"].as_str(), Some("https://httpcan.org"));
write_static(
&dir,
"openapi.json",
r#"{"servers":[{"url":"https://mine.example"}],"paths":{}}"#,
);
let cfg = ServerConfig {
add_current_server: false,
static_dir: Some(dir.clone()),
..ServerConfig::default()
};
let app = test::init_service(create_app(cfg)).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
assert_eq!(v["servers"].as_array().map(Vec::len), Some(1));
assert_eq!(
v["servers"][0]["url"].as_str(),
Some("https://mine.example")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn openapi_servers_do_not_leak_across_requests() {
let dir = temp_static_dir("openapi-leak");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get()
.uri("/openapi.json")
.insert_header(("host", "first.test"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v1: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
let servers1 = v1["servers"].as_array().expect("servers array");
assert_eq!(servers1.len(), 2, "current + spec server, nothing more");
assert!(servers1[0]["url"].as_str().unwrap().contains("first.test"));
let req = test::TestRequest::get()
.uri("/openapi.json")
.insert_header(("host", "second.test"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v2: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
let servers2 = v2["servers"].as_array().expect("servers array");
assert_eq!(servers2.len(), 2, "no accumulation across requests");
assert!(servers2[0]["url"].as_str().unwrap().contains("second.test"));
let body_str = serde_json::to_string(&v2).unwrap();
assert!(
!body_str.contains("first.test"),
"previous request's origin must not leak into later responses"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn openapi_spec_version_matches_crate_version() {
let dir = temp_static_dir("openapi-version");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/openapi.json").to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("valid JSON");
assert_eq!(
v["info"]["version"].as_str(),
Some(env!("CARGO_PKG_VERSION")),
"embedded spec must be bumped together with Cargo.toml"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn favicon_served_from_embedded_bytes_by_default() {
let dir = temp_static_dir("favicon-embedded");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/favicon.png").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("image/png"));
let body = test::read_body(resp).await;
assert!(body.len() > 100, "embedded favicon should be a real PNG");
assert_eq!(&body[..4], b"\x89PNG", "PNG magic bytes");
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn favicon_disk_override_wins() {
let dir = temp_static_dir("favicon-override");
write_static(&dir, "favicon.png", "NOT-A-REAL-PNG-FAVICON");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/favicon.png").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(test::read_body(resp).await, "NOT-A-REAL-PNG-FAVICON");
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn robots_txt_disk_override_served_as_text_plain() {
let dir = temp_static_dir("robots-override");
write_static(&dir, "robots.txt", "User-agent: *\nDisallow: /\n");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/robots.txt").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("text/plain"));
let body = test::read_body(resp).await;
assert_eq!(body, "User-agent: *\nDisallow: /\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn sitemap_disk_override_served_as_application_xml() {
let dir = temp_static_dir("sitemap-override");
write_static(&dir, "sitemap.xml", "<urlset>USER-SITEMAP</urlset>");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/sitemap.xml").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"application/xml",
"override must match the dynamic handler's content type, not mime_guess' text/xml"
);
assert_eq!(test::read_body(resp).await, "<urlset>USER-SITEMAP</urlset>");
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn homepage_index_html_override_replaces_homepage() {
let dir = temp_static_dir("index-override");
write_static(
&dir,
"index.html",
"<!DOCTYPE html><html><body><h1>MyCan</h1></body></html>",
);
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap();
assert!(ct.starts_with("text/html"));
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(body.contains("<h1>MyCan</h1>"));
assert!(
!body.contains("HTTPCan"),
"built-in homepage must be fully replaced"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn api_routes_shadow_same_named_static_files() {
let dir = temp_static_dir("shadow");
write_static(&dir, "json", "SHADOWED-FILE-CONTENT");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/json").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(
body.contains("slideshow"),
"API route must win over static/json"
);
assert!(!body.contains("SHADOWED-FILE-CONTENT"));
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn api_catalog_stays_dynamic_even_with_all_overrides() {
let dir = temp_static_dir("catalog");
for (name, content) in [
("openapi.json", r#"{"paths":{}}"#),
("favicon.png", "x"),
("index.html", "<html></html>"),
("robots.txt", "Disallow: *"),
("sitemap.xml", "<urlset/>"),
] {
write_static(&dir, name, content);
}
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get()
.uri("/.well-known/api-catalog")
.insert_header(("host", "catalog.test"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let ct = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(ct.starts_with("application/linkset+json"));
let v: serde_json::Value =
serde_json::from_slice(&test::read_body(resp).await).expect("linkset JSON");
assert_eq!(
v["linkset"][0]["anchor"].as_str(),
Some("http://catalog.test/"),
"api-catalog is always generated from the request, never overridable"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn dynamic_robots_and_sitemap_served_when_no_override() {
let dir = temp_static_dir("dynamic-seo");
let app = test::init_service(create_app(cfg_with_static(&dir))).await;
let req = test::TestRequest::get().uri("/robots.txt").to_request();
let resp = test::call_service(&app, req).await;
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(body.contains("Disallow: /deny"));
assert!(body.contains("Sitemap: "));
let req = test::TestRequest::get().uri("/sitemap.xml").to_request();
let resp = test::call_service(&app, req).await;
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(body.contains("<urlset"));
assert!(body.contains("<loc>"));
let _ = std::fs::remove_dir_all(&dir);
}
#[actix_web::test]
async fn range_honors_custom_max_bytes() {
let custom = ServerConfig::default().max_bytes(10);
let app = test::init_service(create_app(custom)).await;
let req = test::TestRequest::get().uri("/range/20").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"over-limit /range must 404, not silently cap"
);
let req = test::TestRequest::get().uri("/range/10").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn drip_honors_custom_max_bytes() {
let custom = ServerConfig::default().max_bytes(10);
let app = test::init_service(create_app(custom)).await;
let req = test::TestRequest::get()
.uri("/drip?numbytes=20&duration=0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"over-limit /drip numbytes must 404, not silently clamp"
);
let req = test::TestRequest::get()
.uri("/drip?numbytes=5&duration=0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
fn llm_sorted_keys(v: &serde_json::Value) -> Vec<String> {
let mut keys: Vec<String> = v.as_object().unwrap().keys().cloned().collect();
keys.sort();
keys
}
fn llm_sse_frames(text: &str) -> Vec<&str> {
text.split("\n\n").filter(|f| !f.is_empty()).collect()
}
fn llm_frame_data(frame: &str) -> serde_json::Value {
serde_json::from_str(frame.strip_prefix("data: ").unwrap_or(frame)).unwrap()
}
#[actix_web::test]
async fn llm_chat_completions_canonical_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"model":"gpt-5-nano","messages":[{"role":"user","content":"Hello"}]}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
llm_sorted_keys(&v),
vec!["choices", "created", "id", "model", "object", "usage"]
);
assert!(v["id"].as_str().unwrap().starts_with("chatcmpl-"));
assert_eq!(v["object"], "chat.completion");
assert_eq!(v["model"], "gpt-5-nano");
assert!(v["created"].as_i64().unwrap() > 0);
let choice = &v["choices"][0];
assert_eq!(
llm_sorted_keys(choice),
vec!["finish_reason", "index", "logprobs", "message"]
);
assert_eq!(choice["finish_reason"], "stop");
assert!(choice["logprobs"].is_null());
let message = &choice["message"];
assert_eq!(llm_sorted_keys(message), vec!["content", "refusal", "role"]);
assert_eq!(message["role"], "assistant");
assert!(message["refusal"].is_null());
let content = message["content"].as_str().unwrap();
assert!(!content.is_empty());
let usage = &v["usage"];
assert_eq!(
llm_sorted_keys(usage),
vec![
"completion_tokens",
"completion_tokens_details",
"prompt_tokens",
"prompt_tokens_details",
"total_tokens"
]
);
assert_eq!(usage["prompt_tokens"], 3);
assert_eq!(
usage["completion_tokens"],
crate::handlers::llm::estimate_tokens(content)
);
assert_eq!(
usage["total_tokens"],
usage["prompt_tokens"].as_u64().unwrap() + usage["completion_tokens"].as_u64().unwrap()
);
assert!(usage["prompt_tokens_details"].is_null());
}
#[actix_web::test]
async fn llm_chat_completions_custom_content_via_httpcan_block() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"messages":[{"role":"user","content":"hi"}],"httpcan":{"content":"Custom reply!"}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["choices"][0]["message"]["content"], "Custom reply!");
assert_eq!(
v["usage"]["completion_tokens"],
crate::handlers::llm::estimate_tokens("Custom reply!")
);
}
#[actix_web::test]
async fn llm_chat_completions_n_choices() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"messages":[{"role":"user","content":"hi"}],"n":2}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
let choices = v["choices"].as_array().unwrap();
assert_eq!(choices.len(), 2);
assert_eq!(choices[0]["index"], 0);
assert_eq!(choices[1]["index"], 1);
}
#[actix_web::test]
async fn llm_chat_completions_silent_alias_path() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"messages":[{"role":"user","content":"Hello"}]}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["object"], "chat.completion");
assert!(v["id"].as_str().unwrap().starts_with("chatcmpl-"));
}
#[actix_web::test]
async fn llm_chat_completions_invalid_json_openai_error_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload("not json")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(llm_sorted_keys(&v), vec!["error"]);
assert_eq!(v["error"]["type"], "invalid_request_error");
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("Invalid JSON"));
}
#[actix_web::test]
async fn llm_chat_completions_missing_messages_is_400() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"model":"gpt-4o"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"]["type"], "invalid_request_error");
assert!(v["error"]["message"].as_str().unwrap().contains("messages"));
}
#[actix_web::test]
async fn llm_chat_completions_get_is_405_with_allow_post() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/llm/v1/chat/completions")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers().get("Allow").unwrap().to_str().unwrap(),
"POST"
);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"]["type"], "invalid_request_error");
}
#[actix_web::test]
async fn llm_chat_completions_stream_protocol() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"messages":[{"role":"user","content":"Hello"}],"stream":true,"httpcan":{"content":"word one two three"}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(!text.contains("event:"));
let frames = llm_sse_frames(&text);
assert_eq!(frames.last().unwrap(), &"data: [DONE]");
let chunks: Vec<serde_json::Value> = frames[..frames.len() - 1]
.iter()
.map(|f| llm_frame_data(f))
.collect();
let id = chunks[0]["id"].as_str().unwrap().to_string();
assert!(id.starts_with("chatcmpl-"));
for chunk in &chunks {
assert_eq!(chunk["object"], "chat.completion.chunk");
assert_eq!(chunk["id"], id.as_str());
assert!(
chunk.get("usage").is_none(),
"usage only appears with include_usage"
);
}
assert_eq!(chunks[0]["choices"][0]["delta"]["role"], "assistant");
assert!(chunks[0]["choices"][0]["finish_reason"].is_null());
let mut reconstructed = String::new();
for chunk in &chunks[1..chunks.len() - 1] {
reconstructed.push_str(chunk["choices"][0]["delta"]["content"].as_str().unwrap());
}
assert_eq!(reconstructed, "word one two three");
let last = chunks.last().unwrap();
assert_eq!(last["choices"][0]["delta"].as_object().unwrap().len(), 0);
assert_eq!(last["choices"][0]["finish_reason"], "stop");
}
#[actix_web::test]
async fn llm_chat_completions_stream_include_usage() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/chat/completions")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"messages":[{"role":"user","content":"Hello"}],"stream":true,"stream_options":{"include_usage":true}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
let frames = llm_sse_frames(&text);
assert_eq!(frames.last().unwrap(), &"data: [DONE]");
let usage_frame = llm_frame_data(frames[frames.len() - 2]);
assert_eq!(usage_frame["choices"].as_array().unwrap().len(), 0);
assert!(usage_frame["usage"].is_object());
assert_eq!(usage_frame["usage"]["prompt_tokens"], 3);
assert_eq!(
usage_frame["usage"]["total_tokens"],
usage_frame["usage"]["prompt_tokens"].as_u64().unwrap()
+ usage_frame["usage"]["completion_tokens"].as_u64().unwrap()
);
let stop_frame = llm_frame_data(frames[frames.len() - 3]);
assert_eq!(stop_frame["choices"][0]["finish_reason"], "stop");
}
#[actix_web::test]
async fn llm_messages_canonical_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/messages")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"model":"claude-sonnet-4-5","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
llm_sorted_keys(&v),
vec![
"content",
"id",
"model",
"role",
"stop_reason",
"stop_sequence",
"type",
"usage"
]
);
assert!(v["id"].as_str().unwrap().starts_with("msg_"));
assert_eq!(v["type"], "message");
assert_eq!(v["role"], "assistant");
assert_eq!(v["model"], "claude-sonnet-4-5");
assert_eq!(v["stop_reason"], "end_turn");
assert!(v["stop_sequence"].is_null());
let block = &v["content"][0];
assert_eq!(llm_sorted_keys(block), vec!["citations", "text", "type"]);
assert_eq!(block["type"], "text");
let text = block["text"].as_str().unwrap();
assert!(!text.is_empty());
let usage = &v["usage"];
assert_eq!(
llm_sorted_keys(usage),
vec!["input_tokens", "output_tokens"]
);
assert_eq!(usage["input_tokens"], 3);
assert_eq!(
usage["output_tokens"],
crate::handlers::llm::estimate_tokens(text)
);
}
#[actix_web::test]
async fn llm_messages_custom_content_and_system_tokens() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/messages")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"max_tokens":100,"system":"You are terse.","messages":[{"role":"user","content":[{"type":"text","text":"Hello"}]}],"httpcan":{"content":"Hi."}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["content"][0]["text"], "Hi.");
assert_eq!(v["usage"]["input_tokens"], 9);
assert!(v["model"].as_str().unwrap().starts_with("claude-"));
}
#[actix_web::test]
async fn llm_messages_invalid_json_anthropic_error_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/messages")
.insert_header(("content-type", "application/json"))
.set_payload("not json")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["type"], "error");
assert_eq!(v["error"]["type"], "invalid_request_error");
assert!(v["error"]["message"]
.as_str()
.unwrap()
.contains("Invalid JSON"));
}
#[actix_web::test]
async fn llm_messages_stream_protocol() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/messages")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"max_tokens":64,"stream":true,"messages":[{"role":"user","content":"Hello"}],"httpcan":{"content":"alpha beta"}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(
!text.contains("[DONE]"),
"Anthropic SSE has no [DONE] sentinel"
);
let mut events: Vec<(String, serde_json::Value)> = Vec::new();
for frame in llm_sse_frames(&text) {
let (event, data) = frame.split_once('\n').unwrap();
let event = event.strip_prefix("event: ").unwrap().to_string();
let data = serde_json::from_str(data.strip_prefix("data: ").unwrap()).unwrap();
events.push((event, data));
}
let types: Vec<&str> = events.iter().map(|(e, _)| e.as_str()).collect();
assert_eq!(
&types[..],
&[
"message_start",
"content_block_start",
"content_block_delta",
"content_block_delta",
"content_block_stop",
"message_delta",
"message_stop"
]
);
let start = &events[0].1;
assert!(start["message"]["id"].as_str().unwrap().starts_with("msg_"));
assert_eq!(start["message"]["usage"]["input_tokens"], 3);
assert_eq!(start["message"]["usage"]["output_tokens"], 0);
let mut reconstructed = String::new();
for (event, data) in &events {
if event == "content_block_delta" {
assert_eq!(data["delta"]["type"], "text_delta");
reconstructed.push_str(data["delta"]["text"].as_str().unwrap());
}
}
assert_eq!(reconstructed, "alpha beta");
let delta = &events[events.len() - 2].1;
assert_eq!(delta["delta"]["stop_reason"], "end_turn");
assert_eq!(
delta["usage"]["output_tokens"],
crate::handlers::llm::estimate_tokens("alpha beta")
);
}
#[actix_web::test]
async fn llm_messages_get_is_405_anthropic_error_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/llm/v1/messages")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers().get("Allow").unwrap().to_str().unwrap(),
"POST"
);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["type"], "error");
}
#[actix_web::test]
async fn llm_models_openai_shape_by_default() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/llm/v1/models").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(llm_sorted_keys(&v), vec!["data", "object"]);
assert_eq!(v["object"], "list");
let data = v["data"].as_array().unwrap();
assert!(!data.is_empty());
for m in data {
assert_eq!(
llm_sorted_keys(m),
vec!["created", "id", "object", "owned_by"]
);
assert_eq!(m["object"], "model");
}
}
#[actix_web::test]
async fn llm_models_anthropic_shape_via_version_header() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/llm/v1/models")
.insert_header(("anthropic-version", "2023-06-01"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
llm_sorted_keys(&v),
vec!["data", "first_id", "has_more", "last_id"]
);
assert_eq!(v["has_more"], false);
let data = v["data"].as_array().unwrap();
assert!(!data.is_empty());
for m in data {
assert_eq!(
llm_sorted_keys(m),
vec![
"capabilities",
"created_at",
"display_name",
"id",
"max_input_tokens",
"max_tokens",
"type"
]
);
assert_eq!(m["type"], "model");
}
assert_eq!(v["first_id"], data[0]["id"]);
assert_eq!(v["last_id"], data[data.len() - 1]["id"]);
}
#[actix_web::test]
async fn llm_model_detail_echoes_any_id() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/llm/v1/models/my-fine-tune-42")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["id"], "my-fine-tune-42");
assert_eq!(v["object"], "model");
let req = test::TestRequest::get()
.uri("/llm/v1/models/claude-opus-9-2099")
.insert_header(("anthropic-version", "2023-06-01"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["id"], "claude-opus-9-2099");
assert_eq!(v["type"], "model");
assert_eq!(v["display_name"], "claude-opus-9-2099");
}
#[actix_web::test]
async fn llm_index_endpoint_lists_contract() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/llm").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert!(v["endpoints"]["chat_completions"]["path"]
.as_str()
.unwrap()
.starts_with("/llm"));
assert!(v["base_url"]["anthropic_sdk"]
.as_str()
.unwrap()
.ends_with("/llm"));
}
#[actix_web::test]
async fn llm_responses_canonical_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/responses")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"model":"gpt-5.6","input":"Hello"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
llm_sorted_keys(&v),
vec![
"completed_at",
"created_at",
"error",
"id",
"incomplete_details",
"instructions",
"max_output_tokens",
"metadata",
"model",
"object",
"output",
"output_text",
"parallel_tool_calls",
"status",
"temperature",
"tool_choice",
"tools",
"top_p",
"usage"
]
);
assert!(v["id"].as_str().unwrap().starts_with("resp_"));
assert_eq!(v["object"], "response");
assert_eq!(v["status"], "completed");
assert_eq!(v["model"], "gpt-5.6");
assert!(v["error"].is_null());
assert!(v["incomplete_details"].is_null());
assert_eq!(v["temperature"], 1.0);
assert_eq!(v["top_p"], 1.0);
assert_eq!(v["tool_choice"], "auto");
assert_eq!(v["parallel_tool_calls"], false);
assert_eq!(v["metadata"].as_object().unwrap().len(), 0);
let item = &v["output"][0];
assert_eq!(
llm_sorted_keys(item),
vec!["content", "id", "role", "status", "type"]
);
assert!(item["id"].as_str().unwrap().starts_with("msg_"));
assert_eq!(item["type"], "message");
assert_eq!(item["role"], "assistant");
assert_eq!(item["status"], "completed");
let part = &item["content"][0];
assert_eq!(llm_sorted_keys(part), vec!["annotations", "text", "type"]);
assert_eq!(part["type"], "output_text");
assert_eq!(part["annotations"].as_array().unwrap().len(), 0);
let text = part["text"].as_str().unwrap();
assert_eq!(v["output_text"], text);
let usage = &v["usage"];
assert_eq!(
llm_sorted_keys(usage),
vec![
"input_tokens",
"input_tokens_details",
"output_tokens",
"output_tokens_details",
"total_tokens"
]
);
assert_eq!(usage["input_tokens"], 2); assert_eq!(usage["input_tokens_details"]["cached_tokens"], 0);
assert_eq!(usage["output_tokens_details"]["reasoning_tokens"], 0);
assert_eq!(
usage["total_tokens"],
usage["input_tokens"].as_u64().unwrap() + usage["output_tokens"].as_u64().unwrap()
);
}
#[actix_web::test]
async fn llm_responses_custom_content_and_instructions_echo() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/responses")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"instructions":"Be terse.","httpcan":{"content":"Ok."}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["output_text"], "Ok.");
assert_eq!(v["instructions"], "Be terse.");
assert_eq!(v["usage"]["input_tokens"], 3);
}
#[actix_web::test]
async fn llm_responses_invalid_json_openai_error_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/responses")
.insert_header(("content-type", "application/json"))
.set_payload("not json")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"]["type"], "invalid_request_error");
}
#[actix_web::test]
async fn llm_responses_get_is_405_with_allow_post() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/llm/v1/responses")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers().get("Allow").unwrap().to_str().unwrap(),
"POST"
);
}
#[actix_web::test]
async fn llm_responses_silent_alias_path() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/responses")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"input":"Hello"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["object"], "response");
assert_eq!(v["model"], "gpt-5.6");
}
#[actix_web::test]
async fn llm_responses_stream_protocol() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/responses")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"input":"Hello","stream":true,"httpcan":{"content":"one two three"}}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(
!text.contains("[DONE]"),
"Responses streams end with response.completed, not [DONE]"
);
let mut events: Vec<(String, serde_json::Value)> = Vec::new();
for frame in llm_sse_frames(&text) {
let (event, data) = frame.split_once('\n').unwrap();
let event = event.strip_prefix("event: ").unwrap().to_string();
let data = serde_json::from_str(data.strip_prefix("data: ").unwrap()).unwrap();
events.push((event, data));
}
let types: Vec<&str> = events.iter().map(|(e, _)| e.as_str()).collect();
assert_eq!(
&types[..],
&[
"response.created",
"response.in_progress",
"response.output_item.added",
"response.content_part.added",
"response.output_text.delta",
"response.output_text.delta",
"response.output_text.delta",
"response.output_text.done",
"response.content_part.done",
"response.output_item.done",
"response.completed",
]
);
for (i, (_, data)) in events.iter().enumerate() {
assert_eq!(data["sequence_number"], i as u64);
}
let created = &events[0].1;
assert_eq!(created["response"]["status"], "in_progress");
assert_eq!(created["response"]["object"], "response");
let mut reconstructed = String::new();
for (event, data) in &events {
if event == "response.output_text.delta" {
reconstructed.push_str(data["delta"].as_str().unwrap());
}
}
assert_eq!(reconstructed, "one two three");
let done = &events[7].1;
assert_eq!(done["text"], "one two three");
let completed = &events.last().unwrap().1;
let final_response = &completed["response"];
assert_eq!(final_response["status"], "completed");
assert_eq!(final_response["output_text"], "one two three");
assert_eq!(final_response["usage"]["input_tokens"], 2);
assert_eq!(
final_response["usage"]["total_tokens"],
final_response["usage"]["input_tokens"].as_u64().unwrap()
+ final_response["usage"]["output_tokens"].as_u64().unwrap()
);
}
#[actix_web::test]
async fn llm_completions_canonical_shape() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"model":"gpt-5.6","prompt":"Hello"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
llm_sorted_keys(&v),
vec!["choices", "created", "id", "model", "object", "usage"]
);
assert!(v["id"].as_str().unwrap().starts_with("cmpl-"));
assert_eq!(v["object"], "text_completion");
assert_eq!(v["model"], "gpt-5.6");
let choice = &v["choices"][0];
assert_eq!(
llm_sorted_keys(choice),
vec!["finish_reason", "index", "logprobs", "text"]
);
assert_eq!(choice["finish_reason"], "stop");
assert!(choice["logprobs"].is_null());
assert!(choice["text"]
.as_str()
.unwrap()
.starts_with("This is a mock completion response"));
assert_eq!(v["usage"]["prompt_tokens"], 2); assert_eq!(
v["usage"]["total_tokens"],
v["usage"]["prompt_tokens"].as_u64().unwrap()
+ v["usage"]["completion_tokens"].as_u64().unwrap()
);
assert_eq!(v["usage"].as_object().unwrap().len(), 5);
}
#[actix_web::test]
async fn llm_completions_prompt_array_echo_and_custom_content() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/completions")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"prompt":["Say hi","Say hi"],"echo":true,"n":2,"httpcan":{"content":"Done."}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
let choices = v["choices"].as_array().unwrap();
assert_eq!(choices.len(), 2);
assert_eq!(choices[0]["index"], 0);
assert_eq!(choices[1]["index"], 1);
assert_eq!(choices[0]["text"], "Say hi Say hiDone.");
assert_eq!(v["usage"]["prompt_tokens"], 4);
assert_eq!(
v["usage"]["completion_tokens"],
crate::handlers::llm::estimate_tokens("Say hi Say hiDone.")
);
assert_eq!(v["model"], "gpt-5.6");
}
#[actix_web::test]
async fn llm_completions_stream_protocol() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"prompt":"Hello","stream":true,"httpcan":{"content":"word one two"}}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap(),
"text/event-stream"
);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(!text.contains("event:"));
let frames = llm_sse_frames(&text);
assert_eq!(frames.last().unwrap(), &"data: [DONE]");
let chunks: Vec<serde_json::Value> = frames[..frames.len() - 1]
.iter()
.map(|f| llm_frame_data(f))
.collect();
let id = chunks[0]["id"].as_str().unwrap().to_string();
assert!(id.starts_with("cmpl-"));
for chunk in &chunks {
assert_eq!(chunk["object"], "text_completion");
assert_eq!(chunk["id"], id.as_str());
assert!(chunk.get("usage").is_none());
}
assert!(chunks[0]["choices"][0]["text"].as_str().unwrap() == "word");
let mut reconstructed = String::new();
for chunk in &chunks[..chunks.len() - 1] {
reconstructed.push_str(chunk["choices"][0]["text"].as_str().unwrap());
}
assert_eq!(reconstructed, "word one two");
let last = chunks.last().unwrap();
assert_eq!(last["choices"][0]["text"], "");
assert_eq!(last["choices"][0]["finish_reason"], "stop");
}
#[actix_web::test]
async fn llm_completions_stream_include_usage() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/v1/completions")
.insert_header(("content-type", "application/json"))
.set_payload(
r#"{"prompt":"Hi","stream":true,"stream_options":{"include_usage":true},"httpcan":{"content":"a b"}}"#,
)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let text = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
let frames = llm_sse_frames(&text);
assert_eq!(frames.last().unwrap(), &"data: [DONE]");
let usage_frame = llm_frame_data(frames[frames.len() - 2]);
assert_eq!(usage_frame["choices"].as_array().unwrap().len(), 0);
assert_eq!(usage_frame["usage"]["prompt_tokens"], 1); let stop_frame = llm_frame_data(frames[frames.len() - 3]);
assert_eq!(stop_frame["choices"][0]["finish_reason"], "stop");
}
#[actix_web::test]
async fn llm_completions_alias_405_and_errors() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/llm/completions")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"prompt":"Hello"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["object"], "text_completion");
let req = test::TestRequest::get()
.uri("/llm/v1/completions")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(
resp.headers().get("Allow").unwrap().to_str().unwrap(),
"POST"
);
let req = test::TestRequest::post()
.uri("/llm/v1/completions")
.insert_header(("content-type", "application/json"))
.set_payload("not json")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"]["type"], "invalid_request_error");
}
fn oauth2_location<B>(resp: &actix_web::dev::ServiceResponse<B>) -> String {
resp.headers()
.get("Location")
.expect("redirect carries Location")
.to_str()
.unwrap()
.to_string()
}
fn oauth2_query_param(location: &str, key: &str) -> Option<String> {
url::Url::parse(location)
.ok()?
.query_pairs()
.find(|(k, _)| k == key)
.map(|(_, v)| v.to_string())
}
fn oauth2_fragment_param(location: &str, key: &str) -> Option<String> {
let fragment = url::Url::parse(location).ok()?.fragment()?.to_string();
fragment
.split('&')
.filter_map(|pair| pair.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| v.to_string())
}
async fn oauth2_decide(
payload: &str,
) -> (
actix_web::dev::ServiceResponse<actix_web::body::EitherBody<actix_web::body::BoxBody>>,
String,
) {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/oauth2/authorize")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload(payload.to_string())
.to_request();
let resp = test::call_service(&app, req).await;
let location = oauth2_location(&resp);
(resp, location)
}
async fn oauth2_token(
payload: &str,
) -> actix_web::dev::ServiceResponse<actix_web::body::EitherBody<actix_web::body::BoxBody>> {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::post()
.uri("/oauth2/token")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload(payload.to_string())
.to_request();
test::call_service(&app, req).await
}
#[actix_web::test]
async fn oauth2_hmac_rfc4231_vector() {
let key = vec![0x0bu8; 20];
let mac = crate::handlers::oauth2::hmac_sha256(&key, b"Hi There");
let expected: [u8; 32] = [
0xb0, 0x34, 0x4c, 0x61, 0xd8, 0xdb, 0x38, 0x53, 0x5c, 0xa8, 0xaf, 0xce, 0xaf, 0x0b, 0xf1,
0x2b, 0x88, 0x1d, 0xc2, 0x00, 0xc9, 0x83, 0x3d, 0xa7, 0x26, 0xe9, 0x37, 0x6c, 0x2e, 0x32,
0xcf, 0xf7,
];
assert_eq!(mac, expected.to_vec());
}
#[actix_web::test]
async fn oauth2_index_and_discovery_metadata() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/oauth2").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
v["grant_types_supported"].as_array().unwrap().len(),
5,
"four token grants plus implicit"
);
let req = test::TestRequest::get()
.uri("/.well-known/oauth-authorization-server")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["issuer"], "http://localhost:8080");
assert!(v["authorization_endpoint"]
.as_str()
.unwrap()
.ends_with("/oauth2/authorize"));
assert!(v["token_endpoint"]
.as_str()
.unwrap()
.ends_with("/oauth2/token"));
assert_eq!(
v["response_types_supported"],
serde_json::json!(["code", "token"])
);
assert_eq!(v["grant_types_supported"].as_array().unwrap().len(), 5);
assert!(v["service_documentation"].as_str().unwrap().ends_with('/'));
}
#[actix_web::test]
async fn oauth2_consent_page_renders_and_escapes() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/authorize?response_type=code&client_id=%3Cscript%3Ealert(1)%3C%2Fscript%3E&redirect_uri=https://example.com/cb")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get("X-Robots-Tag").unwrap(), "noindex");
let body = String::from_utf8(test::read_body(resp).await.to_vec()).unwrap();
assert!(body.contains("OAuth2 Consent"));
assert!(!body.contains("<script>"), "raw script must not survive");
assert!(body.contains("<script>"));
let req = test::TestRequest::get()
.uri("/oauth2/authorize?response_type=code&redirect_uri=https://example.com/cb")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("Location").is_none());
}
#[actix_web::test]
async fn oauth2_invalid_redirect_uri_is_not_redirected() {
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/authorize?response_type=code&client_id=cli&redirect_uri=javascript:alert(1)")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(resp.headers().get("Location").is_none());
}
#[actix_web::test]
async fn oauth2_unsupported_response_type_redirects_with_error() {
let (resp, location) = oauth2_decide(
"response_type=bogus&client_id=cli&redirect_uri=https://example.com/cb&state=xyz&email=u@example.com&decision=approve",
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
oauth2_query_param(&location, "error").as_deref(),
Some("unsupported_response_type")
);
assert_eq!(
oauth2_query_param(&location, "state").as_deref(),
Some("xyz")
);
assert!(oauth2_query_param(&location, "code").is_none());
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/authorize?client_id=cli&redirect_uri=https://example.com/cb")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::FOUND);
let location = oauth2_location(&resp);
assert_eq!(
oauth2_query_param(&location, "error").as_deref(),
Some("invalid_request")
);
}
#[actix_web::test]
async fn oauth2_code_flow_happy_path() {
let (resp, location) = oauth2_decide(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&state=xyz&scope=read&email=user@example.com&decision=approve",
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let code = oauth2_query_param(&location, "code").expect("code in query");
assert_eq!(
oauth2_query_param(&location, "state").as_deref(),
Some("xyz")
);
assert!(oauth2_fragment_param(&location, "access_token").is_none());
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli&client_secret=s3cr3t"
))
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(resp.headers().get("Cache-Control").unwrap(), "no-store");
assert_eq!(resp.headers().get("Pragma").unwrap(), "no-cache");
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["token_type"], "Bearer");
assert_eq!(v["expires_in"], 3600);
assert_eq!(v["scope"], "read");
let access = v["access_token"].as_str().unwrap().to_string();
assert!(v["refresh_token"].is_string());
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {access}")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["sub"], "user@example.com");
assert_eq!(v["email"], "user@example.com");
assert_eq!(v["client_id"], "cli");
}
#[actix_web::test]
async fn oauth2_code_replay_rejected() {
let (_, location) = oauth2_decide(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve",
)
.await;
let code = oauth2_query_param(&location, "code").unwrap();
let body = format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli"
);
let resp = oauth2_token(&body).await;
assert_eq!(resp.status(), StatusCode::OK);
let resp = oauth2_token(&body).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_grant");
}
#[actix_web::test]
async fn oauth2_code_binding_mismatches_rejected() {
let (_, location) = oauth2_decide(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve",
)
.await;
let code = oauth2_query_param(&location, "code").unwrap();
for body in [
format!("grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=other"),
format!("grant_type=authorization_code&code={code}&redirect_uri=https://evil.example/cb&client_id=cli"),
] {
let resp = oauth2_token(&body).await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_grant");
}
}
#[actix_web::test]
async fn oauth2_expired_code_rejected() {
let mut claims = crate::handlers::oauth2::CodeClaims::new(
"cli".into(),
"https://example.com/cb".into(),
None,
"u@example.com".into(),
);
claims.iat = crate::handlers::oauth2::now_unix() - 700;
claims.exp = crate::handlers::oauth2::now_unix() - 100;
let code = crate::handlers::oauth2::sign(&claims);
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli"
))
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_grant");
assert!(v["error_description"].as_str().unwrap().contains("expired"));
}
#[actix_web::test]
async fn oauth2_pkce_s256_and_wrong_verifier() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use sha2::{Digest, Sha256};
let verifier = "correct-horse-battery-staple";
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let (_, location) = oauth2_decide(&format!(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve&code_challenge={challenge}&code_challenge_method=S256"
))
.await;
let code = oauth2_query_param(&location, "code").unwrap();
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli&code_verifier=wrong-verifier"
))
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_grant");
let (_, location) = oauth2_decide(&format!(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve&code_challenge={challenge}&code_challenge_method=S256"
))
.await;
let code = oauth2_query_param(&location, "code").unwrap();
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli&code_verifier={verifier}"
))
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn oauth2_pkce_plain_is_the_default_method() {
let verifier = "plain-verifier-value";
let (_, location) = oauth2_decide(&format!(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve&code_challenge={verifier}"
))
.await;
let code = oauth2_query_param(&location, "code").unwrap();
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli&code_verifier={verifier}"
))
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn oauth2_decline_redirects_access_denied_with_state() {
let (resp, location) = oauth2_decide(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&state=st4te&email=u@example.com&decision=decline",
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
assert_eq!(
oauth2_query_param(&location, "error").as_deref(),
Some("access_denied")
);
assert_eq!(
oauth2_query_param(&location, "state").as_deref(),
Some("st4te")
);
assert!(oauth2_query_param(&location, "code").is_none());
}
#[actix_web::test]
async fn oauth2_implicit_flow_fragment_redirect() {
let (resp, location) = oauth2_decide(
"response_type=token&client_id=spa&redirect_uri=https://example.com/cb&state=imp&scope=profile&email=u@example.com&decision=approve",
)
.await;
assert_eq!(resp.status(), StatusCode::FOUND);
let access = oauth2_fragment_param(&location, "access_token").expect("token in fragment");
assert_eq!(
oauth2_fragment_param(&location, "token_type").as_deref(),
Some("Bearer")
);
assert_eq!(
oauth2_fragment_param(&location, "expires_in").as_deref(),
Some("3600")
);
assert_eq!(
oauth2_fragment_param(&location, "state").as_deref(),
Some("imp")
);
assert!(oauth2_query_param(&location, "access_token").is_none());
assert!(oauth2_fragment_param(&location, "refresh_token").is_none());
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {access}")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["client_id"], "spa");
let (_, location) = oauth2_decide(
"response_type=token&client_id=spa&redirect_uri=https://example.com/cb&state=imp&email=u@example.com&decision=decline",
)
.await;
assert_eq!(
oauth2_fragment_param(&location, "error").as_deref(),
Some("access_denied")
);
}
#[actix_web::test]
async fn oauth2_password_grant_issues_tokens() {
let resp = oauth2_token(
"grant_type=password&username=alice&password=hunter2&client_id=cli&scope=read",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["token_type"], "Bearer");
assert!(v["refresh_token"].is_string());
assert_eq!(v["scope"], "read");
let access = v["access_token"].as_str().unwrap().to_string();
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {access}")))
.to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["sub"], "alice");
let resp = oauth2_token("grant_type=password&password=x&client_id=cli").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_request");
}
#[actix_web::test]
async fn oauth2_refresh_token_rotates() {
let (_, location) = oauth2_decide(
"response_type=code&client_id=cli&redirect_uri=https://example.com/cb&email=u@example.com&decision=approve",
)
.await;
let code = oauth2_query_param(&location, "code").unwrap();
let resp = oauth2_token(&format!(
"grant_type=authorization_code&code={code}&redirect_uri=https://example.com/cb&client_id=cli"
))
.await;
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
let refresh1 = v["refresh_token"].as_str().unwrap().to_string();
let access1 = v["access_token"].as_str().unwrap().to_string();
let resp = oauth2_token(&format!(
"grant_type=refresh_token&refresh_token={refresh1}&client_id=cli"
))
.await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
let refresh2 = v["refresh_token"].as_str().unwrap().to_string();
assert_ne!(refresh1, refresh2);
let resp = oauth2_token(&format!(
"grant_type=refresh_token&refresh_token={refresh1}&client_id=cli"
))
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let resp = oauth2_token(&format!(
"grant_type=refresh_token&refresh_token={refresh2}&client_id=other"
))
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {refresh2}")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {access1}")))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn oauth2_client_credentials_has_no_refresh_token() {
let resp = oauth2_token(
"grant_type=client_credentials&client_id=svc&client_secret=s3cr3t&scope=write",
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert!(v.get("refresh_token").is_none());
assert_eq!(v["scope"], "write");
let access = v["access_token"].as_str().unwrap().to_string();
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", format!("Bearer {access}")))
.to_request();
let resp = test::call_service(&app, req).await;
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(
v["email"],
crate::handlers::oauth2::CLIENT_CREDENTIALS_EMAIL
);
}
#[actix_web::test]
async fn oauth2_basic_and_form_auth_and_invalid_client() {
let resp =
oauth2_token("grant_type=client_credentials&client_id=cli&client_secret=s3cr3t").await;
assert_eq!(resp.status(), StatusCode::OK);
let app = test::init_service(create_app(cfg())).await;
let credentials = {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode("cli:s3cr3t")
};
let req = test::TestRequest::post()
.uri("/oauth2/token")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.insert_header(("Authorization", format!("Basic {credentials}")))
.set_payload("grant_type=client_credentials")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let mut clients = std::collections::HashMap::new();
clients.insert("cli".to_string(), "right".to_string());
let app = test::init_service(create_app(
ServerConfig::default().oauth2_clients(Some(clients)),
))
.await;
let req = test::TestRequest::post()
.uri("/oauth2/token")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("grant_type=client_credentials&client_id=cli&client_secret=wrong")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers().get("WWW-Authenticate").unwrap(), "Basic");
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_client");
let req = test::TestRequest::post()
.uri("/oauth2/token")
.insert_header(("content-type", "application/x-www-form-urlencoded"))
.set_payload("grant_type=client_credentials&client_id=cli&client_secret=right")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn oauth2_token_errors_and_method_guard() {
let resp = oauth2_token("grant_type=jwt-bearer&client_id=cli").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "unsupported_grant_type");
let resp = oauth2_token("client_id=cli").await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_request");
let resp = oauth2_token(
"grant_type=authorization_code&client_id=cli&redirect_uri=https://example.com/cb",
)
.await;
let v: serde_json::Value = serde_json::from_slice(&test::read_body(resp).await).unwrap();
assert_eq!(v["error"], "invalid_request");
let app = test::init_service(create_app(cfg())).await;
let req = test::TestRequest::get().uri("/oauth2/token").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(resp.headers().get("Allow").unwrap(), "POST");
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
assert_eq!(resp.headers().get("WWW-Authenticate").unwrap(), "Bearer");
let req = test::TestRequest::get()
.uri("/oauth2/userinfo")
.insert_header(("Authorization", "Bearer forged.token"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get("WWW-Authenticate")
.unwrap()
.to_str()
.unwrap();
assert!(challenge.contains("error=\"invalid_token\""));
}