test: unit tests for the admin class functions, the models and the admin registration

- classes: every admin action for its changed-row count and idempotency, the ban across several throttle rows, the cleanup of a permanent delete
- classes: the privileged list and IsPrivilegedMember (D-30), also through a handle that carries conditions
- classes: the five-minute boundary of last_seen
- models: PermissionSet round trip, the slug table shared with the SPA, Fillable and Rules, fields that never marshal, the group filter value
- TestAdminRegistration: permissions, navigation, embedded files and phrase keys in en and pl; the admin secret is required
This commit is contained in:
Jakub Zych
2026-10-05 15:15:28 +02:00
parent 43fabfa6c3
commit 493a3ff47c
7 changed files with 1377 additions and 0 deletions

191
models/admin_models_test.go Normal file
View File

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

View File

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

53
models/slug_test.go Normal file
View File

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