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