mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): validate queue response contract
This commit is contained in:
@@ -457,9 +457,9 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
||||
connect_event_stream()
|
||||
session_changed.emit(player_id)
|
||||
elif operation == "queue_create" or operation == "queue_recover" or operation == "queue_heartbeat" or operation == "queue_cancel":
|
||||
if not _valid_response_opaque_id(payload, "ticket_id"):
|
||||
state.fail("Queue response contains an invalid ticket identifier")
|
||||
request_failed.emit(operation, response_code, "invalid ticket identifier")
|
||||
if not _valid_queue_response(payload):
|
||||
state.fail("Queue response contains invalid contract data")
|
||||
request_failed.emit(operation, response_code, "invalid queue response")
|
||||
return
|
||||
if operation == "queue_create":
|
||||
state.begin_queue(String(payload["ticket_id"]), String(payload.get("playlist", "")))
|
||||
@@ -543,6 +543,21 @@ static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
|
||||
return payload.has(key) and payload[key] is String and is_valid_resource_id(String(payload[key]))
|
||||
|
||||
|
||||
static func _valid_queue_response(payload: Dictionary) -> bool:
|
||||
for key in ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"]:
|
||||
if not payload.has(key):
|
||||
return false
|
||||
if not _valid_response_opaque_id(payload, "ticket_id") or not _valid_response_opaque_id(payload, "player_id"):
|
||||
return false
|
||||
if not payload["playlist"] is String or not String(payload["playlist"]) in ["casual", "ranked"]:
|
||||
return false
|
||||
if not payload["state"] is String or not String(payload["state"]) in ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]:
|
||||
return false
|
||||
if not _valid_revision(payload["revision"]):
|
||||
return false
|
||||
return payload["enqueued_at"] is String and is_valid_rfc3339_timestamp(String(payload["enqueued_at"])) and payload["expires_at"] is String and is_valid_rfc3339_timestamp(String(payload["expires_at"]))
|
||||
|
||||
|
||||
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
||||
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("expires_at") or not payload["expires_at"] is String or not is_valid_rfc3339_timestamp(String(payload["expires_at"])) or not payload.has("participants") or not payload["participants"] is Array:
|
||||
return false
|
||||
|
||||
@@ -174,6 +174,20 @@ func test_rest_responses_reject_malformed_resource_identifiers() -> void:
|
||||
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")
|
||||
|
||||
|
||||
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},
|
||||
|
||||
@@ -1611,6 +1611,8 @@ The proposal wire contract now matches the real API participant-object shape (`p
|
||||
|
||||
Proposal responses now require and normalize the contract's RFC3339 `expires_at`; malformed or missing expiry metadata fails closed while already-expired terminal proposals remain representable.
|
||||
|
||||
Queue responses now validate the complete published shape before projection: opaque ticket/player IDs, playlist and lifecycle enums, integral revision, and RFC3339 enqueue/expiry timestamps.
|
||||
|
||||
Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification.
|
||||
|
||||
Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected.
|
||||
|
||||
Reference in New Issue
Block a user