app.rs 1551 lignes · 62506 octets
//! Application builder for Rustwarden
//!
//! Provides a builder pattern to configure and launch a Rustwarden application.
//!
//! # Example (with default UI)
//!
//! ```rust,ignore
//! use rustwarden_core::app::RustwardenApp;
//!
//! #[tokio::main]
//! async fn main() -> anyhow::Result<()> {
//!     RustwardenApp::builder()
//!         .from_env()
//!         .build()
//!         .await?
//!         .run()
//!         .await
//! }
//! ```

use axum::{
    Router,
    extract::Request,
    http::{HeaderValue, Method, header::HeaderName},
    middleware::Next,
    response::Response,
};
use sea_orm::DatabaseConnection;
use std::path::PathBuf;
use std::sync::Arc;
use tower::ServiceBuilder;
use tower_http::cors::{AllowOrigin, Any, CorsLayer};

use crate::auth::session::SessionConfig;
use crate::config::analytics::{AnalyticsConfig, AnalyticsState};
use crate::config::auth::AuthConfig;
use crate::config::database;
use crate::config::email::EmailConfig;
use crate::config::security_headers::{CSP_WITHOUT_UNSAFE_EVAL, SecurityHeadersConfig};
use crate::config::server::ServerConfig;
use crate::dashboard::DashboardCardDTO;
use crate::hooks::{NoopHooks, RustwardenHooks};
use crate::i18n::extractor::Locale;
use crate::i18n::loader::load_translations_from_directory;
use crate::i18n::service::I18nService;
use crate::middleware::admin_service::AdminService;
use crate::middleware::auth_service::AuthService;
use crate::middleware::csrf::{
    CsrfProtectionConfig, DEFAULT_MAX_FORM_BODY_BYTES, csrf_protection_with_config,
};
use crate::services::app_settings_service::AppSettingsService;
use crate::services::default_admin_service;

/// A fully configured Rustwarden application ready to run.
///
/// After `build()`, getters expose internal resources (DB, config, i18n)
/// so consumer apps can run migrations, seed data, or translate messages
/// before calling `run()`.
pub struct RustwardenApp {
    router: Router,
    address: String,
    db: DatabaseConnection,
    auth_config: Arc<AuthConfig>,
    i18n: Arc<I18nService>,
}

impl RustwardenApp {
    /// Create a new builder to configure the application.
    pub fn builder() -> RustwardenBuilder {
        RustwardenBuilder::default()
    }

    /// Access the database connection (pool) used by the framework.
    ///
    /// Allows consumer apps to run business migrations, seed data, or
    /// execute queries on the same connection pool as the framework.
    ///
    /// # Examples
    ///
    /// ```rust,ignore
    /// let app = RustwardenApp::builder().from_env().build().await?;
    /// my_app::migrations::run_migrations(app.database()).await?;
    /// app.run().await
    /// ```
    pub fn database(&self) -> &DatabaseConnection {
        &self.db
    }

    /// Access the authentication configuration.
    ///
    /// Exposes JWT settings, session timeouts, rate limits, and cookie
    /// configuration for consumer app logic that depends on auth parameters.
    pub fn auth_config(&self) -> &AuthConfig {
        &self.auth_config
    }

    /// Access the i18n translation service.
    ///
    /// Allows consumer apps to translate messages in non-handler code
    /// (cron tasks, seeding, CLI) or load additional translations after build.
    pub fn i18n_service(&self) -> &Arc<I18nService> {
        &self.i18n
    }

    /// Server bind address (e.g. "0.0.0.0:3000").
    pub fn address(&self) -> &str {
        &self.address
    }

    /// Get the assembled router (for advanced usage, e.g. testing).
    ///
    /// Callers who serve this router themselves **must** use
    /// [`axum::Router::into_make_service_with_connect_info`] with
    /// [`std::net::SocketAddr`]. Without that extension, the auth IP
    /// rate-limit cannot see the peer address and keys every request as
    /// `unknown`. [`RustwardenApp::run`] does this automatically.
    pub fn into_router(self) -> Router {
        self.router
    }

    /// Start serving HTTP requests.
    pub async fn run(self) -> anyhow::Result<()> {
        let listener = tokio::net::TcpListener::bind(&self.address).await?;
        tracing::info!("Server listening on http://{}", self.address);
        axum::serve(
            listener,
            self.router
                .into_make_service_with_connect_info::<std::net::SocketAddr>(),
        )
        .await
        .map_err(|e| anyhow::anyhow!("Server error: {}", e))
    }
}

type RoutesFn = Box<dyn FnOnce() -> Router<DatabaseConnection> + Send>;

/// Builder for configuring a Rustwarden application.
pub struct RustwardenBuilder {
    database_url: Option<String>,
    db: Option<DatabaseConnection>,
    server_address: Option<String>,
    /// Browser-family routers (session-cookie driven UI). These receive the
    /// CSRF protection layer before being merged into the application router.
    /// Fed by [`RustwardenBuilder::merge_routes`] /
    /// [`RustwardenBuilder::merge_browser_routes`].
    custom_routes: Vec<Router<DatabaseConnection>>,
    /// Machine-family routers (JSON APIs, webhooks, ActivityPub inbox, OAuth
    /// callbacks). These are merged *without* the CSRF layer. Fed by
    /// [`RustwardenBuilder::merge_machine_routes`].
    machine_routes: Vec<Router<DatabaseConnection>>,
    ui_routes_fn: Option<RoutesFn>,
    hooks: Arc<dyn RustwardenHooks>,
    auto_migrate: bool,
    enable_email: bool,
    enable_background_tasks: bool,
    locale_dirs: Vec<PathBuf>,
    supported_locales: Vec<String>,
    default_locale: String,
    /// URL path prefixes that the authentication middleware must protect.
    /// Empty by default; [`RustwardenBuilder::with_ui`] registers the default
    /// UI areas (`/home`, `/profile`, `/settings`, `/admin`) and companion
    /// crates / consumers add their own via `register_protected_prefix`.
    pub(crate) protected_prefixes: Vec<String>,
    /// Optional revocation checker consulted by `AuthService` for remote
    /// JWTs (RFC 0018 / F10). Injected by `rustwarden-fediverse` when
    /// the consumer mounts its routes; left unset for headless apps.
    pub(crate) revocation_checker:
        Option<Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>>,
    /// Extra cards rendered at the bottom of the admin dashboard.
    /// Filled by companion crates (e.g. `rustwarden-fediverse-ui`) via
    /// [`RustwardenBuilder::add_dashboard_card`]; injected as an Axum
    /// `Extension<Vec<DashboardCardDTO>>` so `admin_dashboard` can pick
    /// them up at render time.
    pub(crate) dashboard_cards: Vec<DashboardCardDTO>,
    /// Override for [`crate::config::auth::AuthConfig::login_path`] applied at
    /// build time. `None` keeps the value resolved from the environment.
    login_path: Option<String>,
    /// Override for [`crate::config::auth::AuthConfig::auth_failure_mode`].
    /// `None` keeps the env/default value, unless `headless` flips it.
    auth_failure_mode: Option<crate::config::auth::AuthFailureMode>,
    /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure
    /// mode to `Json401` when no explicit mode was requested.
    headless: bool,
    /// Security response headers applied by the security headers middleware
    /// (RFC 0039). Defaults to the 1.0.1-compatible policy: legacy CSP,
    /// no COOP/CORP. Overridden via [`RustwardenBuilder::security_headers`].
    security_headers: SecurityHeadersConfig,
    /// Audience-measurement configuration. Defaults to disabled, which
    /// renders nothing and leaves the CSP untouched. Set via
    /// [`RustwardenBuilder::analytics`].
    analytics: AnalyticsConfig,
}

