package server import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "galaxy/backend/internal/adminconsole" "galaxy/backend/internal/lobby" "galaxy/backend/internal/runtime" "galaxy/backend/internal/server/middleware/basicauth" "github.com/google/uuid" "go.uber.org/zap" ) type fakeGameAdmin struct { page lobby.GamePage game lobby.GameRecord getErr error created lobby.CreateGameInput createCalls int forceStartCalls int forceStopCalls int banCalls int lastBanUser uuid.UUID lastBanReason string } func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) { return f.page, nil } func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) { return f.game, f.getErr } func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) { f.createCalls++ f.created = in return f.game, nil } func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) { f.forceStartCalls++ return f.game, nil } func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) { f.forceStopCalls++ return f.game, nil } func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) { f.banCalls++ f.lastBanUser = userID f.lastBanReason = reason return lobby.Membership{}, nil } type fakeRuntimeAdmin struct { record runtime.RuntimeRecord getErr error restartCalls int forceNextCalls int patchCalls int lastPatchVersion string } func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) { return f.record, f.getErr } func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) { f.restartCalls++ return runtime.OperationLog{}, nil } func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) { f.patchCalls++ f.lastPatchVersion = target return runtime.OperationLog{}, nil } func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) { f.forceNextCalls++ return runtime.OperationLog{}, nil } type fakeEngineVersionAdmin struct { list []runtime.EngineVersion registered runtime.RegisterInput registerCalls int disableCalls int lastDisabled string } func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) { return f.list, nil } func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) { f.registerCalls++ f.registered = in return runtime.EngineVersion{}, nil } func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) { f.disableCalls++ f.lastDisabled = version return runtime.EngineVersion{}, nil } func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) { t.Helper() csrf := adminconsole.NewCSRF([]byte("test-key")) handler, err := NewRouter(RouterDependencies{ Logger: zap.NewNop(), AdminVerifier: basicauth.NewStaticVerifier("secret"), AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{ CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev, }), }) if err != nil { t.Fatalf("NewRouter: %v", err) } return handler, csrf } func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", "https://galaxy.lan") req.SetBasicAuth("ops", "secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func TestConsoleGamesList(t *testing.T) { games := &fakeGameAdmin{page: lobby.GamePage{ Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}}, Page: 1, PageSize: 50, Total: 1, }} router, _ := newGamesConsoleRouter(t, games, nil, nil) rec := consoleGet(t, router, "/_gm/games") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} { if !strings.Contains(rec.Body.String(), want) { t.Errorf("games list missing %q", want) } } } func TestConsoleGameDetailWithRuntime(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}} rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}} router, csrf := newGamesConsoleRouter(t, games, rt, nil) rec := consoleGet(t, router, "/_gm/games/"+id.String()) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} { if !strings.Contains(rec.Body.String(), want) { t.Errorf("game detail missing %q", want) } } } func TestConsoleGameDetailNoRuntime(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}} rt := &fakeRuntimeAdmin{getErr: errors.New("not found")} router, _ := newGamesConsoleRouter(t, games, rt, nil) rec := consoleGet(t, router, "/_gm/games/"+id.String()) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if !strings.Contains(rec.Body.String(), "No runtime record") { t.Error("expected a no-runtime note") } } func TestConsoleGameCreate(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} router, csrf := newGamesConsoleRouter(t, games, nil, nil) form := "_csrf=" + csrf.Token("ops") + "&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" + "&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0" rec := consolePost(t, router, "/_gm/games", form) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) } if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() { t.Errorf("redirect = %q, want detail page", got) } if games.createCalls != 1 { t.Fatalf("CreateGame called %d times, want 1", games.createCalls) } if games.created.Visibility != lobby.VisibilityPublic { t.Errorf("visibility = %q, want public", games.created.Visibility) } if games.created.GameName != "Nova" { t.Errorf("game name = %q", games.created.GameName) } if games.created.EnrollmentEndsAt.Year() != 2030 { t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year()) } if games.created.OwnerUserID != nil { t.Error("public game must have a nil owner") } } func TestConsoleGameForceStart(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} router, csrf := newGamesConsoleRouter(t, games, nil, nil) rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops")) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if games.forceStartCalls != 1 { t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls) } } func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} router, _ := newGamesConsoleRouter(t, games, nil, nil) rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "") if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } if games.forceStartCalls != 0 { t.Error("force-start must not run without a CSRF token") } } func TestConsoleGameBanMember(t *testing.T) { gameID := uuid.New() target := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} router, csrf := newGamesConsoleRouter(t, games, nil, nil) form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating" rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form) if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) } if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" { t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason) } } func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) { gameID := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} router, csrf := newGamesConsoleRouter(t, games, nil, nil) rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid") if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } if games.banCalls != 0 { t.Error("ban must not run with an invalid user id") } } func TestConsoleRuntimePatch(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} rt := &fakeRuntimeAdmin{} router, csrf := newGamesConsoleRouter(t, games, rt, nil) rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1") if rec.Code != http.StatusSeeOther { t.Fatalf("status = %d, want 303", rec.Code) } if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" { t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion) } } func TestConsoleRuntimePatchMissingVersion(t *testing.T) { id := uuid.New() games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} rt := &fakeRuntimeAdmin{} router, csrf := newGamesConsoleRouter(t, games, rt, nil) rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } if rt.patchCalls != 0 { t.Error("patch must not run without a target version") } } func TestConsoleEngineVersions(t *testing.T) { ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}} router, csrf := newGamesConsoleRouter(t, nil, nil, ev) rec := consoleGet(t, router, "/_gm/engine-versions") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} { if !strings.Contains(rec.Body.String(), want) { t.Errorf("engine versions page missing %q", want) } } rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true") if rec.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303", rec.Code) } if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled { t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled) } rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops")) if rec.Code != http.StatusSeeOther { t.Fatalf("disable status = %d, want 303", rec.Code) } if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" { t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled) } } func TestConsoleGamesUnavailable(t *testing.T) { router, _ := newGamesConsoleRouter(t, nil, nil, nil) rec := consoleGet(t, router, "/_gm/games") if rec.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want 503", rec.Code) } }