refactor(tests): introduce shared tests/common/ support module
Lift duplicated test-harness code (User factories, TestConfig builder, login/CSRF flow, form/json helpers, case-directory seeding, URL path builders) into `server/tests/common/` so future API changes touch one file instead of every integration test. Migrated batches: csrf_attack, auth, login, magic_link, security_headers (28 tests); analyze, case_page, delete_recording, close_watermark, retention_sweep (67 tests). All 95 tests still green. Net line delta across these 10 files: +1113 / -1896 (~780 lines removed), plus ~500 lines of new shared infrastructure under tests/common/.
This commit is contained in:
@@ -5,93 +5,65 @@
|
||||
//! The layer is composed in `doctate_server::with_security_headers`,
|
||||
//! applied to every response by `create_router_with_state`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
mod common;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::response::Response;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
use common::{TestConfig, header_opt, test_user_with_password};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
Arc::new(Config {
|
||||
data_path: std::env::temp_dir().join(format!(
|
||||
"doctate-sechdr-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
)),
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "test-key-123".into(),
|
||||
web_password: bcrypt::hash("secret", 4).unwrap(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
})
|
||||
fn test_app() -> axum::Router {
|
||||
let cfg = TestConfig::new()
|
||||
.with_user(test_user_with_password("dr_test", "secret"))
|
||||
.build();
|
||||
doctate_server::create_router(cfg)
|
||||
}
|
||||
|
||||
fn header_value<'a>(resp: &'a axum::response::Response, name: &str) -> Option<&'a str> {
|
||||
resp.headers().get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
fn count_header_values(resp: &axum::response::Response, name: &str) -> usize {
|
||||
fn count_header_values(resp: &Response, name: &str) -> usize {
|
||||
resp.headers().get_all(name).iter().count()
|
||||
}
|
||||
|
||||
async fn get(app: axum::Router, uri: &str) -> Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.uri(uri)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------- presence tests ----------
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_health_has_all_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
assert!(
|
||||
header_value(&resp, "content-security-policy").is_some(),
|
||||
header_opt(&resp, "content-security-policy").is_some(),
|
||||
"CSP header missing on /api/health"
|
||||
);
|
||||
assert!(
|
||||
header_value(&resp, "permissions-policy").is_some(),
|
||||
header_opt(&resp, "permissions-policy").is_some(),
|
||||
"Permissions-Policy missing on /api/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_login_page_has_all_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/web/login").await;
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_value(&resp, "content-security-policy").is_some());
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_opt(&resp, "content-security-policy").is_some());
|
||||
}
|
||||
|
||||
// ---------- per-attack tests ----------
|
||||
@@ -101,18 +73,9 @@ async fn web_login_page_has_all_security_headers() {
|
||||
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
|
||||
#[tokio::test]
|
||||
async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
let csp = header_opt(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("frame-ancestors 'none'"),
|
||||
"CSP missing frame-ancestors: {csp}"
|
||||
@@ -123,17 +86,8 @@ async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
||||
/// Defense: CSP `object-src 'none'`.
|
||||
#[tokio::test]
|
||||
async fn csp_blocks_object_embed_plugins() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
let csp = header_opt(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("object-src 'none'"),
|
||||
"CSP missing object-src 'none': {csp}"
|
||||
@@ -144,17 +98,8 @@ async fn csp_blocks_object_embed_plugins() {
|
||||
/// Defense: CSP `base-uri 'self'`.
|
||||
#[tokio::test]
|
||||
async fn csp_blocks_base_uri_hijack() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
let csp = header_opt(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("base-uri 'self'"),
|
||||
"CSP missing base-uri 'self': {csp}"
|
||||
@@ -165,17 +110,8 @@ async fn csp_blocks_base_uri_hijack() {
|
||||
/// Defense: CSP `form-action 'self'`.
|
||||
#[tokio::test]
|
||||
async fn csp_blocks_form_action_hijack() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
let csp = header_opt(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("form-action 'self'"),
|
||||
"CSP missing form-action 'self': {csp}"
|
||||
@@ -186,18 +122,9 @@ async fn csp_blocks_form_action_hijack() {
|
||||
/// executed. Defense: `X-Content-Type-Options: nosniff`.
|
||||
#[tokio::test]
|
||||
async fn nosniff_blocks_mime_confusion() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
}
|
||||
@@ -206,34 +133,16 @@ async fn nosniff_blocks_mime_confusion() {
|
||||
/// Defense: `Referrer-Policy: no-referrer`.
|
||||
#[tokio::test]
|
||||
async fn referrer_policy_prevents_leak() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
}
|
||||
|
||||
/// Malicious script requests microphone/camera access in background.
|
||||
/// Defense: `Permissions-Policy` explicitly denies those features.
|
||||
#[tokio::test]
|
||||
async fn permissions_policy_blocks_sensitive_features() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pp = header_value(&resp, "permissions-policy").unwrap_or("");
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
let pp = header_opt(&resp, "permissions-policy").unwrap_or("");
|
||||
for feat in ["microphone", "camera", "geolocation", "payment"] {
|
||||
assert!(
|
||||
pp.contains(&format!("{feat}=()")),
|
||||
@@ -249,25 +158,16 @@ async fn permissions_policy_blocks_sensitive_features() {
|
||||
/// short-circuit on error paths.
|
||||
#[tokio::test]
|
||||
async fn error_redirect_still_carries_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
// /web/cases without cookie → 302 redirect (error path from the
|
||||
// session extractor).
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/cases")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/web/cases").await;
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
header_opt(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_value(&resp, "content-security-policy").is_some());
|
||||
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_opt(&resp, "content-security-policy").is_some());
|
||||
}
|
||||
|
||||
/// `magic.rs` already sets `Referrer-Policy: no-referrer` on the magic
|
||||
@@ -276,22 +176,13 @@ async fn error_redirect_still_carries_security_headers() {
|
||||
/// yet) and must keep passing after the layer lands.
|
||||
#[tokio::test]
|
||||
async fn magic_route_referrer_policy_not_duplicated() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/magic?token=definitely-invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/web/magic?token=definitely-invalid").await;
|
||||
assert_eq!(
|
||||
count_header_values(&resp, "referrer-policy"),
|
||||
1,
|
||||
"Referrer-Policy appeared more than once — header layer should be if_not_present"
|
||||
);
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
}
|
||||
|
||||
/// HSTS must NOT be set until TLS is actually terminated in front of the
|
||||
@@ -299,16 +190,7 @@ async fn magic_route_referrer_policy_not_duplicated() {
|
||||
/// (max-age cache). Regression guard against accidental HSTS additions.
|
||||
#[tokio::test]
|
||||
async fn no_hsts_header_until_tls_is_in_place() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = get(test_app(), "/api/health").await;
|
||||
assert!(
|
||||
resp.headers().get("strict-transport-security").is_none(),
|
||||
"HSTS set but no TLS termination is in place — would break HTTP deployments"
|
||||
|
||||
Reference in New Issue
Block a user