impl Default for RustwardenBuilder {
    fn default() -> Self {
        Self {
            database_url: None,
            db: None,
            server_address: None,
            custom_routes: Vec::new(),
            machine_routes: Vec::new(),
            ui_routes_fn: None,
            hooks: Arc::new(NoopHooks),
            auto_migrate: false,
            enable_email: true,
            enable_background_tasks: true,
            locale_dirs: Vec::new(),
            supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()],
            default_locale: "fr".into(),
            // Empty by default so headless (API-only) apps carry no
            // UI-specific prefixes. `with_ui` registers the default UI set;
            // consumers add their own via `register_protected_prefix`.
            protected_prefixes: Vec::new(),
            revocation_checker: None,
            dashboard_cards: Vec::new(),
            login_path: None,
            auth_failure_mode: None,
            headless: false,
            security_headers: SecurityHeadersConfig::default(),
            analytics: AnalyticsConfig::default(),
        }
    }
}

impl RustwardenBuilder {
    /// Load configuration from environment variables (.env file).
    pub fn from_env(mut self) -> Self {
        dotenvy::dotenv().ok();

        tracing_subscriber::fmt()
            .with_env_filter(
                tracing_subscriber::EnvFilter::try_from_default_env()
                    .unwrap_or_else(|_| "rustwarden_core=debug,rustwarden_ui=debug".into()),
            )
            .init();

        self.database_url = std::env::var("DATABASE_URL").ok();
        self
    }

    /// Set the database URL explicitly.
    pub fn database_url(mut self, url: impl Into<String>) -> Self {
        self.database_url = Some(url.into());
        self
    }

    /// Provide an already-established database connection.
    pub fn database(mut self, db: DatabaseConnection) -> Self {
        self.db = Some(db);
        self
    }

    /// Set the server bind address (e.g. "0.0.0.0:3000").
    pub fn address(mut self, addr: impl Into<String>) -> Self {
        self.server_address = Some(addr.into());
        self
    }

    /// Override security headers (CSP / optional COOP / CORP).
    ///
    /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP,
    /// no COOP/CORP), so consumers that never call this method keep
    /// bit-identical response headers. Call this to supply a stricter
    /// `Content-Security-Policy` or to opt into cross-origin isolation
    /// (e.g. Gitrust dropping its `SetResponseHeaderLayer::overriding`
    /// workaround — RFC 0039).
    pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self {
        self.security_headers = cfg;
        self
    }

    /// Configure audience measurement (analytics).
    ///
    /// Default: [`AnalyticsConfig::default`] — disabled, rendering nothing
    /// and leaving the CSP untouched.
    ///
    /// When a script URL is configured, [`build`](Self::build) allows its
    /// origin in the `script-src` and `connect-src` CSP directives. That
    /// happens **at build time, not here**, so the order of the
    /// `.analytics(..)` and `.security_headers(..)` calls does not matter: a
    /// later `.security_headers(..)` cannot silently drop the allowance.
    ///
    /// The runtime toggle, site identifier and extra attributes can be
    /// overridden from the `app_settings` table; the script URL cannot, since
    /// the CSP is frozen once the router is built. See
    /// [`crate::config::analytics`].
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use rustwarden_core::app::RustwardenApp;
    /// use rustwarden_core::config::analytics::AnalyticsConfig;
    ///
    /// let builder = RustwardenApp::builder().analytics(
    ///     AnalyticsConfig::default()
    ///         .with_script_url("https://stats.example.org/api/script.js")
    ///         .with_site_id("abc123")
    ///         .with_opt_out_storage_key("disable-rybbit")
    ///         .enabled(true),
    /// );
    /// ```
    pub fn analytics(mut self, cfg: AnalyticsConfig) -> Self {
        self.analytics = cfg;
        self
    }

    /// Merge additional custom routes into the **browser** family.
    ///
    /// Browser routes are session-cookie driven and therefore receive the
    /// CSRF protection layer: any `POST`/`PUT`/`DELETE`/`PATCH` reaching a
    /// route merged here is rejected with `403` unless it carries a valid
    /// CSRF token (header `X-CSRF-Token` or form field `csrf_token`), or an
    /// `Authorization: Bearer` header. This is the fail-safe default (D2=A):
    /// a third-party mutating route inherits protection without extra code.
    ///
    /// Routes that authenticate via something other than a session cookie
    /// (JSON APIs, webhooks, ActivityPub inbox, OAuth callbacks) must be
    /// mounted via [`RustwardenBuilder::merge_machine_routes`] instead, or
    /// they will receive spurious `403`s.
    pub fn merge_routes(mut self, router: Router<DatabaseConnection>) -> Self {
        self.custom_routes.push(router);
        self
    }

    /// Explicit alias of [`RustwardenBuilder::merge_routes`]: merge routes
    /// into the **browser** family (CSRF-protected). Prefer this name at call
    /// sites where the browser/machine distinction matters for readability.
    pub fn merge_browser_routes(self, router: Router<DatabaseConnection>) -> Self {
        self.merge_routes(router)
    }

    /// Merge additional custom routes into the **machine** family.
    ///
    /// Machine routes are *not* covered by the CSRF layer. Use this channel
    /// for endpoints that are not browser/session-cookie driven and thus not
    /// CSRF-vulnerable: JSON APIs authenticated by `Authorization: Bearer`,
    /// webhooks, OAuth callbacks, and the ActivityPub inbox (authenticated by
    /// HTTP signatures, where no CSRF token can exist).
    pub fn merge_machine_routes(mut self, router: Router<DatabaseConnection>) -> Self {
        self.machine_routes.push(router);
        self
    }

