mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
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.
This commit is contained in:
@@ -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)
|
||||
@@ -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")
|
||||
Executable
+106
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user