Files
CosmicClash/Game/tests/cases/test_control_plane_client.gd
T
2026-09-02 18:54:10 +01:00

308 lines
23 KiB
GDScript

extends "res://tests/test_case.gd"
const ControlPlaneClient = preload("res://scripts/control_plane_client.gd")
const RankedProfileState = preload("res://scripts/ranked_profile_state.gd")
func test_base_url_validation_rejects_ambiguous_or_insecure_values() -> void:
assert_true(ControlPlaneClient.is_valid_base_url("http://127.0.0.1:8080"), "local HTTP endpoint is valid")
assert_true(ControlPlaneClient.is_valid_base_url("https://match.example"), "HTTPS endpoint is valid")
assert_true(not ControlPlaneClient.is_valid_base_url("match.example"), "scheme is required")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match.example/"), "trailing slash is normalized before validation")
assert_true(not ControlPlaneClient.is_valid_base_url("http://match example"), "whitespace is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://user:pass@match.example"), "userinfo is rejected")
assert_true(not ControlPlaneClient.is_valid_base_url("https://match.example?token=secret"), "query strings are rejected")
var client := ControlPlaneClient.new()
assert_true(client.configure("https://match.example", "session-id:opaque-token"), "safe access token configures")
assert_true(not client.configure("https://match.example", "token\nforged-header"), "header injection is rejected")
assert_true(ControlPlaneClient.is_valid_web_api_ticket("ticket-value"), "ordinary Steam Web API ticket is accepted")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket("ticket\nforged"), "ticket header characters are rejected")
assert_true(not ControlPlaneClient.is_valid_web_api_ticket(""), "empty Steam ticket is rejected")
assert_true(ControlPlaneClient.is_valid_access_token("session-id:opaque-token"), "opaque session format is accepted")
assert_true(not ControlPlaneClient.is_valid_access_token(":opaque-token"), "missing session identifier is rejected")
assert_true(not ControlPlaneClient.is_valid_access_token("session-id:token\nforged"), "session header injection is rejected")
assert_eq(ControlPlaneClient.websocket_url("https://match.example"), "wss://match.example", "TLS control plane uses secure WebSocket")
assert_eq(ControlPlaneClient.websocket_url("http://127.0.0.1:8080"), "ws://127.0.0.1:8080", "local control plane uses WebSocket")
assert_eq(ControlPlaneClient.websocket_url("match.example"), "", "unscoped URL cannot become a WebSocket URL")
var unconfigured := ControlPlaneClient.new()
assert_eq(unconfigured.connect_event_stream(), ERR_UNAUTHORIZED, "event stream requires an authenticated session")
func test_ticket_normalization_preserves_payload_and_derives_expiry() -> void:
var payload := {"ticket_id": "ticket-1", "state": "QUEUED", "expires_at": "2026-08-31T12:00:00Z"}
var normalized := ControlPlaneClient.normalize_ticket(payload)
assert_eq(normalized["ticket_id"], "ticket-1", "normalization preserves ticket identity")
assert_true(normalized.has("expires_at_unix"), "RFC3339 expiry is available to the projection")
assert_true(int(normalized["expires_at_unix"]) > 0, "expiry is converted to a positive epoch")
assert_true(not payload.has("expires_at_unix"), "normalization does not mutate the HTTP payload")
func test_ticket_normalization_derives_authoritative_enqueue_time() -> void:
var normalized := ControlPlaneClient.normalize_ticket({"enqueued_at": "1970-01-01T00:16:40Z"})
assert_eq(int(normalized["enqueued_at_unix"]), 1000, "RFC3339 enqueue time is converted to epoch")
assert_eq(ControlPlaneClient.normalize_ticket({"enqueued_at": "not-a-timestamp"})["enqueued_at_unix"], -1, "malformed enqueue time remains visibly invalid")
assert_eq(ControlPlaneClient.normalize_ticket({"expires_at": 123})["expires_at_unix"], -1, "non-string expiry remains visibly invalid")
func test_session_expiry_is_checked_at_the_boundary_and_fails_closed() -> void:
assert_true(not ControlPlaneClient.is_session_expired("", 1000), "legacy sessions without an expiry remain compatible")
assert_true(not ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 999), "session remains valid before expiry")
assert_true(ControlPlaneClient.is_session_expired("1970-01-01T00:16:40Z", 1000), "session expires at the exact boundary")
assert_true(ControlPlaneClient.is_session_expired("not-a-timestamp", 1000), "malformed non-empty expiry fails closed")
assert_true(ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00.123Z"), "fractional RFC3339 timestamp is accepted")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-02-30T12:00:00Z"), "impossible calendar date is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-13-01T12:00:00Z"), "impossible month is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31 12:00:00Z"), "space-separated timestamp is rejected")
assert_true(not ControlPlaneClient.is_valid_rfc3339_timestamp("2026-08-31T12:00:00"), "timezone-less timestamp is rejected")
var valid_session := {"player_id": "player_1234567890", "access_token": "session-id:opaque-token", "expires_at": "2099-08-31T12:00:00Z"}
assert_true(ControlPlaneClient.is_valid_session_response(valid_session), "future session response is accepted")
var missing_expiry := valid_session.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient.is_valid_session_response(missing_expiry), "session without expiry is rejected")
var malformed_expiry := valid_session.duplicate()
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient.is_valid_session_response(malformed_expiry), "malformed session expiry is rejected")
var expired_session := valid_session.duplicate()
expired_session["expires_at"] = "2000-01-01T00:00:00Z"
assert_true(not ControlPlaneClient.is_valid_session_response(expired_session), "expired session response is rejected")
func test_reconfiguration_discards_the_previous_session_expiry() -> void:
var client := ControlPlaneClient.new()
client.session_expires_at = "1970-01-01T00:00:01Z"
assert_true(client.configure("https://match.example", "new-session:opaque-token"), "new session configures successfully")
assert_eq(client.session_expires_at, "", "new credentials do not inherit the old expiry")
func test_websocket_event_validation_requires_contract_specific_fields() -> void:
var envelope := {"event": "state_changed", "revision": 1, "resource_id": "ticket_123456789", "occurred_at": "2026-08-31T12:00:00Z", "state": "QUEUED"}
assert_true(ControlPlaneClient._valid_websocket_event(envelope), "valid state event is accepted")
var accepted := envelope.duplicate()
accepted["state"] = "ACCEPTED"
assert_true(ControlPlaneClient._valid_websocket_event(accepted), "authoritative accepted queue event is accepted")
for phase in ["ASSIGNED", "RESULT_PENDING", "COMPLETED"]:
var lifecycle := envelope.duplicate()
lifecycle["state"] = phase
assert_true(ControlPlaneClient._valid_websocket_event(lifecycle), "post-match queue event is accepted: " + phase)
var bad_state := envelope.duplicate()
bad_state["state"] = "SECRET"
assert_true(not ControlPlaneClient._valid_websocket_event(bad_state), "unknown state event is rejected")
var assignment := {"event": "assignment_changed", "revision": 0, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "match_id": "match_1234567890", "server_id": "server_123456789"}
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
var short_assignment_id := assignment.duplicate()
short_assignment_id["server_id"] = "server-1"
assert_true(not ControlPlaneClient._valid_websocket_event(short_assignment_id), "short assignment server id is rejected")
assignment.erase("server_id")
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
var fractional := envelope.duplicate()
fractional["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
var negative := envelope.duplicate()
negative["revision"] = -1
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
var malformed_time := envelope.duplicate()
malformed_time["occurred_at"] = "yesterday"
assert_true(not ControlPlaneClient._valid_websocket_event(malformed_time), "malformed event timestamp is rejected")
var short_resource := envelope.duplicate()
short_resource["resource_id"] = "short"
assert_true(not ControlPlaneClient._valid_websocket_event(short_resource), "short resource identifier is rejected")
var unsafe_resource := envelope.duplicate()
unsafe_resource["resource_id"] = "ticket_123456789/secret"
assert_true(not ControlPlaneClient._valid_websocket_event(unsafe_resource), "resource identifier with separators is rejected")
var match_state := {"event": "state_changed", "revision": 4, "resource_id": "match_1234567890", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_1234567890"}
assert_true(ControlPlaneClient._valid_websocket_event(match_state), "match-scoped lifecycle event is accepted")
match_state["match_id"] = "different_match_123"
assert_true(not ControlPlaneClient._valid_websocket_event(match_state), "match lifecycle identity must equal its resource identity")
func test_match_assignment_ready_event_recovers_ticket_and_schedules_assignment_fetch() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket_assignment_1", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
var event := {"event": "state_changed", "revision": 4, "resource_id": "match_assignment_1", "occurred_at": "2026-08-31T12:00:00Z", "state": "ASSIGNMENT_READY", "match_id": "match_assignment_1"}
client._handle_websocket_packet(JSON.stringify(event).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket_assignment_1", "match event requests authoritative ticket recovery")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "assignment lookup no longer depends on a prior assignment GET")
assert_eq(client.state.ticket_id, "ticket_assignment_1", "match resource is never projected as a ticket identity")
client.free()
func test_recovered_assignment_ready_ticket_schedules_fetch_after_missed_revisions() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.player_id = "player_1234567890"
client.state.begin_queue("ticket_assignment_1", "casual")
assert_true(client.state.apply_ticket_update({"ticket_id": "ticket_assignment_1", "revision": 1, "state": "PROPOSED", "playlist": "casual"}), "proposal setup applies")
client._operation = "queue_recover"
var recovered := {"ticket_id": "ticket_assignment_1", "player_id": "player_1234567890", "match_id": "match_assignment_1", "playlist": "casual", "state": "ASSIGNMENT_READY", "revision": 5, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify(recovered).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.ASSIGNMENT_READY, "REST recovery applies a forward authoritative snapshot")
assert_eq(client._pending_assignment_match_id, "match_assignment_1", "recovered snapshot supplies the assignment lookup key")
client.free()
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-reconnect", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._set_websocket_status("CONNECTED")
assert_eq(client._pending_resync_resource_id, "ticket-reconnect", "reconnect recovery is retained until the mutation completes")
client.free()
func test_transient_rest_recovery_failure_does_not_end_matchmaking() -> void:
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_recovery_123", "casual")
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_CANT_CONNECT, 0, PackedStringArray(), PackedByteArray())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "network failure during recovery keeps the active search")
assert_true(client.state.message.contains("retrying"), "recovery failure remains visible and retryable")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), "[]".to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.QUEUED, "malformed transient recovery response does not become terminal")
client.free()
func test_resync_of_terminal_proposal_recovers_the_ticket() -> void:
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", false), "ticket-terminal-resync", "terminal proposal resync targets the requeued ticket")
assert_eq(ControlPlaneClient.resync_target("proposal-terminal-resync", "ticket-terminal-resync", "proposal-terminal-resync", true), "proposal-terminal-resync", "open proposal resync retains the proposal target")
func test_retryable_mutation_policy_only_retries_safe_failures() -> void:
assert_true(ControlPlaneClient.is_retryable_mutation_response(0), "transport failure is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(408), "request timeout is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(429), "rate limit is retryable")
assert_true(ControlPlaneClient.is_retryable_mutation_response(503), "server failure is retryable")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(401), "authentication failure is not blindly replayed")
assert_true(not ControlPlaneClient.is_retryable_mutation_response(409), "revision/idempotency conflict is not blindly replayed")
func test_rest_resource_identifiers_use_the_opaque_contract_shape() -> void:
assert_true(ControlPlaneClient.is_valid_resource_id("ticket_1234567890"), "contract-sized resource id is accepted")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket-1"), "short resource id is rejected")
assert_true(not ControlPlaneClient.is_valid_resource_id("ticket_1234567890/path"), "path separator is rejected")
func test_queue_revision_conflicts_schedule_authoritative_recovery() -> void:
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 409, "ticket-1"), "stale heartbeat recovers the queue ticket")
assert_true(ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, "ticket-1"), "stale cancellation recovers the queue ticket")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_create", 409, "ticket-1"), "create conflict uses its own idempotency path")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_heartbeat", 503, "ticket-1"), "transient outage remains retryable instead of being treated as a revision conflict")
assert_true(not ControlPlaneClient.should_recover_queue_after_conflict("queue_cancel", 409, ""), "missing ticket cannot trigger recovery")
func test_queue_conflict_response_handler_defers_ticket_recovery() -> void:
var client := ControlPlaneClient.new()
client._ready()
assert_true(client.state.begin_queue("ticket-handler", "casual"), "queue setup succeeds")
client._operation = "queue_heartbeat"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "heartbeat conflict queues ticket recovery")
client._operation = "queue_cancel"
client._pending_resync_resource_id = ""
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 409, PackedStringArray(), JSON.stringify({"error": "revision conflict"}).to_utf8_buffer())
assert_eq(client._pending_resync_resource_id, "ticket-handler", "cancel conflict queues ticket recovery")
client.free()
func test_rest_responses_reject_malformed_resource_identifiers() -> void:
var client := ControlPlaneClient.new()
client._ready()
client._operation = "queue_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"ticket_id": "short", "playlist": "casual", "revision": 0, "state": "QUEUED"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed queue response is not projected")
client._operation = "proposal_recover"
client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 200, PackedStringArray(), JSON.stringify({"proposal_id": "proposal/unsafe", "revision": 0, "state": "OPEN"}).to_utf8_buffer())
assert_eq(client.state.phase, MatchmakingState.FAILED, "malformed proposal response is not projected")
client.free()
func test_queue_response_requires_the_complete_contract_shape() -> void:
var valid := {"ticket_id": "ticket_1234567890", "player_id": "player_1234567890", "playlist": "casual", "state": "QUEUED", "revision": 0, "enqueued_at": "2026-08-31T12:00:00Z", "expires_at": "2026-08-31T12:01:00Z"}
assert_true(ControlPlaneClient._valid_queue_response(valid), "complete queue response is accepted")
var missing_expiry := valid.duplicate()
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_queue_response(missing_expiry), "queue response without expiry is rejected")
var fractional_revision := valid.duplicate()
fractional_revision["revision"] = 1.5
assert_true(not ControlPlaneClient._valid_queue_response(fractional_revision), "fractional queue revision is rejected")
var malformed_player := valid.duplicate()
malformed_player["player_id"] = "player/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(malformed_player), "unsafe queue player id is rejected")
var assigned := valid.duplicate()
assigned["state"] = "ASSIGNMENT_READY"
assigned["match_id"] = "match_1234567890"
assert_true(ControlPlaneClient._valid_queue_response(assigned), "recovered assignment-ready ticket carries its match lookup identity")
var premature_match := valid.duplicate()
premature_match["match_id"] = "match_1234567890"
assert_true(not ControlPlaneClient._valid_queue_response(premature_match), "pre-match ticket cannot smuggle a match identity")
assigned["match_id"] = "match/unsafe"
assert_true(not ControlPlaneClient._valid_queue_response(assigned), "unsafe recovered match identity is rejected")
var proposed := valid.duplicate()
proposed["state"] = "PROPOSED"
proposed["proposal_id"] = "proposal_12345678"
assert_true(ControlPlaneClient._valid_queue_response(proposed), "recovered proposed ticket carries its proposal lookup identity")
var client := ControlPlaneClient.new()
client._ready()
client.state.begin_queue("ticket_1234567890", "casual")
client._queue_proposal_if_ready(proposed)
assert_eq(client._pending_proposal_id, "proposal_12345678", "recovered proposal is queued for authoritative fetch")
assert_eq(client.state.proposal_id, "proposal_12345678", "recovered proposal identity becomes the active projection")
client.free()
func test_proposal_response_requires_structured_unique_participants() -> void:
var base := {"proposal_id": "proposal_1234567890", "expires_at": "2099-08-31T12:00:00Z", "participants": [
{"player_id": "player_1234567890", "response": "PENDING", "team": 0, "slot": 0},
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
]}
assert_true(ControlPlaneClient._valid_proposal_response(base), "structured proposal participants are accepted")
var duplicate := base.duplicate(true)
duplicate["participants"][1]["player_id"] = "player_1234567890"
assert_true(not ControlPlaneClient._valid_proposal_response(duplicate), "duplicate participant identity is rejected")
var fractional_slot := base.duplicate(true)
fractional_slot["participants"][0]["slot"] = 0.5
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
var malformed_expiry := base.duplicate(true)
malformed_expiry["expires_at"] = "tomorrow"
assert_true(not ControlPlaneClient._valid_proposal_response(malformed_expiry), "malformed proposal expiry is rejected")
var missing_expiry := base.duplicate(true)
missing_expiry.erase("expires_at")
assert_true(not ControlPlaneClient._valid_proposal_response(missing_expiry), "missing proposal expiry is rejected")
assert_true(int(ControlPlaneClient.normalize_proposal(base)["expires_at_unix"]) > 0, "proposal expiry is normalized")
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
var endpoint := ControlPlaneClient._split_assignment_endpoint("127.0.0.1:31001")
assert_eq(endpoint["host"], "127.0.0.1", "assignment host is separated from the port")
assert_eq(endpoint["port"], 31001, "assignment port is parsed as an integer")
for unsafe in ["127.0.0.1", "127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:31001/path", "https://127.0.0.1:31001"]:
assert_true(ControlPlaneClient._split_assignment_endpoint(unsafe).is_empty(), "unsafe endpoint is rejected: %s" % unsafe)
func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": true, "season_id": "season_1234567890"}), "valid profile applies")
assert_eq(profile.display_text(), "Provisional · 3 ranked games", "provisional status overrides tier presentation")
assert_true(not profile.apply({"rating": -1.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": false}), "negative rating is rejected")
assert_true(not profile.available, "unsafe response is not displayed")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "MASTER", "provisional": false}), "unknown tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3.5, "tier": "GOLD", "provisional": false}), "fractional ranked games is rejected")
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "season_1234567890", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": "not-a-timestamp"}), "malformed season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_ends_at": 123}), "non-string season expiry is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "short"}), "short season identifier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": 123}), "non-string season identifier is rejected")