ecfb2d3351
Tests · Go / test (push) Successful in 1m58s
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).
- GET/POST /_gm/games list + create public game
- GET /_gm/games/{id} detail incl. runtime snapshot
- POST /_gm/games/{id}/force-start|stop game state actions
- POST /_gm/games/{id}/ban-member ban a member (uuid + reason)
- POST /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions registry + register
- POST /_gm/engine-versions/{ver}/disable disable a version
Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.
Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.
Docs: backend/docs/admin-console.md page inventory extended.
354 lines
12 KiB
Go
354 lines
12 KiB
Go
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)
|
|
}
|
|
}
|