cf34710b4f
Tests · Go / test (push) Successful in 1m56s
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.
289 lines
9.2 KiB
Go
289 lines
9.2 KiB
Go
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)
|
|
}
|
|
}
|