    /// Register the default UI routes (login, profile, admin panel).
    /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`).
    ///
    /// This also registers the authenticated UI areas (`/home`, `/profile`,
    /// `/settings`, `/admin`) as protected prefixes, so the default UI stays
    /// protected without the core hard-wiring these paths for every app.
    /// Headless apps, which never call `with_ui`, do not inherit them.
    pub fn with_ui(
        mut self,
        routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static,
    ) -> Self {
        self.ui_routes_fn = Some(Box::new(routes_fn));
        for prefix in ["/home", "/profile", "/settings", "/admin"] {
            if !self.protected_prefixes.iter().any(|p| p == prefix) {
                self.protected_prefixes.push(prefix.to_string());
            }
        }
        self
    }

    /// Register custom lifecycle hooks.
    pub fn hooks(mut self, hooks: impl RustwardenHooks + 'static) -> Self {
        self.hooks = Arc::new(hooks);
        self
    }

    /// Run in headless mode (API-only, no UI).
    ///
    /// Besides dropping the default UI routes, this flips the *default*
    /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so
    /// protected prefixes, `/admin*` and `/api/v1/admin*` answer
    /// unauthenticated requests with `401` JSON instead of redirecting to a
    /// `/login` page that does not exist.
    ///
    /// It is only a default: an explicit
    /// [`RustwardenBuilder::auth_failure_mode`] call wins, and so does an
    /// explicit `AUTH_FAILURE_MODE` environment variable (RFC 0042). Since
    /// 1.2.0 the flip is applied by every enforcement point, including the
    /// prefix middlewares `AuthService` / `AdminService`.
    ///
    /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401
    pub fn headless(mut self) -> Self {
        self.ui_routes_fn = None;
        self.headless = true;
        self
    }

    /// Override the browser login redirect path
    /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`).
    ///
    /// Precedence: this setter, then the `LOGIN_PATH` env value, then `/login`.
    /// Honoured by the
    /// prefix middlewares (`AuthService`, `AdminService`), the extractors
    /// (`AuthUser`, `auth_required`, `admin_required`) and the UI root
    /// redirect (RFC 0042).
    pub fn login_path(mut self, path: impl Into<String>) -> Self {
        self.login_path = Some(path.into());
        self
    }

    /// Override how the auth middleware answers unauthenticated requests to
    /// protected routes
    /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]).
    ///
    /// Precedence: this setter, then the `AUTH_FAILURE_MODE` env value, then
    /// the default flipped by [`RustwardenBuilder::headless`], then `Redirect`.
    /// Honoured by
    /// every enforcement point since 1.2.0 (RFC 0042).
    pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self {
        self.auth_failure_mode = Some(mode);
        self
    }

    /// Register a URL path prefix that the authentication middleware must
    /// protect (redirect anonymous visitors to the login page, or reply
    /// `401` in headless mode). The set is empty by default;
    /// [`RustwardenBuilder::with_ui`] registers the default UI areas
    /// (`/home`, `/profile`, `/settings`, `/admin`), and consumers and
    /// companion crates add more on top — for instance
    /// `rustwarden-fediverse::mount` adds `/fediverse/consent` and
    /// `/fediverse/invite`.
    pub fn register_protected_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.protected_prefixes.push(prefix.into());
        self
    }

    /// Register an implementation of
    /// [`crate::middleware::revocation::RemoteIdentityRevocationChecker`]
    /// so the auth middleware can refuse remote JWTs whose backing
    /// identity has been disabled (RFC 0018 / F10). Called by
    /// `rustwarden_fediverse::mount` ; headless apps without federation
    /// leave this unset and the middleware skips the check.
    pub fn register_revocation_checker(
        mut self,
        checker: Arc<dyn crate::middleware::revocation::RemoteIdentityRevocationChecker>,
    ) -> Self {
        self.revocation_checker = Some(checker);
        self
    }

    /// Append a card to the admin dashboard.
    ///
    /// The card is exposed to handlers as part of an Axum
    /// `Extension<Vec<DashboardCardDTO>>` and rendered by
    /// `admin_dashboard` after the built-in cards. Companion crates
    /// (e.g. `rustwarden-fediverse-ui`) ship a constructor returning a
    /// pre-filled DTO so the consumer only has to pipe it into the
    /// builder.
    pub fn add_dashboard_card(mut self, card: DashboardCardDTO) -> Self {
        self.dashboard_cards.push(card);
        self
    }

    /// Enable automatic database migration at startup.
    pub fn auto_migrate(mut self, enabled: bool) -> Self {
        self.auto_migrate = enabled;
        self
    }

    /// Disable email features.
    pub fn without_email(mut self) -> Self {
        self.enable_email = false;
        self
    }

    /// Disable background tasks (email queue, JWT cleanup, etc.).
    pub fn without_background_tasks(mut self) -> Self {
        self.enable_background_tasks = false;
        self
    }

    /// Add a directory to search for locale YAML files.
    pub fn locale_dir(mut self, path: impl Into<PathBuf>) -> Self {
        self.locale_dirs.push(path.into());
        self
    }

    /// Set supported locales (default: fr, en, de, es).
    pub fn supported_locales(mut self, locales: Vec<String>) -> Self {
        self.supported_locales = locales;
        self
    }

    /// Set the default locale (default: "fr").
    pub fn default_locale(mut self, locale: impl Into<String>) -> Self {
        self.default_locale = locale.into();
        self
    }

    /// Apply the builder overrides on top of the environment-loaded config.
    ///
    /// Pure: the environment is never read here. `failure_mode_set_in_env`
    /// tells whether `AUTH_FAILURE_MODE` was explicitly set, so an operator
    /// value keeps precedence over the `headless()` default flip (RFC 0042).
    ///
    /// Precedence for `auth_failure_mode`: the `auth_failure_mode()` setter,
    /// then the `AUTH_FAILURE_MODE` env value, then the `headless()` flip to
    /// `Json401`, then `Redirect`.
    /// Precedence for `login_path`: the `login_path()` setter, then the
    /// `LOGIN_PATH` env value, then `/login`.
    /// Whether a *valid* `AUTH_FAILURE_MODE` value is present in the
    /// environment. Pure: takes the raw value so it can be unit-tested.
    ///
    /// Presence alone is not enough: `AuthConfig::from_env` silently falls
    /// back to `Redirect` when the value does not parse (empty string, typo),
    /// and that fallback must not suppress the `headless()` flip.
    fn failure_mode_overridden_by_env(raw: Option<&str>) -> bool {
        raw.and_then(|value| value.parse::<crate::config::auth::AuthFailureMode>().ok())
            .is_some()
    }

    fn apply_auth_overrides(
        &self,
        mut cfg: AuthConfig,
        failure_mode_set_in_env: bool,
    ) -> AuthConfig {
        if let Some(path) = &self.login_path {
            cfg.login_path = path.clone();
        }
        if let Some(mode) = self.auth_failure_mode {
            cfg.auth_failure_mode = mode;
        } else if self.headless && !failure_mode_set_in_env {
            cfg.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401;
        }
        cfg
    }

    /// Build the application. Connects to the database, initializes services,
    /// and assembles the router with all middleware.
    pub async fn build(self) -> anyhow::Result<RustwardenApp> {
        tracing::info!("Building Rustwarden application...");

        // 0. Auth config (SEC-M1: load once, share via Extension; RFC 0042:
        // the resolved config is also handed to `AuthService` /
        // `AdminService` at construction so builder overrides reach them).
        // Resolved before the database is taken out of `self`, as the
        // overrides live on the builder.
        let failure_mode_set_in_env = Self::failure_mode_overridden_by_env(
            std::env::var("AUTH_FAILURE_MODE").ok().as_deref(),
        );
        let auth_config = self.apply_auth_overrides(
            AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?,
            failure_mode_set_in_env,
        );

        // 1. Database connection
        let db: DatabaseConnection = if let Some(db) = self.db {
            db
        } else {
            if let Some(ref url) = self.database_url {
                // SAFETY: `build()` runs during single-threaded application
                // initialization, before any worker tasks or background threads
                // are spawned, so mutating the process environment is sound.
                unsafe {
                    std::env::set_var("DATABASE_URL", url);
                }
            }
            database::create_connection().await?
        };
        tracing::info!("Database connection established");

        // 1b. Auto-migrate if enabled
        if self.auto_migrate {
            tracing::info!("Running database migrations...");
            crate::migrations::run_migrations(&db)
                .await
                .map_err(|e| anyhow::anyhow!("Migration error: {}", e))?;
            tracing::info!("Database migrations completed");
        }

        // 2. Default admin & settings
        default_admin_service::ensure_default_admin(&db).await?;
        AppSettingsService::initialize_default_settings(&db).await?;
        tracing::info!("Default admin and app settings initialized");

        // 3. Session layer — built from the final config (after overrides,
        // step 0): it reads the cookie / timeout settings.
        let session_layer = SessionConfig::create_memory_layer(&auth_config);
        let auth_config = Arc::new(auth_config);
        let auth_config_for_admin = auth_config.clone();
        let auth_config_for_auth = auth_config.clone();
        tracing::info!("Session management configured");

        // 4. Email & background tasks
        if self.enable_email && self.enable_background_tasks {
            Self::start_background_tasks(&db).await;
        }

        // 5. JWT cleanup task
        if self.enable_background_tasks {
            let db_for_cleanup = db.clone();
            tokio::spawn(async move {
                let secs = crate::config::tasks::jwt_cleanup_interval_secs();
                let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(secs));
                loop {
                    interval.tick().await;
                    match crate::services::jwt_blacklist_service::JwtBlacklistService::cleanup_expired_tokens(&db_for_cleanup).await {
                        Ok(count) => {
                            if count > 0 {
                                tracing::info!("Cleaned up {} expired JWT tokens from blacklist", count);
                            }
                        }
                        Err(e) => {
                            tracing::error!("Failed to cleanup expired JWT tokens: {}", e);
                        }
                    }
                }
            });
            tracing::info!("JWT blacklist cleanup task started");
        }

        // 6. I18n
        let i18n_service = Self::init_i18n(&self.locale_dirs).await;

        // 6b. OAuth provider registry (if oauth feature enabled)
        #[cfg(feature = "oauth")]
        let oauth_registry = {
            use crate::config::oauth::OAuthConfig;
            use crate::services::oauth::providers::{
                ProviderRegistry, discord::DiscordProvider, github::GitHubProvider,
                google::GoogleProvider, microsoft::MicrosoftProvider,
            };

            let oauth_config = OAuthConfig::load(&db).await;
            let mut registry = ProviderRegistry::new();

            if oauth_config.enabled {
                for provider_cfg in oauth_config.enabled_providers() {
                    match provider_cfg.name.as_str() {
                        "google" => {
                            registry.register(Arc::new(GoogleProvider::new(
                                provider_cfg.client_id.clone(),
                                provider_cfg.client_secret.clone(),
                            )));
                            tracing::info!("OAuth provider registered: Google");
                        }
                        "github" => {
                            registry.register(Arc::new(GitHubProvider::new(
                                provider_cfg.client_id.clone(),
                                provider_cfg.client_secret.clone(),
                            )));
                            tracing::info!("OAuth provider registered: GitHub");
                        }
                        "discord" => {
                            registry.register(Arc::new(DiscordProvider::new(
                                provider_cfg.client_id.clone(),
                                provider_cfg.client_secret.clone(),
                            )));
                            tracing::info!("OAuth provider registered: Discord");
                        }
                        "microsoft" => {
                            registry.register(Arc::new(MicrosoftProvider::new(
                                provider_cfg.client_id.clone(),
                                provider_cfg.client_secret.clone(),
                                provider_cfg.tenant.clone(),
                            )));
                            tracing::info!("OAuth provider registered: Microsoft");
                        }
                        _ => {
                            tracing::warn!("Unknown OAuth provider: {}", provider_cfg.name);
                        }
                    }
                }
            }

            Arc::new(registry)
        };

        // 7. Build router — split into two families (cf. CSRF plan §4.1):
        //   * browser_router  — UI / session-cookie driven, CSRF-protected.
        //   * machine_router  — JSON APIs, webhooks, OAuth callbacks, the
        //                       ActivityPub inbox: never CSRF-protected.
        // The CSRF layer is applied to `browser_router` *before* the merge so
        // it covers the browser family topologically and never leaks onto the
        // machine family. The shared `session_layer` stays global (below) and
        // therefore runs *before* the CSRF check, satisfying the `Session`
        // extractor's prerequisite.
        let db_for_csrf = db.clone();

        let mut browser_router: Router<DatabaseConnection> = Router::new();
        if let Some(ui_fn) = self.ui_routes_fn {
            browser_router = browser_router.merge(ui_fn());
        }
        for custom in self.custom_routes {
            browser_router = browser_router.merge(custom);
        }

        let mut machine_router: Router<DatabaseConnection> = Router::new();
        for machine in self.machine_routes {
            machine_router = machine_router.merge(machine);
        }

        // 7b. OAuth routes (conditional on feature + config) — machine family:
        // JSON API, GETs are safe, the unlink DELETE is Bearer/JSON, never a
        // browser form submission, so no CSRF token applies.
        #[cfg(feature = "oauth")]
        {
            use crate::auth::oauth_handlers;
            use axum::routing::get;

            let oauth_routes = Router::new()
                .route(
                    "/api/v1/auth/oauth/{provider}/redirect",
                    get(oauth_handlers::oauth_redirect),
                )
                .route(
                    "/api/v1/auth/oauth/{provider}/callback",
                    get(oauth_handlers::oauth_callback),
                )
                .route(
                    "/api/v1/auth/oauth/providers",
                    get(oauth_handlers::list_providers),
                )
                .route(
                    "/api/v1/auth/oauth/accounts",
                    get(oauth_handlers::list_user_oauth_accounts),
                )
                .route(
                    "/api/v1/auth/oauth/accounts/{id}",
                    axum::routing::delete(oauth_handlers::unlink_oauth_account),
                );
            machine_router = machine_router.merge(oauth_routes);
        }

        // Federation routes now live in the `rustwarden-fediverse` crate;
        // consumers mount them themselves via `merge_machine_routes`
        // (inbox) / `merge_browser_routes` (UI) — cf. `app/src/main.rs`.

        // Apply CSRF to the browser family only, before merging. Layers
        // attached to a sub-router are preserved across `Router::merge`, so
        // the machine family stays free of the CSRF check.
        let csrf_cfg = Arc::new(CsrfProtectionConfig {
            accept_form_body: true,
            max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES,
        });
        let browser_router = browser_router.layer(axum::middleware::from_fn_with_state(
            (db_for_csrf, csrf_cfg),
            csrf_protection_with_config,
        ));

        let router = browser_router.merge(machine_router);

        // 8. Apply middleware stack
        let supported = self.supported_locales.clone();
        let default = self.default_locale.clone();
        let db_for_admin = db.clone();
        let db_for_auth = db.clone();
        let db_for_app = db.clone(); // Retained for getter access after build

        let hooks = self.hooks.clone();
        let db_for_ext = db.clone();
        let i18n_for_app = i18n_service.clone(); // Retained for getter access after build
        let auth_config_for_app = auth_config.clone(); // Retained for getter access after build

        let origins_str = std::env::var("CORS_ALLOWED_ORIGINS").unwrap_or_default();
        let cors_layer = if origins_str == "*" {
            tracing::warn!("CORS_ALLOWED_ORIGINS=* allows any origin. Use only in development.");
            CorsLayer::new()
                .allow_origin(Any)
                .allow_methods([
                    Method::GET,
                    Method::POST,
                    Method::PUT,
                    Method::DELETE,
                    Method::PATCH,
                ])
                .allow_headers([
                    axum::http::header::CONTENT_TYPE,
                    axum::http::header::AUTHORIZATION,
                    HeaderName::from_static("x-csrf-token"),
                ])
                .max_age(std::time::Duration::from_secs(3600))
        } else if !origins_str.is_empty() {
            let origins: Vec<axum::http::HeaderValue> = origins_str
                .split(',')
                .filter_map(|s| s.trim().parse().ok())
                .collect();
            if origins.is_empty() {
                tracing::warn!("CORS_ALLOWED_ORIGINS set but no valid origins parsed");
                CorsLayer::new().allow_origin(AllowOrigin::list(std::iter::empty::<
                    axum::http::HeaderValue,
                >()))
            } else {
                tracing::info!("CORS configured for {} origins", origins.len());
                CorsLayer::new()
                    .allow_origin(AllowOrigin::list(origins))
                    .allow_methods([
                        Method::GET,
                        Method::POST,
                        Method::PUT,
                        Method::DELETE,
                        Method::PATCH,
                    ])
                    .allow_headers([
                        axum::http::header::CONTENT_TYPE,
                        axum::http::header::AUTHORIZATION,
                        HeaderName::from_static("x-csrf-token"),
                    ])
                    .allow_credentials(true)
                    .max_age(std::time::Duration::from_secs(3600))
            }
        } else {
            CorsLayer::new().allow_origin(AllowOrigin::list(std::iter::empty::<
                axum::http::HeaderValue,
            >()))
        };

        let protected_prefixes = std::sync::Arc::new(self.protected_prefixes.clone());
        let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes);
        let revocation_checker = self.revocation_checker.clone();
        let dashboard_cards = self.dashboard_cards.clone();
        // RFC 0039: the security-headers middleware is the layer right after
        // CORS, i.e. *outside* every `Extension(...)` below. Request
        // extensions are therefore never visible to it on the way in, so the
        // config is captured in the closure as an `Arc` (same pattern as
        // `locale_middleware`), NOT injected via `Extension`.
        // The analytics origin is folded into the CSP *here* rather than in
        // `RustwardenBuilder::analytics`, so that a later `.security_headers(..)`
        // call cannot silently drop the allowance. A disabled configuration
        // with no script URL leaves the policy byte-for-byte untouched.
        let mut security_headers = self.security_headers.clone();
        if let Some(origin) = self.analytics.script_origin() {
            security_headers = security_headers.allowing_script_origin(&origin);
        }
        let headers_cfg = std::sync::Arc::new(security_headers);

        // Resolve the `app_settings` overrides once, at startup. Failing to
        // read them must not prevent the application from booting: fall back
        // to the environment configuration.
        let analytics_state = match AnalyticsState::load(&db, self.analytics.clone()).await {
            Ok(state) => std::sync::Arc::new(state),
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "could not read analytics settings; using the environment configuration"
                );
                std::sync::Arc::new(AnalyticsState::from_config(self.analytics.clone()))
            }
        };

        let app = router.with_state(db).layer(
            ServiceBuilder::new()
                .layer(cors_layer)
                .layer(axum::middleware::from_fn({
                    let cfg = headers_cfg.clone();
                    move |req, next| {
                        let cfg = cfg.clone();
                        async move { security_headers_middleware(cfg, req, next).await }
                    }
                }))
                .layer_fn(move |service| {
                    AdminService::with_auth_config(
                        service,
                        db_for_admin.clone(),
                        auth_config_for_admin.clone(),
                    )
                })
                .layer_fn(move |service| {
                    AuthService::with_auth_config(
                        service,
                        db_for_auth.clone(),
                        prefixes_for_auth.clone(),
                        auth_config_for_auth.clone(),
                    )
                })
                .layer(session_layer)
                .layer(axum::middleware::from_fn(move |req, next| {
                    let supported = supported.clone();
                    let default = default.clone();
                    locale_middleware(req, next, supported, default)
                }))
                .layer(axum::Extension(i18n_service))
                .layer(axum::Extension(hooks))
                .layer(axum::Extension(auth_config))
                .layer(axum::Extension(db_for_ext))
                .layer(axum::Extension(analytics_state))
                .into_inner(),
        );

        // Wire the revocation checker as an Extension so `AuthService`
        // can consult it. Registered by `rustwarden-fediverse` via
        // `RustwardenBuilder::register_revocation_checker`; absent when
        // the federation crate isn't linked.
        let app = if let Some(checker) = revocation_checker {
            app.layer(axum::Extension(checker))
        } else {
            app
        };

        // Expose the configured dashboard cards as an Axum extension so
        // `admin_dashboard` can render them. Always inserted (even when
        // the list is empty) so the handler signature stays uniform.
        let app = app.layer(axum::Extension(dashboard_cards));

        #[cfg(feature = "oauth")]
        let app = app.layer(axum::Extension(oauth_registry));

        // 9. Resolve address
        let address = self
            .server_address
            .unwrap_or_else(|| ServerConfig::from_env().address());

        tracing::info!("Rustwarden application built successfully");

        Ok(RustwardenApp {
            router: app,
            address,
            db: db_for_app,
            auth_config: auth_config_for_app,
            i18n: i18n_for_app,
        })
    }

    async fn start_background_tasks(db: &DatabaseConnection) {
        let email_config = match EmailConfig::from_env() {
            Ok(config) => {
                tracing::info!("Email configuration loaded");
                Some(config)
            }
            Err(e) => {
                tracing::warn!("Email not configured: {}. Email features disabled.", e);
                None
            }
        };

        if let Some(ref email_cfg) = email_config {
            use crate::services::email_service::EmailService;
            use crate::tasks::email_queue_processor::EmailQueueProcessor;

            let db_arc = Arc::new(db.clone());

            match EmailService::new(email_cfg.clone()) {
                Ok(email_service) => {
                    let email_service_arc = Arc::new(email_service);
                    let processor = EmailQueueProcessor::new(
                        db_arc.clone(),
                        email_service_arc,
                        email_cfg.clone(),
                    );
                    processor.start();
                    tracing::info!("Email queue processor started");
                }
                Err(e) => {
                    tracing::error!("Failed to create email service: {}", e);
                }
            }

            {
                use crate::services::imap_bounce_service::ImapBounceService;
                let bounce_service = ImapBounceService::new(email_cfg.clone(), db_arc.clone());
                if bounce_service.is_enabled() {
                    bounce_service.start();
                    tracing::info!("IMAP bounce processor started");
                }
            }

            {
                use crate::tasks::password_change_email_retry::PasswordChangeEmailRetryProcessor;
                let retry_processor =
                    PasswordChangeEmailRetryProcessor::new(db_arc.clone(), email_cfg.clone());
                retry_processor.start();
                tracing::info!("Password change email retry task started");
            }
        }
    }

    async fn init_i18n(extra_dirs: &[PathBuf]) -> Arc<I18nService> {
        let service = I18nService::new();

        // Charger d'abord les traductions des crates du framework,
        // puis les dirs supplementaires (app consommatrice).
        let mut dirs: Vec<String> = vec![
            "crates/rustwarden-core/locales".to_string(),
            "crates/rustwarden-ui/locales".to_string(),
        ];
        for extra in extra_dirs {
            dirs.push(extra.to_string_lossy().to_string());
        }

        for dir in &dirs {
            match load_translations_from_directory(dir).await {
                Ok(all_translations) => {
                    let mut locale_count = 0;
                    let mut key_count = 0;
                    for (locale, translations) in all_translations {
                        key_count += translations.len();
                        locale_count += 1;
                        if let Err(e) = service
                            .load_translations(locale.clone(), translations)
                            .await
                        {
                            tracing::error!("Failed to load translations for {}: {}", locale, e);
                        }
                    }
                    tracing::info!(
                        "Loaded {} locales ({} keys) from {}",
                        locale_count,
                        key_count,
                        dir
                    );
                }
                Err(e) => {
                    tracing::warn!("Failed to load translations from {}: {}", dir, e);
                }
            }
        }

        Arc::new(service)
    }
}

