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:
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