//! 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"); }