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
This commit is contained in:
589
modules/cabana/administrators_harness_test.go
Normal file
589
modules/cabana/administrators_harness_test.go
Normal file
@@ -0,0 +1,589 @@
|
||||
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
|
||||
}
|
||||
92
modules/cabana/administrators_postgres_test.go
Normal file
92
modules/cabana/administrators_postgres_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// manageUsers is the role grant of a delegated administrator.
|
||||
var manageUsers = map[string]int{cabana.PermissionManageUsers: 1}
|
||||
|
||||
// newAdminBody is a create body with a valid, confirmed password.
|
||||
func newAdminBody(login string, extra map[string]any) map[string]any {
|
||||
body := map[string]any{
|
||||
"login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin",
|
||||
"password": "pass-1234", "password_confirmation": "pass-1234",
|
||||
}
|
||||
for key, value := range extra {
|
||||
body[key] = value
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// cleanupLogin soft-deletes and demotes an administrator a test created
|
||||
// through the API, by login.
|
||||
func (e *screensEnv) cleanupLogin(t *testing.T, login string) {
|
||||
t.Helper()
|
||||
t.Cleanup(func() {
|
||||
e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE login = ?`, login)
|
||||
})
|
||||
}
|
||||
|
||||
// countLogin counts the administrators with login, deleted ones included.
|
||||
func (e *screensEnv) countLogin(t *testing.T, login string) int64 {
|
||||
t.Helper()
|
||||
var n int64
|
||||
if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE login = ?`, login).Scan(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestAdministratorsSuperuserFlag (D-05): only an administrator acting as a
|
||||
// superuser may send the superuser switch, on create and on update, whatever
|
||||
// its value; a superuser grants and removes the flag.
|
||||
func TestAdministratorsSuperuserFlag(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
super := env.seedAdmin(t, adminSeed{Name: "flag-super", Superuser: true})
|
||||
superToken := env.login(t, super.Login)
|
||||
delegate := env.seedAdmin(t, adminSeed{Name: "flag-delegate", RolePermissions: manageUsers})
|
||||
delegateToken := env.login(t, delegate.Login)
|
||||
target := env.seedAdmin(t, adminSeed{Name: "flag-target"})
|
||||
|
||||
for _, flag := range []bool{true, false} {
|
||||
login := env.unique("flag-create")
|
||||
env.cleanupLogin(t, login)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPost, usersPath, delegateToken,
|
||||
newAdminBody(login, map[string]any{"is_superuser": flag}), "is_superuser", enSuperuserForbidden, true)
|
||||
if n := env.countLogin(t, login); n != 0 {
|
||||
t.Fatalf("a refused create (is_superuser=%v) left %d rows", flag, n)
|
||||
}
|
||||
|
||||
before := env.userRow(t, target.ID)
|
||||
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "first_name": "Changed", "is_superuser": flag},
|
||||
"is_superuser", enSuperuserForbidden, true)
|
||||
env.unchanged(t, target.ID, before, "a refused is_superuser update")
|
||||
}
|
||||
|
||||
// A superuser grants the flag on create...
|
||||
login := env.unique("flag-granted")
|
||||
env.cleanupLogin(t, login)
|
||||
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken,
|
||||
newAdminBody(login, map[string]any{"is_superuser": true}))
|
||||
id := uint(dataOf(created)["id"].(float64))
|
||||
if got := env.storedUser(t, id); !got.IsSuperuser {
|
||||
t.Fatalf("created superuser = %+v", got)
|
||||
}
|
||||
// ...and removes it from that other superuser through update.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken,
|
||||
map[string]any{"login": login, "email": login + "@example.test", "is_superuser": false})
|
||||
if got := env.storedUser(t, id); got.IsSuperuser {
|
||||
t.Fatal("the superuser flag was not removed")
|
||||
}
|
||||
// The same superuser grants it on update.
|
||||
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), superToken,
|
||||
map[string]any{"login": target.Login, "email": target.Email, "is_superuser": true})
|
||||
if got := env.storedUser(t, target.ID); !got.IsSuperuser {
|
||||
t.Fatal("the superuser flag was not granted on update")
|
||||
}
|
||||
}
|
||||
@@ -1,219 +1,15 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"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"
|
||||
)
|
||||
|
||||
// pingSpy records the runs of the contributed acme_ping record action.
|
||||
type pingSpy struct {
|
||||
mu sync.Mutex
|
||||
ids []uint64
|
||||
withTx []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).
|
||||
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: "acme.pinger.ping"}}
|
||||
}
|
||||
func (p pingPlugin) AdminActionExtensions() []pact.AdminActionExtension {
|
||||
return []pact.AdminActionExtension{{
|
||||
Controller: cabana.AdministratorsControllerID,
|
||||
RecordActions: []pact.AdminRecordAction{{
|
||||
Name: "acme_ping", Label: "Ping", Permissions: []string{"acme.pinger.ping"},
|
||||
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
|
||||
},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
// screensEnv is one assembled admin with the demo plugin, the pinger and the
|
||||
// admin's own screens, on the shared Postgres.
|
||||
type screensEnv struct {
|
||||
h http.Handler
|
||||
gdb *gorm.DB
|
||||
stamp string
|
||||
spy *pingSpy
|
||||
}
|
||||
|
||||
func newScreensEnv(t *testing.T) *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} {
|
||||
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 := []party.Plugin{demoPlugin{fsys: demoFS()}, pingPlugin{spy: spy}}
|
||||
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}
|
||||
}
|
||||
|
||||
// admin creates an activated administrator named name+stamp with the
|
||||
// developer role (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()
|
||||
login := name + "-" + e.stamp
|
||||
user := insertAdmin(t, e.gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
if superuser {
|
||||
if err := e.gdb.Exec(`UPDATE backend_users SET is_superuser = TRUE WHERE id = ?`, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.IsSuperuser = true
|
||||
}
|
||||
if rolePermissions != "" {
|
||||
var roleID uint
|
||||
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
|
||||
VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Role "+login, "role-"+login, rolePermissions).Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("role: id=%d err=%v", roleID, err)
|
||||
}
|
||||
if err := e.gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, user.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user.RoleID = &roleID
|
||||
}
|
||||
rec := postJSON(t, e.h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %s status=%d body=%s", login, rec.Code, rec.Body.String())
|
||||
}
|
||||
return user, accessToken(t, rec.Body.Bytes())
|
||||
}
|
||||
|
||||
// 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()
|
||||
var reader *bytes.Reader
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader = bytes.NewReader(raw)
|
||||
} else {
|
||||
reader = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, adminAPI(rel), reader)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept-Language", "en")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const usersPath = "/summercms/cabana/users"
|
||||
|
||||
func TestBackendScreensPostgresSmoke(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
root, rootToken := env.admin(t, "screens-root", true, "")
|
||||
@@ -260,44 +56,6 @@ func TestBackendScreensPostgresSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// errorOf returns a failure envelope's message and details.
|
||||
func errorOf(t *testing.T, rec *httptest.ResponseRecorder) (string, map[string]any) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
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.Message, body.Error.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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func TestAdministratorsFormSmoke(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
root, rootToken := env.admin(t, "form-root", true, "")
|
||||
@@ -478,8 +236,6 @@ func TestAdministratorsLifecycleSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
const rolesPath = "/summercms/cabana/roles"
|
||||
|
||||
func TestRolesSmoke(t *testing.T) {
|
||||
env := newScreensEnv(t)
|
||||
_, rootToken := env.admin(t, "roles-root", true, "")
|
||||
|
||||
@@ -102,3 +102,8 @@ func IsReorderStale(err error) bool {
|
||||
var stale reorderStale
|
||||
return errors.As(err, &stale)
|
||||
}
|
||||
|
||||
// SuperuserLockKey is the advisory lock key guardLastSuperuser takes. The
|
||||
// last-superuser race tests hold it in a transaction of their own, so both
|
||||
// racing requests queue on it before either counts the other superusers.
|
||||
const SuperuserLockKey = superuserLockKey
|
||||
|
||||
Reference in New Issue
Block a user