package cabana_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "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" "gorm.io/gorm" ) // The screens' paths under the admin API. const ( usersPath = "/summercms/cabana/users" rolesPath = "/summercms/cabana/roles" ) // The pinger's permission codes and its token scope. const ( pingPermission = "acme.pinger.ping" pingScope = "pinger-admins" ) // The English refusal texts of backend::lang (UI-SPEC C8), compared verbatim. const ( enSuperuserForbidden = "Only a superuser can grant, remove or change superuser access." enRoleCeiling = "You can only assign roles whose permissions you have yourself." enSelfPrivileged = "You cannot change your own role, permissions, superuser access or activation. Ask another administrator." enSelfDelete = "You cannot delete your own account." enSelfDeactivate = "You cannot deactivate your own account." enLastSuperuser = "This is the last active superuser, so it cannot be deactivated, deleted or lose superuser access. Make another administrator a superuser first." enOutranked = "This administrator has permissions you do not have, so you cannot change, deactivate or delete their account." enOwnPassword = "Change your own password in My account, where your current password is checked." enLoginTaken = "This login is already used by another administrator." enLoginTakenDeleted = "This login belongs to a deleted administrator. Restore that account instead of creating a new one." enEmailTaken = "This email is already used by another administrator." enEmailTakenDeleted = "This email belongs to a deleted administrator. Restore that account instead of creating a new one." enPermissionLocked = "You cannot change this permission." enLockedByCode = "Granted to this role by a plugin. It cannot be removed here." enLockedSelf = "You cannot change your own permissions." enCodeTaken = "This code is already used by another role." enCodeLocked = "The code of a system role cannot be changed." enDeleteSystem = "System roles cannot be deleted." ) // pingSpy records the runs of the contributed acme_ping record action and // switches its Applies. type pingSpy struct { mu sync.Mutex ids []uint64 withTx []bool // refuse makes Applies answer false while it is set. refuse atomic.Bool } func (s *pingSpy) record(id uint64, tx bool) { s.mu.Lock() defer s.mu.Unlock() s.ids = append(s.ids, id) s.withTx = append(s.withTx, tx) } func (s *pingSpy) runs() ([]uint64, []bool) { s.mu.Lock() defer s.mu.Unlock() return append([]uint64(nil), s.ids...), append([]bool(nil), s.withTx...) } // pingPlugin owns no admin controller; it only adds the acme_ping record // action to the Administrators screen (pact.HasAdminActionExtensions). It // also declares a token scope over backend.manage_users, so a superuser's // scoped token can reach the screen. type pingPlugin struct{ spy *pingSpy } func (pingPlugin) ID() string { return "acme.pinger" } func (pingPlugin) Requires() []string { return nil } func (pingPlugin) Register(*backpack.App) error { return nil } func (pingPlugin) Boot(*backpack.App) error { return nil } func (pingPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: pingPermission}} } func (pingPlugin) TokenScopes() []pact.TokenScope { return []pact.TokenScope{{ Name: pingScope, Label: "Administrators", Description: "Manage administrators through the API.", Permissions: []string{cabana.PermissionManageUsers}, Methods: []string{"GET", "POST", "PUT", "DELETE"}, }} } func (p pingPlugin) AdminActionExtensions() []pact.AdminActionExtension { return []pact.AdminActionExtension{{ Controller: cabana.AdministratorsControllerID, RecordActions: []pact.AdminRecordAction{{ Name: "acme_ping", Label: "Ping", Permissions: []string{pingPermission}, Applies: func(context.Context, any) (bool, error) { return !p.spy.refuse.Load(), nil }, Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) { _, ok := cabana.TxFromContext(ctx) if _, isUser := in.Record.(*cabana.BackendUser); !isUser { return pact.AdminRecordActionResult{}, errors.New("acme_ping: record is not a backend user") } p.spy.record(in.RecordID, ok) return pact.AdminRecordActionResult{Message: "Pinged"}, nil }, }}, }} } // grantPlugin declares permission codes granted in code to roles // (pact.Permission.Roles), the code-declared grants of D-16. type grantPlugin struct { id string grants []pact.Permission } func (p grantPlugin) ID() string { return p.id } func (grantPlugin) Requires() []string { return nil } func (grantPlugin) Register(*backpack.App) error { return nil } func (grantPlugin) Boot(*backpack.App) error { return nil } func (p grantPlugin) Permissions() []pact.Permission { return p.grants } // screensEnv is one assembled admin with the demo plugin, the pinger, any // extra plugins and the admin's own screens, on the shared Postgres. type screensEnv struct { h http.Handler gdb *gorm.DB stamp string spy *pingSpy seq atomic.Int64 } // newScreensEnv assembles the admin like surf does in an application: // compass config with the admin secret and body limits, lagoon.Publish, // phrasebook.Activate and surf.Assemble. func newScreensEnv(t *testing.T, extra ...party.Plugin) *screensEnv { t.Helper() gdb := adminGorm(t) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-screens\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}}) if err != nil { t.Fatal(err) } for key, value := range map[string]any{ "http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576, "admin.password.bcrypt_cost": 4, "admin.login.max_attempts": 100000, } { if err := cfg.Set(key, value); err != nil { t.Fatal(err) } } app := backpack.New(cfg) if err := lagoon.Publish(app, adminSQL, gdb); err != nil { t.Fatal(err) } spy := &pingSpy{} plugins := append([]party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{spy: spy}}, extra...) if err := phrasebook.Activate(app, plugins); err != nil { t.Fatal(err) } h, err := surf.Assemble(app, plugins) if err != nil { t.Fatal(err) } return &screensEnv{h: h, gdb: gdb, stamp: fmt.Sprintf("s%d", time.Now().UnixNano()), spy: spy} } // unique is name plus the env stamp and a per-env sequence number. func (e *screensEnv) unique(name string) string { return fmt.Sprintf("%s-%s-%d", name, e.stamp, e.seq.Add(1)) } // adminSeed describes one administrator seedAdmin inserts. type adminSeed struct { Name string Superuser bool Inactive bool Deleted bool // Role is the role id; when 0, RolePermissions (if not nil) seeds a // role of its own, else the administrator gets an empty role of its // own. Developer uses the developer system role. Role uint RolePermissions map[string]int Developer bool // Permissions are the administrator's own overrides. Permissions map[string]int Password string } // roleSeed describes one role seedRole inserts. type roleSeed struct { Name string Code string Permissions map[string]int System bool } func permissionsJSON(t *testing.T, values map[string]int) string { t.Helper() if values == nil { return "{}" } raw, err := json.Marshal(values) if err != nil { t.Fatal(err) } return string(raw) } // seedRole inserts a role with a unique name and code; t.Cleanup detaches // the role from its holders and deletes it. func (e *screensEnv) seedRole(t *testing.T, seed roleSeed) cabana.BackendUserRole { t.Helper() name := seed.Name if name == "" { name = "role" } name = e.unique(name) code := seed.Code if code == "" { code = "code-" + name } role := cabana.BackendUserRole{Name: "Role " + name, Code: code, Permissions: permissionsJSON(t, seed.Permissions), IsSystem: seed.System} if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) RETURNING id`, role.Name, role.Code, role.Permissions, role.IsSystem).Scan(&role.ID).Error; err != nil || role.ID == 0 { t.Fatalf("seed role %s: id=%d err=%v", role.Code, role.ID, err) } t.Cleanup(func() { e.gdb.Exec(`UPDATE backend_users SET role_id = NULL WHERE role_id = ?`, role.ID) e.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, role.ID) }) return role } // seedAdmin inserts an administrator with a unique login and email; t.Cleanup // removes the superuser flag and soft-deletes the row. func (e *screensEnv) seedAdmin(t *testing.T, seed adminSeed) cabana.BackendUser { t.Helper() name := seed.Name if name == "" { name = "admin" } login := e.unique(name) password := seed.Password if password == "" { password = adminTestPassword } hash, err := bouncer.HashPassword(4, password) if err != nil { t.Fatal(err) } var roleID uint switch { case seed.Role != 0: roleID = seed.Role case seed.Developer: if err := e.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&roleID).Error; err != nil || roleID == 0 { t.Fatalf("developer role: %v id=%d", err, roleID) } default: roleID = e.seedRole(t, roleSeed{Name: name, Permissions: seed.RolePermissions}).ID } user := cabana.BackendUser{ FirstName: "Ada", LastName: "Admin", Login: login, Email: login + "@example.test", Password: hash, IsActivated: !seed.Inactive, IsSuperuser: seed.Superuser, RoleID: &roleID, Permissions: permissionsJSON(t, seed.Permissions), } if err := e.gdb.Create(&user).Error; err != nil { t.Fatal(err) } // GORM skips zero values with a default on Create: write the flags // explicitly. if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = ?, is_activated = ?, permissions = ? WHERE id = ?`, seed.Superuser, !seed.Inactive, user.Permissions, user.ID).Error; err != nil { t.Fatal(err) } if seed.Deleted { if err := e.gdb.Exec(`UPDATE backend_users SET deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil { t.Fatal(err) } } t.Cleanup(func() { e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE id = ?`, user.ID) }) return e.storedUser(t, user.ID) } // login signs login in with the seed password and returns the bearer token. func (e *screensEnv) login(t *testing.T, login string) string { t.Helper() return e.loginWith(t, login, adminTestPassword) } func (e *screensEnv) loginWith(t *testing.T, login, password string) string { t.Helper() rec := postJSON(t, e.h, adminAPI("/auth/login"), map[string]string{"login": login, "password": password}) if rec.Code != http.StatusOK { t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String()) } return accessToken(t, rec.Body.Bytes()) } // admin seeds an activated administrator named name with an empty role of // its own, or a role holding rolePermissions JSON when it is not empty, // optionally a superuser, signs it in and returns it with its token. func (e *screensEnv) admin(t *testing.T, name string, superuser bool, rolePermissions string) (cabana.BackendUser, string) { t.Helper() seed := adminSeed{Name: name, Superuser: superuser, Developer: rolePermissions == ""} if rolePermissions != "" { if err := json.Unmarshal([]byte(rolePermissions), &seed.RolePermissions); err != nil { t.Fatal(err) } } user := e.seedAdmin(t, seed) return user, e.login(t, user.Login) } // isolateSuperusers deactivates every other active superuser for the test, // so keep are the only ones, and activates them again in t.Cleanup. func (e *screensEnv) isolateSuperusers(t *testing.T, keep ...uint) { t.Helper() keep = append(keep, 0) var ids []uint if err := e.gdb.Raw(`UPDATE backend_users SET is_activated = FALSE WHERE is_superuser AND is_activated AND deleted_at IS NULL AND id NOT IN ? RETURNING id`, keep).Scan(&ids).Error; err != nil { t.Fatal(err) } t.Cleanup(func() { if len(ids) > 0 { e.gdb.Exec(`UPDATE backend_users SET is_activated = TRUE WHERE id IN ?`, ids) } }) } // request builds one admin API request with a Bearer token, JSON body, // Accept-Language en and, on writes, X-Requested-With. func (e *screensEnv) request(method, rel, token string, body any) (*http.Request, error) { var raw []byte if body != nil { var err error if raw, err = json.Marshal(body); err != nil { return nil, err } } req := httptest.NewRequest(method, adminAPI(rel), bytes.NewReader(raw)) if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept-Language", "en") if method != http.MethodGet && method != http.MethodHead { req.Header.Set("X-Requested-With", "XMLHttpRequest") } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } return req, nil } // serve runs one request without a *testing.T, for goroutines. func (e *screensEnv) serve(method, rel, token string, body any) *httptest.ResponseRecorder { rec := httptest.NewRecorder() req, err := e.request(method, rel, token, body) if err != nil { rec.Code = http.StatusTeapot rec.Body.WriteString(err.Error()) return rec } e.h.ServeHTTP(rec, req) return rec } // call runs one admin API request with a Bearer token. func (e *screensEnv) call(t *testing.T, method, rel, token string, body any) *httptest.ResponseRecorder { t.Helper() req, err := e.request(method, rel, token, body) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func (e *screensEnv) expect(t *testing.T, status int, method, rel, token string, body any) map[string]any { t.Helper() rec := e.call(t, method, rel, token, body) if rec.Code != status { t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) } var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("%s %s body %s: %v", method, rel, rec.Body.String(), err) } return out } // errorEnvelope decodes a failure envelope's code, message and details. func errorEnvelope(t *testing.T, rec *httptest.ResponseRecorder) (string, string, map[string]any) { t.Helper() var body struct { Error struct { Code string `json:"code"` Message string `json:"message"` Details map[string]any `json:"details"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("error body %s: %v", rec.Body.String(), err) } return body.Error.Code, body.Error.Message, body.Error.Details } // errorOf returns a failure envelope's message and details. func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (string, map[string]any) { t.Helper() _, message, details := errorEnvelope(t, rec) return message, details } // expectRefusal asserts a status and that the error message contains want. func (e *screensEnv) expectRefusal(t *testing.T, status int, method, rel, token string, body any, want string) map[string]any { t.Helper() rec := e.call(t, method, rel, token, body) if rec.Code != status { t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) } message, details := errorOf(t, rec) if want != "" && !strings.Contains(message, want) { t.Fatalf("%s %s message=%q want %q (details %v)", method, rel, message, want, details) } return details } // expectFieldRefusal asserts a status and that details[field] carries want. // A non-empty message must carry it too when inMessage is set. func (e *screensEnv) expectFieldRefusal(t *testing.T, status int, method, rel, token string, body any, field, want string, inMessage bool) { t.Helper() rec := e.call(t, method, rel, token, body) if rec.Code != status { t.Fatalf("%s %s status=%d want %d body=%s", method, rel, rec.Code, status, rec.Body.String()) } _, message, details := errorEnvelope(t, rec) if !detailHas(details, field, want) { t.Fatalf("%s %s details[%s]=%v want %q (message %q)", method, rel, field, details[field], want, message) } if inMessage && message != want { t.Fatalf("%s %s message=%q want %q", method, rel, message, want) } } // detailHas reports whether details[field] lists a text containing want. func detailHas(details map[string]any, field, want string) bool { list, _ := details[field].([]any) for _, item := range list { if text, ok := item.(string); ok && strings.Contains(text, want) { return true } } return false } func (e *screensEnv) storedUser(t *testing.T, id uint) cabana.BackendUser { t.Helper() var user cabana.BackendUser if err := e.gdb.Unscoped().First(&user, id).Error; err != nil { t.Fatal(err) } return user } // userRow is the whole backend_users row as JSON text, for byte-for-byte // "unchanged" checks. func (e *screensEnv) userRow(t *testing.T, id uint) string { t.Helper() var row string if err := e.gdb.Raw(`SELECT row_to_json(u)::text FROM backend_users u WHERE id = ?`, id).Scan(&row).Error; err != nil || row == "" { t.Fatalf("row %d: %q %v", id, row, err) } return row } // roleRow is the whole backend_user_roles row as JSON text. func (e *screensEnv) roleRow(t *testing.T, id uint) string { t.Helper() var row string if err := e.gdb.Raw(`SELECT row_to_json(r)::text FROM backend_user_roles r WHERE id = ?`, id).Scan(&row).Error; err != nil || row == "" { t.Fatalf("role row %d: %q %v", id, row, err) } return row } // unchanged fails when the row of id differs from before. func (e *screensEnv) unchanged(t *testing.T, id uint, before, what string) { t.Helper() if after := e.userRow(t, id); after != before { t.Fatalf("%s changed the row:\nbefore %s\nafter %s", what, before, after) } } // userURL is the record path of an administrator. func userURL(id uint, tail ...string) string { path := fmt.Sprintf("%s/%d", usersPath, id) for _, part := range tail { path += "/" + part } return path } // listIDs returns the ids of a list response's rows. func listIDs(t *testing.T, body map[string]any) map[uint]bool { t.Helper() rows, _ := body["data"].([]any) out := map[uint]bool{} for _, row := range rows { fields, _ := row.(map[string]any) if id, ok := fields["id"].(float64); ok { out[uint(id)] = true } } return out } // recordActionNames returns the names of a show response's meta.actions. func recordActionNames(body map[string]any) []string { meta, _ := body["meta"].(map[string]any) actions, _ := meta["actions"].([]any) out := []string{} for _, action := range actions { if entry, ok := action.(map[string]any); ok { name, _ := entry["name"].(string) out = append(out, name) } } return out } // dataOf is a data envelope's data object. func dataOf(body map[string]any) map[string]any { data, _ := body["data"].(map[string]any) return data } // schemaFields returns a form schema response's fields keyed by name. func schemaFields(body map[string]any) map[string]map[string]any { out := map[string]map[string]any{} fields, _ := dataOf(body)["fields"].([]any) for _, item := range fields { if field, ok := item.(map[string]any); ok { name, _ := field["name"].(string) out[name] = field } } return out } // permissionLocked returns the permission editor field's options as code to // Locked. func permissionLocked(field map[string]any) map[string]bool { out := map[string]bool{} options, _ := field["permissionOptions"].([]any) for _, item := range options { option, _ := item.(map[string]any) code, _ := option["code"].(string) locked, _ := option["locked"].(bool) out[code] = locked } return out }