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

273
admin_registration_test.go Normal file
View File

@@ -0,0 +1,273 @@
package user
import (
"io/fs"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
)
// langKey matches one complete phrase key of the plugin.
var langKey = regexp.MustCompile(`golem15\.user::lang\.[a-z0-9_]+(\.[a-z0-9_]+)+`)
// TestAdminRegistration pins what the plugin registers for the admin: the
// permission catalog, the navigation tree, the embedded admin files and the
// phrase keys they and the controllers name, in English and Polish.
func TestAdminRegistration(t *testing.T) {
plugin := &Plugin{}
// The five permission codes, each on the plugin's tab.
var codes []string
for _, permission := range plugin.Permissions() {
codes = append(codes, permission.Code)
if permission.Tab != "golem15.user::lang.plugin.tab" || !strings.HasPrefix(permission.Label, "golem15.user::lang.plugin.") {
t.Fatalf("permission %+v is not on the plugin tab", permission)
}
}
wantCodes := []string{
"golem15.users.access_users", "golem15.users.access_groups", "golem15.users.access_settings",
"golem15.users.impersonate_user", "golem15.users.manage_privileged_groups",
}
if !reflect.DeepEqual(codes, wantCodes) {
t.Fatalf("permission codes = %v, want %v", codes, wantCodes)
}
// The privileged-groups permission has no default role: nobody holds it
// until it is granted on purpose.
for _, permission := range plugin.Permissions() {
if privileged := permission.Code == permManagePrivileged; privileged != (len(permission.Roles) == 0) {
t.Fatalf("permission %s has roles %v", permission.Code, permission.Roles)
}
}
// The navigation: one main item with three side items, each behind the
// permission of its screen.
nav := plugin.Navigation()
if len(nav) != 1 || nav[0].Code != "user" || nav[0].Order != 555 || nav[0].Controller != "golem15.user.users" ||
!reflect.DeepEqual(nav[0].Permissions, []string{"golem15.users.*"}) {
t.Fatalf("navigation = %+v", nav)
}
type side struct{ code, controller, permission string }
var sides []side
for _, item := range nav[0].SideMenu {
if len(item.Permissions) != 1 || item.Icon == "" || len(item.SideMenu) != 0 {
t.Fatalf("side item %+v", item)
}
sides = append(sides, side{item.Code, item.Controller, item.Permissions[0]})
}
if want := []side{
{"users", "golem15.user.users", permAccessUsers},
{"usergroups", "golem15.user.usergroups", permAccessGroups},
{"organisations", "golem15.user.organisations", permAccessUsers},
}; !reflect.DeepEqual(sides, want) {
t.Fatalf("side menu = %v, want %v", sides, want)
}
// The controllers: three, each naming a config directory that exists in
// the embedded files, each behind the permission its side item names.
ctls := controllers.AdminControllers(func() *backpack.App { return nil })
if len(ctls) != 3 {
t.Fatalf("controllers = %d", len(ctls))
}
required := map[string]string{}
for _, ctl := range ctls {
permissioned, ok := ctl.(pact.AdminPermissioned)
if !ok || len(permissioned.RequiredPermissions()) != 1 {
t.Fatalf("controller %s declares no single permission", ctl.ID())
}
required[ctl.ID()] = permissioned.RequiredPermissions()[0]
if _, err := fs.Stat(plugin.AdminFS(), ctl.ConfigDir()+"/config_list.yaml"); err != nil {
t.Fatalf("controller %s: %v", ctl.ID(), err)
}
}
for _, item := range sides {
if required[item.controller] != item.permission {
t.Fatalf("side item %s asks for %s, its controller for %s", item.code, item.permission, required[item.controller])
}
}
// Every embedded admin file is readable and not empty.
wantFiles := []string{
"controllers/organisations/config_form.yaml", "controllers/organisations/config_list.yaml", "controllers/organisations/config_relation.yaml",
"controllers/usergroups/config_form.yaml", "controllers/usergroups/config_list.yaml",
"controllers/users/_hint.htm", "controllers/users/config_filter.yaml", "controllers/users/config_form.yaml", "controllers/users/config_list.yaml",
"models/organisation/columns.yaml", "models/organisation/fields.yaml",
"models/user/columns.yaml", "models/user/fields.yaml",
"models/usergroup/columns.yaml", "models/usergroup/fields.yaml",
}
var files []string
keys := map[string]string{}
collect := func(source string, text []byte) {
for _, key := range langKey.FindAllString(string(text), -1) {
if _, seen := keys[key]; !seen {
keys[key] = source
}
}
}
err := fs.WalkDir(plugin.AdminFS(), ".", func(name string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
data, err := fs.ReadFile(plugin.AdminFS(), name)
if err != nil {
return err
}
if len(data) == 0 {
t.Errorf("embedded admin file %s is empty", name)
}
files = append(files, name)
collect(name, data)
return nil
})
if err != nil {
t.Fatal(err)
}
sort.Strings(files)
if !reflect.DeepEqual(files, wantFiles) {
t.Fatalf("embedded admin files = %v, want %v", files, wantFiles)
}
// Every admin file on disk is embedded: a new YAML file that is not in the
// embed list would be missing from the binary.
for _, pattern := range []string{"controllers/*/*.yaml", "controllers/*/_*.htm", "models/*/*.yaml"} {
onDisk, err := filepath.Glob(pattern)
if err != nil {
t.Fatal(err)
}
for _, name := range onDisk {
if _, err := fs.Stat(plugin.AdminFS(), filepath.ToSlash(name)); err != nil {
t.Errorf("%s is on disk and not embedded", name)
}
}
}
// The phrase keys of the Go side: literal keys in the sources, and the
// labels, confirms and messages the registered actions name.
for _, pattern := range []string{"*.go", "controllers/*.go", "classes/*.go"} {
sources, err := filepath.Glob(pattern)
if err != nil {
t.Fatal(err)
}
for _, name := range sources {
if strings.HasSuffix(name, "_test.go") {
continue
}
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
collect(name, data)
}
}
for _, permission := range plugin.Permissions() {
keys[permission.Label], keys[permission.Tab] = "permissions", "permissions"
}
keys[nav[0].Label] = "navigation"
for _, item := range nav[0].SideMenu {
keys[item.Label] = "navigation"
}
for _, ctl := range ctls {
if bulk, ok := ctl.(pact.HasAdminBulkActions); ok {
for _, action := range bulk.AdminBulkActions() {
keys[action.Label], keys[action.Confirm] = "bulk action "+action.Name, "bulk action "+action.Name
keys["golem15.user::lang.users."+action.Name+"_selected_success"] = "bulk action " + action.Name
}
}
if record, ok := ctl.(pact.HasAdminRecordActions); ok {
for _, action := range record.AdminRecordActions() {
keys[action.Label], keys[action.Confirm] = "record action "+action.Name, "record action "+action.Name
}
}
}
for _, key := range []string{"activated_success", "unbanned_success", "unsuspend_success"} {
keys["golem15.user::lang.users."+key] = "record action messages"
}
if len(keys) < 80 {
t.Fatalf("only %d phrase keys were collected", len(keys))
}
env := newAdminEnv(t)
tr, ok := env.app.Lookup[*phrasebook.Translator]()
if !ok {
t.Fatal("no translator")
}
var missing []string
for key, source := range keys {
if !strings.HasPrefix(key, "golem15.user::lang.") {
t.Errorf("%s names %q, which is not a phrase key of the plugin", source, key)
continue
}
for _, locale := range []string{"en", "pl"} {
if text := tr.GetIn(locale, key, nil); text == "" || text == key || strings.Contains(text, "::lang.") {
missing = append(missing, locale+" "+key+" ("+source+")")
}
}
}
sort.Strings(missing)
if len(missing) > 0 {
t.Fatalf("%d phrase keys do not resolve:\n%s", len(missing), strings.Join(missing, "\n"))
}
// The two locales carry the same keys.
en, pl := tr.Bundle("en", "golem15.user::lang"), tr.Bundle("pl", "golem15.user::lang")
if len(en) == 0 || len(en) != len(pl) {
t.Fatalf("en has %d phrases, pl has %d", len(en), len(pl))
}
for key := range en {
if _, ok := pl[key]; !ok {
t.Errorf("pl lacks %s", key)
}
}
}
// TestAdminNeedsSecret: a host application that loads the plugin mounts the
// admin API, so assembling its routes without admin.jwt.secret fails loudly
// instead of serving the screens unauthenticated.
func TestAdminNeedsSecret(t *testing.T) {
gdb, sqlDB := adminHarnessDB(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: user-no-admin-secret\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__JWT__SECRET=" + sessionSecret,
}})
if err != nil {
t.Fatal(err)
}
// Everything else the router needs is set, so the secret is the one thing
// missing.
for key, value := range map[string]any{
"http.body_limits.default_bytes": 1048576,
"http.body_limits.upload_bytes": 8388608,
} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
t.Fatal(err)
}
plugins, err := party.Activate(app, []string{"golem15.user"})
if err != nil {
t.Fatal(err)
}
if _, err := surf.Assemble(app, plugins); err == nil || !strings.Contains(err.Error(), "admin.jwt.secret") {
t.Fatalf("assembling without the admin secret: %v, want an error naming admin.jwt.secret", err)
}
// The registered plugin value now points at this app: boot a working
// environment again for whatever runs next.
newAdminEnv(t)
}

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

