use std::sync::Arc;
use actix_web::http::header;
use actix_web::test;
use bytes::Bytes;
use solid_pod_rs::auth::nip98;
use solid_pod_rs::ldp::{CACHE_CONTROL_PRIVATE, CACHE_CONTROL_RDF};
use solid_pod_rs::storage::memory::MemoryBackend;
use solid_pod_rs::storage::Storage;
use solid_pod_rs_server::{build_app, AppState};
const SK_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
fn did_for_key() -> String {
let sk = hex::decode(SK_HEX).unwrap();
let signing = k256::schnorr::SigningKey::from_bytes(&sk).unwrap();
format!(
"did:nostr:{}",
hex::encode(signing.verifying_key().to_bytes())
)
}
fn nip98_auth(method: &str, path: &str) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
static BASE: OnceLock<u64> = OnceLock::new();
static SEQ: AtomicU64 = AtomicU64::new(0);
let base = *BASE.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(30)
});
let now = base + SEQ.fetch_add(1, Ordering::Relaxed);
let url = format!("http://localhost:8080{path}");
let token = nip98::mint_with_payload(&url, method, None, SK_HEX, now)
.expect("nip98-schnorr is enabled in the workspace test build");
format!("Nostr {token}")
}
async fn seeded_state() -> AppState {
let storage = Arc::new(MemoryBackend::new());
let public_acl = r#"{
"@graph": [{
"@type": "acl:Authorization",
"acl:agentClass": {"@id": "foaf:Agent"},
"acl:accessTo": {"@id": "/public/"},
"acl:default": {"@id": "/public/"},
"acl:mode": {"@id": "acl:Read"}
}]
}"#;
let private_acl = format!(
r#"{{
"@graph": [{{
"@type": "acl:Authorization",
"acl:agent": {{"@id": "{did}"}},
"acl:accessTo": {{"@id": "/private/"}},
"acl:default": {{"@id": "/private/"}},
"acl:mode": [{{"@id": "acl:Read"}}, {{"@id": "acl:Control"}}]
}}]
}}"#,
did = did_for_key()
);
for (path, body, ct) in [
("/public.acl", public_acl.to_string(), "application/ld+json"),
("/private.acl", private_acl, "application/ld+json"),
(
"/public/photo.png",
"\u{89}PNG-not-really".to_string(),
"image/png",
),
(
"/public/graph.ttl",
"<#a> <#b> <#c> .".to_string(),
"text/turtle",
),
(
"/private/photo.png",
"\u{89}PNG-secret".to_string(),
"image/png",
),
(
"/private/graph.ttl",
"<#s> <#p> <#o> .".to_string(),
"text/turtle",
),
(
"/private/report.pdf",
"%PDF-1.7 secret".to_string(),
"application/pdf",
),
] {
storage
.put(path, Bytes::from(body), ct)
.await
.expect("seed write");
}
AppState::new(storage)
}
fn cache_control<B>(rsp: &actix_web::dev::ServiceResponse<B>) -> Option<String> {
rsp.headers()
.get(header::CACHE_CONTROL)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
}
fn vary<B>(rsp: &actix_web::dev::ServiceResponse<B>) -> String {
rsp.headers()
.get(header::VARY)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string()
}
fn assert_not_shared_cacheable<B>(rsp: &actix_web::dev::ServiceResponse<B>, what: &str) {
let cc = cache_control(rsp).unwrap_or_else(|| {
panic!("{what}: a private response MUST carry Cache-Control; it had none")
});
let lower = cc.to_ascii_lowercase();
assert!(
lower.contains("private") || lower.contains("no-store"),
"{what}: Cache-Control {cc:?} does not forbid shared caching"
);
}
#[actix_web::test]
async fn private_binary_response_is_not_publicly_cacheable() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/private/photo.png")
.insert_header((
header::AUTHORIZATION,
nip98_auth("GET", "/private/photo.png"),
))
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200, "the owner may read it");
assert_not_shared_cacheable(&rsp, "private image/png");
assert_eq!(cache_control(&rsp).as_deref(), Some(CACHE_CONTROL_PRIVATE));
}
#[actix_web::test]
async fn private_pdf_response_is_not_publicly_cacheable() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/private/report.pdf")
.insert_header((
header::AUTHORIZATION,
nip98_auth("GET", "/private/report.pdf"),
))
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
assert_not_shared_cacheable(&rsp, "private application/pdf");
}
#[actix_web::test]
async fn private_rdf_response_is_no_store_not_merely_no_cache() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/private/graph.ttl")
.insert_header((
header::AUTHORIZATION,
nip98_auth("GET", "/private/graph.ttl"),
))
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
assert_eq!(cache_control(&rsp).as_deref(), Some(CACHE_CONTROL_PRIVATE));
assert!(cache_control(&rsp).unwrap().contains("no-store"));
}
#[actix_web::test]
async fn private_responses_vary_on_authorization() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/private/photo.png")
.insert_header((
header::AUTHORIZATION,
nip98_auth("GET", "/private/photo.png"),
))
.to_request();
let rsp = test::call_service(&app, req).await;
assert!(
vary(&rsp)
.split(',')
.any(|f| f.trim().eq_ignore_ascii_case("Authorization")),
"expected Vary to include Authorization, got {:?}",
vary(&rsp)
);
}
#[actix_web::test]
async fn public_rdf_keeps_the_rdf_policy() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/public/graph.ttl")
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
assert_eq!(
cache_control(&rsp).as_deref(),
Some(CACHE_CONTROL_RDF),
"a world-readable RDF resource must keep the cheap-revalidation policy"
);
}
#[actix_web::test]
async fn public_binary_is_left_to_ordinary_caching() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/public/photo.png")
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
let cc = cache_control(&rsp);
assert!(
cc.is_none() || !cc.as_deref().unwrap().contains("no-store"),
"a world-readable image must not be forced uncacheable, got {cc:?}"
);
}
#[actix_web::test]
async fn an_authenticated_read_of_a_public_resource_stays_public() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/public/graph.ttl")
.insert_header((
header::AUTHORIZATION,
nip98_auth("GET", "/public/graph.ttl"),
))
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
assert_eq!(cache_control(&rsp).as_deref(), Some(CACHE_CONTROL_RDF));
}
#[actix_web::test]
async fn a_private_container_listing_is_not_publicly_cacheable() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/private/")
.insert_header((header::AUTHORIZATION, nip98_auth("GET", "/private/")))
.to_request();
let rsp = test::call_service(&app, req).await;
assert_eq!(rsp.status().as_u16(), 200);
assert_not_shared_cacheable(&rsp, "private container listing");
}
#[actix_web::test]
async fn an_acl_sidecar_read_is_always_private() {
let app = test::init_service(build_app(seeded_state().await)).await;
let req = test::TestRequest::get()
.uri("/public.acl")
.insert_header((header::AUTHORIZATION, nip98_auth("GET", "/public.acl")))
.to_request();
let rsp = test::call_service(&app, req).await;
if rsp.status().as_u16() == 200 {
assert_not_shared_cacheable(&rsp, "ACL sidecar body");
} else {
assert!(
rsp.status().is_client_error(),
"an ACL read without Control must be denied, got {}",
rsp.status().as_u16()
);
}
}
#[actix_web::test]
async fn the_policy_function_never_leaves_a_private_response_unmarked() {
use solid_pod_rs::ldp::{cache_control_for_response, ResponseAudience};
for ct in [
"text/turtle",
"application/ld+json",
"image/png",
"application/pdf",
"application/octet-stream",
"video/mp4",
"",
] {
assert_eq!(
cache_control_for_response(ct, ResponseAudience::Private),
Some(CACHE_CONTROL_PRIVATE),
"a private {ct:?} response must always be marked"
);
}
assert_eq!(
cache_control_for_response("text/turtle", ResponseAudience::Public),
Some(CACHE_CONTROL_RDF)
);
assert_eq!(
cache_control_for_response("image/png", ResponseAudience::Public),
None
);
}