mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 01:32:04 +00:00
fix(multiplayer): validate proposal expiry
This commit is contained in:
@@ -337,6 +337,17 @@ static func normalize_ticket(payload: Dictionary) -> Dictionary:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func normalize_proposal(payload: Dictionary) -> Dictionary:
|
||||||
|
var result := payload.duplicate(true)
|
||||||
|
if not result.has("expires_at"):
|
||||||
|
return result
|
||||||
|
if not result["expires_at"] is String or not is_valid_rfc3339_timestamp(String(result["expires_at"])):
|
||||||
|
result["expires_at_unix"] = -1
|
||||||
|
else:
|
||||||
|
result["expires_at_unix"] = int(Time.get_unix_time_from_datetime_string(String(result["expires_at"])))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
|
func _start_request(operation: String, method: HTTPClient.Method, path: String, payload: Dictionary, idempotency_key: String, expected_revision: int = -1) -> Error:
|
||||||
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
|
if _request == null or not _operation.is_empty() or not is_valid_base_url(base_url):
|
||||||
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
|
return ERR_BUSY if not _operation.is_empty() else ERR_UNAUTHORIZED
|
||||||
@@ -460,7 +471,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
|
|||||||
if operation.begins_with("queue_"):
|
if operation.begins_with("queue_"):
|
||||||
state.apply_ticket_update(normalize_ticket(payload))
|
state.apply_ticket_update(normalize_ticket(payload))
|
||||||
elif operation.begins_with("proposal_"):
|
elif operation.begins_with("proposal_"):
|
||||||
state.apply_proposal_update(payload)
|
state.apply_proposal_update(normalize_proposal(payload))
|
||||||
elif operation == "ranked_profile":
|
elif operation == "ranked_profile":
|
||||||
if not ranked_profile.apply(payload):
|
if not ranked_profile.apply(payload):
|
||||||
request_failed.emit(operation, response_code, ranked_profile.error_message)
|
request_failed.emit(operation, response_code, ranked_profile.error_message)
|
||||||
@@ -533,7 +544,7 @@ static func _valid_response_opaque_id(payload: Dictionary, key: String) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
static func _valid_proposal_response(payload: Dictionary) -> bool:
|
||||||
if not _valid_response_opaque_id(payload, "proposal_id") or not payload.has("participants") or not payload["participants"] is Array:
|
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
|
return false
|
||||||
var participants: Array = payload["participants"]
|
var participants: Array = payload["participants"]
|
||||||
if participants.size() < 2 or participants.size() > 6:
|
if participants.size() < 2 or participants.size() > 6:
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func test_rest_responses_reject_malformed_resource_identifiers() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func test_proposal_response_requires_structured_unique_participants() -> void:
|
func test_proposal_response_requires_structured_unique_participants() -> void:
|
||||||
var base := {"proposal_id": "proposal_1234567890", "participants": [
|
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_1234567890", "response": "PENDING", "team": 0, "slot": 0},
|
||||||
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
|
{"player_id": "player_1234567891", "response": "PENDING", "team": 1, "slot": 3}
|
||||||
]}
|
]}
|
||||||
@@ -186,6 +186,13 @@ func test_proposal_response_requires_structured_unique_participants() -> void:
|
|||||||
var fractional_slot := base.duplicate(true)
|
var fractional_slot := base.duplicate(true)
|
||||||
fractional_slot["participants"][0]["slot"] = 0.5
|
fractional_slot["participants"][0]["slot"] = 0.5
|
||||||
assert_true(not ControlPlaneClient._valid_proposal_response(fractional_slot), "fractional participant slot is rejected")
|
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:
|
func test_assignment_endpoint_split_never_accepts_url_or_bad_port() -> void:
|
||||||
|
|||||||
@@ -1609,6 +1609,8 @@ MatchNet admission configuration now requires exact string opaque match/server I
|
|||||||
|
|
||||||
The proposal wire contract now matches the real API participant-object shape (`player_id`, response, team, slot), with JSON tags on the Go model and client validation for count, uniqueness, identities, enums, and integer team/slot assignments.
|
The proposal wire contract now matches the real API participant-object shape (`player_id`, response, team, slot), with JSON tags on the Go model and client validation for count, uniqueness, identities, enums, and integer team/slot assignments.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
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