191
models/admin_models_test.go Normal file
View File

@@ -0,0 +1,191 @@
package models
import (
"encoding/json"
"reflect"
"sort"
"strings"
"testing"
"time"
)
// TestAdminModelContracts pins what the admin screens read off the models:
// fill lists, rules, morph names, attachment relations and filter scopes.
func TestAdminModelContracts(t *testing.T) {
if got := (UserGroup{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "code", "description"}) {
t.Fatalf("UserGroup.Fillable = %v", got)
}
if got := (UserGroup{}).Rules(); !reflect.DeepEqual(got, map[string]string{"name": "required|between:3,64", "code": "required|unique:user_groups"}) {
t.Fatalf("UserGroup.Rules = %v", got)
}
if got := (Organisation{}).Fillable(); !reflect.DeepEqual(got, []string{"name", "slug", "description"}) {
t.Fatalf("Organisation.Fillable = %v", got)
}
if got := (Organisation{}).Rules(); !reflect.DeepEqual(got, map[string]string{
"name": "required|max:255", "slug": "required|unique:golem15_user_organisations", "description": "nullable|max:5000",
}) {
t.Fatalf("Organisation.Rules = %v", got)
}
// Neither fill list reaches a column that carries privilege or ownership.
for _, forbidden := range []string{"permissions", "id", "users_count", "members", "created_at"} {
for _, list := range [][]string{(UserGroup{}).Fillable(), (Organisation{}).Fillable()} {
for _, name := range list {
if name == forbidden {
t.Fatalf("%s is fillable", forbidden)
}
}
}
}
// The user's fill list is the register and update contract and is not
// widened by the admin screen.
fillable := strings.Join((User{}).Fillable(), ",")
for _, protected := range []string{"is_activated", "permissions", "last_seen", "activation_code", "reset_password_code", "tokens_valid_after", "deleted_at", "id"} {
for _, name := range (User{}).Fillable() {
if name == protected {
t.Fatalf("users.%s is fillable (%s)", protected, fillable)
}
}
}
if !reflect.DeepEqual((User{}).Hidden(), []string{"password", "reset_password_code", "activation_code"}) {
t.Fatalf("User.Hidden = %v", (User{}).Hidden())
}
if (User{}).MorphName() != `Golem15\User\Models\User` || (Organisation{}).MorphName() != `Golem15\User\Models\Organisation` {
t.Fatalf("morph names = %q / %q", (User{}).MorphName(), (Organisation{}).MorphName())
}
for name, relations := range map[string]int{"user": len((User{}).AttachRelations()), "organisation": len((Organisation{}).AttachRelations())} {
if relations != 1 {
t.Fatalf("%s attach relations = %d", name, relations)
}
}
if rel := (User{}).AttachRelations()[0]; AvatarField != "avatar" || rel.Name != AvatarField || !rel.Public {
t.Fatalf("user avatar relation = %+v", rel)
}
if rel := (Organisation{}).AttachRelations()[0]; rel.Name != OrganisationAvatarField || !rel.Public {
t.Fatalf("organisation avatar relation = %+v", rel)
}
if got := (User{}).FilterScopes(); !reflect.DeepEqual(got, []string{FilterByGroup}) || FilterByGroup != "filterByGroup" {
t.Fatalf("User.FilterScopes = %v", got)
}
// A nil handle passes through for every scope name.
if (User{}).FilterScope(FilterByGroup, nil, 1) != nil || (User{}).FilterScope("other", nil, 1) != nil {
t.Fatal("FilterScope invented a handle")
}
for model, table := range map[string][2]string{
"User": {(User{}).TableName(), "users"}, "UserGroup": {(UserGroup{}).TableName(), "user_groups"},
"UsersGroup": {(UsersGroup{}).TableName(), "users_groups"}, "Organisation": {(Organisation{}).TableName(), "golem15_user_organisations"},
"FrontendPermission": {(FrontendPermission{}).TableName(), "golem15_user_frontend_permissions"},
} {
if table[0] != table[1] {
t.Fatalf("%s table = %q, want %q", model, table[0], table[1])
}
}
if GroupGuest != "guest" || GroupRegistered != "registered" {
t.Fatalf("seeded group codes = %q, %q", GroupGuest, GroupRegistered)
}
}
// TestOrganisationBeforeValidate: an empty slug is filled from the name, a
// given slug is kept.
func TestOrganisationBeforeValidate(t *testing.T) {
for _, tc := range []struct{ name, slug, want string }{
{"Acme Records", "", "acme-records"},
{"Acme Records", "custom", "custom"},
{"", "", ""},
{"---", "", ""},
} {
organisation := Organisation{Name: tc.name, Slug: tc.slug}
if err := organisation.BeforeValidate(nil); err != nil || organisation.Slug != tc.want {
t.Fatalf("BeforeValidate(%q, %q) = %q %v, want %q", tc.name, tc.slug, organisation.Slug, err, tc.want)
}
}
}
// TestAdminFieldsNeverMarshal: the columns and relations the admin screens
// added never appear when a model is serialized (D-08, D-17; T-12.1-24,
// T-12.1-34).
func TestAdminFieldsNeverMarshal(t *testing.T) {
now := time.Now()
code := "admin"
secret := "one-time-code"
user := User{
ID: 7, Email: "marshal@example.test", Password: "$2a$10$stored-hash", LastSeen: &now, CreatedAt: now, UpdatedAt: now,
Permissions: PermissionSet{"blog.post": 1},
ActivationCode: &secret, ResetPasswordCode: &secret,
Groups: []UserGroup{{ID: 3, Name: "Site admins", Code: &code}},
}
raw, err := json.Marshal(user)
if err != nil {
t.Fatal(err)
}
var keys map[string]any
if err := json.Unmarshal(raw, &keys); err != nil {
t.Fatal(err)
}
for _, key := range []string{"LastSeen", "Permissions", "CreatedAt", "UpdatedAt", "Groups", "Password", "ActivationCode", "ResetPasswordCode"} {
if _, ok := keys[key]; ok {
t.Fatalf("a marshalled user has the key %s: %s", key, raw)
}
}
lower := strings.ToLower(string(raw))
for _, leak := range []string{"last_seen", "permissions", "groups", "blog.post", "site admins", "stored-hash", "one-time-code"} {
if strings.Contains(lower, leak) {
t.Fatalf("a marshalled user carries %q: %s", leak, raw)
}
}
// The rest of the payload is unchanged by the admin fields.
if keys["Email"] != "marshal@example.test" || keys["ID"] != float64(7) {
t.Fatalf("marshalled user = %s", raw)
}
organisation, err := json.Marshal(Organisation{ID: 1, Name: "Acme", Members: []User{user}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(organisation), "Members") || strings.Contains(string(organisation), "marshal@example.test") {
t.Fatalf("a marshalled organisation carries its members: %s", organisation)
}
group, err := json.Marshal(UserGroup{ID: 3, Name: "Site admins", UsersCount: 5})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(group), "UsersCount") {
t.Fatalf("a marshalled group carries the member count: %s", group)
}
}
// TestModelRegistry: the plugin registers its seven models, the pivot and the
// permission catalog included.
func TestModelRegistry(t *testing.T) {
var names []string
for _, model := range All() {
names = append(names, reflect.TypeOf(model).Name())
}
sort.Strings(names)
for _, want := range []string{"FrontendPermission", "Organisation", "User", "UserGroup", "UsersGroup"} {
found := false
for _, name := range names {
found = found || name == want
}
if !found {
t.Fatalf("model %s is not registered: %v", want, names)
}
}
}
// TestGroupFilterID: the group filter takes a positive whole number, as a
// number or as its decimal text, and nothing else.
func TestGroupFilterID(t *testing.T) {
for _, value := range []any{"7", " 7 ", 7, uint(7), int64(7), float64(7), json.Number("7")} {
if id, ok := groupFilterID(value); !ok || id != 7 {
t.Errorf("groupFilterID(%#v) = %d %v, want 7", value, id, ok)
}
}
for _, value := range []any{nil, "", " ", "abc", "7 OR 1=1", "7;", "-1", "0", 0, -3, 7.5, "7.0", "1e3", true, []int{7}, "99999999999999999999"} {
if id, ok := groupFilterID(value); ok {
t.Errorf("groupFilterID(%#v) = %d, want a refusal", value, id)
}
}
}

