Files
summercms/modules/cabana/administrators_harness_test.go
Jakub Zych 536ea8c1bf test(19.6.1-03): screens test env and the superuser flag rule over HTTP
- administrators_harness_test.go: newScreensEnv with extra plugins, seedAdmin,
  seedRole, login, isolateSuperusers, row snapshots and error decoders; the
  harness moved out of backend_screens_smoke_test.go and gained cleanup
- export_test.go: SuperuserLockKey for the last-superuser race
- TestAdministratorsSuperuserFlag (D-05) through the assembled handler
2026-10-22 11:54:48 +02:00

590 lines
20 KiB
Go

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
}