mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 18:43:43 +00:00
521b8122ac
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.
113 lines
4.4 KiB
Go
113 lines
4.4 KiB
Go
// 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)
|
|
}
|