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