mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
d588898f5d
The just-landed signed workload token embedded (allocation_id, match_id, server_id) as claims. That doesn't actually work for its intended delivery channel: the token is meant to be requested as a GameServerAllocation annotation in the SAME request that asks Agones to pick a server, so at mint time the allocator knows allocation_id (it generates it) but not yet which server_id Agones will return -- server_id only exists in Agones's response, after the annotation request has already been sent. Embedding it was simply not possible for the real caller this was built for; only the (allocator -> signed_token) unit tests and hand-constructed integration tests happened to supply it directly, masking the gap. Fixes it by having the token bind only allocation_id (the one identifier actually known at mint time) plus expiry. match_id/server_id are resolved at verify time from the durable allocations table via the new store.AllocationBindingByAllocationID, keyed by allocation_id -- which the allocator already records immediately after Agones responds. This is strictly stronger, not just a workaround: a caller can no longer claim any match/server pairing at all, even one that happens to be internally consistent -- the binding returned is entirely durable-record-derived. Verified: server/workload's unit tests updated for the new two-field claim shape; server/api's Postgres integration suite gains TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding (two distinct real allocations each resolve to their own, and only their own, match/server pairing) replacing the now-inapplicable mismatched-triple test. Full `go build ./... && go vet ./... && gofmt -l . && go test ./... -race` and `go test -tags integration ./... -race` both clean; the api integration suite re-run 3x clean against a live postgres:17-alpine container.
193 lines
8.0 KiB
Go
193 lines
8.0 KiB
Go
//go:build integration
|
|
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/cosmic-clash/cosmic-clash/server/workload"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
// This binary is deliberately opt-in, matching store's integration suite: it
|
|
// requires a disposable PostgreSQL instance supplied by
|
|
// scripts/run_postgres_integration.sh.
|
|
func openIntegrationPostgres(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dsn := os.Getenv("COSMIC_CLASH_POSTGRES_DSN")
|
|
if dsn == "" {
|
|
t.Skip("COSMIC_CLASH_POSTGRES_DSN is not set")
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open PostgreSQL: %v", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
t.Fatalf("ping PostgreSQL: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
migrationDir := os.Getenv("COSMIC_CLASH_MIGRATIONS_DIR")
|
|
if migrationDir == "" {
|
|
migrationDir = filepath.Join("..", "migrations")
|
|
}
|
|
if err := migrations.Apply(ctx, db, migrationDir); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
// seedRealAllocation claims a real ready server and allocation row, exactly
|
|
// the durable state a signed workload token's allocation_id must resolve
|
|
// against (see store.AllocationBindingByAllocationID).
|
|
func seedRealAllocation(t *testing.T, db *sql.DB, allocationID, matchID string, now time.Time) domain.Allocation {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
serverID := "server-" + allocationID
|
|
if err := store.RegisterReadyServer(ctx, db, domain.ReadyServer{ServerID: serverID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet", State: domain.ServerReady}, now); err != nil {
|
|
t.Fatalf("register ready server: %v", err)
|
|
}
|
|
allocation, err := store.ClaimAllocation(ctx, db, domain.AllocationRequest{AllocationID: allocationID, MatchID: matchID, Region: "EU", Build: "build-1", Protocol: 1, Transport: "enet"}, now)
|
|
if err != nil {
|
|
t.Fatalf("claim allocation: %v", err)
|
|
}
|
|
return allocation
|
|
}
|
|
|
|
// TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation proves the full
|
|
// wired path: a token issued by workload.IssueSignedWorkloadToken naming only
|
|
// a real allocation_id verifies successfully through
|
|
// WorkloadVerifierFromSignedToken and returns a binding whose match_id/
|
|
// server_id came from the durable allocation record (the token itself never
|
|
// carries them -- see signed_token.go), matching what serverMutation
|
|
// actually checks (ServerID, MatchID). This is the "wired, working"
|
|
// counterpart to cmd/control-plane's
|
|
// TestServerRoutesRequireWorkloadVerifyToBeWired, which pins the
|
|
// unconfigured-503 case.
|
|
func TestWorkloadVerifierFromSignedTokenAcceptsARealAllocation(t *testing.T) {
|
|
db := openIntegrationPostgres(t)
|
|
now := time.Now().UTC()
|
|
allocation := seedRealAllocation(t, db, "alloc-verify-1", "match-verify-1", now)
|
|
|
|
secret := []byte("integration-test-secret")
|
|
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, now, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
|
|
verify := WorkloadVerifierFromSignedToken(secret, db)
|
|
if verify == nil {
|
|
t.Fatal("WorkloadVerifierFromSignedToken returned nil with a real secret and database")
|
|
}
|
|
binding, err := verify(token, now.Add(30*time.Second))
|
|
if err != nil {
|
|
t.Fatalf("verify: %v", err)
|
|
}
|
|
if binding.ServerID != allocation.ServerID || binding.MatchID != allocation.MatchID || binding.AllocationID != allocation.AllocationID {
|
|
t.Fatalf("unexpected binding: %+v, want server=%s match=%s allocation=%s", binding, allocation.ServerID, allocation.MatchID, allocation.AllocationID)
|
|
}
|
|
}
|
|
|
|
// TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation proves the
|
|
// durable lookup actually runs: a validly-signed, unexpired token whose
|
|
// allocation was never recorded (e.g. simply fabricated) must still be
|
|
// rejected. Signature and expiry checks alone are not enough.
|
|
func TestWorkloadVerifierFromSignedTokenRejectsAnUnknownAllocation(t *testing.T) {
|
|
db := openIntegrationPostgres(t)
|
|
now := time.Now().UTC()
|
|
secret := []byte("integration-test-secret")
|
|
token, err := workload.IssueSignedWorkloadToken(secret, "alloc-never-recorded", now, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
verify := WorkloadVerifierFromSignedToken(secret, db)
|
|
if _, err := verify(token, now.Add(time.Second)); err == nil {
|
|
t.Fatal("expected rejection for a token naming an allocation that was never recorded")
|
|
}
|
|
}
|
|
|
|
// TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding proves
|
|
// the binding returned is entirely derived from the durable allocation row,
|
|
// never from anything embedded in or inferable from the token: two distinct
|
|
// allocations produce tokens that resolve to their own, and only their own,
|
|
// match/server pairing.
|
|
func TestWorkloadVerifierFromSignedTokenNeverTrustsCallerSuppliedBinding(t *testing.T) {
|
|
db := openIntegrationPostgres(t)
|
|
now := time.Now().UTC()
|
|
first := seedRealAllocation(t, db, "alloc-verify-2a", "match-verify-2a", now)
|
|
second := seedRealAllocation(t, db, "alloc-verify-2b", "match-verify-2b", now)
|
|
secret := []byte("integration-test-secret")
|
|
verify := WorkloadVerifierFromSignedToken(secret, db)
|
|
|
|
firstToken, err := workload.IssueSignedWorkloadToken(secret, first.AllocationID, now, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("issue first token: %v", err)
|
|
}
|
|
firstBinding, err := verify(firstToken, now.Add(time.Second))
|
|
if err != nil {
|
|
t.Fatalf("verify first: %v", err)
|
|
}
|
|
if firstBinding.MatchID != first.MatchID || firstBinding.ServerID != first.ServerID {
|
|
t.Fatalf("first binding %+v resolved to the wrong allocation", firstBinding)
|
|
}
|
|
|
|
secondToken, err := workload.IssueSignedWorkloadToken(secret, second.AllocationID, now, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("issue second token: %v", err)
|
|
}
|
|
secondBinding, err := verify(secondToken, now.Add(time.Second))
|
|
if err != nil {
|
|
t.Fatalf("verify second: %v", err)
|
|
}
|
|
if secondBinding.MatchID != second.MatchID || secondBinding.ServerID != second.ServerID {
|
|
t.Fatalf("second binding %+v resolved to the wrong allocation", secondBinding)
|
|
}
|
|
if secondBinding.MatchID == firstBinding.MatchID || secondBinding.ServerID == firstBinding.ServerID {
|
|
t.Fatalf("distinct allocations resolved to the same binding: %+v vs %+v", firstBinding, secondBinding)
|
|
}
|
|
}
|
|
|
|
// TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap proves the
|
|
// 503-by-default gap pinned by
|
|
// cmd/control-plane.TestServerRoutesRequireWorkloadVerifyToBeWired is
|
|
// actually closed once a secret and database are wired: a Service built the
|
|
// same way newAPIHandler builds one now accepts a validly-issued token for a
|
|
// real allocation, through the exact Service.WorkloadVerify field the HTTP
|
|
// handler calls. (serverMutation's deeper match-state transition --
|
|
// requiring the match to already be ALLOCATING -- is exercised separately by
|
|
// the store package's own allocation/match integration tests; this test's
|
|
// job is only the WorkloadVerify boundary itself.)
|
|
func TestWorkloadVerifierFromSignedTokenClosesTheDefaultUnwiredGap(t *testing.T) {
|
|
db := openIntegrationPostgres(t)
|
|
now := time.Now().UTC()
|
|
allocation := seedRealAllocation(t, db, "alloc-verify-3", "match-verify-3", now)
|
|
secret := []byte("integration-test-secret")
|
|
token, err := workload.IssueSignedWorkloadToken(secret, allocation.AllocationID, now, time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("issue token: %v", err)
|
|
}
|
|
|
|
svc := &Service{
|
|
ServerRegistrar: ServerRegistrarFromStore(db),
|
|
WorkloadVerify: WorkloadVerifierFromSignedToken(secret, db),
|
|
Now: func() time.Time { return now.Add(time.Second) },
|
|
}
|
|
binding, err := svc.WorkloadVerify(token, now.Add(time.Second))
|
|
if err != nil {
|
|
t.Fatalf("WorkloadVerify rejected a validly-issued token for a real allocation: %v", err)
|
|
}
|
|
if binding.ServerID != allocation.ServerID {
|
|
t.Fatalf("binding.ServerID = %q, want %q", binding.ServerID, allocation.ServerID)
|
|
}
|
|
}
|