From cee0163eac0eafabbaad3247a5a2841f201e18ac Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:22:02 +0100 Subject: [PATCH] fix(multiplayer): verify complete durable assignment rosters --- multiplayer-next.md | 2 +- server/store/assignment_sql.go | 103 ++++++++++++++++++++-- server/store/assignment_sql_test.go | 24 ++++- server/store/postgres_integration_test.go | 54 ++++++++++++ 4 files changed, 171 insertions(+), 12 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index d5ff3f35..785e2734 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1224,7 +1224,7 @@ production fallback. | 8.28 `[D:8.6,8.27]` | **IN PROGRESS.** Supervisor separates explicit process-ready from Agones Ready and never scrapes stdout; allocated mode refuses to mark Ready without a configured readiness probe. The durable control-plane counterpart now exists: a workload-authenticated `POST /v1/servers/{id}/register` (and its `/api/v1` contract alias) advances a match's `ALLOCATING -> PROCESS_READY -> ASSIGNMENT_READY` state, and every participant's queue ticket with it, as one idempotent SERIALIZABLE transaction, gated on every participant already holding a live, unexpired assignment. The supervisor now calls it: once Agones Ready succeeds, it POSTs process-ready (`assignment_ready=false`) using a workload token read fresh from disk each call (matching kubelet's in-place rotation of a projected service-account token), and a registration failure kills the child rather than leaving an Agones-Ready-but-control-plane-unregistered process running; `ControlPlaneURL` unset (the default) is a total no-op. It then reports assignment-ready too: `server_boot.gd` already verifies its mounted roster synchronously before `/ready` is ever exposed (so process-ready implies the roster was valid), and the API's `ASSIGNMENT_READY` gate checks only durable `assignments` rows server-side — so no new Godot-side state was needed, correcting an earlier overcautious note here. The supervisor retries assignment-ready (default 5 attempts, 2s apart, configurable) since those durable rows may lag process-ready slightly; a persistent failure there does not kill the child, unlike process-ready. Per-allocation data (currently `match-id`) now has a real channel to an already-Ready pod: `server/agones.Client.Allocate` requests `cosmic-clash.io/match-id`/`cosmic-clash.io/allocation-id` as `GameServerAllocation.spec.metadata.annotations` (Agones applies these to the allocated GameServer's own `object_meta` — the only channel that exists post-allocation, since env vars are fixed at pod creation), and the supervisor reads them back from its existing `/gameserver` SDK call, falling back to them only when `MatchID` isn't explicitly configured. The image now exists: a new `Dockerfile` `game-server` target packages the supervisor as ENTRYPOINT alongside the same dedicated-server export `server` produces; the base Fleet now invokes that target with the control-plane URL, server/image Downward API identity, roster/signing/drain material, and exported Godot executable. | `server/supervisor/` tests prove Ready follows the probe and direct mode remains functional; `server_control.gd`, `agones_sdk.gd` and process-level smokes prove loopback `/ready`, `/health`, bearer-protected `/drain`, sidecar-shaped Health/Ready calls and drain admission fencing; `server/api/service.go`, `server/store/allocation_match_sql.go` and adversarial tests cover the registration route, digest/protocol validation, idempotent replay/conflict and the assignment-count gate; `server/supervisor/supervisor_test.go` covers opt-in registration, the workload-token/body/idempotency-key shape, the kill-on-failure path, the match-ID annotation fallback (both that it's used and that its absence fails closed before any HTTP call), the full process-ready-then-assignment-ready sequence and body shapes, and a retry test that fails assignment-ready twice with 409 before succeeding, asserting `Start()` still succeeds and the child is never killed; `server/agones/allocation_test.go` covers the requested annotations. `docker build --target game-server` verified for real: both binaries present, correct permissions, supervisor prints its usage; `server/store/stalled_allocation_sql.go`/`_test.go` and a live `TestPostgreSQLStalledAllocationsAreReclaimedWithoutPenalisingPlayers` cover the deadline boundary (a recent match must survive untouched), the no-penalty requeue and refreshed expiry, participant release, and idempotence against a second pass. **Fixed in passing**: `Dockerfile`'s `server` stage's `ubuntu` base digest had gone dead on Docker Hub (`docker pull` returned "not found", verified independently) — `make verify-phase6` was silently broken for a clean build before the re-pin; confirmed fixed with a full `make verify-phase6` run (arenas rotated, both goals observed, clean teardown). `deploy/cosmic-clash-server` now wraps its exec in `stdbuf -oL -eL` (falling back to unwrapped if unavailable), fixing a real, live-confirmed bug: a genuinely detached (`docker run -d`) container showed zero `docker logs` output — not even the startup line — for 20+ seconds while the process ran normally, and `docker stop`'s SIGTERM lost that buffered output permanently rather than delaying it; re-verified fixed against the real launcher script, then a full `make verify-phase6` re-run confirmed no regression. Health-reclaim now exists: `store.ExpireStalledAllocations` reclaims a match stuck in `ALLOCATING`/`PROCESS_READY`/`ASSIGNMENT_READY` past a deadline (server crashed, or was reclaimed by Agones as unhealthy, before ever registering) by failing the match and requeuing every participant to `QUEUED` with a fresh expiry rather than penalising them — task 8.50's own "infrastructure-caused cases cannot penalise affected players" criterion directly settles the requeue-vs-fail design question this had been blocked on. Wired into `cmd/maintenance` alongside the season-rollover sweep (`--stalled-allocation-deadline` default 2m, `--stalled-allocation-batch`). **Superseding the `fleet.yaml` framing above**: §8.10's `WorkloadVerify` blocker, and its delivery channel, are both now closed — a control-plane-self-issued signed token (not a Kubernetes JWT), minted by `cmd/allocator` into a `cosmic-clash.io/workload-token` annotation and read back by the supervisor, exactly the way `match-id` already worked — see §8.10. `/register` and `/result` no longer 503 unconditionally once every `--workload-secret` (control plane, allocator) is set consistently. The remaining gates are live Agones annotation/shutdown behavior and production cluster readiness; those are covered by §8.49 and remain explicitly open. | | 8.29 `[D:8.26,8.27]` | **IN PROGRESS.** Supervisor discovers and validates the Agones endpoint, propagates the actual dynamic `--port`, and exports `SDR_LISTEN_PORT`/`SDR_IP` only for Hosted-SDR while preserving an isolated ENet path | `server/supervisor/` tests cover invalid address/port rejection, dynamic port argument/env propagation and SDR-vs-ENet separation; real Agones dynamic/passthrough mapping, POP/cert/firewall/NAT and multi-match fixture remain | | 8.30 `[D:8.18,8.26,8.28,8.29]` | **IN PROGRESS.** PostgreSQL leases each `ALLOCATING` match under a deterministic allocation ID, derives immutable compatibility from its accepted roster, and atomically binds only a durably recorded provider allocation while advancing every ticket. Fresh and recovered provider results now share the same fail-closed validation of allocation/match/server identity, region, build, protocol, arena, transport, allocated state, and non-empty endpoint before persistence or binding. Workers bind the canonical allocation returned by durable reconciliation rather than the provider's pre-persistence object, preserving server-owned timestamps and normalization. Ambiguous provider outcomes retain the lease and recover by allocation ID before another external request. Agones request/response parsing and Fleet labels remain provider-portable | Unit/adversarial tests cover every fresh/recovered compatibility mismatch, empty endpoint, canonical durable result propagation, lease recovery, bind/release fencing, quota behavior, accepted-proposal gating, provider ambiguity, malformed responses, and immutable labels. PostgreSQL-tagged allocator/race/integration suites and the Agones-shaped HTTP runner remain committed; this provider-validation change awaits live database/cluster reruns while Docker storage, kind, and Helm are unavailable. Full unknown-outcome cluster recovery and signed roster metadata remain | -| 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Pure Go assignment gate requires Allocated state, exact allocation ID/match/server/region/build/protocol/transport compatibility, non-empty hosted endpoint and verified manifest signature before exposure; allocator publication cannot expose Ready state; durable roster persistence now verifies each canonical join-authorisation signature before publishing player rows; allocator service gates roster publication on allocated state and endpoint presence | `server/domain/assignment.go`, `allocator.go`, `store/assignment_sql.go` plus adversarial fixtures cover early-connect, tampered signature/manifest, wrong compatibility, empty endpoint, unknown allocation, forged roster signature, valid signature, premature publication and post-publication mutation rejection; Agones metadata watch, hosted-address registration, production signer and client-ticket publication remain | +| 8.31 `[D:8.9,8.30]` | **IN PROGRESS.** Assignment exposure requires Allocated state, exact allocation/match/server/region/build/protocol/transport compatibility, a hosted endpoint, and verified manifest/signature. Signed roster persistence now runs as one serializable transaction and proves the submitted set exactly equals the active durable match roster before writing any player row: allocation/server compatibility, player and Steam identity, canonical global slot, and team must all match. Partial rosters, unknown/substituted players, duplicate slots, mixed match/server/manifest batches, and zero revisions fail closed. Player recovery remains owner-, match-state-, server-, and expiry-scoped | Domain/store/allocator/API tests cover early exposure, tampered manifests/signatures, wrong compatibility, partial/mixed/duplicate rosters, durable Steam/team/slot mismatch, atomic no-row-on-failure behavior, expiry, and identical replay. PostgreSQL-tagged exact-roster regressions compile; live database and Agones reruns remain environment-dependent. Hosted-address registration, production signer, and client-ticket publication remain | | 8.32 `[D:8.2,8.26,8.30]` | **IN PROGRESS.** Provider-neutral FleetAutoscaler baseline preserves a two-process Ready buffer, caps warm capacity, and leaves Allocated scale-down independent of the Ready floor; Fleet image references remain digest-pinned for current/rollback pre-pull | `deploy/k8s/base/fleet-autoscaler.yaml` and manifest tests cover Fleet ownership, Buffer policy and floor/cap invariants; regional on-demand node pools/failure domains, pre-pull rollout, warm-allocation p95/p99 and N+1 certification remain | | 8.33 `[D:8.26,8.32]` | **IN PROGRESS.** Fleet scheduling now requires on-demand capacity and spreads Ready processes across zones with skew 1; the autoscaler preserves the two-process Ready floor | `deploy/k8s/base/fleet.yaml` and manifest tests reject interruptible placement and single-zone concentration structurally; regional node pools, forced node-loss testing and measured N+1 headroom remain | | 8.34 `[D:8.28,8.29]` | Native x86_64 benchmark of boot-to-process-ready and assignment-ready, p99 CPU/RSS/network and 60 Hz ticks; limits/node cap with 30% headroom | Measurements replace old estimates and certify density with no tick backlog | diff --git a/server/store/assignment_sql.go b/server/store/assignment_sql.go index 7a93e8d8..2f560357 100644 --- a/server/store/assignment_sql.go +++ b/server/store/assignment_sql.go @@ -76,8 +76,20 @@ FROM assignments WHERE match_id = $1 AND server_id = $2 AND expires_at > $3 ORDER BY slot, player_id` +const AssignmentExpectedRosterSQL = `SELECT mp.player_id, i.steam_id, mp.slot, mp.team +FROM match_participants mp +JOIN identities i ON i.player_id = mp.player_id +JOIN matches m ON m.match_id = mp.match_id +JOIN allocations a ON a.allocation_id = m.allocation_id AND a.match_id = m.match_id AND a.server_id = m.server_id +WHERE mp.match_id = $1 AND m.allocation_id = $2 AND m.server_id = $3 + AND m.region = $4 AND m.protocol_version = $5 + AND a.region = $4 AND a.build = $6 AND a.protocol_version = $5 AND a.transport = $7 + AND a.state = 'ALLOCATED' AND mp.participation_active +ORDER BY mp.player_id +FOR UPDATE OF mp` + func validateDurableAssignment(assignment DurableAssignment) error { - if assignment.MatchID == "" || assignment.PlayerID == "" || assignment.AllocationID == "" || assignment.ServerID == "" || assignment.Slot < 0 || assignment.Slot > 5 || (assignment.Region != "EU" && assignment.Region != "NA") || assignment.ClientBuild == "" || assignment.ProtocolVersion < 1 || (assignment.Transport != "enet" && assignment.Transport != "steam_sdr") || assignment.Endpoint == "" || assignment.JoinAuthorisation == "" || len(assignment.ManifestDigest) == 0 || assignment.ExpiresAt.IsZero() || assignment.Revision < 0 { + if assignment.MatchID == "" || assignment.PlayerID == "" || assignment.AllocationID == "" || assignment.ServerID == "" || assignment.Slot < 0 || assignment.Slot > 5 || (assignment.Region != "EU" && assignment.Region != "NA") || assignment.ClientBuild == "" || assignment.ProtocolVersion < 1 || (assignment.Transport != "enet" && assignment.Transport != "steam_sdr") || assignment.Endpoint == "" || assignment.JoinAuthorisation == "" || len(assignment.ManifestDigest) == 0 || assignment.ExpiresAt.IsZero() || assignment.Revision == 0 { return fmt.Errorf("invalid durable assignment") } return nil @@ -112,21 +124,42 @@ func SaveAssignments(ctx context.Context, db *sql.DB, assignments []DurableAssig if db == nil || len(assignments) == 0 { return fmt.Errorf("invalid assignment batch") } - tx, err := db.BeginTx(ctx, nil) - if err != nil { + if err := validateAssignmentBatch(assignments); err != nil { return err } - defer tx.Rollback() + return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { + return saveAssignmentsTx(ctx, tx, assignments) + }) +} + +func validateAssignmentBatch(assignments []DurableAssignment) error { + if len(assignments) == 0 { + return fmt.Errorf("invalid assignment batch") + } + first := assignments[0] seen := make(map[string]struct{}, len(assignments)) + seenSlots := make(map[int]struct{}, len(assignments)) for _, assignment := range assignments { if err := validateDurableAssignment(assignment); err != nil { return err } - key := assignment.MatchID + "\x00" + assignment.PlayerID - if _, ok := seen[key]; ok { + if assignment.MatchID != first.MatchID || assignment.AllocationID != first.AllocationID || assignment.ServerID != first.ServerID || assignment.Region != first.Region || assignment.ClientBuild != first.ClientBuild || assignment.ProtocolVersion != first.ProtocolVersion || assignment.Transport != first.Transport || assignment.Endpoint != first.Endpoint || string(assignment.ManifestDigest) != string(first.ManifestDigest) || assignment.Revision != first.Revision { + return fmt.Errorf("mixed assignment batch") + } + if _, ok := seen[assignment.PlayerID]; ok { return fmt.Errorf("duplicate assignment in batch") } - seen[key] = struct{}{} + if _, ok := seenSlots[assignment.Slot]; ok { + return fmt.Errorf("duplicate assignment slot in batch") + } + seen[assignment.PlayerID] = struct{}{} + seenSlots[assignment.Slot] = struct{}{} + } + return nil +} + +func saveAssignmentsTx(ctx context.Context, tx *sql.Tx, assignments []DurableAssignment) error { + for _, assignment := range assignments { result, err := tx.ExecContext(ctx, AssignmentUpsertSQL, assignment.MatchID, assignment.PlayerID, assignment.AllocationID, assignment.ServerID, assignment.Slot, assignment.Region, assignment.ClientBuild, assignment.ProtocolVersion, assignment.Transport, assignment.Endpoint, assignment.JoinAuthorisation, assignment.ManifestDigest, assignment.ExpiresAt, assignment.Revision) if err != nil { return err @@ -139,7 +172,7 @@ func SaveAssignments(ctx context.Context, db *sql.DB, assignments []DurableAssig return fmt.Errorf("assignment persistence conflict") } } - return tx.Commit() + return nil } // SaveVerifiedAssignmentRoster converts the backend-verified signed roster to @@ -179,7 +212,59 @@ func SaveVerifiedAssignmentRoster(ctx context.Context, db *sql.DB, assignment do ManifestDigest: digest[:], ExpiresAt: auth.ExpiresAt, Revision: 1, }) } - return SaveAssignments(ctx, db, rows) + if db == nil { + return fmt.Errorf("invalid assignment database") + } + if err := validateAssignmentBatch(rows); err != nil { + return err + } + return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error { + if err := validateExpectedAssignmentRoster(ctx, tx, assignment, roster); err != nil { + return err + } + return saveAssignmentsTx(ctx, tx, rows) + }) +} + +func validateExpectedAssignmentRoster(ctx context.Context, tx *sql.Tx, assignment domain.Assignment, roster []domain.SignedJoinAuthorisation) error { + rows, err := tx.QueryContext(ctx, AssignmentExpectedRosterSQL, + assignment.Allocation.MatchID, assignment.Allocation.AllocationID, assignment.Allocation.ServerID, + assignment.Allocation.Region, assignment.Allocation.Protocol, assignment.Allocation.Build, assignment.Allocation.Transport) + if err != nil { + return err + } + defer rows.Close() + type expectedPlayer struct { + steamID string + slot int + team int + } + expected := make(map[string]expectedPlayer, len(roster)) + for rows.Next() { + var playerID string + var player expectedPlayer + if err := rows.Scan(&playerID, &player.steamID, &player.slot, &player.team); err != nil { + return err + } + expected[playerID] = player + } + if err := rows.Err(); err != nil { + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(expected) == 0 || len(expected) != len(roster) { + return fmt.Errorf("signed assignment roster is incomplete") + } + for _, signed := range roster { + auth := signed.Authorisation + player, ok := expected[auth.PlayerID] + if !ok || player.steamID != auth.SteamID || player.slot != auth.Slot || player.team != auth.Team { + return fmt.Errorf("signed assignment roster does not match durable participants") + } + } + return nil } func validateSignedRosterEntry(assignment domain.Assignment, signed domain.SignedJoinAuthorisation, verify func([]byte, []byte) bool) error { diff --git a/server/store/assignment_sql_test.go b/server/store/assignment_sql_test.go index c166bb13..0c8e4b7e 100644 --- a/server/store/assignment_sql_test.go +++ b/server/store/assignment_sql_test.go @@ -9,8 +9,9 @@ import ( func TestAssignmentSQLBindsPlayerAndPreservesIdenticalReplay(t *testing.T) { for query, fragments := range map[string][]string{ - AssignmentUpsertSQL: {"ON CONFLICT (match_id, player_id)", "WHERE assignments.allocation_id = EXCLUDED.allocation_id", "join_authorisation", "manifest_digest"}, - AssignmentSelectSQL: {"a.match_id = $1", "a.player_id = $2", "a.expires_at > $3", "JOIN matches", "ASSIGNMENT_READY", "m.server_id = a.server_id"}, + AssignmentUpsertSQL: {"ON CONFLICT (match_id, player_id)", "WHERE assignments.allocation_id = EXCLUDED.allocation_id", "join_authorisation", "manifest_digest"}, + AssignmentSelectSQL: {"a.match_id = $1", "a.player_id = $2", "a.expires_at > $3", "JOIN matches", "ASSIGNMENT_READY", "m.server_id = a.server_id"}, + AssignmentExpectedRosterSQL: {"match_participants", "identities", "allocations", "m.allocation_id = $2", "m.server_id = $3", "a.state = 'ALLOCATED'", "participation_active", "FOR UPDATE OF mp"}, } { for _, fragment := range fragments { if !contains(query, fragment) { @@ -20,6 +21,25 @@ func TestAssignmentSQLBindsPlayerAndPreservesIdenticalReplay(t *testing.T) { } } +func TestAssignmentBatchRejectsMixedAuthorityAndDuplicateSlots(t *testing.T) { + base := DurableAssignment{MatchID: "match-1", PlayerID: "player-1", AllocationID: "allocation-1", ServerID: "server-1", Slot: 0, Region: "EU", ClientBuild: "build-1", ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:1", JoinAuthorisation: "join-1", ManifestDigest: []byte("digest"), ExpiresAt: time.Unix(1001, 0), Revision: 1} + other := base + other.PlayerID = "player-2" + other.JoinAuthorisation = "join-2" + if err := validateAssignmentBatch([]DurableAssignment{base, other}); err == nil { + t.Fatal("duplicate slot accepted") + } + other.Slot = 3 + other.ServerID = "server-2" + if err := validateAssignmentBatch([]DurableAssignment{base, other}); err == nil { + t.Fatal("mixed server batch accepted") + } + other.ServerID = base.ServerID + if err := validateAssignmentBatch([]DurableAssignment{base, other}); err != nil { + t.Fatalf("valid assignment batch rejected: %v", err) + } +} + func TestAssignmentStoreRejectsInvalidRecoveryAndManifestInputs(t *testing.T) { if _, err := GetAssignment(nil, nil, "player-1", "match-1", time.Unix(1000, 0)); err == nil { t.Fatal("nil database accepted") diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index 6cc6f3f7..3c0ec839 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -515,6 +515,60 @@ func TestPostgreSQLAssignmentPersistenceIsPlayerScopedAndExpiryBound(t *testing. } } +func TestPostgreSQLVerifiedAssignmentRosterMustMatchDurableParticipants(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + now := time.Now().UTC().Truncate(time.Microsecond) + ctx := context.Background() + for index, player := range []string{"roster-player-a", "roster-player-b"} { + if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2)`, player, "steam-"+player); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO queue_tickets (ticket_id, player_id, playlist, state, client_build, protocol_version, enqueued_at, expires_at) VALUES ($1, $2, 'casual', 'ALLOCATING', 'build-1', 1, $3, $4)`, fmt.Sprintf("roster-ticket-%d", index), player, now, now.Add(time.Minute)); err != nil { + t.Fatal(err) + } + } + if _, err := db.ExecContext(ctx, `INSERT INTO game_servers (server_id, region, build, protocol_version, transport, state, updated_at) VALUES ('roster-server', 'EU', 'build-1', 1, 'enet', 'ALLOCATED', $1)`, now); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO allocations (allocation_id, match_id, server_id, region, build, protocol_version, transport, request_digest, state, allocated_at) VALUES ('roster-allocation', 'roster-match', 'roster-server', 'EU', 'build-1', 1, 'enet', 'digest', 'ALLOCATED', $1)`, now); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO matches (match_id, playlist, state, region, protocol_version, server_id, allocation_id, allocation_claimed_at) VALUES ('roster-match', 'casual', 'ALLOCATING', 'EU', 1, 'roster-server', 'roster-allocation', $1)`, now); err != nil { + t.Fatal(err) + } + for index, player := range []string{"roster-player-a", "roster-player-b"} { + if _, err := db.ExecContext(ctx, `INSERT INTO match_participants (match_id, player_id, ticket_id, slot, team) VALUES ('roster-match', $1, $2, $3, $4)`, player, fmt.Sprintf("roster-ticket-%d", index), index*3, index); err != nil { + t.Fatal(err) + } + } + allocation := domain.Allocation{AllocationID: "roster-allocation", MatchID: "roster-match", ServerID: "roster-server", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerAllocated, AllocatedAt: now} + assignment := domain.Assignment{Allocation: allocation, Manifest: domain.AllocationManifest{AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport, RosterDigest: "roster-digest"}, Endpoint: "127.0.0.1:7777"} + roster := []domain.SignedJoinAuthorisation{ + {Authorisation: domain.JoinAuthorisation{MatchID: "roster-match", ServerID: "roster-server", PlayerID: "roster-player-a", SteamID: "steam-roster-player-a", Slot: 0, Team: 0, Protocol: "1", Generation: 1, ExpiresAt: now.Add(time.Minute)}, Signature: []byte("sig-a")}, + {Authorisation: domain.JoinAuthorisation{MatchID: "roster-match", ServerID: "roster-server", PlayerID: "roster-player-b", SteamID: "steam-roster-player-b", Slot: 3, Team: 1, Protocol: "1", Generation: 1, ExpiresAt: now.Add(time.Minute)}, Signature: []byte("sig-b")}, + } + verify := func([]byte, []byte) bool { return true } + if err := SaveVerifiedAssignmentRoster(ctx, db, assignment, roster[:1], verify); err == nil { + t.Fatal("partial signed roster was accepted") + } + var count int + if err := db.QueryRow(`SELECT count(*) FROM assignments WHERE match_id = 'roster-match'`).Scan(&count); err != nil || count != 0 { + t.Fatalf("partial roster persisted rows=%d err=%v", count, err) + } + if err := SaveVerifiedAssignmentRoster(ctx, db, assignment, roster, verify); err != nil { + t.Fatalf("complete durable roster rejected: %v", err) + } + if err := db.QueryRow(`SELECT count(*) FROM assignments WHERE match_id = 'roster-match'`).Scan(&count); err != nil || count != 2 { + t.Fatalf("complete roster rows=%d err=%v", count, err) + } + forged := append([]domain.SignedJoinAuthorisation(nil), roster...) + forged[1].Authorisation.SteamID = "steam-other" + if err := SaveVerifiedAssignmentRoster(ctx, db, assignment, forged, verify); err == nil { + t.Fatal("signed roster with wrong durable Steam identity was accepted") + } +} + func TestPostgreSQLConnectionReceiptsStartCompleteRelaxedCasualRoster(t *testing.T) { db := openIntegrationPostgres(t) applyIntegrationMigrations(t, db)