auth_service_resolved_config.rs
237 lignes · 8457 octets
//! RFC 0042 — `AuthService` / `AdminService` carry the `AuthConfig` resolved //! at construction (`with_auth_config`), so builder overrides are honoured //! and the per-request `AuthConfig::from_env()` is gone from the hot path. //! //! This file is a **separate test binary** on purpose: several `mod tests` //! of the library binary call `dotenvy::dotenv()` and load the repository //! `.env` (which contains `JWT_SECRET`) into the process. Here, with //! `#[serial_test::serial(env)]` serialising every test that mutates the //! environment (same intent as the mutex of //! `middleware_authconfig_fallback.rs`, without holding a `MutexGuard` //! across `.await`), we can really remove `JWT_SECRET` and prove that: //! //! - `with_auth_config` answers `401` without any environment at all; //! - the deprecated `new` path still answers `500` in the same situation //! (historical behaviour, RFC 0022), and still honours an //! `Extension(Arc<AuthConfig>)` posed *outside* the middleware. //! //! Plus an `#[ignore]`d micro-bench (RFC 0042 testing plan #16). #![allow(deprecated)] use std::sync::Arc; use std::time::Instant; use axum::body::Body; use axum::http::{Request, StatusCode, header::LOCATION}; use axum::response::Response; use axum::{Extension, Router, routing::get}; use rustwarden_core::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; use rustwarden_core::middleware::admin_service::AdminService; use rustwarden_core::middleware::auth_service::AuthService; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; use tower::ServiceExt; // for `oneshot` fn set_minimal_env() { // SAFETY: every caller is a `#[serial_test::serial(env)]` test, so no // other thread reads or writes the environment concurrently. unsafe { std::env::set_var( "JWT_SECRET", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ); std::env::set_var("APP_DEBUG", "true"); std::env::remove_var("AUTH_FAILURE_MODE"); std::env::remove_var("LOGIN_PATH"); } } fn clear_env() { // SAFETY: see set_minimal_env. unsafe { std::env::remove_var("JWT_SECRET"); std::env::remove_var("APP_DEBUG"); std::env::remove_var("AUTH_FAILURE_MODE"); std::env::remove_var("LOGIN_PATH"); } } fn injected_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { AuthConfig { jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), jwt_expiration_minutes: 15, jwt_issuer: "test".to_string(), refresh_token_expiration_days: 7, remember_me_expiration_days: 30, session_timeout_minutes: 30, session_backend: SessionBackend::Memory, redis_url: None, rate_limit_login_per_minute: 5, rate_limit_refresh_per_minute: 10, rate_limit_general_per_minute: 100, cookie_secure: false, cookie_same_site: "Strict".to_string(), login_path: login_path.to_string(), auth_failure_mode: mode, } } fn mock_db() -> DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres).into_connection() } fn prefixes() -> Arc<Vec<String>> { Arc::new(vec!["/private".to_string()]) } fn routes() -> Router { Router::new() .route("/private", get(|| async { "secret" })) .route("/admin", get(|| async { "admin" })) } /// Canonical 1.2.0 wiring: both middlewares carry the resolved config. fn stack_with_auth_config(cfg: AuthConfig) -> Router { let cfg = Arc::new(cfg); let (db_admin, cfg_admin) = (mock_db(), cfg.clone()); let (db_auth, cfg_auth, p) = (mock_db(), cfg, prefixes()); routes() .layer(tower::layer::layer_fn(move |inner| { AuthService::with_auth_config(inner, db_auth.clone(), p.clone(), cfg_auth.clone()) })) .layer(tower::layer::layer_fn(move |inner| { AdminService::with_auth_config(inner, db_admin.clone(), cfg_admin.clone()) })) } /// Legacy wiring (deprecated `new`): no captured config. fn stack_legacy() -> Router { let (db_admin, db_auth, p) = (mock_db(), mock_db(), prefixes()); routes() .layer(tower::layer::layer_fn(move |inner| { AuthService::new(inner, db_auth.clone(), p.clone()) })) .layer(tower::layer::layer_fn(move |inner| { AdminService::new(inner, db_admin.clone()) })) } async fn get_path(router: Router, path: &str) -> Response { let req = Request::builder() .uri(path) .body(Body::empty()) .expect("request"); router.oneshot(req).await.expect("oneshot") } fn location(resp: &Response) -> Option<&str> { resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) } #[tokio::test] #[serial_test::serial(env)] async fn with_auth_config_answers_without_any_environment() { clear_env(); assert!( AuthConfig::from_env().is_err(), "precondition: no JWT_SECRET in the environment" ); let cfg = injected_config(AuthFailureMode::Json401, "/login"); let resp = get_path(stack_with_auth_config(cfg), "/private").await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let cfg = injected_config(AuthFailureMode::Json401, "/login"); let resp = get_path(stack_with_auth_config(cfg), "/admin").await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] #[serial_test::serial(env)] async fn with_auth_config_honours_a_custom_login_path_without_environment() { clear_env(); let cfg = injected_config(AuthFailureMode::Redirect, "/auth/signin"); let resp = get_path(stack_with_auth_config(cfg), "/private").await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); } #[tokio::test] #[serial_test::serial(env)] async fn legacy_new_without_extension_nor_environment_answers_500() { clear_env(); let resp = get_path(stack_legacy(), "/private").await; assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); let resp = get_path(stack_legacy(), "/admin").await; assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); } #[tokio::test] #[serial_test::serial(env)] async fn legacy_new_uses_an_extension_posed_outside_the_middleware() { clear_env(); // `Router::layer` after the middlewares = outermost = visible to them. let cfg = Arc::new(injected_config(AuthFailureMode::Redirect, "/auth/signin")); let resp = get_path(stack_legacy().layer(Extension(cfg)), "/private").await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); } #[tokio::test] #[serial_test::serial(env)] async fn legacy_new_falls_back_to_the_environment_and_ignores_overrides() { set_minimal_env(); // No extension, env has no AUTH_FAILURE_MODE → from_env() default: Redirect to /login. let resp = get_path(stack_legacy(), "/private").await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(location(&resp), Some("/login?redirect=%2Fprivate")); clear_env(); } /// RFC 0042 testing plan #16 — micro-bench, run with /// `cargo test -p rustwarden-core --test auth_service_resolved_config -- --ignored --nocapture bench`. #[tokio::test] #[serial_test::serial(env)] #[ignore = "micro-bench, run explicitly with --ignored --nocapture"] async fn bench_from_env_versus_captured_config() { set_minimal_env(); const N: u32 = 10_000; let start = Instant::now(); for _ in 0..N { let _ = AuthConfig::from_env(); } let from_env = start.elapsed(); let start = Instant::now(); for _ in 0..N { let resp = get_path(stack_legacy(), "/private").await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); } let legacy = start.elapsed(); let start = Instant::now(); for _ in 0..N { let cfg = injected_config(AuthFailureMode::Redirect, "/login"); let resp = get_path(stack_with_auth_config(cfg), "/private").await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); } let captured = start.elapsed(); println!( "bench RFC 0042 (N={N}): from_env alone = {:?} ({:?}/call); legacy stack = {:?} ({:?}/req); with_auth_config stack = {:?} ({:?}/req)", from_env, from_env / N, legacy, legacy / N, captured, captured / N, ); clear_env(); }
GitRust