feat: user preview, bulk and record actions and the permanent delete

- the list opens the preview, which shows one status hint (banned,
  deactivated, not activated, suspended) and the IP address fields
- bulk actions activate, deactivate, restore, ban and unban; record
  actions activate, unban and unsuspend, offered while they apply
- delete (form button and bulk) removes the user for good with the
  throttle rows, group memberships and attachments in one transaction;
  blobs go after the commit
- a privileged-group member is still refused, a bulk delete as a whole
This commit is contained in:
Jakub Zych
2026-10-05 13:10:12 +02:00
parent ebaf3a0817
commit 970eba7734
12 changed files with 1125 additions and 15 deletions

View File

@@ -6,10 +6,14 @@ import (
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
)
const (
@@ -374,9 +378,80 @@ func TestAdminPrivilegedMember(t *testing.T) {
if got := env.reload(t, member.ID); got.Email != memberEmail {
t.Fatalf("manager email change = %q", got.Email)
}
// The delete is permanent, so the refusal must leave everything that
// hangs off the member in place: throttle rows, memberships, the avatar
// row and its blob.
env.seedThrottle(t, member, "203.0.113.7", false, nil)
memberAvatar := env.seedAvatar(t, member)
memberHangers := env.hangers(t, member)
if memberHangers.Throttle != 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 {
t.Fatalf("member seed = %+v", memberHangers)
}
rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil)
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberDeleteForbidden {
t.Fatalf("delete refusal = %s", rec.Body.String())
}
if env.reload(t, member.ID) == nil || env.hangers(t, member) != memberHangers || !env.blobExists(t, memberAvatar) {
t.Fatalf("a refused delete changed the member: %+v", env.hangers(t, member))
}
// A bulk delete with one privileged member in the selection is refused as
// a whole: the two ordinary users and their rows stay as well.
first := env.seedUser(t, "bulk-first", nil)
second := env.seedUser(t, "bulk-second", nil)
env.join(t, first, ordinary)
env.seedThrottle(t, first, "", false, nil)
firstAvatar := env.seedAvatar(t, first)
firstHangers := env.hangers(t, first)
// Ids are processed in ascending order, so the member (the lowest id) is
// refused first; the reverse order is covered by the second selection.
late := env.seedUser(t, "late-member", nil)
env.join(t, late, adminGroup)
for _, selection := range [][]*models.User{{member, first, second}, {first, second, late}} {
rec = env.bulk(t, limited, "delete", selection...)
if rec.Code != http.StatusForbidden || adminDecode(t, rec).Error.Message != privilegedMemberDeleteForbidden {
t.Fatalf("bulk delete refusal = %d %s", rec.Code, rec.Body.String())
}
for _, user := range selection {
if env.reload(t, user.ID) == nil {
t.Fatalf("a refused bulk delete removed user %d", user.ID)
}
}
if env.hangers(t, first) != firstHangers || env.hangers(t, member) != memberHangers ||
!env.blobExists(t, firstAvatar) || !env.blobExists(t, memberAvatar) {
t.Fatal("a refused bulk delete removed rows or blobs")
}
}
// Deactivating a privileged member is not a permanent delete and needs
// only the users permission; so does restoring them.
if rec = env.bulk(t, limited, "deactivate", late); rec.Code != http.StatusOK {
t.Fatalf("bulk deactivate of a privileged member = %d %s", rec.Code, rec.Body.String())
}
if got := env.reload(t, late.ID); got == nil || !got.DeletedAt.Valid {
t.Fatalf("privileged member after deactivate = %+v", got)
}
if rec = env.bulk(t, limited, "restore", late); rec.Code != http.StatusOK {
t.Fatalf("bulk restore of a privileged member = %d %s", rec.Code, rec.Body.String())
}
// With the permission the form delete and the bulk delete remove the
// member for good, with the full cleanup.
env.expect(t, http.StatusOK, http.MethodDelete, userPath(member.ID, ""), manager, nil)
if got := env.reload(t, member.ID); got != nil && !got.DeletedAt.Valid {
t.Fatalf("manager delete left the user active: %+v", got)
if env.reload(t, member.ID) != nil || env.hangers(t, member) != (hangers{}) || env.blobExists(t, memberAvatar) {
t.Fatalf("manager delete left something of the member: %+v", env.hangers(t, member))
}
if rec = env.bulk(t, manager, "delete", first, second, late); rec.Code != http.StatusOK {
t.Fatalf("manager bulk delete = %d %s", rec.Code, rec.Body.String())
}
for _, user := range []*models.User{first, second, late} {
if env.reload(t, user.ID) != nil || env.hangers(t, user) != (hangers{}) {
t.Fatalf("manager bulk delete left user %d: %+v", user.ID, env.hangers(t, user))
}
}
if env.blobExists(t, firstAvatar) {
t.Fatal("manager bulk delete left the avatar blob")
}
// The privileged list is config, read per request: with the ordinary
@@ -388,7 +463,7 @@ func TestAdminPrivilegedMember(t *testing.T) {
}
})
movedLimited := moved.admin(t, permAccessUsers)
rec := moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"})
rec = moved.expect(t, http.StatusForbidden, http.MethodPut, userPath(plain.ID, ""), movedLimited, map[string]any{"name": "Plain", "email": "plain-moved-" + stamp + "@example.test"})
if answer := adminDecode(t, rec); answer.Error.Message != privilegedMemberForbidden || len(answer.Error.Details["email"]) != 1 {
t.Fatalf("moved refusal = %s", rec.Body.String())
}
@@ -404,3 +479,398 @@ func TestAdminPrivilegedMember(t *testing.T) {
t.Fatal("a refusal carries the admin secret")
}
}
// bulkAnswer is the data of a bulk action answer.
type bulkAnswer struct {
Message string `json:"message"`
Affected int `json:"affected"`
}
// TestAdminUserActions covers the five bulk actions, the three record
// actions and the preview status hint (D-11, D-14), and that none of them
// writes users_groups.
func TestAdminUserActions(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stranger := env.admin(t)
// Nothing but the ported actions is offered: no impersonate, no guest.
var schema struct {
RecordURL string `json:"recordUrl"`
BulkActions []struct {
Name string `json:"name"`
Label string `json:"label"`
Confirm string `json:"confirm"`
} `json:"bulkActions"`
ToolbarButtons []string `json:"toolbarButtons"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/list"), token, nil), &schema)
var bulkNames []string
for _, action := range schema.BulkActions {
bulkNames = append(bulkNames, action.Name)
if action.Label == "" || strings.Contains(action.Label, "::") || strings.Contains(action.Confirm, "::") {
t.Fatalf("bulk action copy is not translated: %+v", action)
}
}
if want := []string{"delete", "activate", "deactivate", "restore", "ban", "unban"}; !reflect.DeepEqual(bulkNames, want) {
t.Fatalf("bulk actions = %v, want %v", bulkNames, want)
}
if schema.RecordURL != "golem15/user/users/preview/:id" {
t.Fatalf("recordUrl = %q", schema.RecordURL)
}
var registered []string
for _, ctl := range controllers.AdminControllers(func() *backpack.App { return env.app }) {
if ctl.ID() != "golem15.user.users" {
continue
}
for _, action := range ctl.(pact.HasAdminRecordActions).AdminRecordActions() {
registered = append(registered, action.Name)
}
}
if want := []string{"activate", "unban", "unsuspend"}; !reflect.DeepEqual(registered, want) {
t.Fatalf("record actions = %v, want %v", registered, want)
}
var form struct {
Preview *struct {
HeaderPartial string `json:"headerPartial"`
} `json:"preview"`
Fields []struct {
Name string `json:"name"`
Context any `json:"context"`
} `json:"fields"`
Messages map[string]map[string]string `json:"messages"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form)
if form.Preview == nil || form.Preview.HeaderPartial != "hint" || form.Messages["edit"]["other"] != "Update details" {
t.Fatalf("form preview = %+v messages = %v", form.Preview, form.Messages["edit"])
}
previewOnly := map[string]bool{}
for _, field := range form.Fields {
if field.Context == "preview" {
previewOnly[field.Name] = true
}
}
if !previewOnly["created_ip_address"] || !previewOnly["last_ip_address"] || len(previewOnly) != 2 {
t.Fatalf("preview-only fields = %v", previewOnly)
}
group := env.seedGroup(t, "Actions "+adminStamp(), "actions-"+adminStamp())
seed := func(label string, mutate func(*models.User)) *models.User {
user := env.seedUser(t, label, mutate)
env.join(t, user, group)
return user
}
notActive := func(u *models.User) { u.IsActivated = false }
idleA, idleB, live := seed("idle-a", notActive), seed("idle-b", notActive), seed("live", nil)
code := "pending-code"
if err := env.gdb.Exec(`UPDATE users SET activation_code = ?, activation_code_issued_at = NOW() WHERE id = ?`, code, idleA.ID).Error; err != nil {
t.Fatal(err)
}
everyone := []*models.User{idleA, idleB, live}
memberships := func() map[uint][]uint {
out := map[uint][]uint{}
for _, user := range everyone {
out[user.ID] = env.groupIDs(t, user.ID)
}
return out
}
before := memberships()
untouched := func(step string) {
t.Helper()
if got := memberships(); !reflect.DeepEqual(got, before) {
t.Fatalf("%s changed users_groups: %v, want %v", step, got, before)
}
}
run := func(action string, users ...*models.User) bulkAnswer {
t.Helper()
rec := env.bulk(t, token, action, users...)
if rec.Code != http.StatusOK {
t.Fatalf("bulk %s = %d %s", action, rec.Code, rec.Body.String())
}
var answer bulkAnswer
adminInto(t, rec, &answer)
untouched("bulk " + action)
return answer
}
// Activate skips the user who is already active and reports the count
// without the fixed success phrase, which would overstate.
if answer := run("activate", everyone...); answer.Affected != 2 || answer.Message != "" {
t.Fatalf("bulk activate = %+v", answer)
}
for _, user := range everyone {
got := env.reload(t, user.ID)
if !got.IsActivated || (user.ID != live.ID && got.ActivatedAt == nil) || got.ActivationCode != nil {
t.Fatalf("user %d after bulk activate = %+v", user.ID, got)
}
}
if answer := run("activate", everyone...); answer.Affected != 0 || answer.Message != "" {
t.Fatalf("second bulk activate = %+v", answer)
}
// When every selected user changes, the fixed phrase is the message.
fresh := seed("fresh", notActive)
everyone = append(everyone, fresh)
before = memberships()
if answer := run("activate", fresh); answer.Affected != 1 || answer.Message != "Successfully activated the selected users." {
t.Fatalf("full bulk activate = %+v", answer)
}
// Deactivate is the soft delete; the users stay listed as deleted.
if answer := run("deactivate", idleA, idleB); answer.Affected != 2 || answer.Message != "Successfully deactivated the selected users." {
t.Fatalf("bulk deactivate = %+v", answer)
}
page := env.users(t, token, "search="+idleA.Email)
if !reflect.DeepEqual(page.states(idleA.ID), []string{"deleted"}) {
t.Fatalf("deactivated row = %s", page.Raw)
}
if got := env.reload(t, idleB.ID); !got.DeletedAt.Valid {
t.Fatal("bulk deactivate did not set deleted_at")
}
if answer := run("deactivate", idleA, live); answer.Affected != 1 || answer.Message != "" {
t.Fatalf("mixed bulk deactivate = %+v", answer)
}
// Restore brings them back.
if answer := run("restore", idleA, idleB, live); answer.Affected != 3 || answer.Message != "Successfully restored the selected users." {
t.Fatalf("bulk restore = %+v", answer)
}
for _, user := range []*models.User{idleA, idleB, live} {
if got := env.reload(t, user.ID); got.DeletedAt.Valid {
t.Fatalf("user %d is still deactivated", user.ID)
}
}
// Activating a deactivated, not activated user restores them as well.
sleeper := seed("sleeper", notActive)
everyone = append(everyone, sleeper)
before = memberships()
run("deactivate", sleeper)
if answer := run("activate", sleeper); answer.Affected != 1 {
t.Fatalf("activate of a deactivated user = %+v", answer)
}
if got := env.reload(t, sleeper.ID); !got.IsActivated || got.DeletedAt.Valid {
t.Fatalf("sleeper after activate = %+v", got)
}
// Ban refuses the login from any address and marks the row; unban lifts it.
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
t.Fatalf("login before the ban = %d", status)
}
if answer := run("ban", live); answer.Affected != 1 || answer.Message != "Successfully banned the selected users." {
t.Fatalf("bulk ban = %+v", answer)
}
for range 2 {
if status, token := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusUnauthorized || token != "" {
t.Fatalf("banned login = %d", status)
}
}
page = env.users(t, token, "search="+live.Email)
if !reflect.DeepEqual(page.states(live.ID), []string{"negative"}) {
t.Fatalf("banned row = %s", page.Raw)
}
if answer := run("ban", live, idleA); answer.Affected != 1 || answer.Message != "" {
t.Fatalf("second bulk ban = %+v", answer)
}
if answer := run("unban", live, idleA, idleB); answer.Affected != 2 || answer.Message != "" {
t.Fatalf("bulk unban = %+v", answer)
}
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
t.Fatalf("login after the unban = %d", status)
}
// Record actions are offered only while they apply.
pending := seed("pending", notActive)
everyone = append(everyone, pending)
before = memberships()
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"activate"}) {
t.Fatalf("offered for a not activated user = %v", got)
}
if got := env.offered(t, token, live.ID); len(got) != 0 {
t.Fatalf("offered for an ordinary user = %v", got)
}
act := func(status int, action string, user *models.User) string {
t.Helper()
rec := env.expect(t, status, http.MethodPost, userPath(user.ID, "/actions/"+action), token, map[string]any{})
untouched("record " + action)
if status != http.StatusOK {
return ""
}
var answer struct {
Message string `json:"message"`
}
adminInto(t, rec, &answer)
return answer.Message
}
if message := act(http.StatusOK, "activate", pending); message != "User has been activated" {
t.Fatalf("activate message = %q", message)
}
if got := env.reload(t, pending.ID); !got.IsActivated || got.ActivatedAt == nil {
t.Fatalf("pending after activate = %+v", got)
}
act(http.StatusConflict, "activate", pending)
act(http.StatusConflict, "unban", pending)
act(http.StatusConflict, "unsuspend", pending)
run("ban", pending)
if got := env.offered(t, token, pending.ID); !reflect.DeepEqual(got, []string{"unban"}) {
t.Fatalf("offered for a banned user = %v", got)
}
if message := act(http.StatusOK, "unban", pending); message != "User has been unbanned" {
t.Fatalf("unban message = %q", message)
}
if got := env.offered(t, token, pending.ID); len(got) != 0 {
t.Fatalf("offered after unban = %v", got)
}
// Suspended means suspended inside the window; an expired suspension
// offers nothing and is not lifted by reading it.
now, longAgo := time.Now(), time.Now().Add(-2*time.Hour)
suspended := seed("suspended", nil)
expired := seed("expired", nil)
everyone = append(everyone, suspended, expired)
before = memberships()
env.seedThrottle(t, suspended, "203.0.113.9", false, &now)
env.seedThrottle(t, expired, "203.0.113.9", false, &longAgo)
if got := env.offered(t, token, suspended.ID); !reflect.DeepEqual(got, []string{"unsuspend"}) {
t.Fatalf("offered for a suspended user = %v", got)
}
if got := env.offered(t, token, expired.ID); len(got) != 0 {
t.Fatalf("offered for an expired suspension = %v", got)
}
var stillFlagged int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_throttle WHERE user_id = ? AND is_suspended`, expired.ID).Scan(&stillFlagged).Error; err != nil || stillFlagged != 1 {
t.Fatalf("reading an expired suspension changed it: %d %v", stillFlagged, err)
}
if message := act(http.StatusOK, "unsuspend", suspended); message != "User has been unsuspended." {
t.Fatalf("unsuspend message = %q", message)
}
var row models.Throttle
if err := env.gdb.Where("user_id = ?", suspended.ID).Take(&row).Error; err != nil || row.IsSuspended || row.SuspendedAt != nil || row.Attempts != 0 {
t.Fatalf("throttle row after unsuspend = %+v err=%v", row, err)
}
act(http.StatusConflict, "unsuspend", suspended)
// The preview hint: one callout, by precedence banned, deactivated, not
// activated, suspended.
hint := func(user *models.User) string {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id="+strconv.FormatUint(uint64(user.ID), 10)), token, nil)
return rec.Body.String()
}
worst := seed("worst", notActive)
everyone = append(everyone, worst)
before = memberships()
env.seedThrottle(t, worst, "203.0.113.9", false, &now)
if body := hint(worst); !strings.Contains(body, "User not activated!") || !strings.Contains(body, "summer-callout--warning") || strings.Contains(body, "suspended") {
t.Fatalf("not activated beats suspended: %s", body)
}
run("deactivate", worst)
if body := hint(worst); !strings.Contains(body, "User has deactivated their account") || !strings.Contains(body, "summer-callout--danger") || strings.Contains(body, "not activated") {
t.Fatalf("deactivated beats not activated: %s", body)
}
run("ban", worst)
if body := hint(worst); !strings.Contains(body, "User has been banned") || strings.Contains(body, "deactivated their account") {
t.Fatalf("banned beats deactivated: %s", body)
}
lifted := seed("lifted", nil)
everyone = append(everyone, lifted)
before = memberships()
env.seedThrottle(t, lifted, "203.0.113.9", false, &now)
if body := hint(lifted); !strings.Contains(body, "User is temporarily suspended") || !strings.Contains(body, "summer-callout--warning") {
t.Fatalf("suspended hint: %s", body)
}
if body := hint(live); strings.Contains(body, "summer-callout") {
t.Fatalf("hint for an ordinary user: %s", body)
}
// Every action needs the users permission.
if rec := env.bulk(t, stranger, "ban", live); rec.Code != http.StatusForbidden {
t.Fatalf("bulk ban without the permission = %d", rec.Code)
}
env.expect(t, http.StatusForbidden, http.MethodPost, userPath(lifted.ID, "/actions/unsuspend"), stranger, map[string]any{})
env.expect(t, http.StatusForbidden, http.MethodGet, adminAPI(adminUsersPath+"/partials/hint?id=1"), stranger, nil)
if status, _ := env.userLogin(t, live.Email, "correct-horse"); status != http.StatusOK {
t.Fatal("a refused ban changed the user")
}
// An undeclared action name is not run.
if rec := env.bulk(t, token, "impersonate", live); rec.Code == http.StatusOK {
t.Fatalf("an unknown bulk action answered %d", rec.Code)
}
if rec := env.call(t, http.MethodPost, userPath(live.ID, "/actions/impersonate"), token, map[string]any{}); rec.Code == http.StatusOK {
t.Fatalf("an unknown record action answered %d", rec.Code)
}
}
// TestAdminUserForceDelete covers the permanent delete (D-13): the form
// button and the bulk delete remove the user row with its throttle rows,
// group memberships and attachments, and the blobs after the commit.
func TestAdminUserForceDelete(t *testing.T) {
env := newAdminEnv(t)
token := env.admin(t, permAccessUsers)
stranger := env.admin(t)
group := env.seedGroup(t, "Delete "+adminStamp(), "delete-"+adminStamp())
loaded := func(label string) (*models.User, string) {
user := env.seedUser(t, label, nil)
env.join(t, user, group)
// The throttle foreign key has no ON DELETE: without the cleanup the
// delete would fail on it.
env.seedThrottle(t, user, "203.0.113.20", false, nil)
env.seedThrottle(t, user, "", true, nil)
return user, env.seedAvatar(t, user)
}
assertGone := func(user *models.User, key string) {
t.Helper()
if env.reload(t, user.ID) != nil {
t.Fatalf("user %d still exists", user.ID)
}
if left := env.hangers(t, user); left != (hangers{}) {
t.Fatalf("user %d left orphans: %+v", user.ID, left)
}
if env.blobExists(t, key) {
t.Fatalf("user %d left its avatar blob", user.ID)
}
}
// Without the permission nothing is deleted.
victim, victimKey := loaded("victim")
env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(victim.ID, ""), stranger, nil)
if rec := env.bulk(t, stranger, "delete", victim); rec.Code != http.StatusForbidden {
t.Fatalf("bulk delete without the permission = %d", rec.Code)
}
if env.reload(t, victim.ID) == nil || env.hangers(t, victim) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, victimKey) {
t.Fatalf("a refused delete changed the user: %+v", env.hangers(t, victim))
}
// The form delete.
env.expect(t, http.StatusOK, http.MethodDelete, userPath(victim.ID, ""), token, nil)
assertGone(victim, victimKey)
env.expect(t, http.StatusNotFound, http.MethodGet, userPath(victim.ID, ""), token, nil)
// The bulk delete, with a deactivated user in the selection.
one, oneKey := loaded("bulk-one")
two, twoKey := loaded("bulk-two")
bystander, bystanderKey := loaded("bystander")
if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, two.ID).Error; err != nil {
t.Fatal(err)
}
rec := env.bulk(t, token, "delete", one, two)
if rec.Code != http.StatusOK {
t.Fatalf("bulk delete = %d %s", rec.Code, rec.Body.String())
}
var deleted struct {
Deleted int `json:"deleted"`
}
adminInto(t, rec, &deleted)
if deleted.Deleted != 2 {
t.Fatalf("bulk delete count = %d", deleted.Deleted)
}
assertGone(one, oneKey)
assertGone(two, twoKey)
if env.reload(t, bystander.ID) == nil || env.hangers(t, bystander) != (hangers{Throttle: 2, Groups: 1, Files: 1}) || !env.blobExists(t, bystanderKey) {
t.Fatal("the bulk delete touched a user outside the selection")
}
// The group itself is untouched.
var groups int64
if err := env.gdb.Raw(`SELECT COUNT(*) FROM user_groups WHERE id = ?`, group.ID).Scan(&groups).Error; err != nil || groups != 1 {
t.Fatalf("the delete removed the group: %d %v", groups, err)
}
}