noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
use std::sync::OnceLock;

use actix_cors::Cors;
use actix_web::http::header::{self, HeaderName, HeaderValue};
use actix_web::http::Method;
use actix_web::{HttpRequest, HttpResponse};

use crate::config::CorsConfig;

static SETTINGS: OnceLock<CorsConfig> = OnceLock::new();

pub(crate) fn install(cfg: CorsConfig) {
    let _ = SETTINGS.set(cfg);
}

/// CORS middleware from process config (after [`crate::start`]). Wrap **your** `App`.
/// Last `.wrap()` is outermost — put this **after** `request_context` so 401s still get ACAO.
pub fn cors() -> Cors {
    match SETTINGS.get() {
        Some(cfg) => cors_from(cfg),
        None => cors_from(&CorsConfig::default()),
    }
}

/// CORS middleware from an explicit config (tests, or before `start`).
pub fn cors_from(cfg: &CorsConfig) -> Cors {
    let mut cors = Cors::default();
    let any = any_origin(cfg);
    if any {
        cors = cors.allow_any_origin();
    } else {
        for origin in &cfg.origins {
            cors = cors.allowed_origin(origin);
        }
        if cfg.credentials {
            cors = cors.supports_credentials();
        }
    }

    let methods: Vec<Method> = cfg.methods.iter().filter_map(|m| m.parse().ok()).collect();
    if !methods.is_empty() {
        cors = cors.allowed_methods(methods);
    }

    let headers: Vec<HeaderName> = cfg.headers.iter().filter_map(|h| h.parse().ok()).collect();
    if !headers.is_empty() {
        cors = cors.allowed_headers(headers);
    }

    cors.max_age(Some(cfg.max_age as usize))
}

/// Same origin policy as [`cors_from`], for handshake / early-reject responses
/// that never reach the CORS middleware (or whose 101 body must stay intact).
pub(crate) fn apply_cors(req: &HttpRequest, res: HttpResponse) -> HttpResponse {
    match SETTINGS.get() {
        Some(cfg) => apply_cors_from(cfg, req, res),
        None => apply_cors_from(&CorsConfig::default(), req, res),
    }
}

pub fn apply_cors_from(cfg: &CorsConfig, req: &HttpRequest, mut res: HttpResponse) -> HttpResponse {
    let Some(origin) = req.headers().get(header::ORIGIN) else {
        return res;
    };
    if !origin_is_allowed(cfg, origin) {
        return res;
    }
    res.headers_mut()
        .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone());
    if cfg.credentials && !any_origin(cfg) {
        res.headers_mut().insert(
            header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
            HeaderValue::from_static("true"),
        );
    }
    res
}

fn any_origin(cfg: &CorsConfig) -> bool {
    cfg.origins.iter().any(|o| o == "*")
}

fn origin_is_allowed(cfg: &CorsConfig, origin: &HeaderValue) -> bool {
    if any_origin(cfg) {
        return true;
    }
    let Ok(origin) = origin.to_str() else {
        return false;
    };
    cfg.origins.iter().any(|allowed| allowed == origin)
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::http::{StatusCode, header};
    use actix_web::{App, HttpResponse, test, web};

    #[actix_web::test]
    async fn configured_origin_is_allowed() {
        let cfg = CorsConfig {
            origins: vec!["http://localhost:3000".into()],
            ..CorsConfig::default()
        };
        let srv = test::init_service(App::new().wrap(cors_from(&cfg)).route(
            "/x",
            web::get().to(|| async { HttpResponse::Ok().finish() }),
        ))
        .await;
        let req = test::TestRequest::get()
            .uri("/x")
            .insert_header((header::ORIGIN, "http://localhost:3000"))
            .to_request();
        let resp = test::call_service(&srv, req).await;
        assert_eq!(resp.status(), StatusCode::OK);
        assert_eq!(
            resp.headers()
                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
                .unwrap(),
            "http://localhost:3000"
        );
    }

    #[actix_web::test]
    async fn empty_origins_allow_none() {
        let srv = test::init_service(App::new().wrap(cors_from(&CorsConfig::default())).route(
            "/x",
            web::get().to(|| async { HttpResponse::Ok().finish() }),
        ))
        .await;
        let req = test::TestRequest::get()
            .uri("/x")
            .insert_header((header::ORIGIN, "http://localhost:3000"))
            .to_request();
        let resp = test::call_service(&srv, req).await;
        assert!(
            resp.headers()
                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
                .is_none()
        );
    }

    #[actix_web::test]
    async fn star_echoes_request_origin() {
        let cfg = CorsConfig {
            origins: vec!["*".into()],
            ..CorsConfig::default()
        };
        let srv = test::init_service(App::new().wrap(cors_from(&cfg)).route(
            "/x",
            web::get().to(|| async { HttpResponse::Ok().finish() }),
        ))
        .await;
        let req = test::TestRequest::get()
            .uri("/x")
            .insert_header((header::ORIGIN, "http://localhost:8080"))
            .to_request();
        let resp = test::call_service(&srv, req).await;
        assert_eq!(
            resp.headers()
                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
                .unwrap(),
            "http://localhost:8080"
        );
    }

    #[actix_web::test]
    async fn apply_cors_from_stamps_unauthorized() {
        let cfg = CorsConfig {
            origins: vec!["*".into()],
            ..CorsConfig::default()
        };
        let req = test::TestRequest::default()
            .insert_header((header::ORIGIN, "http://localhost:8080"))
            .to_http_request();
        let res = apply_cors_from(
            &cfg,
            &req,
            HttpResponse::Unauthorized().body("invalid token"),
        );
        assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
        assert_eq!(
            res.headers()
                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
                .unwrap(),
            "http://localhost:8080"
        );
    }

    #[actix_web::test]
    async fn apply_cors_from_skips_unknown_origin() {
        let cfg = CorsConfig {
            origins: vec!["http://localhost:3000".into()],
            ..CorsConfig::default()
        };
        let req = test::TestRequest::default()
            .insert_header((header::ORIGIN, "http://evil.example"))
            .to_http_request();
        let res = apply_cors_from(&cfg, &req, HttpResponse::Unauthorized().finish());
        assert!(
            res.headers()
                .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
                .is_none()
        );
    }
}