feat(admin-console): Stage 5 — operators (admin accounts)
Tests · Go / test (push) Successful in 1m59s

Add the operator-management page over *admin.Service (no new business logic).

- GET/POST /_gm/operators                       list + create operator
- POST     /_gm/operators/{user}/disable|enable  toggle access
- POST     /_gm/operators/{user}/reset-password  set a new password

Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.

Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.

Docs: backend/docs/admin-console.md page inventory extended.
This commit is contained in:
Ilia Denisov
2026-05-31 20:31:16 +02:00
parent ecfb2d3351
commit 87a272166b
8 changed files with 381 additions and 0 deletions
@@ -32,6 +32,7 @@ type AdminConsoleHandlers struct {
games GameAdmin
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
operators OperatorAdmin
logger *zap.Logger
}
@@ -49,6 +50,7 @@ type AdminConsoleDeps struct {
Games GameAdmin
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Operators OperatorAdmin
Logger *zap.Logger
}
@@ -89,6 +91,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
games: deps.Games,
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
operators: deps.Operators,
logger: logger.Named("http.admin.console"),
}
}
@@ -0,0 +1,149 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// OperatorAdmin is the subset of the admin-account service the console uses.
// *admin.Service satisfies it.
type OperatorAdmin interface {
List(ctx context.Context) ([]admin.Admin, error)
Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
Disable(ctx context.Context, username string) (admin.Admin, error)
Enable(ctx context.Context, username string) (admin.Admin, error)
ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
}
// OperatorsList renders GET /_gm/operators.
func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
admins, err := h.operators.List(c.Request.Context())
if err != nil {
h.logger.Error("admin console: list operators", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
}
}
// OperatorCreate handles POST /_gm/operators.
func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
_, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
Username: strings.TrimSpace(c.PostForm("username")),
Password: c.PostForm("password"),
})
if err != nil {
switch {
case errors.Is(err, admin.ErrUsernameTaken):
h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
case errors.Is(err, admin.ErrInvalidInput):
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
default:
h.logger.Error("admin console: create operator", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
}
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// OperatorDisable handles POST /_gm/operators/:username/disable.
func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
return h.operatorAction("disable", func(ctx context.Context, username string) error {
_, err := h.operators.Disable(ctx, username)
return err
})
}
// OperatorEnable handles POST /_gm/operators/:username/enable.
func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
return h.operatorAction("enable", func(ctx context.Context, username string) error {
_, err := h.operators.Enable(ctx, username)
return err
})
}
// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
username := c.Param("username")
password := c.PostForm("password")
if strings.TrimSpace(password) == "" {
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
return
}
if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
if errors.Is(err, admin.ErrNotFound) {
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
return
}
if errors.Is(err, admin.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
return
}
h.logger.Error("admin console: reset operator password", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// operatorAction is the shared shape for operator POST actions that take only
// the username and redirect back to the list.
func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
return func(c *gin.Context) {
if h.operators == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
return
}
if err := run(c.Request.Context(), c.Param("username")); err != nil {
if errors.Is(err, admin.ErrNotFound) {
h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
return
}
h.logger.Error("admin console: operator "+label, zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
return
}
c.Redirect(http.StatusSeeOther, "/_gm/operators")
}
}
// toOperatorsData maps admin accounts into the operators view model.
func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
for _, a := range admins {
data.Items = append(data.Items, adminconsole.OperatorRow{
Username: a.Username,
CreatedAt: fmtConsoleTime(a.CreatedAt),
LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
Disabled: a.DisabledAt != nil,
})
}
return data
}
@@ -0,0 +1,166 @@
package server
import (
"context"
"net/http"
"strings"
"testing"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"go.uber.org/zap"
)
type fakeOperatorAdmin struct {
list []admin.Admin
createErr error
created admin.CreateInput
createCalls int
disableCalls int
enableCalls int
resetCalls int
lastResetUser string
lastResetPass string
}
func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
f.createCalls++
f.created = in
if f.createErr != nil {
return admin.Admin{}, f.createErr
}
return admin.Admin{Username: in.Username}, nil
}
func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
f.disableCalls++
return admin.Admin{Username: username}, nil
}
func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
f.enableCalls++
return admin.Admin{Username: username}, nil
}
func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
f.resetCalls++
f.lastResetUser = username
f.lastResetPass = password
return admin.Admin{Username: username}, nil
}
func operatorsRouter(t *testing.T, operators OperatorAdmin) (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, Operators: operators}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleOperatorsList(t *testing.T) {
fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
router, _ := operatorsRouter(t, fake)
rec := consoleGet(t, router, "/_gm/operators")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"root", "Create operator", "Reset"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("operators page missing %q", want)
}
}
}
func TestConsoleOperatorCreate(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
}
}
func TestConsoleOperatorCreateConflict(t *testing.T) {
fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
}
func TestConsoleOperatorDisableEnable(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
t.Fatalf("disable status = %d, want 303", rec.Code)
}
if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
t.Fatalf("enable status = %d, want 303", rec.Code)
}
if fake.disableCalls != 1 || fake.enableCalls != 1 {
t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
}
}
func TestConsoleOperatorResetPassword(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
}
}
func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, csrf := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if fake.resetCalls != 0 {
t.Error("reset must not run without a password")
}
}
func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
fake := &fakeOperatorAdmin{}
router, _ := operatorsRouter(t, fake)
rec := consolePost(t, router, "/_gm/operators/root/disable", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if fake.disableCalls != 0 {
t.Error("disable must not run without a CSRF token")
}
}
func TestConsoleOperatorsUnavailable(t *testing.T) {
router, _ := operatorsRouter(t, nil)
rec := consoleGet(t, router, "/_gm/operators")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
+6
View File
@@ -408,6 +408,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
group.GET("/operators", deps.AdminConsole.OperatorsList())
group.POST("/operators", deps.AdminConsole.OperatorCreate())
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
}
// allowedMethodsForPath returns the comma-separated list of methods