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.
411 lines
14 KiB
Rust
411 lines
14 KiB
Rust
//! HTTP routes and WebSocket protocol of the platform.
|
|
|
|
mod helpers;
|
|
|
|
use futures_util::SinkExt;
|
|
use helpers::*;
|
|
use serde_json::json;
|
|
use tokio_tungstenite::tungstenite::handshake::client::Response as WsResponse;
|
|
|
|
async fn create_and_start(test: &helpers::TestPlatform) -> (String, String, String, String) {
|
|
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
|
|
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
|
|
let router = test.router();
|
|
let lobby = body_json(
|
|
call(
|
|
&router,
|
|
post_json(
|
|
"/api/games",
|
|
Some(&alice),
|
|
&json!({"options": {"target": 3}}),
|
|
),
|
|
)
|
|
.await,
|
|
)
|
|
.await;
|
|
let game_id = lobby["id"].as_str().unwrap().to_string();
|
|
let code = lobby["join_code"].as_str().unwrap().to_string();
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 200);
|
|
(game_id, code, alice, bob)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn game_types_lists_the_registered_engine() {
|
|
let test = make_platform(50);
|
|
let router = test.router();
|
|
let response = call(&router, get("/api/game-types", None)).await;
|
|
assert_eq!(response.status(), 200);
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["results"][0]["id"], "dummy");
|
|
assert_eq!(body["results"][0]["min_players"], 2);
|
|
assert_eq!(body["results"][0]["options_schema"]["type"], "object");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_requires_authentication() {
|
|
let test = make_platform(50);
|
|
let router = test.router();
|
|
let response = call(&router, post_json("/api/games", None, &json!({}))).await;
|
|
assert_eq!(response.status(), 401);
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["error"], "unauthenticated");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_validates_game_type_and_options() {
|
|
let test = make_platform(50);
|
|
let cookie = login_cookie(&test.session_store, "default", "alice", "Alice").await;
|
|
let router = test.router();
|
|
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games", Some(&cookie), &json!({"game_type": "nope"})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 400);
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["error"], "unknown game_type: 'nope'");
|
|
|
|
let response = call(
|
|
&router,
|
|
post_json(
|
|
"/api/games",
|
|
Some(&cookie),
|
|
&json!({"options": {"target": 0}}),
|
|
),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 400);
|
|
let body = body_json(response).await;
|
|
assert_eq!(body["error"], "target must be a positive integer");
|
|
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games", Some(&cookie), &json!({"options": 3})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 400);
|
|
assert_eq!(
|
|
body_json(response).await["error"],
|
|
"options must be an object"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn join_flow_and_snapshot_authorization() {
|
|
let test = make_platform(50);
|
|
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
|
|
let router = test.router();
|
|
let lobby =
|
|
body_json(call(&router, post_json("/api/games", Some(&alice), &json!({}))).await).await;
|
|
let game_id = lobby["id"].as_str().unwrap().to_string();
|
|
let code = lobby["join_code"].as_str().unwrap().to_string();
|
|
|
|
// Missing / unknown code.
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&alice), &json!({})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 400);
|
|
assert_eq!(body_json(response).await["error"], "code is required");
|
|
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&bob), &json!({"code": "ZZZZZZ"})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 404);
|
|
|
|
// Preview before joining.
|
|
let preview = body_json(
|
|
call(
|
|
&router,
|
|
get(&format!("/api/games/preview?code={code}"), Some(&bob)),
|
|
)
|
|
.await,
|
|
)
|
|
.await;
|
|
assert_eq!(preview["game_name"], "Dummy game");
|
|
assert_eq!(preview["seats_open"], 1);
|
|
assert_eq!(preview["players"][0]["sub"], "alice");
|
|
|
|
// The creator cannot join their own (still open) lobby twice.
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&alice), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 409);
|
|
assert_eq!(
|
|
body_json(response).await["error"],
|
|
"already joined this game"
|
|
);
|
|
|
|
// Bob joins and starts the match.
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 200);
|
|
let started = body_json(response).await;
|
|
assert_eq!(started["started"], true);
|
|
assert_eq!(started["your_turn"], json!(null));
|
|
|
|
// Bob cannot rejoin a started game; a third player finds the lobby full.
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 409);
|
|
assert_eq!(
|
|
body_json(response).await["error"],
|
|
"game has already started"
|
|
);
|
|
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
|
|
let response = call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&carol), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
assert_eq!(response.status(), 409);
|
|
assert_eq!(
|
|
body_json(response).await["error"],
|
|
"game has already started"
|
|
);
|
|
|
|
// Snapshots: unknown game 404, non-seated 403, seated 200, anonymous 401.
|
|
let response = call(&router, get("/api/games/no-such-id", Some(&alice))).await;
|
|
assert_eq!(response.status(), 404);
|
|
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&carol))).await;
|
|
assert_eq!(response.status(), 403);
|
|
let response = call(&router, get(&format!("/api/games/{game_id}"), Some(&alice))).await;
|
|
assert_eq!(response.status(), 200);
|
|
let snapshot = body_json(response).await;
|
|
assert_eq!(snapshot["id"], game_id);
|
|
assert_eq!(snapshot["viewer_seated"], true);
|
|
let response = call(&router, get(&format!("/api/games/{game_id}"), None)).await;
|
|
assert_eq!(response.status(), 401);
|
|
|
|
// The whoami endpoint reflects the session identity.
|
|
let me = body_json(call(&router, get("/api/me", Some(&alice))).await).await;
|
|
assert_eq!(me["sub"], "alice");
|
|
assert_eq!(me["name"], "Alice");
|
|
let response = call(&router, get("/api/me", None)).await;
|
|
assert_eq!(response.status(), 401);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_helpers_are_provider_agnostic() {
|
|
let test = make_platform(50);
|
|
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
|
|
let router = test.router();
|
|
|
|
let providers = body_json(call(&router, get("/api/auth/providers", None)).await).await;
|
|
assert_eq!(
|
|
providers["providers"],
|
|
json!([{"name": "default", "login_url": "/auth/default/login"}])
|
|
);
|
|
|
|
let response = call(&router, get("/auth/login", None)).await;
|
|
assert_eq!(response.status(), 302);
|
|
assert_eq!(
|
|
response.headers()["location"].to_str().unwrap(),
|
|
"/auth/default/login"
|
|
);
|
|
|
|
let response = call(&router, get("/auth/logout", Some(&alice))).await;
|
|
assert_eq!(response.status(), 302);
|
|
assert_eq!(
|
|
response.headers()["location"].to_str().unwrap(),
|
|
"/auth/default/logout"
|
|
);
|
|
let response = call(&router, get("/auth/logout", None)).await;
|
|
assert_eq!(response.headers()["location"].to_str().unwrap(), "/");
|
|
|
|
let response = call(&router, get("/api/openapi.json", None)).await;
|
|
assert_eq!(response.status(), 200);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_rejects_unauthorized_unknown_and_unseated() {
|
|
let test = make_platform(50);
|
|
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
|
|
let carol = login_cookie(&test.session_store, "default", "carol", "Carol").await;
|
|
let addr = start_server(test.router()).await;
|
|
|
|
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, None))
|
|
.await
|
|
.expect_err("anonymous upgrade must be refused");
|
|
match error {
|
|
tokio_tungstenite::tungstenite::Error::Http(response) => {
|
|
assert_eq!(response.status(), 401);
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
|
|
let error = tokio_tungstenite::connect_async(ws_request(addr, "unknown-game", Some(&alice)))
|
|
.await
|
|
.expect_err("unknown game upgrade must be refused");
|
|
match error {
|
|
tokio_tungstenite::tungstenite::Error::Http(response) => {
|
|
assert_eq!(response.status(), 404);
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
|
|
let error = tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&carol)))
|
|
.await
|
|
.expect_err("non-seated upgrade must be refused");
|
|
match error {
|
|
tokio_tungstenite::tungstenite::Error::Http(response) => {
|
|
assert_eq!(response.status(), 403);
|
|
}
|
|
other => panic!("unexpected error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_broadcasts_moves_and_game_over() {
|
|
let test = make_platform(50);
|
|
let (game_id, _code, alice, bob) = create_and_start(&test).await;
|
|
let addr = start_server(test.router()).await;
|
|
|
|
let (mut alice_ws, _): (_, WsResponse) =
|
|
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
|
|
.await
|
|
.expect("alice connects");
|
|
let (mut bob_ws, _): (_, WsResponse) =
|
|
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&bob)))
|
|
.await
|
|
.expect("bob connects");
|
|
|
|
let first = read_json(&mut alice_ws).await;
|
|
assert_eq!(first["type"], "state");
|
|
assert_eq!(first["game"]["viewer_seated"], true);
|
|
let first_bob = read_json(&mut bob_ws).await;
|
|
assert_eq!(first_bob["type"], "state");
|
|
|
|
// Alice plays; both connections see the update.
|
|
alice_ws
|
|
.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!({"action": "play"}).to_string(),
|
|
))
|
|
.await
|
|
.expect("send play");
|
|
let update = read_json(&mut alice_ws).await;
|
|
assert_eq!(update["game"]["plays"], 1);
|
|
let update = read_json(&mut bob_ws).await;
|
|
assert_eq!(update["game"]["plays"], 1);
|
|
|
|
// Requesting a snapshot resends the state.
|
|
bob_ws
|
|
.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!({"action": "state"}).to_string(),
|
|
))
|
|
.await
|
|
.expect("send state");
|
|
let snapshot = read_json(&mut bob_ws).await;
|
|
assert_eq!(snapshot["type"], "state");
|
|
assert_eq!(snapshot["game"]["plays"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_reports_errors_and_malformed_messages() {
|
|
let test = make_platform(50);
|
|
let (game_id, _code, alice, _bob) = create_and_start(&test).await;
|
|
let addr = start_server(test.router()).await;
|
|
let (mut ws, _): (_, WsResponse) =
|
|
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
|
|
.await
|
|
.expect("alice connects");
|
|
read_json(&mut ws).await;
|
|
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
"not json".into(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let error = read_json(&mut ws).await;
|
|
assert_eq!(error["type"], "error");
|
|
assert_eq!(error["message"], "invalid JSON");
|
|
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!([1, 2]).to_string(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let error = read_json(&mut ws).await;
|
|
assert_eq!(error["message"], "message must be a JSON object");
|
|
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!({"action": "dance"}).to_string(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let error = read_json(&mut ws).await;
|
|
assert_eq!(error["type"], "error");
|
|
assert_eq!(error["code"], "illegal_move");
|
|
assert_eq!(error["message"], "unknown action: 'dance'");
|
|
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!({"action": 7}).to_string(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let error = read_json(&mut ws).await;
|
|
assert_eq!(error["message"], "unknown action: 7");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn websocket_sends_game_over_once_the_match_finishes() {
|
|
let test = make_platform(50);
|
|
let alice = login_cookie(&test.session_store, "default", "alice", "Alice").await;
|
|
let bob = login_cookie(&test.session_store, "default", "bob", "Bob").await;
|
|
let router = test.router();
|
|
let lobby = body_json(
|
|
call(
|
|
&router,
|
|
post_json(
|
|
"/api/games",
|
|
Some(&alice),
|
|
&json!({"options": {"target": 1}}),
|
|
),
|
|
)
|
|
.await,
|
|
)
|
|
.await;
|
|
let game_id = lobby["id"].as_str().unwrap().to_string();
|
|
let code = lobby["join_code"].as_str().unwrap().to_string();
|
|
call(
|
|
&router,
|
|
post_json("/api/games/join", Some(&bob), &json!({"code": code})),
|
|
)
|
|
.await;
|
|
|
|
let addr = start_server(test.router()).await;
|
|
let (mut ws, _): (_, WsResponse) =
|
|
tokio_tungstenite::connect_async(ws_request(addr, &game_id, Some(&alice)))
|
|
.await
|
|
.expect("alice connects");
|
|
read_json(&mut ws).await;
|
|
|
|
ws.send(tokio_tungstenite::tungstenite::Message::Text(
|
|
json!({"action": "play"}).to_string(),
|
|
))
|
|
.await
|
|
.unwrap();
|
|
let state = read_json(&mut ws).await;
|
|
assert_eq!(state["type"], "state");
|
|
assert_eq!(state["game"]["finished"], true);
|
|
let game_over = read_json(&mut ws).await;
|
|
assert_eq!(game_over["type"], "game_over");
|
|
}
|