- 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
274 lines
9.7 KiB
Go
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)
|
|
}
|