/// SEC-H1: Middleware that adds standard security headers to every response.
///
/// RFC 0039: the CSP (and opt-in COOP/CORP) come from the
/// [`SecurityHeadersConfig`] captured as an `Arc` when the layer is built
/// (see `RustwardenBuilder::build`). The config is deliberately NOT read
/// from request extensions: this layer sits outside every `Extension(...)`
/// layer, so extensions are not populated yet when the request passes here.
async fn security_headers_middleware(
    cfg: Arc<SecurityHeadersConfig>,
    request: Request,
    next: Next,
) -> Response {
    let mut response = next.run(request).await;
    let headers = response.headers_mut();
    headers.insert(
        HeaderName::from_static("x-content-type-options"),
        HeaderValue::from_static("nosniff"),
    );
    headers.insert(
        HeaderName::from_static("x-frame-options"),
        HeaderValue::from_static("DENY"),
    );
    // Disable legacy XSS auditor (can introduce vulnerabilities)
    headers.insert(
        HeaderName::from_static("x-xss-protection"),
        HeaderValue::from_static("0"),
    );
    headers.insert(
        HeaderName::from_static("referrer-policy"),
        HeaderValue::from_static("strict-origin-when-cross-origin"),
    );
    headers.insert(
        HeaderName::from_static("permissions-policy"),
        HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
    );
    // SEC-FIX: CSP header to mitigate XSS attacks. Value comes from the
    // configured policy; an invalid header value (e.g. control characters)
    // falls back to the default policy (no 'unsafe-eval') instead of panicking.
    let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()).unwrap_or_else(|_| {
        tracing::warn!(
            "Invalid Content-Security-Policy value in SecurityHeadersConfig; \
             falling back to the default policy"
        );
        HeaderValue::from_static(CSP_WITHOUT_UNSAFE_EVAL)
    });
    headers.insert(HeaderName::from_static("content-security-policy"), csp);
    // RFC 0039: opt-in cross-origin isolation headers. `None` (the default)
    // means the header is not emitted at all; a malformed value is skipped
    // with a warning rather than failing the response.
    if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() {
        match HeaderValue::from_str(coop) {
            Ok(val) => {
                headers.insert(HeaderName::from_static("cross-origin-opener-policy"), val);
            }
            Err(_) => {
                tracing::warn!(
                    "Invalid Cross-Origin-Opener-Policy value in SecurityHeadersConfig; \
                     header skipped"
                );
            }
        }
    }
    if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() {
        match HeaderValue::from_str(corp) {
            Ok(val) => {
                headers.insert(HeaderName::from_static("cross-origin-resource-policy"), val);
            }
            Err(_) => {
                tracing::warn!(
                    "Invalid Cross-Origin-Resource-Policy value in SecurityHeadersConfig; \
                     header skipped"
                );
            }
        }
    }
    // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE
    if !std::env::var("APP_DEBUG")
        .unwrap_or_default()
        .eq_ignore_ascii_case("true")
    {
        headers.insert(
            HeaderName::from_static("strict-transport-security"),
            HeaderValue::from_static("max-age=31536000; includeSubDomains"),
        );
    }
    response
}

