- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go, marker; plugins, consumers, docker, admin and docs are stubs until the later tasks fill them) - modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged - TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres part through the screens harness, which now keeps its app)
591 lines
20 KiB
Go
591 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
|
|
app *backpack.App
|
|
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, app: app, 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
|
|
}
|