View File

@@ -0,0 +1,90 @@
package models
import (
"reflect"
"testing"
)
// TestPermissionSetRoundTrip: what Value writes, Scan and ParsePermissionSet
// read back unchanged; Text is the same text for a plain string column.
func TestPermissionSetRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
set PermissionSet
text string // "" stands for NULL
back PermissionSet
}{
{"allow and deny", PermissionSet{"blog.post": 1, "blog.edit": -1}, `{"blog.edit":-1,"blog.post":1}`, PermissionSet{"blog.post": 1, "blog.edit": -1}},
{"a zero is not stored", PermissionSet{"blog.post": 1, "blog.zero": 0}, `{"blog.post":1}`, PermissionSet{"blog.post": 1}},
{"a wildcard code", PermissionSet{"blog.*": 1}, `{"blog.*":1}`, PermissionSet{"blog.*": 1}},
{"an empty set", PermissionSet{}, "", PermissionSet{}},
{"a nil set", nil, "", PermissionSet{}},
{"only zeros", PermissionSet{"blog.zero": 0}, "", PermissionSet{}},
} {
t.Run(tc.name, func(t *testing.T) {
value, err := tc.set.Value()
if err != nil {
t.Fatal(err)
}
text := tc.set.Text()
if tc.text == "" {
if value != nil || text != nil {
t.Fatalf("Value = %#v, Text = %v, want NULL", value, text)
}
} else if value != tc.text || text == nil || *text != tc.text {
t.Fatalf("Value = %#v, Text = %v, want %s", value, text, tc.text)
}
var scanned PermissionSet
if err := scanned.Scan(value); err != nil || !reflect.DeepEqual(scanned, tc.back) {
t.Fatalf("Scan(Value) = %v %v, want %v", scanned, err, tc.back)
}
parsed, err := ParsePermissionSet([]byte(tc.text))
if err != nil || !reflect.DeepEqual(parsed, tc.back) {
t.Fatalf("ParsePermissionSet(%q) = %v %v, want %v", tc.text, parsed, err, tc.back)
}
})
}
if got := (PermissionSet{}).GormDataType(); got != "text" {
t.Fatalf("GormDataType = %q", got)
}
}
// TestParsePermissionSet: the tolerant read of what PHP may have stored, and
// the error for content that is not a JSON object.
func TestParsePermissionSet(t *testing.T) {
for raw, want := range map[string]PermissionSet{
"": {},
" ": {},
"[]": {},
"null": {},
" {} ": {},
`{"a":1,"b":"1","c":" -1 "}`: {"a": 1, "b": 1, "c": -1},
// Values that are not integers are skipped, never read as granted.
`{"a":true,"b":"yes","c":1.5,"d":null,"e":[1],"f":{"x":1},"g":1}`: {"g": 1},
`{"big":99999999999999999999}`: {},
} {
got, err := ParsePermissionSet([]byte(raw))
if err != nil || got == nil || !reflect.DeepEqual(got, want) {
t.Errorf("ParsePermissionSet(%q) = %v %v, want %v", raw, got, err, want)
}
}
for _, raw := range []string{`{"a":1`, `["a"]`, `1`, `"a"`, `true`, `{"a":1}{"b":1}`, `{"a":1} x`} {
if got, err := ParsePermissionSet([]byte(raw)); err == nil {
t.Errorf("ParsePermissionSet(%q) = %v, want an error", raw, got)
}
}
// Scan keeps the previous value on an error and takes text or bytes.
set := PermissionSet{"kept": 1}
if err := set.Scan(`["a"]`); err == nil || !reflect.DeepEqual(set, PermissionSet{"kept": 1}) {
t.Fatalf("a failed Scan changed the set: %v %v", set, err)
}
if err := set.Scan([]byte(`{"a":-1}`)); err != nil || !reflect.DeepEqual(set, PermissionSet{"a": -1}) {
t.Fatalf("Scan(bytes) = %v %v", set, err)
}
if err := set.Scan(nil); err != nil || set == nil || len(set) != 0 {
t.Fatalf("Scan(nil) = %#v %v", set, err)
}
if err := set.Scan(3.5); err == nil {
t.Fatal("Scan accepted a float")
}
}

