feat(admin-console): server-rendered operator console at /_gm #87
@@ -375,10 +375,13 @@ func run(ctx context.Context) (err error) {
|
||||
}
|
||||
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
|
||||
CSRF: consoleCSRF,
|
||||
Monitor: opsstatus.NewStore(db),
|
||||
Ready: ready,
|
||||
Users: userSvc,
|
||||
Logger: logger,
|
||||
Monitor: opsstatus.NewStore(db),
|
||||
Ready: ready,
|
||||
Users: userSvc,
|
||||
Games: lobbySvc,
|
||||
Runtime: runtimeSvc,
|
||||
EngineVersions: engineVersionSvc,
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
|
||||
|
||||
@@ -91,10 +91,23 @@ changes.
|
||||
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
|
||||
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
|
||||
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). |
|
||||
| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. |
|
||||
| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. |
|
||||
| `/_gm/games/{id}/force-start` | POST | Force-start the game. |
|
||||
| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. |
|
||||
| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). |
|
||||
| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. |
|
||||
| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. |
|
||||
| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. |
|
||||
| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. |
|
||||
| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. |
|
||||
|
||||
Each page reuses the same service layer as the corresponding `/api/v1/admin/*`
|
||||
JSON endpoint; the console adds no business logic. Unblocking a user is not yet
|
||||
available because the JSON admin API exposes no remove-sanction endpoint.
|
||||
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||
mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`)
|
||||
so a static action segment never collides with a path parameter in the gin
|
||||
router. Unblocking a user is not yet available because the JSON admin API
|
||||
exposes no remove-sanction endpoint.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -98,3 +98,5 @@ button {
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button.danger { background: var(--danger); color: #1a0606; }
|
||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||
.actions form { margin: 0; }
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package adminconsole
|
||||
|
||||
// GameRow is one line in the games list table.
|
||||
type GameRow struct {
|
||||
GameID string
|
||||
GameName string
|
||||
Visibility string
|
||||
Status string
|
||||
Owner string
|
||||
Players string
|
||||
TurnSchedule string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// GamesListData is the view model for the paginated games list.
|
||||
type GamesListData struct {
|
||||
Items []GameRow
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevPage int
|
||||
NextPage int
|
||||
}
|
||||
|
||||
// GameDetailData is the view model for a single game, combining the lobby
|
||||
// record with the runtime snapshot and the available actions.
|
||||
type GameDetailData struct {
|
||||
GameID string
|
||||
GameName string
|
||||
Description string
|
||||
Visibility string
|
||||
Status string
|
||||
Owner string
|
||||
MinPlayers int32
|
||||
MaxPlayers int32
|
||||
StartGapHours int32
|
||||
StartGapPlayers int32
|
||||
TurnSchedule string
|
||||
TargetEngineVersion string
|
||||
EnrollmentEndsAt string
|
||||
CreatedAt string
|
||||
StartedAt string
|
||||
FinishedAt string
|
||||
|
||||
HasRuntime bool
|
||||
RuntimeStatus string
|
||||
CurrentEngineVersion string
|
||||
EngineHealth string
|
||||
CurrentTurn int32
|
||||
NextGenerationAt string
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// EngineVersionRow is one line in the engine-version registry table.
|
||||
type EngineVersionRow struct {
|
||||
Version string
|
||||
ImageRef string
|
||||
Enabled bool
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// EngineVersionsData is the view model for the engine-version registry page.
|
||||
type EngineVersionsData struct {
|
||||
Items []EngineVersionRow
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Engine versions</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Version}}</td>
|
||||
<td><code>{{.ImageRef}}</code></td>
|
||||
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Register version</h2>
|
||||
<form method="post" action="/_gm/engine-versions" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
|
||||
<label>Image ref <input type="text" name="image_ref" required></label>
|
||||
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,65 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
{{with .Data}}
|
||||
<p><a href="/_gm/games">« all games</a></p>
|
||||
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Game</h2>
|
||||
<ul class="kv">
|
||||
<li>Game ID: <code>{{.GameID}}</code></li>
|
||||
<li>Visibility: {{.Visibility}}</li>
|
||||
<li>Status: {{.Status}}</li>
|
||||
<li>Owner: {{.Owner}}</li>
|
||||
<li>Players: {{.MinPlayers}}–{{.MaxPlayers}}</li>
|
||||
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
|
||||
<li>Turn schedule: {{.TurnSchedule}}</li>
|
||||
<li>Target engine: {{.TargetEngineVersion}}</li>
|
||||
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
|
||||
<li>Created: {{.CreatedAt}}</li>
|
||||
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
|
||||
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
|
||||
</ul>
|
||||
{{if .Description}}<p>{{.Description}}</p>{{end}}
|
||||
<div class="actions">
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Runtime</h2>
|
||||
{{if .HasRuntime}}
|
||||
<ul class="kv">
|
||||
<li>Status: {{.RuntimeStatus}}</li>
|
||||
<li>Engine version: {{.CurrentEngineVersion}}</li>
|
||||
<li>Engine health: {{.EngineHealth}}</li>
|
||||
<li>Current turn: {{.CurrentTurn}}</li>
|
||||
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
|
||||
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
|
||||
</div>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
|
||||
<button type="submit">Patch</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="note">No runtime record for this game yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Ban member</h2>
|
||||
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>User ID <input type="text" name="user_id" required></label>
|
||||
<label>Reason <input type="text" name="reason"></label>
|
||||
<button type="submit" class="danger">Ban member</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content" -}}
|
||||
{{$csrf := .CSRFToken}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
|
||||
<td>{{.Visibility}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Owner}}</td>
|
||||
<td>{{.Players}}</td>
|
||||
<td>{{.TurnSchedule}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||
<span>page {{.Page}} · {{.Total}} total</span>
|
||||
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
<section class="panel">
|
||||
<h2>Create public game</h2>
|
||||
<form method="post" action="/_gm/games" class="form">
|
||||
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||
<label>Name <input type="text" name="game_name" required></label>
|
||||
<label>Description <input type="text" name="description"></label>
|
||||
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
|
||||
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
|
||||
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
|
||||
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
|
||||
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
|
||||
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
|
||||
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
</section>
|
||||
{{- end}}
|
||||
@@ -26,10 +26,13 @@ type AdminConsoleHandlers struct {
|
||||
renderer *adminconsole.Renderer
|
||||
csrf *adminconsole.CSRF
|
||||
assets http.Handler
|
||||
monitor opsstatus.Reader
|
||||
ready func() bool
|
||||
users UserAdmin
|
||||
logger *zap.Logger
|
||||
monitor opsstatus.Reader
|
||||
ready func() bool
|
||||
users UserAdmin
|
||||
games GameAdmin
|
||||
runtime RuntimeAdmin
|
||||
engineVersions EngineVersionAdmin
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// AdminConsoleDeps bundles the collaborators for the operator console. Every
|
||||
@@ -40,10 +43,13 @@ type AdminConsoleHandlers struct {
|
||||
type AdminConsoleDeps struct {
|
||||
Renderer *adminconsole.Renderer
|
||||
CSRF *adminconsole.CSRF
|
||||
Monitor opsstatus.Reader
|
||||
Ready func() bool
|
||||
Users UserAdmin
|
||||
Logger *zap.Logger
|
||||
Monitor opsstatus.Reader
|
||||
Ready func() bool
|
||||
Users UserAdmin
|
||||
Games GameAdmin
|
||||
Runtime RuntimeAdmin
|
||||
EngineVersions EngineVersionAdmin
|
||||
Logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewAdminConsoleHandlers constructs the console handler set from deps. It
|
||||
@@ -77,10 +83,13 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
||||
renderer: renderer,
|
||||
csrf: csrf,
|
||||
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
|
||||
monitor: deps.Monitor,
|
||||
ready: deps.Ready,
|
||||
users: deps.Users,
|
||||
logger: logger.Named("http.admin.console"),
|
||||
monitor: deps.Monitor,
|
||||
ready: deps.Ready,
|
||||
users: deps.Users,
|
||||
games: deps.Games,
|
||||
runtime: deps.Runtime,
|
||||
engineVersions: deps.EngineVersions,
|
||||
logger: logger.Named("http.admin.console"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/adminconsole"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/runtime"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GameAdmin is the subset of the lobby service the console uses for games.
|
||||
type GameAdmin interface {
|
||||
ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
|
||||
GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
|
||||
AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
|
||||
AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
|
||||
}
|
||||
|
||||
// RuntimeAdmin is the subset of the runtime service the console uses.
|
||||
type RuntimeAdmin interface {
|
||||
GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
|
||||
AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||
AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
|
||||
AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
|
||||
}
|
||||
|
||||
// EngineVersionAdmin is the subset of the engine-version service the console uses.
|
||||
type EngineVersionAdmin interface {
|
||||
List(ctx context.Context) ([]runtime.EngineVersion, error)
|
||||
Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
|
||||
Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
|
||||
}
|
||||
|
||||
// GamesList renders GET /_gm/games.
|
||||
func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
page := parsePositiveQueryInt(c.Query("page"), 1)
|
||||
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
|
||||
result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list games", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
|
||||
}
|
||||
}
|
||||
|
||||
// GameCreate handles POST /_gm/games — create a public game.
|
||||
func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
|
||||
OwnerUserID: nil,
|
||||
Visibility: lobby.VisibilityPublic,
|
||||
GameName: strings.TrimSpace(c.PostForm("game_name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
MinPlayers: formInt32(c, "min_players"),
|
||||
MaxPlayers: formInt32(c, "max_players"),
|
||||
StartGapHours: formInt32(c, "start_gap_hours"),
|
||||
StartGapPlayers: formInt32(c, "start_gap_players"),
|
||||
EnrollmentEndsAt: enrollmentEndsAt,
|
||||
TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
|
||||
TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, lobby.ErrInvalidInput) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: create game", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
|
||||
}
|
||||
}
|
||||
|
||||
// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
|
||||
func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
game, err := h.games.GetGame(c.Request.Context(), gameID)
|
||||
if err != nil {
|
||||
if errors.Is(err, lobby.ErrNotFound) {
|
||||
h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: get game", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
|
||||
var runtimeRecord *runtime.RuntimeRecord
|
||||
if h.runtime != nil {
|
||||
if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
|
||||
runtimeRecord = &record
|
||||
}
|
||||
}
|
||||
h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
|
||||
}
|
||||
}
|
||||
|
||||
// GameForceStart handles POST /_gm/games/:game_id/force-start.
|
||||
func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
|
||||
return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.games.AdminForceStart(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GameForceStop handles POST /_gm/games/:game_id/force-stop.
|
||||
func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
|
||||
return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.games.AdminForceStop(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// GameBanMember handles POST /_gm/games/:game_id/ban-member.
|
||||
func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
|
||||
if err != nil {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
|
||||
return
|
||||
}
|
||||
if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
|
||||
h.logger.Error("admin console: ban member", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
|
||||
func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
|
||||
return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.runtime.AdminRestart(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
|
||||
func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
|
||||
return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
|
||||
_, err := h.runtime.AdminForceNextTurn(ctx, gameID)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
|
||||
func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.runtime == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
target := strings.TrimSpace(c.PostForm("target_version"))
|
||||
if target == "" {
|
||||
h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
|
||||
return
|
||||
}
|
||||
if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
|
||||
h.logger.Error("admin console: runtime patch", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// gameAction is the shared shape for game-state POST actions that take only the
|
||||
// game id and redirect back to the detail page.
|
||||
func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.games == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
if err := run(c.Request.Context(), gameID); err != nil {
|
||||
h.logger.Error("admin console: game "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeAction is the shared shape for runtime POST actions that take only the
|
||||
// game id and redirect back to the detail page.
|
||||
func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.runtime == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
|
||||
return
|
||||
}
|
||||
gameID, ok := parseGameIDParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
back := "/_gm/games/" + gameID.String()
|
||||
if err := run(c.Request.Context(), gameID); err != nil {
|
||||
h.logger.Error("admin console: runtime "+label, zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, back)
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionsList renders GET /_gm/engine-versions.
|
||||
func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
items, err := h.engineVersions.List(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin console: list engine versions", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionRegister handles POST /_gm/engine-versions.
|
||||
func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
enabled := c.PostForm("enabled") == "true"
|
||||
_, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
|
||||
Version: strings.TrimSpace(c.PostForm("version")),
|
||||
ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
|
||||
Enabled: &enabled,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
|
||||
h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin console: register engine version", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||
}
|
||||
}
|
||||
|
||||
// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
|
||||
func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if h.engineVersions == nil {
|
||||
h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
|
||||
return
|
||||
}
|
||||
version := strings.TrimSpace(c.Param("version"))
|
||||
if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
|
||||
h.logger.Error("admin console: disable engine version", zap.Error(err))
|
||||
h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
|
||||
}
|
||||
}
|
||||
|
||||
// formInt32 reads a non-negative int32 form field, defaulting to 0.
|
||||
func formInt32(c *gin.Context, name string) int32 {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
|
||||
if err != nil || parsed < 0 {
|
||||
return 0
|
||||
}
|
||||
return int32(parsed)
|
||||
}
|
||||
|
||||
// parseConsoleDateTime parses the value of an <input type="datetime-local">
|
||||
// (or an RFC 3339 timestamp) as UTC.
|
||||
func parseConsoleDateTime(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, errors.New("invalid date/time")
|
||||
}
|
||||
|
||||
// toGamesListData maps a game page into the games list view model.
|
||||
func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
|
||||
data := adminconsole.GamesListData{
|
||||
Items: make([]adminconsole.GameRow, 0, len(page.Items)),
|
||||
Page: page.Page,
|
||||
PageSize: page.PageSize,
|
||||
Total: page.Total,
|
||||
PrevPage: page.Page - 1,
|
||||
NextPage: page.Page + 1,
|
||||
HasPrev: page.Page > 1,
|
||||
HasNext: page.Page*page.PageSize < page.Total,
|
||||
}
|
||||
for _, game := range page.Items {
|
||||
data.Items = append(data.Items, adminconsole.GameRow{
|
||||
GameID: game.GameID.String(),
|
||||
GameName: game.GameName,
|
||||
Visibility: game.Visibility,
|
||||
Status: game.Status,
|
||||
Owner: ownerLabel(game.OwnerUserID),
|
||||
Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
|
||||
TurnSchedule: game.TurnSchedule,
|
||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toGameDetailData maps a game record and optional runtime record into the
|
||||
// detail view model.
|
||||
func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
|
||||
data := adminconsole.GameDetailData{
|
||||
GameID: game.GameID.String(),
|
||||
GameName: game.GameName,
|
||||
Description: game.Description,
|
||||
Visibility: game.Visibility,
|
||||
Status: game.Status,
|
||||
Owner: ownerLabel(game.OwnerUserID),
|
||||
MinPlayers: game.MinPlayers,
|
||||
MaxPlayers: game.MaxPlayers,
|
||||
StartGapHours: game.StartGapHours,
|
||||
StartGapPlayers: game.StartGapPlayers,
|
||||
TurnSchedule: game.TurnSchedule,
|
||||
TargetEngineVersion: game.TargetEngineVersion,
|
||||
EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
|
||||
CreatedAt: fmtConsoleTime(game.CreatedAt),
|
||||
StartedAt: fmtConsoleTimePtr(game.StartedAt),
|
||||
FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
|
||||
}
|
||||
if rec != nil {
|
||||
data.HasRuntime = true
|
||||
data.RuntimeStatus = rec.Status
|
||||
data.CurrentEngineVersion = rec.CurrentEngineVersion
|
||||
data.EngineHealth = rec.EngineHealth
|
||||
data.CurrentTurn = rec.CurrentTurn
|
||||
data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
|
||||
data.Paused = rec.Paused
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// toEngineVersionsData maps engine versions into the registry view model.
|
||||
func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
|
||||
data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
|
||||
for _, v := range items {
|
||||
data.Items = append(data.Items, adminconsole.EngineVersionRow{
|
||||
Version: v.Version,
|
||||
ImageRef: v.ImageRef,
|
||||
Enabled: v.Enabled,
|
||||
CreatedAt: fmtConsoleTime(v.CreatedAt),
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ownerLabel renders an optional owner id; public games have no owner.
|
||||
func ownerLabel(ownerID *uuid.UUID) string {
|
||||
if ownerID == nil {
|
||||
return "—"
|
||||
}
|
||||
return ownerID.String()
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,20 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
||||
group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
|
||||
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
|
||||
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
|
||||
|
||||
group.GET("/games", deps.AdminConsole.GamesList())
|
||||
group.POST("/games", deps.AdminConsole.GameCreate())
|
||||
group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
|
||||
group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
|
||||
group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
|
||||
group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
|
||||
group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
|
||||
group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
|
||||
group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
|
||||
|
||||
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
|
||||
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
|
||||
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
|
||||
}
|
||||
|
||||
// allowedMethodsForPath returns the comma-separated list of methods
|
||||
|
||||
Reference in New Issue
Block a user