async fn locale_middleware(
    request: Request,
    next: Next,
    supported: Vec<String>,
    default: String,
) -> Response {
    let (mut parts, body) = request.into_parts();

    let cookie_jar = axum_extra::extract::CookieJar::from_headers(&parts.headers);
    let locale_str = cookie_jar
        .get("locale")
        .and_then(|c| {
            let v = c.value().to_string();
            if supported.contains(&v) {
                Some(v)
            } else {
                None
            }
        })
        .or_else(|| {
            parts
                .headers
                .get("accept-language")
                .and_then(|h| h.to_str().ok())
                .and_then(|h| {
                    h.split(',')
                        .next()
                        .and_then(|s| s.split(';').next())
                        .map(|s| s.trim())
                        .filter(|s| s.len() >= 2)
                        .map(|s| s[..2].to_string())
                        .filter(|s| supported.contains(s))
                })
        })
        .unwrap_or(default);

    parts.extensions.insert(Locale(locale_str));

    let request = Request::from_parts(parts, body);
    next.run(request).await
}

#[cfg(test)]
mod builder_tests {
    use super::*;

    #[test]
    fn default_builder_has_no_protected_prefixes() {
        assert!(RustwardenBuilder::default().protected_prefixes.is_empty());
    }

    #[test]
    fn with_ui_registers_default_ui_prefixes() {
        let builder = RustwardenBuilder::default().with_ui(Router::<DatabaseConnection>::new);
        for prefix in ["/home", "/profile", "/settings", "/admin"] {
            assert!(
                builder.protected_prefixes.iter().any(|p| p == prefix),
                "with_ui must register {prefix}"
            );
        }
    }

