- classes: every admin action for its changed-row count and idempotency, the ban across several throttle rows, the cleanup of a permanent delete - classes: the privileged list and IsPrivilegedMember (D-30), also through a handle that carries conditions - classes: the five-minute boundary of last_seen - models: PermissionSet round trip, the slug table shared with the SPA, Fillable and Rules, fields that never marshal, the group filter value - TestAdminRegistration: permissions, navigation, embedded files and phrase keys in en and pl; the admin secret is required
440 lines
16 KiB
Go
440 lines
16 KiB
Go
package classes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"strconv"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
|
"gocloud.dev/blob/memblob"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// actionUser stores an active user for the admin action tests.
|
|
func actionUser(t *testing.T, db *gorm.DB, label string, mutate func(*models.User)) *models.User {
|
|
t.Helper()
|
|
user := &models.User{Email: "action-" + label + "@example.test", IsActivated: true}
|
|
if mutate != nil {
|
|
mutate(user)
|
|
}
|
|
if err := db.Omit("Groups").Create(user).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
// actionReload reads a user row, deactivated or not.
|
|
func actionReload(t *testing.T, db *gorm.DB, id uint) models.User {
|
|
t.Helper()
|
|
var user models.User
|
|
if err := db.Unscoped().Take(&user, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return user
|
|
}
|
|
|
|
// actionThrottle returns the user's throttle rows ordered by id.
|
|
func actionThrottle(t *testing.T, db *gorm.DB, id uint) []models.Throttle {
|
|
t.Helper()
|
|
var rows []models.Throttle
|
|
if err := db.Where("user_id = ?", id).Order("id").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// actionPivot is the whole users_groups table, for "nothing changed" checks.
|
|
func actionPivot(t *testing.T, db *gorm.DB) []models.UsersGroup {
|
|
t.Helper()
|
|
var rows []models.UsersGroup
|
|
if err := db.Order("user_id, user_group_id").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// TestAdminActions covers the admin screen's user actions against a real
|
|
// Postgres: the changed-row count of each, its idempotency, and that none of
|
|
// them writes users_groups (D-13, D-14, D-29).
|
|
func TestAdminActions(t *testing.T) {
|
|
ctx := t.Context()
|
|
db := throttleDB(t)
|
|
group := models.UserGroup{Name: "Action group"}
|
|
if err := db.Create(&group).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
join := func(user *models.User) {
|
|
t.Helper()
|
|
if err := db.Create(&models.UsersGroup{UserID: user.ID, UserGroupID: group.ID}).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
t.Run("a missing handle is an error", func(t *testing.T) {
|
|
users := []*models.User{{ID: 1}}
|
|
if _, err := ActivateUsers(ctx, nil, users); err == nil {
|
|
t.Error("ActivateUsers")
|
|
}
|
|
if _, err := DeactivateUsers(ctx, nil, users); err == nil {
|
|
t.Error("DeactivateUsers")
|
|
}
|
|
if _, err := RestoreUsers(ctx, nil, users); err == nil {
|
|
t.Error("RestoreUsers")
|
|
}
|
|
if _, err := BanUsers(ctx, nil, users); err == nil {
|
|
t.Error("BanUsers")
|
|
}
|
|
if _, err := UnbanUsers(ctx, nil, users); err == nil {
|
|
t.Error("UnbanUsers")
|
|
}
|
|
if err := UnsuspendUser(ctx, nil, 1); err == nil {
|
|
t.Error("UnsuspendUser")
|
|
}
|
|
if _, _, err := ThrottleStates(ctx, nil, []uint{1}); err == nil {
|
|
t.Error("ThrottleStates")
|
|
}
|
|
if err := ForceDeleteCleanup(ctx, nil, nil, &models.User{ID: 1}); err == nil {
|
|
t.Error("ForceDeleteCleanup")
|
|
}
|
|
})
|
|
|
|
t.Run("activate, deactivate and restore", func(t *testing.T) {
|
|
code := "pending"
|
|
issued := time.Now()
|
|
idle := actionUser(t, db, "idle", func(u *models.User) {
|
|
u.IsActivated, u.ActivationCode, u.ActivationCodeIssuedAt = false, &code, &issued
|
|
})
|
|
gone := actionUser(t, db, "gone-idle", func(u *models.User) {
|
|
u.IsActivated = false
|
|
u.DeletedAt = gorm.DeletedAt{Time: time.Now(), Valid: true}
|
|
})
|
|
live := actionUser(t, db, "live", nil)
|
|
join(idle)
|
|
join(live)
|
|
before := actionPivot(t, db)
|
|
everyone := []*models.User{idle, nil, gone, live, {}}
|
|
|
|
n, err := ActivateUsers(ctx, db, everyone)
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("ActivateUsers = %d %v, want the two users that were not active", n, err)
|
|
}
|
|
for _, id := range []uint{idle.ID, gone.ID} {
|
|
got := actionReload(t, db, id)
|
|
if !got.IsActivated || got.ActivatedAt == nil || got.ActivationCode != nil || got.ActivationCodeIssuedAt != nil || got.DeletedAt.Valid {
|
|
t.Fatalf("user %d after activate = %+v", id, got)
|
|
}
|
|
}
|
|
// The passed values are updated too, so the caller's next step sees them.
|
|
if !idle.IsActivated || idle.ActivationCode != nil || gone.DeletedAt.Valid {
|
|
t.Fatalf("the passed users were not updated: %+v %+v", idle, gone)
|
|
}
|
|
if n, err = ActivateUsers(ctx, db, everyone); err != nil || n != 0 {
|
|
t.Fatalf("second ActivateUsers = %d %v, want 0", n, err)
|
|
}
|
|
|
|
if n, err = DeactivateUsers(ctx, db, everyone); err != nil || n != 3 {
|
|
t.Fatalf("DeactivateUsers = %d %v, want 3", n, err)
|
|
}
|
|
for _, user := range []*models.User{idle, gone, live} {
|
|
if got := actionReload(t, db, user.ID); !got.DeletedAt.Valid || !got.IsActivated {
|
|
t.Fatalf("user %d after deactivate = %+v", user.ID, got)
|
|
}
|
|
}
|
|
if n, err = DeactivateUsers(ctx, db, everyone); err != nil || n != 0 {
|
|
t.Fatalf("second DeactivateUsers = %d %v, want 0", n, err)
|
|
}
|
|
if n, err = RestoreUsers(ctx, db, []*models.User{idle, live}); err != nil || n != 2 {
|
|
t.Fatalf("RestoreUsers = %d %v, want 2", n, err)
|
|
}
|
|
if got := actionReload(t, db, idle.ID); got.DeletedAt.Valid {
|
|
t.Fatal("restore left deleted_at")
|
|
}
|
|
if got := actionReload(t, db, gone.ID); !got.DeletedAt.Valid {
|
|
t.Fatal("restore touched a user outside its selection")
|
|
}
|
|
if n, err = RestoreUsers(ctx, db, []*models.User{idle, live}); err != nil || n != 0 {
|
|
t.Fatalf("second RestoreUsers = %d %v, want 0", n, err)
|
|
}
|
|
if !reflect.DeepEqual(actionPivot(t, db), before) {
|
|
t.Fatal("an action changed users_groups")
|
|
}
|
|
// Conditions on the caller's handle never narrow an action: each one
|
|
// starts from an empty statement.
|
|
scoped := db.Where("1 = 0")
|
|
late := actionUser(t, db, "scoped", func(u *models.User) { u.IsActivated = false })
|
|
if n, err = ActivateUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || !actionReload(t, db, late.ID).IsActivated {
|
|
t.Fatalf("ActivateUsers through a scoped handle = %d %v", n, err)
|
|
}
|
|
if n, err = DeactivateUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || !actionReload(t, db, late.ID).DeletedAt.Valid {
|
|
t.Fatalf("DeactivateUsers through a scoped handle = %d %v", n, err)
|
|
}
|
|
if n, err = BanUsers(ctx, scoped, []*models.User{late}); err != nil || n != 1 || len(actionThrottle(t, db, late.ID)) != 1 {
|
|
t.Fatalf("BanUsers through a scoped handle = %d %v", n, err)
|
|
}
|
|
if banned, _, err := ThrottleStates(ctx, scoped, []uint{late.ID}); err != nil || !banned[late.ID] {
|
|
t.Fatalf("ThrottleStates through a scoped handle = %v %v", banned, err)
|
|
}
|
|
})
|
|
|
|
t.Run("ban and unban", func(t *testing.T) {
|
|
several := actionUser(t, db, "several", nil)
|
|
none := actionUser(t, db, "none", nil)
|
|
anyRow := actionUser(t, db, "any-row", nil)
|
|
first, second := "203.0.113.1", "203.0.113.2"
|
|
for _, row := range []models.Throttle{
|
|
{UserID: several.ID, IPAddress: &first, Attempts: 2},
|
|
{UserID: several.ID, IPAddress: &second},
|
|
{UserID: anyRow.ID},
|
|
} {
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
join(several)
|
|
before := actionPivot(t, db)
|
|
everyone := []*models.User{several, none, anyRow, nil}
|
|
|
|
n, err := BanUsers(ctx, db, everyone)
|
|
if err != nil || n != 3 {
|
|
t.Fatalf("BanUsers = %d %v, want 3", n, err)
|
|
}
|
|
// Every row is banned, and each user has exactly one row without an
|
|
// address: created for the two who had none, kept for the third.
|
|
for user, want := range map[*models.User]int{several: 3, none: 1, anyRow: 1} {
|
|
rows := actionThrottle(t, db, user.ID)
|
|
nullRows := 0
|
|
for _, row := range rows {
|
|
if !row.IsBanned {
|
|
t.Fatalf("user %d has a row that is not banned: %+v", user.ID, row)
|
|
}
|
|
if row.IPAddress == nil {
|
|
nullRows++
|
|
}
|
|
}
|
|
if len(rows) != want || nullRows != 1 {
|
|
t.Fatalf("user %d has %d rows (%d without an address), want %d and 1", user.ID, len(rows), nullRows, want)
|
|
}
|
|
}
|
|
if rows := actionThrottle(t, db, several.ID); rows[0].Attempts != 2 {
|
|
t.Fatalf("a ban changed the attempt counter: %+v", rows[0])
|
|
}
|
|
if n, err = BanUsers(ctx, db, everyone); err != nil || n != 0 {
|
|
t.Fatalf("second BanUsers = %d %v, want 0", n, err)
|
|
}
|
|
if rows := actionThrottle(t, db, none.ID); len(rows) != 1 {
|
|
t.Fatalf("a second ban created another row: %d", len(rows))
|
|
}
|
|
banned, suspended, err := ThrottleStates(ctx, db, []uint{several.ID, none.ID, anyRow.ID, 987654})
|
|
if err != nil || len(banned) != 3 || len(suspended) != 0 {
|
|
t.Fatalf("ThrottleStates = %v %v %v", banned, suspended, err)
|
|
}
|
|
|
|
// A mixed selection counts only the users who were banned.
|
|
fresh := actionUser(t, db, "never-banned", nil)
|
|
if n, err = UnbanUsers(ctx, db, []*models.User{several, fresh, nil}); err != nil || n != 1 {
|
|
t.Fatalf("UnbanUsers = %d %v, want 1", n, err)
|
|
}
|
|
for _, row := range actionThrottle(t, db, several.ID) {
|
|
if row.IsBanned {
|
|
t.Fatalf("a row stayed banned: %+v", row)
|
|
}
|
|
}
|
|
if rows := actionThrottle(t, db, fresh.ID); len(rows) != 0 {
|
|
t.Fatalf("unban created a row for a user who was not banned: %+v", rows)
|
|
}
|
|
if rows := actionThrottle(t, db, none.ID); !rows[0].IsBanned {
|
|
t.Fatal("unban touched a user outside its selection")
|
|
}
|
|
if n, err = UnbanUsers(ctx, db, []*models.User{several}); err != nil || n != 0 {
|
|
t.Fatalf("second UnbanUsers = %d %v, want 0", n, err)
|
|
}
|
|
if !reflect.DeepEqual(actionPivot(t, db), before) {
|
|
t.Fatal("ban or unban changed users_groups")
|
|
}
|
|
})
|
|
|
|
t.Run("the suspension window is a pure read", func(t *testing.T) {
|
|
inside := actionUser(t, db, "inside", nil)
|
|
expired := actionUser(t, db, "expired", nil)
|
|
flagged := actionUser(t, db, "flagged", nil)
|
|
recent, longAgo := time.Now().Add(-14*time.Minute), time.Now().Add(-16*time.Minute)
|
|
ip := "203.0.113.9"
|
|
for _, row := range []models.Throttle{
|
|
{UserID: inside.ID, IPAddress: &ip, IsSuspended: true, SuspendedAt: &recent, Attempts: 5},
|
|
{UserID: expired.ID, IPAddress: &ip, IsSuspended: true, SuspendedAt: &longAgo, Attempts: 5},
|
|
// Suspended without a time: not inside any window.
|
|
{UserID: flagged.ID, IPAddress: &ip, IsSuspended: true, Attempts: 5},
|
|
} {
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ids := []uint{inside.ID, expired.ID, flagged.ID}
|
|
_, suspended, err := ThrottleStates(ctx, db, ids)
|
|
if err != nil || !reflect.DeepEqual(suspended, map[uint]bool{inside.ID: true}) {
|
|
t.Fatalf("suspended = %v %v, want only the user inside the 15 minute window", suspended, err)
|
|
}
|
|
// A shorter window from the context moves the boundary.
|
|
short := withThrottle(ctx, throttleSettings{use: true, limit: 5, minutes: 10})
|
|
if _, suspended, err = ThrottleStates(short, db, ids); err != nil || len(suspended) != 0 {
|
|
t.Fatalf("suspended under a 10 minute window = %v %v", suspended, err)
|
|
}
|
|
long := withThrottle(ctx, throttleSettings{use: true, limit: 5, minutes: 30})
|
|
if _, suspended, err = ThrottleStates(long, db, ids); err != nil || len(suspended) != 2 {
|
|
t.Fatalf("suspended under a 30 minute window = %v %v", suspended, err)
|
|
}
|
|
// A window of zero falls back to the default.
|
|
zero := withThrottle(ctx, throttleSettings{use: true})
|
|
if _, suspended, err = ThrottleStates(zero, db, ids); err != nil || len(suspended) != 1 {
|
|
t.Fatalf("suspended under a zero window = %v %v", suspended, err)
|
|
}
|
|
// Reading changed nothing: the expired suspension is still flagged.
|
|
for _, id := range ids {
|
|
if rows := actionThrottle(t, db, id); len(rows) != 1 || !rows[0].IsSuspended || rows[0].Attempts != 5 {
|
|
t.Fatalf("reading the state changed the row of %d: %+v", id, rows)
|
|
}
|
|
}
|
|
banned, suspended, err := ThrottleStates(ctx, db, nil)
|
|
if err != nil || banned == nil || suspended == nil || len(banned)+len(suspended) != 0 {
|
|
t.Fatalf("ThrottleStates without ids = %v %v %v", banned, suspended, err)
|
|
}
|
|
|
|
if err := UnsuspendUser(ctx, db, inside.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows := actionThrottle(t, db, inside.ID); rows[0].IsSuspended || rows[0].SuspendedAt != nil || rows[0].Attempts != 0 {
|
|
t.Fatalf("row after unsuspend = %+v", rows[0])
|
|
}
|
|
if rows := actionThrottle(t, db, expired.ID); !rows[0].IsSuspended {
|
|
t.Fatal("unsuspend touched another user")
|
|
}
|
|
// Idempotent, and a user without rows is no error.
|
|
if err := UnsuspendUser(ctx, db, inside.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := UnsuspendUser(ctx, db, 987654); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
t.Run("the cleanup of a permanent delete", func(t *testing.T) {
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
// The class harness runs the plugin's migrations only; system_files is
|
|
// the framework's table.
|
|
if err := db.AutoMigrate(&attach.File{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
loaded := func(label string) (*models.User, string) {
|
|
t.Helper()
|
|
user := actionUser(t, db, label, nil)
|
|
join(user)
|
|
ip := "203.0.113.20"
|
|
for _, row := range []models.Throttle{{UserID: user.ID, IPAddress: &ip}, {UserID: user.ID, IsBanned: true}} {
|
|
if err := db.Create(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
disk := "cleanup-" + label + ".jpg"
|
|
key := attach.BlobKey(disk)
|
|
if err := bucket.WriteAll(ctx, key, []byte("avatar"), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
file := attach.File{DiskName: disk, FileName: "a.jpg", FileSize: 6, ContentType: "image/jpeg", Field: models.AvatarField,
|
|
AttachmentID: strconv.FormatUint(uint64(user.ID), 10), AttachmentType: user.MorphName()}
|
|
if err := db.Create(&file).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return user, key
|
|
}
|
|
left := func(user *models.User) [3]int64 {
|
|
t.Helper()
|
|
var out [3]int64
|
|
for i, query := range []string{
|
|
`SELECT COUNT(*) FROM user_throttle WHERE user_id = ?`,
|
|
`SELECT COUNT(*) FROM users_groups WHERE user_id = ?`,
|
|
} {
|
|
if err := db.Raw(query, user.ID).Scan(&out[i]).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
err := db.Raw(`SELECT COUNT(*) FROM system_files WHERE attachment_type = ? AND attachment_id = ?`,
|
|
user.MorphName(), strconv.FormatUint(uint64(user.ID), 10)).Scan(&out[2]).Error
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
exists := func(key string) bool {
|
|
t.Helper()
|
|
ok, err := bucket.Exists(ctx, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// A transaction that rolls back keeps the rows and the blob.
|
|
kept, keptKey := loaded("kept")
|
|
bystander, bystanderKey := loaded("bystander")
|
|
rollback := errors.New("rollback")
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
if err := ForceDeleteCleanup(ctx, tx, bucket, kept); err != nil {
|
|
return err
|
|
}
|
|
return rollback
|
|
})
|
|
if !errors.Is(err, rollback) || left(kept) != [3]int64{2, 1, 1} || !exists(keptKey) {
|
|
t.Fatalf("after a rollback: err=%v rows=%v blob=%v", err, left(kept), exists(keptKey))
|
|
}
|
|
// A committed one removes the rows, and the blob after the commit.
|
|
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
if err := ForceDeleteCleanup(ctx, tx, bucket, kept); err != nil {
|
|
return err
|
|
}
|
|
if !exists(keptKey) {
|
|
t.Error("the blob was removed before the commit")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil || left(kept) != [3]int64{} || exists(keptKey) {
|
|
t.Fatalf("after the commit: err=%v rows=%v blob=%v", err, left(kept), exists(keptKey))
|
|
}
|
|
// The user row itself is the caller's to delete.
|
|
if got := actionReload(t, db, kept.ID); got.ID != kept.ID {
|
|
t.Fatal("the cleanup deleted the user row")
|
|
}
|
|
if left(bystander) != [3]int64{2, 1, 1} || !exists(bystanderKey) {
|
|
t.Fatal("the cleanup touched another user")
|
|
}
|
|
// Idempotent: nothing left to remove is no error.
|
|
if err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
return ForceDeleteCleanup(ctx, tx, bucket, kept)
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Without a bucket the rows still go; the blob is left for the host.
|
|
err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
return ForceDeleteCleanup(ctx, tx, nil, bystander)
|
|
})
|
|
if err != nil || left(bystander) != [3]int64{} || !exists(bystanderKey) {
|
|
t.Fatalf("without a bucket: err=%v rows=%v blob=%v", err, left(bystander), exists(bystanderKey))
|
|
}
|
|
for _, user := range []*models.User{nil, {}} {
|
|
if err := ForceDeleteCleanup(ctx, db, bucket, user); err == nil {
|
|
t.Fatalf("cleanup of %+v was accepted", user)
|
|
}
|
|
}
|
|
// Only the group itself is left of the pivot's side.
|
|
var groups int64
|
|
if err := db.Model(&models.UserGroup{}).Where("id = ?", group.ID).Count(&groups).Error; err != nil || groups != 1 {
|
|
t.Fatalf("the cleanup removed the group: %d %v", groups, err)
|
|
}
|
|
})
|
|
}
|