Add the operator console's user-administration pages over the existing
*user.Service (no new business logic).
- GET /_gm/users paginated account list
- GET /_gm/users/{id} account detail: profile, entitlement, sanctions
- POST /_gm/users/{id}/block apply permanent_block (reason required)
- POST /_gm/users/{id}/entitlement set the entitlement tier
- POST /_gm/users/{id}/soft-delete soft-delete the account (cascades)
The console depends on a UserAdmin interface (satisfied by *user.Service) so the
pages render in tests without a database. All writes flow through the CSRF
guard, carry the operator as the audit actor, and answer with a 303 redirect;
a generic message page handles not-found, validation, and failure notices.
Unblock is intentionally absent — the admin API exposes no remove-sanction
endpoint.
Tests: list/detail render, not-found, block (with actor/scope/reason
assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete
redirect, and the service-unavailable path.
Docs: backend/docs/admin-console.md gains the page inventory.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user