    #[test]
    fn headless_sets_flag_without_ui_prefixes() {
        let builder = RustwardenBuilder::default().headless();
        assert!(builder.headless);
        assert!(builder.protected_prefixes.is_empty());
    }

    #[test]
    fn register_protected_prefix_appends_custom_prefix() {
        let builder = RustwardenBuilder::default().register_protected_prefix("/custom");
        assert!(builder.protected_prefixes.iter().any(|p| p == "/custom"));
    }

    /// RFC 0042 — precedence of the auth overrides, exercised on the pure
    /// `apply_auth_overrides` without touching the process environment.
    mod apply_auth_overrides {
        use super::*;
        use crate::config::auth::{AuthFailureMode, SessionBackend};

        fn base_config() -> 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".to_string(),
                auth_failure_mode: AuthFailureMode::Redirect,
            }
        }

        #[test]
        fn headless_flips_default_to_json401() {
            let cfg = RustwardenBuilder::default()
                .headless()
                .apply_auth_overrides(base_config(), false);
            assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401);
        }

        #[test]
        fn explicit_setter_wins_over_headless() {
            let cfg = RustwardenBuilder::default()
                .headless()
                .auth_failure_mode(AuthFailureMode::Redirect)
                .apply_auth_overrides(base_config(), false);
            assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
        }

        #[test]
        fn env_value_wins_over_headless_flip() {
            // `base_config()` stands for what `from_env()` produced from an
            // explicit `AUTH_FAILURE_MODE=redirect`.
            let cfg = RustwardenBuilder::default()
                .headless()
                .apply_auth_overrides(base_config(), true);
            assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
        }

        #[test]
        fn explicit_setter_wins_over_env_value() {
            let cfg = RustwardenBuilder::default()
                .auth_failure_mode(AuthFailureMode::Json401)
                .apply_auth_overrides(base_config(), true);
            assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Json401);
        }

        #[test]
        fn login_path_setter_is_propagated() {
            let cfg = RustwardenBuilder::default()
                .login_path("/auth/signin")
                .apply_auth_overrides(base_config(), false);
            assert_eq!(cfg.login_path, "/auth/signin");
        }

        #[test]
        fn non_headless_without_setter_is_unchanged() {
            let cfg = RustwardenBuilder::default().apply_auth_overrides(base_config(), false);
            assert_eq!(cfg.auth_failure_mode, AuthFailureMode::Redirect);
            assert_eq!(cfg.login_path, "/login");
        }

        /// Only a *parseable* env value counts as an operator override: an
        /// empty or misspelled `AUTH_FAILURE_MODE` makes `from_env` fall back
        /// to `Redirect`, and that must not suppress the `headless()` flip.
        #[test]
        fn env_signal_requires_a_valid_value() {
            assert!(!RustwardenBuilder::failure_mode_overridden_by_env(None));
            assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some("")));
            assert!(!RustwardenBuilder::failure_mode_overridden_by_env(Some(
                "json-401"
            )));
            assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some(
                "json401"
            )));
            assert!(RustwardenBuilder::failure_mode_overridden_by_env(Some(
                "Redirect"
            )));
        }
    }

    #[test]
    fn default_builder_uses_default_security_headers() {
        let builder = RustwardenBuilder::default();
        assert_eq!(
            builder.security_headers.content_security_policy,
            CSP_WITHOUT_UNSAFE_EVAL
        );
        assert!(
            builder
                .security_headers
                .cross_origin_opener_policy
                .is_none()
        );
        assert!(
            builder
                .security_headers
                .cross_origin_resource_policy
                .is_none()
        );
    }

    #[test]
    fn security_headers_setter_overrides_config() {
        let builder = RustwardenBuilder::default()
            .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation());
        assert_eq!(
            builder
                .security_headers
                .cross_origin_opener_policy
                .as_deref(),
            Some("same-origin")
        );
    }
}

