CI / Build and push docker image (push) Successful in 4m20s
Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
381 lines
11 KiB
Rust
381 lines
11 KiB
Rust
//! 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<T>(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,
|
|
}
|
|
}
|