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.
This commit is contained in:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -0,0 +1,319 @@
|
||||
//! End-to-end OIDC login flow against a mock provider.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
|
||||
const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u
|
||||
l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5
|
||||
8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH
|
||||
EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV
|
||||
q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U
|
||||
Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm
|
||||
aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9
|
||||
W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc
|
||||
x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx
|
||||
XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq
|
||||
XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5
|
||||
y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI
|
||||
8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd
|
||||
2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH
|
||||
0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq
|
||||
z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl
|
||||
MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0
|
||||
iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6
|
||||
c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs
|
||||
4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP
|
||||
sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h
|
||||
NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2
|
||||
rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP
|
||||
WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh
|
||||
nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr
|
||||
BMZbIcEMr49N2ns4yHzJ3w==
|
||||
-----END PRIVATE KEY-----
|
||||
"#;
|
||||
|
||||
const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct IdpState {
|
||||
issuer: String,
|
||||
nonce: Arc<Mutex<String>>,
|
||||
}
|
||||
|
||||
struct Idp {
|
||||
state: IdpState,
|
||||
addr: SocketAddr,
|
||||
}
|
||||
|
||||
async fn start_idp() -> Idp {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind idp");
|
||||
let addr = listener.local_addr().expect("idp addr");
|
||||
let base = format!("http://{addr}");
|
||||
let state = IdpState {
|
||||
issuer: base.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/.well-known/openid-configuration",
|
||||
get({
|
||||
let base = base.clone();
|
||||
move || async move {
|
||||
Json(json!({
|
||||
"issuer": base,
|
||||
"authorization_endpoint": format!("{base}/authorize"),
|
||||
"token_endpoint": format!("{base}/token"),
|
||||
"userinfo_endpoint": format!("{base}/userinfo"),
|
||||
"jwks_uri": format!("{base}/jwks"),
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/authorize",
|
||||
get(
|
||||
|State(state): State<IdpState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>| async move {
|
||||
if let Some(nonce) = params.get("nonce") {
|
||||
*state.nonce.lock().await = nonce.clone();
|
||||
}
|
||||
let state_param = params.get("state").cloned().unwrap_or_default();
|
||||
let redirect_uri = params
|
||||
.get("redirect_uri")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let location = format!("{redirect_uri}?code=test-code&state={state_param}");
|
||||
(
|
||||
axum::http::StatusCode::FOUND,
|
||||
[(axum::http::header::LOCATION, location)],
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/token",
|
||||
post(|State(state): State<IdpState>| async move {
|
||||
let nonce = state.nonce.lock().await.clone();
|
||||
let now = Utc::now().timestamp();
|
||||
let claims = json!({
|
||||
"iss": state.issuer,
|
||||
"aud": "tavolo",
|
||||
"sub": "alice",
|
||||
"exp": now + 600,
|
||||
"iat": now,
|
||||
"nonce": nonce,
|
||||
});
|
||||
let token = jsonwebtoken::encode(
|
||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
|
||||
&claims,
|
||||
&jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
Json(json!({
|
||||
"access_token": "access-token",
|
||||
"token_type": "Bearer",
|
||||
"id_token": token,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/userinfo",
|
||||
get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }),
|
||||
)
|
||||
.route(
|
||||
"/jwks",
|
||||
get(|| async { Json(json!({"keys": [serde_json::from_str::<Value>(JWK).unwrap()]})) }),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
Idp { state, addr }
|
||||
}
|
||||
|
||||
fn test_settings(issuer: &str, redirect_uri: &str) -> Settings {
|
||||
Settings {
|
||||
database_url: "postgres://unused".into(),
|
||||
oidc_providers: vec![OidcProviderConfig {
|
||||
provider: "default".into(),
|
||||
issuer: issuer.into(),
|
||||
client_id: "tavolo".into(),
|
||||
client_secret: Some("dev-secret".into()),
|
||||
redirect_uri: redirect_uri.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,
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie_header(response: &axum::response::Response) -> String {
|
||||
let value = response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.expect("set-cookie")
|
||||
.to_str()
|
||||
.expect("cookie header");
|
||||
value.split(';').next().expect("cookie pair").to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_login_callback_and_logout_flow() {
|
||||
// The mock IdP must know its own issuer before we can start it, so bind
|
||||
// first and rebuild the settings from the recorded address.
|
||||
let idp = start_idp().await;
|
||||
let issuer = format!("http://{}", idp.addr);
|
||||
let callback = "http://localhost:8080/auth/default/callback".to_string();
|
||||
let settings = test_settings(&issuer, &callback);
|
||||
|
||||
let (platform, session_store) = app::in_memory_platform(&settings).expect("platform");
|
||||
let router = app::build_router(platform, session_store.clone(), &settings);
|
||||
|
||||
// 1. Start the login: a 302 to the provider with state/nonce/PKCE and a
|
||||
// fresh session cookie.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/login")
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
let location = response.headers()["location"].to_str().unwrap().to_string();
|
||||
let login_cookie = cookie_header(&response);
|
||||
let url = url::Url::parse(&location).expect("authorization url");
|
||||
let params: std::collections::HashMap<String, String> = url
|
||||
.query_pairs()
|
||||
.map(|(key, value)| (key.into_owned(), value.into_owned()))
|
||||
.collect();
|
||||
assert_eq!(params["client_id"], "tavolo");
|
||||
assert_eq!(params["code_challenge_method"], "S256");
|
||||
assert_eq!(params["scope"], "openid email profile");
|
||||
assert_eq!(
|
||||
params["redirect_uri"],
|
||||
"http://localhost:8080/auth/default/callback"
|
||||
);
|
||||
let state = params["state"].clone();
|
||||
// Simulate the browser visiting the provider: the mock signs the nonce
|
||||
// the client generated.
|
||||
*idp.state.nonce.lock().await = params["nonce"].clone();
|
||||
|
||||
// 2. The provider redirects back with the code and state.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri(format!(
|
||||
"/auth/default/callback?code=test-code&state={state}"
|
||||
))
|
||||
.header("cookie", &login_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
assert_eq!(response.headers()["location"], "/");
|
||||
let session_cookie = cookie_header(&response);
|
||||
assert_ne!(
|
||||
session_cookie, login_cookie,
|
||||
"session id must rotate on login"
|
||||
);
|
||||
|
||||
// A wrong state is rejected without touching the provider.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/callback?code=test-code&state=wrong")
|
||||
.header("cookie", &login_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 400);
|
||||
|
||||
// 3. The session is authenticated and /api/me reflects the userinfo.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/api/me")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let me: Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(me, json!({"sub": "alice", "name": "Alice"}));
|
||||
|
||||
// 4. Logout clears the session and sends an expired cookie.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/auth/default/logout")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 302);
|
||||
let clear_cookie = response
|
||||
.headers()
|
||||
.get(axum::http::header::SET_COOKIE)
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap();
|
||||
assert!(
|
||||
clear_cookie.contains("Max-Age=0"),
|
||||
"cookie not cleared: {clear_cookie}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! End-to-end scopone flow: lobby, four players, live play over the
|
||||
//! WebSocket and the turn-timeout auto-play, plus SPA/static hosting.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use tavolo_app::app;
|
||||
use tavolo_app::config::Settings;
|
||||
use tavolo_platform::oidc::OidcProviderConfig;
|
||||
use tavolo_platform::session::SharedSessionStore;
|
||||
|
||||
const PLAYERS: [&str; 4] = ["alice", "bob", "carol", "dave"];
|
||||
|
||||
fn settings(static_dir: &str, turn_timeout: i64) -> 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: static_dir.into(),
|
||||
hand_ack_timeout_seconds: 30,
|
||||
turn_timeout_seconds: turn_timeout,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn login(store: &SharedSessionStore, sub: &str, name: &str) -> String {
|
||||
let id = format!("session-{sub}");
|
||||
let mut data = Map::new();
|
||||
data.insert(
|
||||
"oidc:default:user".into(),
|
||||
json!({"sub": sub, "preferred_username": name}),
|
||||
);
|
||||
store.save(&id, &data, Some(3600)).await.expect("session");
|
||||
format!("session_id={id}")
|
||||
}
|
||||
|
||||
async fn body_json(response: axum::response::Response) -> Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
serde_json::from_slice(&bytes).expect("json")
|
||||
}
|
||||
|
||||
fn post(uri: &str, cookie: &str, body: &Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.header("cookie", cookie)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn get(uri: &str, cookie: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder().uri(uri);
|
||||
if let Some(cookie) = cookie {
|
||||
builder = builder.header("cookie", cookie);
|
||||
}
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
async fn start_server(router: axum::Router) -> SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn ws_request(addr: SocketAddr, game_id: &str, cookie: &str) -> Request<()> {
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
let mut request = format!("ws://{addr}/ws/games/{game_id}")
|
||||
.into_client_request()
|
||||
.expect("request");
|
||||
request.headers_mut().insert(
|
||||
"cookie",
|
||||
axum::http::HeaderValue::from_str(cookie).expect("cookie"),
|
||||
);
|
||||
request
|
||||
}
|
||||
|
||||
async fn read_json(
|
||||
socket: &mut tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
) -> Value {
|
||||
let message = socket.next().await.expect("message").expect("ok");
|
||||
match message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
serde_json::from_str(&text).expect("json")
|
||||
}
|
||||
other => panic!("expected text, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scopone_lobby_play_and_turn_timeout() {
|
||||
let temp = std::env::temp_dir().join(format!("tavolo-test-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
||||
let test_settings = settings(temp.to_str().unwrap(), 1);
|
||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions.clone(), &test_settings);
|
||||
|
||||
// Create the game as alice and join with the other three players.
|
||||
let alice = login(&sessions, "alice", "Alice").await;
|
||||
let created = router
|
||||
.clone()
|
||||
.oneshot(post(
|
||||
"/api/games",
|
||||
&alice,
|
||||
&json!({"options": {"target_score": 11}}),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let lobby = body_json(created).await;
|
||||
let game_id = lobby["id"].as_str().unwrap().to_string();
|
||||
let code = lobby["join_code"].as_str().unwrap().to_string();
|
||||
assert_eq!(lobby["seats_open"], 3);
|
||||
assert_eq!(lobby["players"][0]["team"], "A");
|
||||
|
||||
for (index, player) in PLAYERS.iter().enumerate().skip(1) {
|
||||
let cookie = login(&sessions, player, &player.to_uppercase()).await;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(post("/api/games/join", &cookie, &json!({"code": code})))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "player {player}");
|
||||
let body = body_json(response).await;
|
||||
assert_eq!(body["players"].as_array().unwrap().len(), index + 1);
|
||||
if index == 3 {
|
||||
assert_eq!(body["phase"], "playing");
|
||||
assert_eq!(body["hand_number"], 1);
|
||||
assert_eq!(body["dealer"], 0);
|
||||
assert_eq!(body["turn"], 1);
|
||||
assert_eq!(body["players"][index]["hand"].as_array().unwrap().len(), 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Bob is on turn (seat 1): his hand is visible to him and hidden from
|
||||
// alice's personalized view.
|
||||
let bob = login(&sessions, "bob", "Bob").await;
|
||||
let bob_view = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&bob)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(bob_view["turn"], 1);
|
||||
assert_eq!(bob_view["your_turn"], true);
|
||||
assert_eq!(bob_view["players"][1]["hand"].as_array().unwrap().len(), 10);
|
||||
assert!(bob_view["players"][0].get("hand").is_none());
|
||||
assert!(bob_view["legal_moves"].is_object());
|
||||
|
||||
let alice_view = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
alice_view["players"][0]["hand"].as_array().unwrap().len(),
|
||||
10
|
||||
);
|
||||
assert!(alice_view["players"][1].get("hand").is_none());
|
||||
|
||||
let addr = start_server(router.clone()).await;
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(ws_request(addr, &game_id, &bob))
|
||||
.await
|
||||
.expect("bob connects");
|
||||
let state = read_json(&mut ws).await;
|
||||
assert_eq!(state["type"], "state");
|
||||
// The table starts empty and the turn belongs to bob (seat 1).
|
||||
assert_eq!(state["game"]["turn"], 1);
|
||||
assert_eq!(state["game"]["table"].as_array().unwrap().len(), 0);
|
||||
// Bob is on turn on the websocket view.
|
||||
assert_eq!(state["game"]["your_turn"], true);
|
||||
let hand = state["game"]["players"][1]["hand"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let card = hand[0].as_str().unwrap().to_string();
|
||||
let legal = state["game"]["legal_moves"]
|
||||
.as_object()
|
||||
.and_then(|moves| moves.get(&card))
|
||||
.cloned();
|
||||
let mut message = json!({"action": "play", "card": card});
|
||||
if let Some(legal) = legal {
|
||||
if let Some(option) = legal.as_array().and_then(|options| options.first()) {
|
||||
message["capture"] = option.clone();
|
||||
}
|
||||
}
|
||||
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
||||
message.to_string(),
|
||||
))
|
||||
.await
|
||||
.expect("send play");
|
||||
let update = read_json(&mut ws).await;
|
||||
assert_eq!(update["type"], "state");
|
||||
assert_eq!(update["game"]["turn"], 2);
|
||||
assert_eq!(update["game"]["last_move"]["seat"], 1);
|
||||
assert_eq!(update["game"]["last_move"]["card"], card);
|
||||
|
||||
// Carol (seat 2) is on turn now with a 1s timeout: the server auto-plays.
|
||||
let before = update["game"]["players"][2]["cards_left"].as_u64().unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1800)).await;
|
||||
let after = body_json(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(get(&format!("/api/games/{game_id}"), Some(&alice)))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(after["turn"], 3);
|
||||
assert_eq!(
|
||||
after["players"][2]["cards_left"].as_u64().unwrap(),
|
||||
before - 1
|
||||
);
|
||||
assert_eq!(after["last_move"]["seat"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spa_shell_and_static_mounts() {
|
||||
let temp = std::env::temp_dir().join(format!("tavolo-static-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(temp.join("assets")).expect("temp dir");
|
||||
std::fs::write(temp.join("index.html"), "<html>app shell</html>").expect("index");
|
||||
std::fs::write(temp.join("assets/card.png"), b"png-bytes").expect("asset");
|
||||
let test_settings = settings(temp.to_str().unwrap(), 30);
|
||||
let (platform, sessions) = app::in_memory_platform(&test_settings).expect("platform");
|
||||
let router = app::build_router(platform, sessions, &test_settings);
|
||||
|
||||
let response = router.clone().oneshot(get("/", None)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/game/abc-123", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "SPA fallback");
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/static/index.html", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/assets/card.png", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&bytes[..], b"png-bytes");
|
||||
|
||||
// Unknown API paths also fall back to the SPA shell, like kaya's glob;
|
||||
// an unknown OIDC provider path (no route registered) does too.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/api/does-not-exist", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(get("/auth/nope/login", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
std::fs::remove_dir_all(temp).ok();
|
||||
}
|
||||
Reference in new issue
Block a user