//! Configuration parsing and CORS behavior of the app composition root. use std::sync::Mutex; use axum::body::Body; use axum::http::{Request, StatusCode}; use tower::ServiceExt; use tavolo_app::app; use tavolo_app::config::Settings; use tavolo_platform::oidc::OidcProviderConfig; static ENV_LOCK: Mutex<()> = Mutex::new(()); const ENV_VARS: &[&str] = &[ "DATABASE_URL", "DATABASE_ENGINE", "DATABASE_HOST", "DATABASE_PORT", "DATABASE_NAME", "DATABASE_USER", "DATABASE_PASSWORD", "DATABASE_OPTIONS", "APP_HOST", "APP_PORT", "REDIS_URL", "GAME_TTL_SECONDS", "STATIC_DIR", "HAND_ACK_TIMEOUT_SECONDS", "TURN_TIMEOUT_SECONDS", "DEADLINE_HEARTBEAT_MS", "LOGGING_CONFIG", "CORS_ALLOW_ORIGINS", "CORS_ALLOW_ORIGIN_REGEX", "CORS_ALLOW_METHODS", "CORS_ALLOW_HEADERS", "CORS_ALLOW_CREDENTIALS", "CORS_EXPOSE_HEADERS", "CORS_MAX_AGE", "OTEL_ENABLED", "OTEL_SERVICE_NAME", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_EXCLUDED_PATHS", "OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "OTEL_METRICS_POLL_INTERVAL_SECONDS", "OIDC_ISSUER", "OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_REDIRECT_URI", "OIDC_POST_LOGIN_REDIRECT", "OIDC_POST_LOGOUT_REDIRECT", ]; fn clear_oidc_env() { for (name, _) in std::env::vars() { if name.starts_with("OIDC_") { std::env::remove_var(&name); } } for name in ENV_VARS { std::env::remove_var(name); } } fn with_env(vars: &[(&str, &str)], f: impl FnOnce() -> T) -> T { let _guard = ENV_LOCK.lock().expect("env lock"); clear_oidc_env(); for (name, value) in vars { std::env::set_var(name, value); } let result = f(); clear_oidc_env(); result } #[test] fn defaults_match_the_python_settings() { let settings = with_env(&[], Settings::from_env).expect("settings"); assert_eq!(settings.app_host, "0.0.0.0"); assert_eq!(settings.app_port, 8080); assert_eq!(settings.game_ttl_seconds, 86400); assert_eq!(settings.static_dir, "web/dist"); assert_eq!(settings.oidc_providers.len(), 1); let provider = &settings.oidc_providers[0]; assert_eq!(provider.provider, "default"); assert_eq!(provider.issuer, "http://localhost:8180/tavolo"); assert_eq!(provider.client_id, "tavolo"); assert_eq!( provider.redirect_uri, "http://localhost:8080/auth/default/callback" ); assert_eq!( settings.database_url, "postgres://tavolo:password@localhost/tavolo" ); assert!(!settings.otel_enabled); assert_eq!(settings.otel_excluded_paths, vec!["/api/health"]); } #[test] fn indexed_oidc_providers_are_parsed_in_order() { let settings = with_env( &[ ("OIDC_0_PROVIDER", "google"), ("OIDC_0_ISSUER", "https://accounts.google.com"), ("OIDC_0_CLIENT_ID", "gid"), ("OIDC_0_CLIENT_SECRET", "gsecret"), ("OIDC_1_PROVIDER", "github"), ("OIDC_1_ISSUER", "https://github.com/login/oauth"), ("OIDC_1_CLIENT_ID", "ghid"), ], Settings::from_env, ) .expect("settings"); assert_eq!(settings.oidc_providers.len(), 2); assert_eq!(settings.oidc_providers[0].provider, "google"); assert_eq!( settings.oidc_providers[0].redirect_uri, "http://localhost:8080/auth/google/callback" ); assert_eq!( settings.oidc_providers[0].client_secret.as_deref(), Some("gsecret") ); assert_eq!(settings.oidc_providers[1].provider, "github"); assert!(settings.oidc_providers[1].client_secret.is_none()); } #[test] fn invalid_oidc_configurations_are_rejected() { let error = with_env( &[ ("OIDC_0_PROVIDER", "a"), ("OIDC_0_ISSUER", "https://x"), ("OIDC_0_CLIENT_ID", "x"), ("OIDC_2_PROVIDER", "b"), ("OIDC_2_ISSUER", "https://y"), ("OIDC_2_CLIENT_ID", "y"), ], Settings::from_env, ) .expect_err("missing index must fail"); assert!(error.contains("contiguous"), "{error}"); let error = with_env(&[("OIDC_ISSUER", "https://legacy")], Settings::from_env) .expect_err("legacy variables must fail"); assert!(error.contains("indexed per provider"), "{error}"); let error = with_env( &[ ("OIDC_0_PROVIDER", "bad name!"), ("OIDC_0_ISSUER", "https://x"), ("OIDC_0_CLIENT_ID", "x"), ], Settings::from_env, ) .expect_err("invalid provider names must fail"); assert!(error.contains("letters, digits"), "{error}"); let error = with_env( &[ ("OIDC_0_PROVIDER", "a"), ("OIDC_0_ISSUER", "https://x"), ("OIDC_0_CLIENT_ID", "x"), ("OIDC_0_WAT", "?"), ], Settings::from_env, ) .expect_err("unknown variables must fail"); assert!( error.contains("unknown OIDC environment variable"), "{error}" ); } #[test] fn database_dsn_quotes_credentials() { let settings = with_env( &[ ("DATABASE_USER", "ta:v@lo"), ("DATABASE_PASSWORD", "p@ss word"), ("DATABASE_HOST", "db.example"), ("DATABASE_PORT", "5433"), ("DATABASE_NAME", "tavolo"), ("DATABASE_OPTIONS", "ssl=require"), ], Settings::from_env, ) .expect("settings"); assert_eq!( settings.database_url, "postgres://ta%3Av%40lo:p%40ss%20word@db.example:5433/tavolo?ssl=require" ); } #[tokio::test] async fn cors_is_disabled_without_configuration() { let settings = base_settings(); let (platform, sessions) = app::in_memory_platform(&settings).expect("platform"); let router = app::build_router(platform, sessions, &settings); let response = router .clone() .oneshot( Request::builder() .uri("/api/health") .header("origin", "https://cards.example") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert!(response .headers() .get("access-control-allow-origin") .is_none()); } #[tokio::test] async fn cors_allows_configured_origins_and_regexes() { let mut settings = base_settings(); settings.cors_allow_origins = Some(vec!["https://cards.example".into()]); settings.cors_allow_origin_regex = Some(r"https://preview-[a-z0-9-]+\.example\.com".into()); settings.cors_allow_methods = Some(vec!["GET".into(), "POST".into()]); settings.cors_max_age = 3600; let (platform, sessions) = app::in_memory_platform(&settings).expect("platform"); let router = app::build_router(platform, sessions, &settings); // Allowed origin: echoed. Disallowed origin: no header, request still runs. let response = router .clone() .oneshot( Request::builder() .uri("/api/health") .header("origin", "https://cards.example") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( response.headers()["access-control-allow-origin"], "https://cards.example" ); let response = router .clone() .oneshot( Request::builder() .uri("/api/health") .header("origin", "https://preview-abc.example.com") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( response.headers()["access-control-allow-origin"], "https://preview-abc.example.com" ); let response = router .clone() .oneshot( Request::builder() .uri("/api/health") .header("origin", "https://mallory.example") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert!(response .headers() .get("access-control-allow-origin") .is_none()); // Preflight. let response = router .clone() .oneshot( Request::builder() .method("OPTIONS") .uri("/api/health") .header("origin", "https://cards.example") .header("access-control-request-method", "POST") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let methods = response.headers()["access-control-allow-methods"] .to_str() .unwrap() .to_string(); assert!(methods.contains("POST"), "{methods}"); assert_eq!(response.headers()["access-control-max-age"], "3600"); let response = router .clone() .oneshot( Request::builder() .method("OPTIONS") .uri("/api/health") .header("origin", "https://mallory.example") .header("access-control-request-method", "GET") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn cors_credentials_echo_the_origin_for_wildcards() { let mut settings = base_settings(); settings.cors_allow_origins = Some(vec!["*".into()]); settings.cors_allow_credentials = true; let (platform, sessions) = app::in_memory_platform(&settings).expect("platform"); let router = app::build_router(platform, sessions, &settings); let response = router .clone() .oneshot( Request::builder() .uri("/api/health") .header("origin", "https://cards.example") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!( response.headers()["access-control-allow-origin"], "https://cards.example" ); assert_eq!( response.headers()["access-control-allow-credentials"], "true" ); } fn base_settings() -> Settings { Settings { database_url: "postgres://unused".into(), oidc_providers: vec![OidcProviderConfig { provider: "default".into(), issuer: "http://localhost:8180/tavolo".into(), client_id: "tavolo".into(), client_secret: None, redirect_uri: "http://localhost:8080/auth/default/callback".into(), post_login_redirect: "/".into(), post_logout_redirect: "/".into(), }], app_host: "127.0.0.1".into(), app_port: 0, redis_url: None, game_ttl_seconds: 86400, static_dir: "/nonexistent-web-dist".into(), hand_ack_timeout_seconds: 30, turn_timeout_seconds: 30, deadline_heartbeat_ms: 50, logging_config: None, cors_allow_origins: None, cors_allow_origin_regex: None, cors_allow_methods: None, cors_allow_headers: None, cors_allow_credentials: false, cors_expose_headers: None, cors_max_age: 600, otel_enabled: false, otel_service_name: "tavolo".into(), otel_exporter_endpoint: None, otel_exporter_headers: None, otel_excluded_paths: vec!["/api/health".into()], otel_metric_export_interval_millis: 60000, otel_metrics_poll_interval_seconds: 15, } }