You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

136 lines
3.6 KiB
Rust

//! Integration tests for CSRF protection middleware.
//!
//! Verifies that:
//! - POST/PUT/DELETE requests without X-Requested-With header are rejected (403)
//! - POST/PUT/DELETE requests with X-Requested-With: XMLHttpRequest pass through
//! - GET requests are exempt from CSRF checks
//!
//! Requires a running Postgres instance. Set `TEST_DATABASE_URL` to run.
mod common;
use axum::http::StatusCode;
fn require_test_db() -> bool {
std::env::var("TEST_DATABASE_URL").is_ok()
}
#[tokio::test]
async fn post_without_csrf_header_returns_403() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let body = serde_json::json!({
"email": "test@example.com",
"turnstile_token": "test-token"
});
let (status, resp_body) = app.post_without_csrf("/api/v1/auth/register", &body).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"POST without X-Requested-With should return 403"
);
assert_eq!(
resp_body["error"], "forbidden",
"Error type should be 'forbidden'"
);
assert!(
resp_body["message"]
.as_str()
.unwrap_or("")
.contains("X-Requested-With"),
"Error message should mention X-Requested-With header"
);
}
#[tokio::test]
async fn post_with_csrf_header_passes_csrf_check() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
let body = serde_json::json!({
"email": "csrf-test@example.com",
"turnstile_token": "test-token"
});
// This should pass the CSRF check (may return 200 from the handler)
let (status, _) = app.post("/api/v1/auth/register", &body).await;
assert_ne!(
status,
StatusCode::FORBIDDEN,
"POST with X-Requested-With should NOT return 403 (CSRF passed)"
);
// Should be 200 since registration with bypassed turnstile works
assert_eq!(
status,
StatusCode::OK,
"Registration should succeed with valid CSRF header"
);
}
#[tokio::test]
async fn get_request_is_exempt_from_csrf_check() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
// GET /api/v1/health does not need X-Requested-With
let (status, _) = app.get("/api/v1/health").await;
assert_eq!(
status,
StatusCode::OK,
"GET request should be exempt from CSRF check"
);
}
#[tokio::test]
async fn put_without_csrf_header_returns_403() {
if !require_test_db() {
eprintln!("SKIPPED: TEST_DATABASE_URL not set");
return;
}
let app = common::TestApp::new().await;
// Build a PUT request without X-Requested-With
let body = serde_json::json!({
"theme": "Test",
"max_age_days": 7,
"categories": ["Cat"],
"max_items_per_category": 4,
"search_agent_behavior": ""
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let req = axum::http::Request::builder()
.method(axum::http::Method::PUT)
.uri("/api/v1/settings")
.header("Content-Type", "application/json")
// Deliberately omit X-Requested-With
.body(axum::body::Body::from(body_bytes))
.unwrap();
let response = app.raw_request(req).await;
let status = response.status();
assert_eq!(
status,
StatusCode::FORBIDDEN,
"PUT without X-Requested-With should return 403"
);
}