feat(admin-console): server-rendered operator console at /_gm #87

Merged
developer merged 6 commits from feature/admin-console into development 2026-05-31 19:07:48 +00:00
12 changed files with 780 additions and 0 deletions
Showing only changes of commit cf34710b4f - Show all commits
+1
View File
@@ -377,6 +377,7 @@ func run(ctx context.Context) (err error) {
CSRF: consoleCSRF,
Monitor: opsstatus.NewStore(db),
Ready: ready,
Users: userSvc,
Logger: logger,
})
+16
View File
@@ -80,6 +80,22 @@ exporters on `backend` and `gateway` are wired and enabled in the dev
deployment so a future Prometheus + Grafana stack can scrape them without code
changes.
## Pages
| Path | Method | Purpose |
| --------------------------------- | -------- | -------------------------------------------------------------- |
| `/_gm`, `/_gm/` | GET | Dashboard: health, runtime/mail/notification status, queues. |
| `/_gm/assets/*` | GET | Embedded stylesheet. |
| `/_gm/users` | GET | Paginated account list. |
| `/_gm/users/{id}` | GET | Account detail: profile, entitlement, active sanctions. |
| `/_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). |
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.
## Configuration
| Variable | Where | Notes |
@@ -69,3 +69,32 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
.ok { color: var(--ok); }
.bad { color: var(--danger); }
.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
.list th { color: var(--ink-dim); font-weight: 600; }
.list tr:hover td { background: var(--panel-hi); }
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
.form input, .form select {
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.5rem;
font: inherit;
}
button {
background: var(--accent);
color: #06121f;
border: 0;
border-radius: 6px;
padding: 0.4rem 0.9rem;
font: inherit;
font-weight: 600;
cursor: pointer;
}
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; }
+11
View File
@@ -0,0 +1,11 @@
package adminconsole
// MessageData is the view model for the generic message page used to render
// not-found, validation, and operation-failure notices. Class selects the CSS
// styling (for example "bad" for errors); BackHref, when set, renders a link
// back to a relevant page.
type MessageData struct {
Message string
Class string
BackHref string
}
@@ -0,0 +1,7 @@
{{define "content" -}}
<h1>{{.Title}}</h1>
{{with .Data}}
<p class="{{.Class}}">{{.Message}}</p>
{{if .BackHref}}<p><a href="{{.BackHref}}">&laquo; back</a></p>{{end}}
{{end}}
{{- end}}
@@ -0,0 +1,68 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/users">&laquo; all users</a></p>
<h1>{{.Email}}</h1>
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
<section class="panel">
<h2>Account</h2>
<ul class="kv">
<li>User ID: <code>{{.UserID}}</code></li>
<li>User name: {{.UserName}}</li>
<li>Display name: {{.DisplayName}}</li>
<li>Preferred language: {{.PreferredLanguage}}</li>
<li>Time zone: {{.TimeZone}}</li>
<li>Declared country: {{.DeclaredCountry}}</li>
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Updated: {{.UpdatedAt}}</li>
</ul>
</section>
<section class="panel">
<h2>Entitlement</h2>
<ul class="kv">
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
<li>Source: {{.EntitlementSource}}</li>
<li>Reason: {{.EntitlementReason}}</li>
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
</ul>
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Tier
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
</label>
<label>Source <input type="text" name="source" value="admin"></label>
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
<button type="submit">Update entitlement</button>
</form>
</section>
<section class="panel">
<h2>Active sanctions</h2>
{{if .Sanctions}}
<table class="counts"><tbody>
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="note">none</p>{{end}}
{{if .Blocked}}
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
{{else}}
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Reason <input type="text" name="reason_code" required></label>
<button type="submit" class="danger">Permanently block</button>
</form>
{{end}}
</section>
<section class="panel">
<h2>Danger zone</h2>
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<button type="submit" class="danger">Soft-delete account</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
<td>{{.UserName}}</td>
<td>{{.DisplayName}}</td>
<td>{{.Tier}}</td>
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+61
View File
@@ -0,0 +1,61 @@
package adminconsole
// UserRow is one line in the users list table.
type UserRow struct {
UserID string
Email string
UserName string
DisplayName string
Tier string
Blocked bool
Deleted bool
CreatedAt string
}
// UsersListData is the view model for the paginated users list.
type UsersListData struct {
Items []UserRow
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// SanctionView is one active sanction shown on the user detail page.
type SanctionView struct {
SanctionCode string
Scope string
ReasonCode string
AppliedAt string
ExpiresAt string
}
// UserDetailData is the view model for a single user's detail page,
// combining the account aggregate with the form option lists.
type UserDetailData struct {
UserID string
Email string
UserName string
DisplayName string
PreferredLanguage string
TimeZone string
DeclaredCountry string
Blocked bool
Deleted bool
CreatedAt string
UpdatedAt string
Tier string
IsPaid bool
EntitlementSource string
EntitlementReason string
EntitlementEnds string
Sanctions []SanctionView
// Tiers lists the selectable entitlement tiers for the form.
Tiers []string
}
@@ -28,6 +28,7 @@ type AdminConsoleHandlers struct {
assets http.Handler
monitor opsstatus.Reader
ready func() bool
users UserAdmin
logger *zap.Logger
}
@@ -41,6 +42,7 @@ type AdminConsoleDeps struct {
CSRF *adminconsole.CSRF
Monitor opsstatus.Reader
Ready func() bool
Users UserAdmin
Logger *zap.Logger
}
@@ -77,6 +79,7 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
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"),
}
}
@@ -168,6 +171,17 @@ func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNa
c.Data(status, "text/html; charset=utf-8", buf.Bytes())
}
// renderMessage renders the generic message page (not-found, validation, or
// operation-failure notices). class selects the CSS styling and backHref, when
// non-empty, adds a back link.
func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
Message: message,
Class: class,
BackHref: backHref,
})
}
// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
// CSRF guard may let through without a token.
func isSafeHTTPMethod(method string) bool {
@@ -0,0 +1,252 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"time"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// UserAdmin is the subset of the user service the operator console depends on.
// *user.Service satisfies it; tests supply a fake so the console pages render
// without a database.
type UserAdmin interface {
ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
}
// consoleTiers lists the selectable entitlement tiers in display order.
var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
// UsersList renders GET /_gm/users — the paginated account list.
func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
if err != nil {
h.logger.Error("admin console: list users", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
}
}
// UserDetail renders GET /_gm/users/:user_id.
func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
account, err := h.users.GetAccount(c.Request.Context(), userID)
if err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
return
}
h.logger.Error("admin console: get user", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
return
}
h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
}
}
// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
back := "/_gm/users/" + userID.String()
reason := strings.TrimSpace(c.PostForm("reason_code"))
if reason == "" {
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
return
}
_, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
UserID: userID,
SanctionCode: user.SanctionCodePermanentBlock,
Scope: "account",
ReasonCode: reason,
Actor: actorFromContext(c),
})
if err != nil {
h.logger.Error("admin console: block user", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
back := "/_gm/users/" + userID.String()
tier := strings.TrimSpace(c.PostForm("tier"))
source := strings.TrimSpace(c.PostForm("source"))
if source == "" {
source = "admin"
}
_, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
UserID: userID,
Tier: tier,
Source: source,
Actor: actorFromContext(c),
ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
})
if err != nil {
if errors.Is(err, user.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
return
}
h.logger.Error("admin console: apply entitlement", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
return func(c *gin.Context) {
if h.users == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
return
}
userID, ok := parseUserIDParam(c)
if !ok {
return
}
if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
if errors.Is(err, user.ErrAccountNotFound) {
h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
return
}
// A cascade error does not undo the soft delete; log and proceed.
h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
}
c.Redirect(http.StatusSeeOther, "/_gm/users")
}
}
// actorFromContext builds the admin ActorRef for audit trails from the
// authenticated operator username stored by the Basic Auth middleware.
func actorFromContext(c *gin.Context) user.ActorRef {
username, _ := basicauth.UsernameFromContext(c.Request.Context())
return user.ActorRef{Type: "admin", ID: username}
}
// toUsersListData maps an account page into the users list view model.
func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
data := adminconsole.UsersListData{
Items: make([]adminconsole.UserRow, 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 _, account := range page.Items {
data.Items = append(data.Items, adminconsole.UserRow{
UserID: account.UserID.String(),
Email: account.Email,
UserName: account.UserName,
DisplayName: account.DisplayName,
Tier: account.Entitlement.Tier,
Blocked: account.PermanentBlock,
Deleted: account.DeletedAt != nil,
CreatedAt: fmtConsoleTime(account.CreatedAt),
})
}
return data
}
// toUserDetailData maps an account aggregate into the detail view model.
func toUserDetailData(account user.Account) adminconsole.UserDetailData {
data := adminconsole.UserDetailData{
UserID: account.UserID.String(),
Email: account.Email,
UserName: account.UserName,
DisplayName: account.DisplayName,
PreferredLanguage: account.PreferredLanguage,
TimeZone: account.TimeZone,
DeclaredCountry: account.DeclaredCountry,
Blocked: account.PermanentBlock,
Deleted: account.DeletedAt != nil,
CreatedAt: fmtConsoleTime(account.CreatedAt),
UpdatedAt: fmtConsoleTime(account.UpdatedAt),
Tier: account.Entitlement.Tier,
IsPaid: account.Entitlement.IsPaid,
EntitlementSource: account.Entitlement.Source,
EntitlementReason: account.Entitlement.ReasonCode,
EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
Tiers: consoleTiers,
}
for _, sanction := range account.ActiveSanctions {
data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
SanctionCode: sanction.SanctionCode,
Scope: sanction.Scope,
ReasonCode: sanction.ReasonCode,
AppliedAt: fmtConsoleTime(sanction.AppliedAt),
ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
})
}
return data
}
// fmtConsoleTime renders a timestamp for display in the console.
func fmtConsoleTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
func fmtConsoleTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtConsoleTime(*t)
}
@@ -0,0 +1,288 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/server/middleware/basicauth"
"galaxy/backend/internal/user"
"github.com/google/uuid"
"go.uber.org/zap"
)
// fakeUserAdmin records calls so the console handlers can be exercised without
// a database.
type fakeUserAdmin struct {
page user.AccountPage
account user.Account
getErr error
sanctionCalls int
lastSanction user.ApplySanctionInput
entitlementCall int
lastEntitlement user.ApplyEntitlementInput
softDeleteCalls int
lastSoftActor user.ActorRef
}
func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
return f.page, nil
}
func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
return f.account, f.getErr
}
func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
f.sanctionCalls++
f.lastSanction = in
return f.account, nil
}
func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
f.entitlementCall++
f.lastEntitlement = in
return f.account, nil
}
func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
f.softDeleteCalls++
f.lastSoftActor = actor
return nil
}
func newUsersConsoleRouter(t *testing.T, users UserAdmin) (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, Users: users}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleUsersList(t *testing.T) {
fake := &fakeUserAdmin{page: user.AccountPage{
Items: []user.Account{
{UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
{UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
},
Page: 1, PageSize: 50, Total: 2,
}}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
if !strings.Contains(body, want) {
t.Errorf("users list missing %q", want)
}
}
}
func TestConsoleUserDetailRendersForms(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{
UserID: id, Email: "alice@example.test", UserName: "alice",
Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
}}
router, csrf := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{
"alice@example.test",
"Permanently block",
"Update entitlement",
"Soft-delete account",
csrf.Token("ops"),
} {
if !strings.Contains(body, want) {
t.Errorf("user detail missing %q", want)
}
}
}
func TestConsoleUserDetailNotFound(t *testing.T) {
fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(rec.Body.String(), "not found") {
t.Error("expected a not-found message")
}
}
func TestConsoleUserBlock(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.sanctionCalls != 1 {
t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
}
if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
}
if fake.lastSanction.Scope != "account" {
t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
}
if fake.lastSanction.ReasonCode != "spam" {
t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
}
if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
}
if fake.lastSanction.UserID != id {
t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
}
}
func TestConsoleUserBlockMissingReason(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops")
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", 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)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if fake.sanctionCalls != 0 {
t.Errorf("ApplySanction must not be called without a reason")
}
}
func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, _ := newUsersConsoleRouter(t, fake)
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
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)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if fake.sanctionCalls != 0 {
t.Errorf("ApplySanction must not run when the CSRF token is missing")
}
}
func TestConsoleUserEntitlement(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
if fake.entitlementCall != 1 {
t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
}
if fake.lastEntitlement.Tier != user.TierMonthly {
t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
}
if fake.lastEntitlement.Actor.ID != "ops" {
t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
}
}
func TestConsoleUserSoftDelete(t *testing.T) {
id := uuid.New()
fake := &fakeUserAdmin{account: user.Account{UserID: id}}
router, csrf := newUsersConsoleRouter(t, fake)
form := "_csrf=" + csrf.Token("ops")
req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", 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)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if got := rec.Header().Get("Location"); got != "/_gm/users" {
t.Errorf("redirect Location = %q, want /_gm/users", got)
}
if fake.softDeleteCalls != 1 {
t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
}
if fake.lastSoftActor.ID != "ops" {
t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
}
}
func TestConsoleUsersUnavailable(t *testing.T) {
router, _ := newUsersConsoleRouter(t, nil) // no user service wired
req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
req.SetBasicAuth("ops", "secret")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
+6
View File
@@ -388,6 +388,12 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
group.GET("/assets/*filepath", deps.AdminConsole.Asset())
group.GET("", deps.AdminConsole.Dashboard())
group.GET("/", deps.AdminConsole.Dashboard())
group.GET("/users", deps.AdminConsole.UsersList())
group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
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())
}
// allowedMethodsForPath returns the comma-separated list of methods