test: unit tests for the admin class functions, the models and the admin registration
- 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
This commit is contained in:
439
classes/admin_actions_test.go
Normal file
439
classes/admin_actions_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
124
classes/last_seen_test.go
Normal file
124
classes/last_seen_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// contextCancelled returns a context that is already cancelled, so a query
|
||||
// through it fails.
|
||||
func contextCancelled(t *testing.T) (context.Context, context.CancelFunc) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
// TestTouchLastSeen pins the five-minute rule of users.last_seen on both
|
||||
// sides of the boundary, for an active and a deactivated user (D-17).
|
||||
func TestTouchLastSeen(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := throttleDB(t)
|
||||
user := models.User{Email: "touch@example.test", IsActivated: true}
|
||||
gone := models.User{Email: "touch-gone@example.test", IsActivated: true, DeletedAt: gorm.DeletedAt{Time: time.Now(), Valid: true}}
|
||||
for _, u := range []*models.User{&user, &gone} {
|
||||
if err := db.Omit("Groups").Create(u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
read := func(id uint) *time.Time {
|
||||
t.Helper()
|
||||
var row struct{ LastSeen *time.Time }
|
||||
if err := db.Raw(`SELECT last_seen FROM users WHERE id = ?`, id).Scan(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.LastSeen
|
||||
}
|
||||
set := func(id uint, age time.Duration) time.Time {
|
||||
t.Helper()
|
||||
at := time.Now().Add(-age).Truncate(time.Microsecond)
|
||||
if err := db.Exec(`UPDATE users SET last_seen = ? WHERE id = ?`, at, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
updatedAt := func(id uint) time.Time {
|
||||
t.Helper()
|
||||
var at time.Time
|
||||
if err := db.Raw(`SELECT updated_at FROM users WHERE id = ?`, id).Scan(&at).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
for _, id := range []uint{user.ID, gone.ID} {
|
||||
if read(id) != nil {
|
||||
t.Fatalf("user %d starts with last_seen", id)
|
||||
}
|
||||
stamped := updatedAt(id)
|
||||
started := time.Now().Add(-time.Second)
|
||||
if err := TouchLastSeen(ctx, db, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := read(id)
|
||||
if first == nil || first.Before(started) {
|
||||
t.Fatalf("user %d: a NULL last_seen was not stamped: %v", id, first)
|
||||
}
|
||||
if !updatedAt(id).Equal(stamped) {
|
||||
t.Fatalf("user %d: the stamp touched updated_at", id)
|
||||
}
|
||||
// Just inside the window: untouched. Just outside: stamped again.
|
||||
for _, tc := range []struct {
|
||||
age time.Duration
|
||||
stamped bool
|
||||
}{
|
||||
{0, false}, {4*time.Minute + 55*time.Second, false},
|
||||
{5*time.Minute + 5*time.Second, true}, {24 * time.Hour, true},
|
||||
} {
|
||||
was := set(id, tc.age)
|
||||
if err := TouchLastSeen(ctx, db, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := read(id)
|
||||
if now == nil || now.Equal(was) == tc.stamped {
|
||||
t.Fatalf("user %d, last seen %s ago: stamped = %v, want %v", id, tc.age, !now.Equal(was), tc.stamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
// One user's stamp never moves another's.
|
||||
other := set(gone.ID, time.Hour)
|
||||
if err := TouchLastSeen(ctx, db, user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := read(gone.ID); got == nil || !got.Equal(other) {
|
||||
t.Fatal("a stamp moved another user's last_seen")
|
||||
}
|
||||
// An unknown id and the zero id are no error; a missing handle and a
|
||||
// failed write are.
|
||||
if err := TouchLastSeen(ctx, db, 987654); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := TouchLastSeen(ctx, db, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := TouchLastSeen(ctx, nil, user.ID); err == nil {
|
||||
t.Fatal("TouchLastSeen without a handle")
|
||||
}
|
||||
cancelled, cancel := contextCancelled(t)
|
||||
defer cancel()
|
||||
set(user.ID, time.Hour)
|
||||
if err := TouchLastSeen(cancelled, db, user.ID); err == nil {
|
||||
t.Fatal("TouchLastSeen swallowed a failed write")
|
||||
}
|
||||
// The handle's own conditions do not leak into the update.
|
||||
if err := TouchLastSeen(ctx, db.Where("1 = 0"), user.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := read(user.ID); got == nil || time.Since(*got) > time.Minute {
|
||||
t.Fatalf("a scoped handle kept the stamp from being written: %v", got)
|
||||
}
|
||||
}
|
||||
207
classes/privileged_test.go
Normal file
207
classes/privileged_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-user-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// privilegedConfig opens a config whose golem15.user.privileged_groups is
|
||||
// value; a nil value leaves the key out.
|
||||
func privilegedConfig(t *testing.T, value any, environ ...string) *compass.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: privileged\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: append([]string{"SUMMER_ENV=development"}, environ...)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value != nil {
|
||||
if err := cfg.Set(privilegedGroupsKey, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestPrivilegedGroupCodes pins how the privileged list is read (D-05): the
|
||||
// default, an override, blank entries and the comma-separated form of an
|
||||
// environment variable.
|
||||
func TestPrivilegedGroupCodes(t *testing.T) {
|
||||
if got := PrivilegedGroupCodes(nil); !reflect.DeepEqual(got, []string{"admin"}) {
|
||||
t.Fatalf("nil config = %v", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
want []string
|
||||
}{
|
||||
{"a missing key", nil, []string{"admin"}},
|
||||
{"a list", []string{"admin", "moderator"}, []string{"admin", "moderator"}},
|
||||
{"a list of any", []any{"owner", 7, "staff"}, []string{"owner", "staff"}},
|
||||
{"blank entries", []string{" admin ", "", " ", "staff"}, []string{"admin", "staff"}},
|
||||
{"an empty list", []string{}, []string{}},
|
||||
{"a comma-separated string", "admin, staff ,,owner", []string{"admin", "staff", "owner"}},
|
||||
{"an empty string", "", []string{}},
|
||||
{"a value of another type", 5, []string{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := PrivilegedGroupCodes(privilegedConfig(t, tc.value))
|
||||
if got == nil || !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("codes = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The key is read on every call: a change applies without a new config.
|
||||
cfg := privilegedConfig(t, []string{"first"})
|
||||
if got := PrivilegedGroupCodes(cfg); !reflect.DeepEqual(got, []string{"first"}) {
|
||||
t.Fatalf("codes = %v", got)
|
||||
}
|
||||
if err := cfg.Set(privilegedGroupsKey, []string{"second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := PrivilegedGroupCodes(cfg); !reflect.DeepEqual(got, []string{"second"}) {
|
||||
t.Fatalf("codes after the change = %v", got)
|
||||
}
|
||||
if PermissionManagePrivilegedGroups != "golem15.users.manage_privileged_groups" {
|
||||
t.Fatalf("permission code = %q", PermissionManagePrivilegedGroups)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPrivilegedCode: codes compare case-sensitively and a group without a
|
||||
// code is never privileged.
|
||||
func TestIsPrivilegedCode(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
codes := []string{"admin", "Staff"}
|
||||
for _, tc := range []struct {
|
||||
code *string
|
||||
want bool
|
||||
}{
|
||||
{str("admin"), true}, {str("Staff"), true},
|
||||
{str("Admin"), false}, {str("ADMIN"), false}, {str("staff"), false},
|
||||
{str("admin "), false}, {str(" admin"), false}, {str("administrator"), false},
|
||||
{str(""), false}, {nil, false},
|
||||
} {
|
||||
if got := IsPrivilegedCode(codes, tc.code); got != tc.want {
|
||||
label := "<nil>"
|
||||
if tc.code != nil {
|
||||
label = *tc.code
|
||||
}
|
||||
t.Errorf("IsPrivilegedCode(%q) = %v, want %v", label, got, tc.want)
|
||||
}
|
||||
}
|
||||
if IsPrivilegedCode(nil, str("admin")) || IsPrivilegedCode([]string{}, str("admin")) {
|
||||
t.Error("an empty list has a privileged code")
|
||||
}
|
||||
// An empty entry in the list never matches a group without a code.
|
||||
if IsPrivilegedCode([]string{""}, str("")) || IsPrivilegedCode([]string{""}, nil) {
|
||||
t.Error("an empty code is privileged")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPrivilegedMember covers the membership question behind the takeover
|
||||
// guard (D-30) and PrivilegedGroupIDs behind the groups lock (D-07).
|
||||
func TestIsPrivilegedMember(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := throttleDB(t)
|
||||
str := func(s string) *string { return &s }
|
||||
admins := models.UserGroup{Name: "Admins", Code: str("admin")}
|
||||
second := models.UserGroup{Name: "Second admins", Code: str("admin2")}
|
||||
ordinary := models.UserGroup{Name: "Ordinary", Code: str("ordinary")}
|
||||
upper := models.UserGroup{Name: "Upper", Code: str("ADMIN")}
|
||||
uncoded := models.UserGroup{Name: "Uncoded"}
|
||||
for _, group := range []*models.UserGroup{&admins, &second, &ordinary, &upper, &uncoded} {
|
||||
if err := db.Create(group).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
user := func(label string, groups ...*models.UserGroup) uint {
|
||||
t.Helper()
|
||||
u := models.User{Email: "privileged-" + label + "@example.test", IsActivated: true}
|
||||
if err := db.Omit("Groups").Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, group := range groups {
|
||||
if err := db.Create(&models.UsersGroup{UserID: u.ID, UserGroupID: group.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
nobody := user("nobody")
|
||||
plain := user("plain", &ordinary)
|
||||
codeless := user("codeless", &uncoded, &upper)
|
||||
member := user("member", &ordinary, &admins)
|
||||
gone := user("gone", &admins)
|
||||
if err := db.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defaults := PrivilegedGroupCodes(nil)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
codes []string
|
||||
user uint
|
||||
want bool
|
||||
}{
|
||||
{"a user in no group", defaults, nobody, false},
|
||||
{"an ordinary group only", defaults, plain, false},
|
||||
{"a group without a code and a case variant", defaults, codeless, false},
|
||||
{"a member of a listed group", defaults, member, true},
|
||||
{"a deactivated member", defaults, gone, true},
|
||||
{"an overridden list without admin", []string{"ordinary"}, gone, false},
|
||||
{"an overridden list with the ordinary code", []string{"ordinary"}, plain, true},
|
||||
{"an overridden list, a member of both", []string{"ordinary"}, member, true},
|
||||
{"a case variant in the list", []string{"ADMIN"}, codeless, true},
|
||||
{"an empty list", []string{}, member, false},
|
||||
{"a nil list", nil, member, false},
|
||||
{"an unknown user", defaults, 987654, false},
|
||||
{"the zero user", defaults, 0, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := IsPrivilegedMember(ctx, db, tc.codes, tc.user)
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("IsPrivilegedMember = %v %v, want %v", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := IsPrivilegedMember(ctx, nil, defaults, member); err == nil {
|
||||
t.Fatal("IsPrivilegedMember without a handle")
|
||||
}
|
||||
// The handle's own conditions do not leak into the query.
|
||||
scoped := db.Where("1 = 0")
|
||||
if got, err := IsPrivilegedMember(ctx, scoped, defaults, member); err != nil || !got {
|
||||
t.Fatalf("with a scoped handle = %v %v", got, err)
|
||||
}
|
||||
// A failing query is an error, never "not a member".
|
||||
cancelled, cancel := contextCancelled(t)
|
||||
defer cancel()
|
||||
if got, err := IsPrivilegedMember(cancelled, db, defaults, member); err == nil || got {
|
||||
t.Fatalf("a failed query answered %v %v", got, err)
|
||||
}
|
||||
|
||||
ids, err := PrivilegedGroupIDs(ctx, db, []string{"admin", "admin2", "missing"})
|
||||
if err != nil || !reflect.DeepEqual(ids, []uint{admins.ID, second.ID}) {
|
||||
t.Fatalf("PrivilegedGroupIDs = %v %v", ids, err)
|
||||
}
|
||||
if ids, err = PrivilegedGroupIDs(ctx, scoped, defaults); err != nil || !reflect.DeepEqual(ids, []uint{admins.ID}) {
|
||||
t.Fatalf("PrivilegedGroupIDs with a scoped handle = %v %v", ids, err)
|
||||
}
|
||||
for _, codes := range [][]string{nil, {}, {"nothing"}} {
|
||||
if ids, err = PrivilegedGroupIDs(ctx, db, codes); err != nil || ids == nil || len(ids) != 0 {
|
||||
t.Fatalf("PrivilegedGroupIDs(%v) = %#v %v", codes, ids, err)
|
||||
}
|
||||
}
|
||||
if _, err = PrivilegedGroupIDs(ctx, nil, defaults); err == nil {
|
||||
t.Fatal("PrivilegedGroupIDs without a handle")
|
||||
}
|
||||
if _, err = PrivilegedGroupIDs(cancelled, db, defaults); err == nil {
|
||||
t.Fatal("PrivilegedGroupIDs swallowed a failed query")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user