Files
sm-user-plugin/admin_registration_test.go
Jakub Zych 493a3ff47c 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
2026-10-05 15:15:28 +02:00

274 lines
9.7 KiB
Go

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