From 0364d3f17252aacb08a06df427b9fda221a665d2 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:00:31 +0100 Subject: [PATCH] test: add offline Steam and allocator fakes --- multiplayer-todo.md | 2 +- server/testkit/fakes.go | 49 ++++++++++++++++++++++++++++++++++++ server/testkit/fakes_test.go | 38 ++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 server/testkit/fakes.go create mode 100644 server/testkit/fakes_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index bed1485e..a466f94a 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1239,7 +1239,7 @@ the local/CI/community transport, not a silent production fallback. | 8.44 `[D:8.3,8.4,8.28,8.31]` | Propagate queue/proposal/match/server IDs and process-ready/assignment-ready through logs, metrics, traces and replay metadata; redact credentials | One ID traces queue→result across components and automated secret-canary tests find no auth/relay ticket | | 8.45 `[D:8.2,8.44]` | Dashboards/alerts for wait/MMR/RTT, proposals, allocation/Ready/image pull, connect/no-show, tick/crash/flood, result conflict/lag, abandons and cost | Each SLO and security/cost signal has an exercised alert and runbook | | 8.46 `[D:8.5,8.7,8.9,8.10,8.14,8.18,8.21,8.23,8.25]` | **IN PROGRESS.** Go unit/race coverage spans the current domain/store/supervisor policies, and fuzz targets now exercise queue input, result payload hashing and revision events | `server/domain/*_test.go`, `server/store/*_test.go`, `server/supervisor/*_test.go` and `server/domain/fuzz_test.go` pass normal/race suites; `go test -fuzz`, PostgreSQL concurrency/migration execution, fake Steam/allocator and full lost-Redis/transaction fixtures remain | -| 8.47 `[D:8.7,8.30]` | Fake Steam verifier and fake allocator for deterministic CI | Normal CI needs no Steam/cloud secret or internet access and can force every success/failure deterministically | +| 8.47 `[D:8.7,8.30]` | **IN PROGRESS.** Offline testkit provides deterministic fake Steam verification and fake allocation with forced failure injection | `server/testkit/` covers verified identity/replay and cloud-free forced allocation failure; API/Compose integration and exhaustive success/failure matrix remain | | 8.48 `[D:8.10,8.14,8.17,8.18,8.27,8.31,8.35,8.47]` | Second Compose flow: fake backend → queue/proposal → process-ready/allocation/assignment-ready → ENet roster → result ack → shutdown; do not edit Phase 6 fixture | Both server models have independent green gates; existing Make invocations remain unchanged | | 8.49 `[D:8.25,8.26,8.28,8.29,8.30,8.31,8.35,8.36]` | Disposable `kind` + Agones integration gate | CI covers dynamic ports, both readiness stages, roster/no-show, races, multi-match node, result-pending reconciliation, drain and rollback | | 8.50 `[D:8.25,8.37,8.43,8.49]` | Network/chaos suite: 100 ms RTT, jitter/loss, client/API/matcher restart, game-pod death, node drain, Redis failover and control-plane loss | System recovers to a defined state; infrastructure-caused cases cannot penalise affected players | diff --git a/server/testkit/fakes.go b/server/testkit/fakes.go new file mode 100644 index 00000000..7ce1a2c7 --- /dev/null +++ b/server/testkit/fakes.go @@ -0,0 +1,49 @@ +// Package testkit provides deterministic offline collaborators for control +// plane integration tests. It contains no network or Steam/cloud dependency. +package testkit + +import ( + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +type FakeSteamVerifier struct { + Verifier *domain.TicketVerifier + Identities map[string]string +} + +func NewFakeSteamVerifier(appID uint64) (*FakeSteamVerifier, error) { + verifier, err := domain.NewTicketVerifier(appID) + if err != nil { + return nil, err + } + return &FakeSteamVerifier{Verifier: verifier, Identities: make(map[string]string)}, nil +} + +func (f *FakeSteamVerifier) Verify(ticket domain.SteamTicket, now time.Time) (domain.VerifiedIdentity, error) { + return f.Verifier.Verify(ticket, func(steamID string) (string, bool) { + playerID, ok := f.Identities[steamID] + return playerID, ok + }, now) +} + +type FakeAllocator struct { + Allocator *domain.Allocator + ForcedError error +} + +func NewFakeAllocator(servers []domain.ReadyServer) (*FakeAllocator, error) { + allocator, err := domain.NewAllocator(servers) + if err != nil { + return nil, err + } + return &FakeAllocator{Allocator: allocator}, nil +} + +func (f *FakeAllocator) Allocate(request domain.AllocationRequest, now time.Time) (domain.Allocation, error) { + if f.ForcedError != nil { + return domain.Allocation{}, f.ForcedError + } + return f.Allocator.Allocate(request, now) +} diff --git a/server/testkit/fakes_test.go b/server/testkit/fakes_test.go new file mode 100644 index 00000000..003ee606 --- /dev/null +++ b/server/testkit/fakes_test.go @@ -0,0 +1,38 @@ +package testkit + +import ( + "errors" + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) + +func TestFakeSteamVerifierIsDeterministicAndOffline(t *testing.T) { + now := time.Unix(1000, 0) + fake, err := NewFakeSteamVerifier(480) + if err != nil { + t.Fatal(err) + } + fake.Identities["steam-1"] = "player-1" + ticket := domain.SteamTicket{TicketID: "ticket-1", SteamID: "steam-1", AppID: 480, ExpiresAt: now.Add(time.Minute)} + identity, err := fake.Verify(ticket, now) + if err != nil || identity.PlayerID != "player-1" { + t.Fatalf("identity = %+v err=%v", identity, err) + } + if _, err := fake.Verify(ticket, now); err == nil { + t.Fatal("fake accepted ticket replay") + } +} + +func TestFakeAllocatorCanForceFailureWithoutCloudState(t *testing.T) { + fake, err := NewFakeAllocator([]domain.ReadyServer{{ServerID: "server-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}}) + if err != nil { + t.Fatal(err) + } + fake.ForcedError = errors.New("forced allocation failure") + _, err = fake.Allocate(domain.AllocationRequest{AllocationID: "allocation-1", MatchID: "match-1", Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, time.Unix(1000, 0)) + if err == nil || err.Error() != "forced allocation failure" { + t.Fatalf("forced failure = %v", err) + } +}