diff --git a/admin_registration_test.go b/admin_registration_test.go new file mode 100644 index 0000000..dc7b9ed --- /dev/null +++ b/admin_registration_test.go @@ -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) +} diff --git a/classes/admin_actions_test.go b/classes/admin_actions_test.go new file mode 100644 index 0000000..842a8b3 --- /dev/null +++ b/classes/admin_actions_test.go @@ -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) + } + }) +} diff --git a/classes/last_seen_test.go b/classes/last_seen_test.go new file mode 100644 index 0000000..49932ee --- /dev/null +++ b/classes/last_seen_test.go @@ -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) + } +} diff --git a/classes/privileged_test.go b/classes/privileged_test.go new file mode 100644 index 0000000..287f42e --- /dev/null +++ b/classes/privileged_test.go @@ -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 := "" + 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") + } +} diff --git a/models/admin_models_test.go b/models/admin_models_test.go new file mode 100644 index 0000000..2e11cda --- /dev/null +++ b/models/admin_models_test.go @@ -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) + } + } +} diff --git a/models/permission_set_test.go b/models/permission_set_test.go new file mode 100644 index 0000000..e3d8309 --- /dev/null +++ b/models/permission_set_test.go @@ -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") + } +} diff --git a/models/slug_test.go b/models/slug_test.go new file mode 100644 index 0000000..954d959 --- /dev/null +++ b/models/slug_test.go @@ -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) + } + } + } +}