From 521b8122aca5823b5ac63b8c4d121d3ff610ff79 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:13:52 +0100 Subject: [PATCH] test(multiplayer): add a real Go+Postgres+Godot end-to-end integration test Every existing test of the client/control-plane boundary is either a Go unit test with a mocked HTTP layer or a GDScript unit test with no network at all (multiplayer-next.md 8.40's own evidence names "live multi-process control-plane/game verification" as remaining). Nothing before this actually ran the real compiled Go binary, a real PostgreSQL instance, and a real headless Godot process talking real HTTP to each other -- and it immediately found a real bug (previous commit). server/cmd/testkit-api is a new, deliberately separate, clearly-marked test-only binary wired identically to cmd/control-plane except for SteamLogin: cmd/control-plane has no way to authenticate against a real Steam Web API from this sandbox (task 8.7's own documented blocker), so testkit-api accepts any non-empty ticket string and derives a deterministic identity instead. This bypass is confined to its own binary -- never a flag on cmd/control-plane, never referenced by any Dockerfile stage or Kubernetes manifest -- specifically so it can't become a footgun on the real one. Game/tests/control_plane_smoke.gd drives the real ControlPlaneClient autoload through login -> queue_create -> heartbeat against a real server and prints SMOKE PASS/FAIL, matching the existing net_smoke.gd convention. scripts/verify_control_plane_integration.sh orchestrates both sides (real postgres:17-alpine, the built testkit-api binary, the Godot client) end to end. Two real bugs surfaced building this, both fixed and re-verified, not just the target bug: the smoke script's own use of `go run` left a zombie process that survived cleanup and squatting on its port corrupted the NEXT run with a misleading "http=401 unauthorized" (now builds and runs a real binary directly, plus a belt-and-suspenders port-kill in cleanup); and calling heartbeat() synchronously from within a request_succeeded handler produced a spurious "Busy" because ControlPlaneClient's own internal resync (see previous commit) was still in flight -- the test now waits for ControlPlaneClient to go idle via a real Timer (call_deferred alone floods the message queue without ever yielding a frame for the in-flight request to complete). Verified stable across 3 consecutive full runs: real PostgreSQL container up, migrations applied, testkit-api built and started, real headless Godot client round-tripping login/queue/heartbeat, clean teardown with no leftover processes, containers, or bound ports each time. --- Game/tests/control_plane_smoke.gd | 122 ++++++++++++++++++++ Game/tests/control_plane_smoke.tscn | 6 + scripts/verify_control_plane_integration.sh | 106 +++++++++++++++++ server/cmd/testkit-api/main.go | 112 ++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 Game/tests/control_plane_smoke.gd create mode 100644 Game/tests/control_plane_smoke.tscn create mode 100755 scripts/verify_control_plane_integration.sh create mode 100644 server/cmd/testkit-api/main.go diff --git a/Game/tests/control_plane_smoke.gd b/Game/tests/control_plane_smoke.gd new file mode 100644 index 00000000..32301cf2 --- /dev/null +++ b/Game/tests/control_plane_smoke.gd @@ -0,0 +1,122 @@ +extends Node + +# Real end-to-end smoke test for ControlPlaneClient against a REAL running +# control-plane HTTP server backed by REAL PostgreSQL -- proving the actual +# wire format (GDScript's HTTPRequest/JSON on one side, the real compiled Go +# api.Service on the other) is compatible, not just that each side's own unit +# tests pass in isolation. Every other ControlPlaneClient test in this repo +# is either pure parsing/validation logic or drives the client against a +# mock; nothing before this exercised a real network round trip end to end +# (multiplayer-next.md 8.40's own evidence names this "live... verification" +# as remaining). +# +# Run against scripts/verify_control_plane_integration.sh's server/cmd/testkit-api +# instance -- see that script's own header for why a separate, clearly-marked +# test-only binary exists rather than a flag on the real cmd/control-plane: +# +# godot --headless --path Game res://tests/control_plane_smoke.tscn -- \ +# --control-plane-url=http://127.0.0.1:PORT +# +# Prints one "SMOKE PASS/FAIL: ..." line and exits 0/1. + +const TIMEOUT_SECONDS := 10.0 + +var _finished := false +var _ticket_id := "" + + +func _ready() -> void: + var control_plane_url := "" + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--control-plane-url="): + control_plane_url = arg.substr("--control-plane-url=".length()) + if control_plane_url.is_empty(): + _finish(false, "missing --control-plane-url") + return + # A syntactically valid but semantically meaningless placeholder token: + # configure() validates format eagerly, but real auth doesn't exist until + # login_steam()'s response overwrites it below. There is no other way to + # set base_url alone. + if not ControlPlaneClient.configure(control_plane_url, "0:0"): + _finish(false, "configure() rejected a valid-looking base URL") + return + + _ticket_id = "smoke-ticket-%d" % Time.get_unix_time_from_system() + ControlPlaneClient.request_succeeded.connect(_on_request_succeeded) + ControlPlaneClient.request_failed.connect(_on_request_failed) + + var web_api_ticket := "smoke-web-api-ticket-%d" % Time.get_ticks_usec() + var err := ControlPlaneClient.login_steam(web_api_ticket) + if err != OK: + _finish(false, "login_steam() failed to start: %s" % error_string(err)) + return + print("SMOKE: logging in against %s..." % control_plane_url) + + var timer := Timer.new() + timer.wait_time = TIMEOUT_SECONDS + timer.one_shot = true + timer.timeout.connect(func(): _finish(false, "timed out after %.1fs" % TIMEOUT_SECONDS)) + add_child(timer) + timer.start() + + +func _on_request_succeeded(operation: String, payload: Dictionary) -> void: + if _finished: + return + match operation: + "steam_session": + print("SMOKE: logged in as %s, creating a queue ticket..." % ControlPlaneClient.player_id) + var err := ControlPlaneClient.queue_create(_ticket_id, "casual", "smoke-build", 1) + if err != OK: + _finish(false, "queue_create() failed to start: %s" % error_string(err)) + "queue_create": + if payload.get("ticket_id", "") != _ticket_id or payload.get("state", "") != "QUEUED": + _finish(false, "unexpected queue_create payload: %s" % payload) + return + # apply_ticket_update (called for every "queue_"-prefixed response, + # including this one) can itself decide the ticket needs a resync + # and fire off a recover_queue() call -- a real, existing part of + # MatchmakingState's own state machine, not something this test + # controls. Wait for ControlPlaneClient to go idle before sending + # the next request rather than assuming queue_create was the only + # thing in flight. + print("SMOKE: ticket %s QUEUED at revision %d, heartbeating once idle..." % [_ticket_id, ControlPlaneClient.state.revision]) + _send_heartbeat_once_idle() + "queue_recover": + pass # Expected background resync; the idle-wait above handles it. + "queue_heartbeat": + if int(payload.get("revision", -1)) <= 0: + _finish(false, "heartbeat did not advance the revision: %s" % payload) + return + _finish(true, "login -> queue_create -> heartbeat all round-tripped against a real server") + + +func _send_heartbeat_once_idle() -> void: + if not ControlPlaneClient._operation.is_empty(): + # call_deferred alone floods the message queue without ever letting a + # real frame (and therefore the in-flight HTTP request) actually + # process -- a real Timer yields to the engine between checks. + var poll := get_tree().create_timer(0.05) + poll.timeout.connect(_send_heartbeat_once_idle) + return + var err := ControlPlaneClient.heartbeat(_ticket_id, ControlPlaneClient.state.revision) + if err != OK: + _finish(false, "heartbeat() failed to start: %s" % error_string(err)) + + +func _on_request_failed(operation: String, http_code: int, detail: String) -> void: + if _finished: + return + _finish(false, "%s failed: http=%d detail=%s" % [operation, http_code, detail]) + + +func _finish(passed: bool, detail: String) -> void: + if _finished: + return + _finished = true + if passed: + print("SMOKE PASS: %s" % detail) + get_tree().quit(0) + else: + print("SMOKE FAIL: %s" % detail) + get_tree().quit(1) diff --git a/Game/tests/control_plane_smoke.tscn b/Game/tests/control_plane_smoke.tscn new file mode 100644 index 00000000..0f729039 --- /dev/null +++ b/Game/tests/control_plane_smoke.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/control_plane_smoke.gd" id="1_cps"] + +[node name="ControlPlaneSmoke" type="Node"] +script = ExtResource("1_cps") diff --git a/scripts/verify_control_plane_integration.sh b/scripts/verify_control_plane_integration.sh new file mode 100755 index 00000000..a58be5b3 --- /dev/null +++ b/scripts/verify_control_plane_integration.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Real end-to-end verification: a real PostgreSQL instance, the real Go +# api.Service wired exactly like cmd/control-plane (except for auth -- see +# below), and a real headless Godot process driving ControlPlaneClient over +# an actual network connection. Every other test of this boundary is either +# a Go unit test with a mocked HTTP layer or a GDScript unit test with no +# network at all; this is the one place that proves the wire format the two +# languages actually agree on, not just that each side's own tests pass. +# +# Uses server/cmd/testkit-api rather than the real cmd/control-plane binary: +# that binary has no way to authenticate a Steam Web API ticket without a +# real Steam backend, which this sandbox cannot provide (see +# multiplayer-next.md task 8.7). testkit-api is wired identically otherwise +# and is never referenced by any Dockerfile stage or Kubernetes manifest -- +# see its own file header for why that bypass is confined to a distinctly +# named, obviously-not-production binary rather than a flag on the real one. + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +godot_bin="${GODOT_BIN:-godot}" +container_name="cosmic-clash-control-plane-integration" +database="cosmic_clash_test" +user="cosmic_clash_test" +password="cosmic_clash_test" +pg_port="55434" +api_port="18099" +logs_dir="$(mktemp -d "${TMPDIR:-/tmp}/cosmic-clash-control-plane.XXXXXX")" + +testkit_pid="" +cleanup() { + local status=$? + if (( status != 0 )); then + for log_file in "$logs_dir"/*.log; do + [[ -f "$log_file" ]] || continue + echo "--- $log_file" >&2 + cat "$log_file" >&2 + done + fi + [[ -n "$testkit_pid" ]] && kill "$testkit_pid" 2>/dev/null || true + # Belt-and-suspenders after the go-run zombie above: make sure nothing is + # left listening on this run's own port before the trap exits. + lsof -ti "tcp:${api_port}" 2>/dev/null | xargs -r kill -9 2>/dev/null || true + docker rm -f "$container_name" >/dev/null 2>&1 || true + echo "Control-plane integration logs: $logs_dir" +} +trap cleanup EXIT + +docker rm -f "$container_name" >/dev/null 2>&1 || true +docker run --rm -d --name "$container_name" \ + -e POSTGRES_DB="$database" \ + -e POSTGRES_USER="$user" \ + -e POSTGRES_PASSWORD="$password" \ + -p "${pg_port}:5432" postgres:17-alpine >/dev/null + +for attempt in $(seq 1 30); do + if docker exec "$container_name" pg_isready -U "$user" -d "$database" >/dev/null 2>&1; then + break + fi + if [ "$attempt" = 30 ]; then + echo "PostgreSQL did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +dsn="postgres://${user}:${password}@127.0.0.1:${pg_port}/${database}?sslmode=disable" + +# `go run` wraps the real binary in a build/exec parent whose own PID does +# not reliably propagate a `kill` to the child it spawns -- confirmed the +# hard way: a prior run's leftover process survived cleanup, kept squatting +# on this exact port bound to an already-torn-down PostgreSQL container, and +# silently intercepted the NEXT run's connection, turning a real login into +# an "http=401 unauthorized" failure with no indication the server it +# actually reached was a zombie from a previous run. Build once and run the +# real binary directly so its own PID is what gets killed. +go -C server build -o "$logs_dir/testkit-api" ./cmd/testkit-api +COSMIC_CLASH_POSTGRES_DSN="$dsn" "$logs_dir/testkit-api" --listen="127.0.0.1:${api_port}" --migrations="$root_dir/server/migrations" \ + >"$logs_dir/testkit-api.log" 2>&1 & +testkit_pid=$! + +for attempt in $(seq 1 30); do + if curl -sSf "http://127.0.0.1:${api_port}/healthz" >/dev/null 2>&1; then + break + fi + if [ "$attempt" = 30 ]; then + echo "testkit-api did not become ready" >&2 + exit 1 + fi + sleep 1 +done + +"$godot_bin" --headless --path Game res://tests/control_plane_smoke.tscn -- \ + --control-plane-url="http://127.0.0.1:${api_port}" \ + >"$logs_dir/godot-client.log" 2>&1 +status=$? + +if [ "$status" -ne 0 ] || ! grep -q "^SMOKE PASS:" "$logs_dir/godot-client.log"; then + echo "Control-plane integration FAILED (exit $status)" >&2 + cat "$logs_dir/godot-client.log" >&2 + exit 1 +fi + +echo "Control-plane integration PASS" diff --git a/server/cmd/testkit-api/main.go b/server/cmd/testkit-api/main.go new file mode 100644 index 00000000..481d891a --- /dev/null +++ b/server/cmd/testkit-api/main.go @@ -0,0 +1,112 @@ +// Package main is a TEST-ONLY control-plane binary, built solely to give +// scripts/verify_control_plane_integration.sh a real, running HTTP server -- +// backed by real PostgreSQL, running the actual api.Service used in +// production -- for the Godot client to talk to over a real network +// connection. It is never referenced by any Dockerfile stage or Kubernetes +// manifest and must never be treated as a deployment target: fakeSteamLogin +// below accepts ANY non-empty ticket string as a valid identity instead of +// verifying it against the real Steam Web API, which is exactly the kind of +// bypass that must stay confined to a clearly-separate binary, never a flag +// on the real one (see cmd/control-plane, which has no such flag and never +// should). Every other adapter here is wired identically to cmd/control-plane. +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "flag" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/api" + "github.com/cosmic-clash/cosmic-clash/server/domain" + "github.com/cosmic-clash/cosmic-clash/server/migrations" + "github.com/cosmic-clash/cosmic-clash/server/store" + _ "github.com/jackc/pgx/v5/stdlib" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:0", "HTTP listen address; port 0 picks a free port, printed on startup") + dsn := flag.String("dsn", os.Getenv("COSMIC_CLASH_POSTGRES_DSN"), "PostgreSQL connection string") + migrationDir := flag.String("migrations", "migrations", "directory containing numbered SQL migrations") + flag.Parse() + if *dsn == "" { + fatalf("--dsn or COSMIC_CLASH_POSTGRES_DSN is required") + } + db, err := sql.Open("pgx", *dsn) + if err != nil { + fatalf("open PostgreSQL: %v", err) + } + defer db.Close() + startupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := db.PingContext(startupCtx); err != nil { + fatalf("ping PostgreSQL: %v", err) + } + if err := migrations.Apply(startupCtx, db, *migrationDir); err != nil { + fatalf("apply migrations: %v", err) + } + handler := (&api.Service{ + SessionBackend: store.PostgresSessions{DB: db}, + SessionIssuer: store.PostgresSessions{DB: db}, + SteamLogin: fakeSteamLogin{db: db}, + QueueBackend: store.PostgresQueue{DB: db}, + ProposalBackend: api.ProposalProviderFromStore(db), + ProposalPromoter: api.ProposalPromoterFromStore(db), + ServerRegistrar: api.ServerRegistrarFromStore(db), + ResultSubmitter: store.PostgresResults{DB: db}, + Assignment: api.AssignmentProviderFromStore(db), + ProbeRecorder: store.PostgresQueue{DB: db}, + Now: func() time.Time { return time.Now().UTC() }, + }).Handler() + listener, err := net.Listen("tcp", *listen) + if err != nil { + fatalf("listen: %v", err) + } + fmt.Printf("testkit-api listening on http://%s\n", listener.Addr()) + server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} + serveErr := make(chan error, 1) + go func() { serveErr <- server.Serve(listener) }() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + select { + case err := <-serveErr: + if err != nil && err != http.ErrServerClosed { + fatalf("serve: %v", err) + } + case <-ctx.Done(): + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer shutdownCancel() + _ = server.Shutdown(shutdownCtx) + } +} + +// fakeSteamLogin derives a deterministic identity from the ticket string +// itself (never a real Steam Web API ticket in this binary) and ensures its +// identities row exists so session issuance's foreign key is satisfied. +type fakeSteamLogin struct{ db *sql.DB } + +func (f fakeSteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) { + if ticket == "" { + return domain.VerifiedIdentity{}, fmt.Errorf("empty ticket") + } + digest := sha256.Sum256([]byte(ticket)) + playerID := "testkit-" + hex.EncodeToString(digest[:8]) + steamID := "testkit-steam-" + hex.EncodeToString(digest[8:16]) + if _, err := f.db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ($1, $2) ON CONFLICT (player_id) DO NOTHING`, playerID, steamID); err != nil { + return domain.VerifiedIdentity{}, err + } + return domain.VerifiedIdentity{PlayerID: playerID, SteamID: steamID}, nil +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "testkit-api: "+format+"\n", args...) + os.Exit(1) +}