use std::sync::Arc; use std::time::Duration; use axum::Router; use axum::body::Body; use axum::http::{Request, StatusCode, header}; use axum::middleware; use axum::routing::{get, post}; use tower::ServiceExt; use nomifun_auth::{ CookieConfig, CurrentUser, RateLimiter, api_rate_limit_middleware, auth_rate_limit_middleware, authenticated_action_rate_limit_middleware, csrf_middleware, security_headers_middleware, }; // ============================================================ // T12.1 — Security response headers // ============================================================ #[tokio::test] async fn t12_1_security_headers_on_get() { let app = Router::new() .route("/test", get(|| async { "ok" })) .layer(middleware::from_fn(security_headers_middleware)); let resp = app .oneshot(Request::get("/test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("x-frame-options").unwrap(), "DENY"); assert_eq!(resp.headers().get("x-content-type-options").unwrap(), "nosniff"); assert_eq!(resp.headers().get("x-xss-protection").unwrap(), "1; mode=block"); assert_eq!( resp.headers().get("referrer-policy").unwrap(), "strict-origin-when-cross-origin" ); } // ============================================================ // T12.2 — CSRF protection // ============================================================ fn csrf_app() -> Router { let config = Arc::new(CookieConfig { secure: false, same_site: "Lax", }); Router::new() .route("/api/test", post(|| async { "ok" })) .route("/login", post(|| async { "logged in" })) .route("/api/auth/qr-login", post(|| async { "qr ok" })) .route("/get-test", get(|| async { "get ok" })) .layer(middleware::from_fn_with_state(config, csrf_middleware)) } #[tokio::test] async fn t12_2_get_requests_bypass_csrf() { let app = csrf_app(); let resp = app .oneshot(Request::get("/get-test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn t12_2_post_without_csrf_token_rejected() { let app = csrf_app(); let resp = app .oneshot(Request::post("/api/test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn t12_2_post_with_matching_csrf_tokens_accepted() { let app = csrf_app(); let token = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; let resp = app .oneshot( Request::post("/api/test") .header("cookie", format!("nomifun-csrf-token={token}")) .header("x-csrf-token", token) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn t12_2_post_with_mismatched_csrf_tokens_rejected() { let app = csrf_app(); let resp = app .oneshot( Request::post("/api/test") .header("cookie", "nomifun-csrf-token=token_a") .header("x-csrf-token", "token_b") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn t12_2_login_exempt_from_csrf() { let app = csrf_app(); let resp = app .oneshot(Request::post("/login").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn t12_2_qr_login_exempt_from_csrf() { let app = csrf_app(); let resp = app .oneshot(Request::post("/api/auth/qr-login").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn t12_2_csrf_cookie_set_on_first_request() { let app = csrf_app(); let resp = app .oneshot(Request::get("/get-test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let set_cookie = resp.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap(); assert!(set_cookie.contains("nomifun-csrf-token=")); // NOT HttpOnly (JS must read it) assert!(!set_cookie.contains("HttpOnly")); } // ============================================================ // Rate limiter middleware // ============================================================ fn rate_limit_app(limiter: Arc) -> Router { Router::new() .route("/test", get(|| async { "ok" })) .layer(middleware::from_fn_with_state(limiter, api_rate_limit_middleware)) } #[tokio::test] async fn api_rate_limit_allows_within_quota() { let limiter = Arc::new(RateLimiter::new(3, Duration::from_secs(60))); let app = rate_limit_app(limiter); for _ in 0..3 { let resp = app .clone() .oneshot(Request::get("/test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } } #[tokio::test] async fn api_rate_limit_rejects_over_quota() { let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60))); let app = rate_limit_app(limiter); // First two pass for _ in 0..2 { let resp = app .clone() .oneshot(Request::get("/test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } // Third rejected let resp = app .oneshot(Request::get("/test").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); } #[tokio::test] async fn auth_rate_limit_skips_successful_responses() { let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60))); let app = Router::new() .route("/login", post(|| async { "ok" })) .layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware)); // Successful responses (200) don't count toward the limit for _ in 0..5 { let resp = app .clone() .oneshot(Request::post("/login").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } } #[tokio::test] async fn auth_rate_limit_counts_failed_responses() { let limiter = Arc::new(RateLimiter::new(2, Duration::from_secs(60))); let app = Router::new() .route("/login", post(|| async { StatusCode::UNAUTHORIZED })) .layer(middleware::from_fn_with_state(limiter, auth_rate_limit_middleware)); // First two failures pass through for _ in 0..2 { let resp = app .clone() .oneshot(Request::post("/login").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } // Third request blocked by rate limiter let resp = app .oneshot(Request::post("/login").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); } #[tokio::test] async fn authenticated_action_limit_uses_user_id_key() { let limiter = Arc::new(RateLimiter::new(1, Duration::from_secs(60))); // Handler that injects a CurrentUser extension before the limiter let app = Router::new() .route("/action", post(|| async { "done" })) .layer(middleware::from_fn_with_state( limiter.clone(), authenticated_action_rate_limit_middleware, )) .layer(middleware::from_fn( |mut request: axum::extract::Request, next: axum::middleware::Next| async { request.extensions_mut().insert(CurrentUser { id: "user_42".into(), username: "admin".into(), }); Ok::<_, std::convert::Infallible>(next.run(request).await) }, )); // First request passes let resp = app .clone() .oneshot(Request::post("/action").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); // Second request for same user is rate limited let resp = app .oneshot(Request::post("/action").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); } // ============================================================ // T12.3 — Cookie security attributes (via CookieConfig) // ============================================================ #[test] fn t12_3_session_cookie_is_httponly() { let config = CookieConfig { secure: false, same_site: "Lax", }; let cookie = config.build_session_cookie("token123"); assert!(cookie.contains("HttpOnly")); assert!(cookie.contains("SameSite=Lax")); assert!(cookie.contains("Max-Age=")); } #[test] fn t12_3_session_cookie_secure_when_https() { let config = CookieConfig { secure: true, same_site: "Strict", }; let cookie = config.build_session_cookie("token123"); assert!(cookie.contains("; Secure")); assert!(cookie.contains("SameSite=Strict")); } // ============================================================ // T13 — Token extraction strategy // ============================================================ #[test] fn t13_1_authorization_header_takes_priority() { let mut headers = axum::http::HeaderMap::new(); headers.insert(header::AUTHORIZATION, "Bearer header_tok".parse().unwrap()); headers.insert(header::COOKIE, "nomifun-session=cookie_tok".parse().unwrap()); assert_eq!( nomifun_auth::extract_token_from_headers(&headers), Some("header_tok".into()) ); } #[test] fn t13_2_cookie_fallback() { let mut headers = axum::http::HeaderMap::new(); headers.insert(header::COOKIE, "nomifun-session=fallback_tok".parse().unwrap()); assert_eq!( nomifun_auth::extract_token_from_headers(&headers), Some("fallback_tok".into()) ); } #[test] fn t13_3_no_token_returns_none() { let headers = axum::http::HeaderMap::new(); assert_eq!(nomifun_auth::extract_token_from_headers(&headers), None); }