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:
Jakub Zych
2026-10-05 15:15:28 +02:00
parent 43fabfa6c3
commit 493a3ff47c
7 changed files with 1377 additions and 0 deletions

View 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
View 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
View 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")
}
}