use std::sync::{
Arc,
atomic::{AtomicU32, Ordering},
};
use std::time::Duration;
use axum::Router;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::routing::get;
use http_body_util::BodyExt;
use tower::ServiceExt;
use umbral_cache::{Cache, cache_page::cache_page};
async fn body_string(resp: axum::http::Response<Body>) -> (StatusCode, String) {
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
fn counting_router(counter: Arc<AtomicU32>, cache: Cache) -> Router {
let c = counter.clone();
Router::new()
.route(
"/page",
get(move || {
let cc = c.clone();
async move {
let n = cc.fetch_add(1, Ordering::SeqCst) + 1;
format!("hit #{n}")
}
}),
)
.layer(cache_page(Duration::from_secs(60)).with_cache(cache))
}
fn get_with_host(uri: &str, host: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header("Host", host)
.body(Body::empty())
.unwrap()
}
fn get_with_session_cookie(uri: &str, session_value: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header("Cookie", format!("umbral_session={session_value}"))
.body(Body::empty())
.unwrap()
}
fn get_with_cookie_and_host(uri: &str, cookie_header: &str, host: &str) -> Request<Body> {
Request::builder()
.method(Method::GET)
.uri(uri)
.header("Host", host)
.header("Cookie", cookie_header)
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn different_hosts_do_not_share_cache_entries() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let (s1, b1) = body_string(
router
.clone()
.oneshot(get_with_host("/page", "tenant-a.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(b1, "hit #1");
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"handler fired once (tenant A)"
);
let (s2, b2) = body_string(
router
.oneshot(get_with_host("/page", "tenant-b.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(s2, StatusCode::OK);
assert_eq!(
b2, "hit #2",
"tenant-B request must NOT get tenant-A's cached body"
);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"handler must fire again for a different Host"
);
}
#[tokio::test]
async fn same_host_requests_still_share_cache() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let (_, b1) = body_string(
router
.clone()
.oneshot(get_with_host("/page", "www.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(b1, "hit #1");
let (_, b2) = body_string(
router
.oneshot(get_with_host("/page", "www.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(
b2, "hit #1",
"same-host second request must come from cache"
);
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"handler must NOT fire for same-host second request"
);
}
#[tokio::test]
async fn session_cookie_request_is_not_served_from_cache() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let req_anon = Request::builder()
.method(Method::GET)
.uri("/page")
.body(Body::empty())
.unwrap();
let (_, b0) = body_string(router.clone().oneshot(req_anon).await.unwrap()).await;
assert_eq!(b0, "hit #1", "anonymous request populates cache");
let (s1, b1) = body_string(
router
.clone()
.oneshot(get_with_session_cookie("/page", "abc123"))
.await
.unwrap(),
)
.await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(
b1, "hit #2",
"session-cookie request must NOT receive the anonymous cached body"
);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"handler must fire for session-cookie request"
);
}
#[tokio::test]
async fn session_cookie_response_is_not_written_to_cache() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let (s1, b1) = body_string(
router
.clone()
.oneshot(get_with_session_cookie("/page", "user-session-xyz"))
.await
.unwrap(),
)
.await;
assert_eq!(s1, StatusCode::OK);
assert_eq!(b1, "hit #1");
let req_anon = Request::builder()
.method(Method::GET)
.uri("/page")
.body(Body::empty())
.unwrap();
let (s2, b2) = body_string(router.clone().oneshot(req_anon).await.unwrap()).await;
assert_eq!(s2, StatusCode::OK);
assert_eq!(
b2, "hit #2",
"anonymous request after a session-cookie request must hit the handler, \
not receive the session response"
);
assert_eq!(counter.load(Ordering::SeqCst), 2);
let req_anon2 = Request::builder()
.method(Method::GET)
.uri("/page")
.body(Body::empty())
.unwrap();
let (_, b3) = body_string(router.oneshot(req_anon2).await.unwrap()).await;
assert_eq!(
b3, "hit #2",
"third anonymous request must be served from cache (the anonymous entry)"
);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"handler must NOT fire again"
);
}
#[tokio::test]
async fn session_cookie_detected_among_multiple_cookies() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let req = Request::builder()
.method(Method::GET)
.uri("/page")
.header("Cookie", "pref=dark; umbral_session=tok42; lang=en")
.body(Body::empty())
.unwrap();
let (s, _) = body_string(router.clone().oneshot(req).await.unwrap()).await;
assert_eq!(s, StatusCode::OK);
assert_eq!(counter.load(Ordering::SeqCst), 1, "handler must fire");
let req2 = Request::builder()
.method(Method::GET)
.uri("/page")
.header("Cookie", "pref=dark; umbral_session=tok42; lang=en")
.body(Body::empty())
.unwrap();
let (_, b2) = body_string(router.oneshot(req2).await.unwrap()).await;
assert_eq!(b2, "hit #2", "still bypasses cache on repeat");
assert_eq!(counter.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn non_session_cookie_does_not_bypass_cache() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let req1 = Request::builder()
.method(Method::GET)
.uri("/page")
.header("Cookie", "not_umbral_session=abc")
.body(Body::empty())
.unwrap();
let (_, b1) = body_string(router.clone().oneshot(req1).await.unwrap()).await;
assert_eq!(b1, "hit #1");
let req2 = Request::builder()
.method(Method::GET)
.uri("/page")
.header("Cookie", "not_umbral_session=abc")
.body(Body::empty())
.unwrap();
let (_, b2) = body_string(router.oneshot(req2).await.unwrap()).await;
assert_eq!(b2, "hit #1", "non-session cookie must not disable caching");
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn session_cookie_bypass_independent_of_host() {
let counter = Arc::new(AtomicU32::new(0));
let cache = Cache::memory();
let router = counting_router(counter.clone(), cache);
let (_, b1) = body_string(
router
.clone()
.oneshot(get_with_host("/page", "a.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(b1, "hit #1");
let (_, b2) = body_string(
router
.clone()
.oneshot(get_with_cookie_and_host(
"/page",
"umbral_session=logged-in",
"a.example.com",
))
.await
.unwrap(),
)
.await;
assert_eq!(
b2, "hit #2",
"session-cookie bypass wins over same-host cache hit"
);
assert_eq!(counter.load(Ordering::SeqCst), 2);
let (_, b3) = body_string(
router
.oneshot(get_with_host("/page", "a.example.com"))
.await
.unwrap(),
)
.await;
assert_eq!(
b3, "hit #1",
"anonymous cache entry for host A is still intact"
);
assert_eq!(
counter.load(Ordering::SeqCst),
2,
"handler must not fire again"
);
}