/// HTTP-level tests for `security_headers_middleware` (RFC 0039 testing
/// plan). The middleware is intentionally private to this module, so these
/// integration-style tests (axum `Router` + `tower::ServiceExt::oneshot`,
/// same pattern as `tests/csrf_protection.rs`) live in the crate's unit
/// test tree instead of `tests/`.
#[cfg(test)]
mod security_headers_middleware_tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request as HttpRequest;
    use std::borrow::Cow;
    use tower::ServiceExt; // for `oneshot`

    /// Build a minimal router wired exactly like `RustwardenBuilder::build`:
    /// the config is captured in the `from_fn` closure as an `Arc`.
    ///
    /// Negative control for the `Extension` trap (RFC 0039): NO
    /// `axum::Extension(cfg)` layer is added anywhere here. In production the
    /// security-headers layer is more external than every `Extension(...)`
    /// layer, so `request.extensions()` would always come back empty for it —
    /// the `Arc` capture below is the only supported wiring, and these tests
    /// prove it works without any extension being injected.
    fn test_router(cfg: SecurityHeadersConfig) -> Router {
        let headers_cfg = std::sync::Arc::new(cfg);
        Router::new()
            .route("/", axum::routing::get(|| async { "ok" }))
            .layer(axum::middleware::from_fn({
                let cfg = headers_cfg.clone();
                move |req, next| {
                    let cfg = cfg.clone();
                    async move { security_headers_middleware(cfg, req, next).await }
                }
            }))
    }

    async fn get_root(router: Router) -> Response {
        let req = HttpRequest::builder()
            .uri("/")
            .body(Body::empty())
            .expect("request");
        router.oneshot(req).await.expect("oneshot")
    }

    #[tokio::test]
    async fn default_config_emits_legacy_csp_and_no_coop_corp() {
        let resp = get_root(test_router(SecurityHeadersConfig::default())).await;
        let headers = resp.headers();
        assert_eq!(
            headers
                .get("content-security-policy")
                .expect("csp header present"),
            CSP_WITHOUT_UNSAFE_EVAL
        );
        assert!(headers.get("cross-origin-opener-policy").is_none());
        assert!(headers.get("cross-origin-resource-policy").is_none());
    }

    #[tokio::test]
    async fn same_origin_isolation_emits_all_three_headers() {
        let cfg = SecurityHeadersConfig::default().with_same_origin_isolation();
        let resp = get_root(test_router(cfg)).await;
        let headers = resp.headers();
        assert_eq!(
            headers
                .get("content-security-policy")
                .expect("csp header present"),
            CSP_WITHOUT_UNSAFE_EVAL
        );
        assert_eq!(
            headers
                .get("cross-origin-opener-policy")
                .expect("coop header present"),
            "same-origin"
        );
        assert_eq!(
            headers
                .get("cross-origin-resource-policy")
                .expect("corp header present"),
            "same-origin"
        );
    }

    #[tokio::test]
    async fn invalid_csp_falls_back_to_default_without_panicking() {
        let cfg = SecurityHeadersConfig {
            // `\n` is a control character: `HeaderValue::from_str` rejects it.
            content_security_policy: Cow::Owned("default-src 'self'\n".to_string()),
            ..SecurityHeadersConfig::default()
        };
        let resp = get_root(test_router(cfg)).await;
        assert_eq!(
            resp.headers()
                .get("content-security-policy")
                .expect("csp header present"),
            CSP_WITHOUT_UNSAFE_EVAL,
            "invalid CSP must fall back to the default policy"
        );
    }

    #[tokio::test]
    async fn invalid_coop_is_skipped_but_csp_is_still_emitted() {
        let cfg = SecurityHeadersConfig {
            cross_origin_opener_policy: Some(Cow::Owned("same-origin\n".to_string())),
            ..SecurityHeadersConfig::default()
        };
        let resp = get_root(test_router(cfg)).await;
        let headers = resp.headers();
        assert!(
            headers.get("cross-origin-opener-policy").is_none(),
            "malformed COOP must be skipped, not emitted"
        );
        assert_eq!(
            headers
                .get("content-security-policy")
                .expect("csp header present"),
            CSP_WITHOUT_UNSAFE_EVAL
        );
    }

    /// Regression guard: the hardcoded headers untouched by RFC 0039 must
    /// keep their 1.0.1 values whatever the configuration.
    #[tokio::test]
    async fn hardcoded_headers_are_unchanged() {
        let resp = get_root(test_router(SecurityHeadersConfig::default())).await;
        let headers = resp.headers();
        assert_eq!(
            headers
                .get("x-frame-options")
                .expect("x-frame-options present"),
            "DENY"
        );
        assert_eq!(
            headers
                .get("x-content-type-options")
                .expect("x-content-type-options present"),
            "nosniff"
        );
        assert_eq!(
            headers
                .get("x-xss-protection")
                .expect("x-xss-protection present"),
            "0"
        );
        assert_eq!(
            headers
                .get("referrer-policy")
                .expect("referrer-policy present"),
            "strict-origin-when-cross-origin"
        );
        assert_eq!(
            headers
                .get("permissions-policy")
                .expect("permissions-policy present"),
            "camera=(), microphone=(), geolocation=()"
        );
    }

    // --- Analytics origin folded into the CSP -------------------------------

    /// Mirror of what `RustwardenBuilder::build` does with the analytics
    /// configuration, so these tests exercise the real composition rule.
    fn headers_for(analytics: &AnalyticsConfig, base: SecurityHeadersConfig) -> String {
        let cfg = match analytics.script_origin() {
            Some(origin) => base.allowing_script_origin(&origin),
            None => base,
        };
        cfg.content_security_policy.to_string()
    }

    #[tokio::test]
    async fn analytics_disabled_leaves_the_csp_byte_identical() {
        // A consumer that never configures analytics must see the default
        // policy (RFC 0039 phase 2: no 'unsafe-eval'), unchanged.
        let csp = headers_for(
            &AnalyticsConfig::default(),
            SecurityHeadersConfig::default(),
        );
        let cfg = SecurityHeadersConfig {
            content_security_policy: Cow::Owned(csp),
            ..SecurityHeadersConfig::default()
        };
        let resp = get_root(test_router(cfg)).await;
        assert_eq!(
            resp.headers()
                .get("content-security-policy")
                .expect("csp header present"),
            CSP_WITHOUT_UNSAFE_EVAL
        );
    }

    #[tokio::test]
    async fn analytics_origin_reaches_the_emitted_header() {
        let analytics = AnalyticsConfig::default()
            .with_script_url("https://stats.example.org/api/script.js")
            .with_site_id("abc123")
            .enabled(true);
        let csp = headers_for(&analytics, SecurityHeadersConfig::default());
        let cfg = SecurityHeadersConfig {
            content_security_policy: Cow::Owned(csp),
            ..SecurityHeadersConfig::default()
        };

        let resp = get_root(test_router(cfg)).await;
        let header = resp
            .headers()
            .get("content-security-policy")
            .and_then(|v| v.to_str().ok())
            .unwrap_or_default()
            .to_string();

        let directive = |name: &str| -> String {
            header
                .split(';')
                .map(str::trim)
                .find(|d| d.split_whitespace().next() == Some(name))
                .unwrap_or_default()
                .to_string()
        };

        assert!(
            directive("script-src").contains("https://stats.example.org"),
            "script-src must allow the tracker: {}",
            header
        );
        assert!(
            directive("connect-src").contains("https://stats.example.org"),
            "connect-src must allow the beacons: {}",
            header
        );
        // Only the path is dropped — the origin keeps scheme and host.
        assert!(!header.contains("/api/script.js"));
    }

    #[tokio::test]
    async fn analytics_origin_is_allowed_even_while_tracking_is_off() {
        // The runtime toggle lives in the database and may be flipped on
        // without a restart, so the CSP must already allow the origin.
        let analytics = AnalyticsConfig::default()
            .with_script_url("https://stats.example.org/api/script.js")
            .enabled(false);
        let csp = headers_for(&analytics, SecurityHeadersConfig::default());
        assert!(csp.contains("https://stats.example.org"));
    }
}