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:
273
admin_registration_test.go
Normal file
273
admin_registration_test.go
Normal 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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
191
models/admin_models_test.go
Normal file
191
models/admin_models_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
models/permission_set_test.go
Normal file
90
models/permission_set_test.go
Normal 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
53
models/slug_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user