53
models/slug_test.go Normal file
View File

@@ -0,0 +1,53 @@
package models
import (
"strings"
"testing"
)
// slugCases is the table the admin SPA's slug preset is tested against as
// well (admin/tests/form/formState.test.ts in the framework repository,
// "slug preset matches the server rule"): the organisation model and the form
// must derive the same slug from the same name.
var slugCases = []struct{ text, want string }{
{"Acme Records", "acme-records"},
{" Acme -- Records ", "acme-records"},
{"ACME_Records 2", "acme-records-2"},
{"Zażółć gęślą", "za-g-l"},
{"Rock & Roll!!!", "rock-roll"},
{"...leading and trailing...", "leading-and-trailing"},
{"tab\tand\nnewline", "tab-and-newline"},
{"emoji 🎵 music", "emoji-music"},
{"CamelCaseText", "camelcasetext"},
{"9 lives", "9-lives"},
{"a", "a"},
{"---", ""},
{" ", ""},
{"", ""},
{"ąęść", ""},
{strings.Repeat("Long Name ", 40), strings.TrimSuffix(strings.Repeat("long-name-", 40), "-")},
}
func TestSlugifyTable(t *testing.T) {
for _, tc := range slugCases {
if got := Slugify(tc.text); got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.text, got, tc.want)
}
}
// A slug is a fixed point, and it only holds lower-case ASCII letters,
// digits and single hyphens between them.
for _, tc := range slugCases {
slug := Slugify(tc.text)
if again := Slugify(slug); again != slug {
t.Errorf("Slugify(Slugify(%q)) = %q, want %q", tc.text, again, slug)
}
if strings.HasPrefix(slug, "-") || strings.HasSuffix(slug, "-") || strings.Contains(slug, "--") {
t.Errorf("Slugify(%q) = %q has a stray hyphen", tc.text, slug)
}
for _, r := range slug {
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
t.Errorf("Slugify(%q) = %q holds %q", tc.text, slug, r)
}
}
}
}