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();
}