Files
summercms/modules/cabana/backend_screens_smoke_test.go
Jakub Zych 653307de0c feat(19.6.1-01): Roles screen
- Settings -> System -> Roles (summercms.cabana.roles, backend.manage_roles,
  order 110): name, code slugged from the name, description, checkbox
  permission editor with the editor's ceiling
- system roles (is_system or code-declared grants) keep their code and
  cannot be deleted; a role any admin holds (deleted ones included) cannot
  be deleted, the refusal names the role and the count
- role codes unique among roles
2026-10-22 11:18:30 +02:00

540 lines
23 KiB
Go

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, "")
dev, devToken := env.admin(t, "screens-dev", false, "")
list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil)
ids := listIDs(t, list)
if !ids[root.ID] || !ids[dev.ID] {
t.Fatalf("superuser list ids = %v, want %d and %d", ids, root.ID, dev.ID)
}
show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, dev.ID), rootToken, nil)
if names := recordActionNames(show); len(names) == 0 || names[len(names)-1] != "acme_ping" {
t.Fatalf("meta.actions = %v, want acme_ping last", names)
}
result := env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/acme_ping", usersPath, dev.ID), rootToken, map[string]any{})
if data, _ := result["data"].(map[string]any); data["message"] != "Pinged" {
t.Fatalf("action result = %v", result)
}
runs, withTx := env.spy.runs()
if len(runs) != 1 || runs[0] != uint64(dev.ID) || !withTx[0] {
t.Fatalf("acme_ping runs = %v tx = %v", runs, withTx)
}
settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil)
found := false
entries, _ := settings["data"].([]any)
for _, entry := range entries {
item, _ := entry.(map[string]any)
if item["code"] == "administrators" {
found = item["controller"] == cabana.AdministratorsControllerID && item["category"] == "System" && item["label"] == "Administrators"
}
}
if !found {
t.Fatalf("superuser settings = %v", entries)
}
if rec := env.call(t, http.MethodGet, usersPath, devToken, nil); rec.Code != http.StatusForbidden {
t.Fatalf("developer list status=%d body=%s", rec.Code, rec.Body.String())
}
settings = env.expect(t, http.StatusOK, http.MethodGet, "/settings", devToken, nil)
if strings.Contains(fmt.Sprint(settings["data"]), "administrators") {
t.Fatalf("developer settings list administrators: %v", settings["data"])
}
}
// 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, "")
// Create with password and confirmation (D-11, D-13).
login := "form-new-" + env.stamp
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, rootToken, map[string]any{
"login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin",
"password": "pass-1234", "password_confirmation": "pass-1234",
})
data, _ := created["data"].(map[string]any)
newID := uint(data["id"].(float64))
stored := env.storedUser(t, newID)
if !stored.IsActivated || stored.ActivatedAt == nil || stored.IsSuperuser || !bouncer.CheckPassword(stored.Password, "pass-1234") {
t.Fatalf("created admin = %+v", stored)
}
// A taken login answers 422 on the field.
details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, rootToken, map[string]any{
"login": strings.ToUpper(login), "email": "other-" + login + "@example.test",
"password": "pass-1234", "password_confirmation": "pass-1234",
}, "")
if !strings.Contains(fmt.Sprint(details["login"]), "already used") {
t.Fatalf("taken login details = %v", details)
}
// Pitfall 1: a superuser reads the stored flag, and an untouched save keeps it.
own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil)
if data, _ := own["data"].(map[string]any); data["is_superuser"] != true {
t.Fatalf("own record data = %v", own["data"])
}
// D-08: the superuser cannot switch off their own flag.
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken,
map[string]any{"login": root.Login, "email": root.Email, "is_superuser": false}, "your own role")
if !env.storedUser(t, root.ID).IsSuperuser {
t.Fatal("the superuser lost the flag")
}
// D-20: an own password is changed in My account, not here.
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken,
map[string]any{"login": root.Login, "email": root.Email, "password": "pass-5678", "password_confirmation": "pass-5678"}, "My account")
// D-09 (sequential path): with a second superuser, demoting it succeeds.
second, _ := env.admin(t, "form-second", true, "")
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken,
map[string]any{"login": second.Login, "email": second.Email, "is_superuser": false})
if env.storedUser(t, second.ID).IsSuperuser {
t.Fatal("the second superuser was not demoted")
}
shown := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, second.ID), rootToken, nil)
if data, _ := shown["data"].(map[string]any); data["is_superuser"] != false {
t.Fatalf("demoted record data = %v", shown["data"])
}
// A limited administrator holding backend.manage_users (D-03, D-05).
limitedUser, limitedToken := env.admin(t, "form-limited", false, `{"backend.manage_users":1}`)
peer, _ := env.admin(t, "form-peer", false, `{"backend.manage_users":1}`)
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), limitedToken, nil, "")
schema := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", limitedToken, nil)
if strings.Contains(fmt.Sprint(schema["data"]), "is_superuser") {
t.Fatal("the form schema shows is_superuser to a non-superuser")
}
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken,
map[string]any{"login": peer.Login, "email": peer.Email, "is_superuser": false}, "Only a superuser")
// The same editor may rename the peer: the hidden field stays out of the body.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, peer.ID), limitedToken,
map[string]any{"login": peer.Login, "email": peer.Email, "first_name": "Renamed"})
if got := env.storedUser(t, peer.ID); got.FirstName != "Renamed" || got.IsSuperuser {
t.Fatalf("renamed peer = %+v", got)
}
// D-08: a change of the editor's own role is refused after assignment
// and rolled back.
limited := env.storedUser(t, limitedUser.ID)
if limited.RoleID == nil || peer.RoleID == nil {
t.Fatalf("fixture roles: %+v %+v", limited, peer)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, limited.ID), limitedToken,
map[string]any{"login": limited.Login, "email": limited.Email, "role": *peer.RoleID}, "your own role")
if got := env.storedUser(t, limited.ID); got.RoleID == nil || *got.RoleID != *limited.RoleID {
t.Fatalf("own role changed to %v", got.RoleID)
}
}
// rowStates returns a list response's meta.row_states for id.
func rowStates(body map[string]any, id uint) []string {
meta, _ := body["meta"].(map[string]any)
states, _ := meta["row_states"].(map[string]any)
raw, _ := states[fmt.Sprint(id)].([]any)
out := []string{}
for _, state := range raw {
if text, ok := state.(string); ok {
out = append(out, text)
}
}
return out
}
func TestAdministratorsLifecycleSmoke(t *testing.T) {
env := newScreensEnv(t)
root, rootToken := env.admin(t, "life-root", true, "")
// Deactivate by record action ends the target's sessions at once (D-12).
target, targetToken := env.admin(t, "life-target", false, "")
if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusOK {
t.Fatalf("target /auth/me before status=%d", rec.Code)
}
show := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, target.ID), rootToken, nil)
if names := recordActionNames(show); len(names) == 0 || names[0] != "deactivate" {
t.Fatalf("offered actions = %v, want deactivate first", names)
}
env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/deactivate", usersPath, target.ID), rootToken, map[string]any{})
if got := env.storedUser(t, target.ID); got.IsActivated || got.TokensValidAfter == nil {
t.Fatalf("deactivated target = %+v", got)
}
if rec := env.call(t, http.MethodGet, "/auth/me", targetToken, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("target /auth/me after deactivate status=%d body=%s", rec.Code, rec.Body.String())
}
// The superuser is not offered Deactivate on their own record.
own := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil)
for _, name := range recordActionNames(own) {
if name == "deactivate" {
t.Fatal("deactivate offered on the editor's own record")
}
}
// Delete is a soft delete; the row lists as deleted and restores (D-10).
gone, _ := env.admin(t, "life-gone", false, "")
env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, gone.ID), rootToken, nil)
stored := env.storedUser(t, gone.ID)
if !stored.DeletedAt.Valid || stored.TokensValidAfter == nil {
t.Fatalf("deleted admin = %+v", stored)
}
hint := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, gone.ID), rootToken, nil)
if !strings.Contains(fmt.Sprint(hint["data"]), "This account has been deleted") {
t.Fatalf("deleted admin hint = %v", hint["data"])
}
list := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil)
if !listIDs(t, list)[gone.ID] || fmt.Sprint(rowStates(list, gone.ID)) != "[deleted]" {
t.Fatalf("deleted admin row states = %v", rowStates(list, gone.ID))
}
env.expect(t, http.StatusOK, http.MethodPost, fmt.Sprintf("%s/%d/actions/restore", usersPath, gone.ID), rootToken, map[string]any{})
list = env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, rootToken, nil)
if !listIDs(t, list)[gone.ID] || len(rowStates(list, gone.ID)) != 0 {
t.Fatalf("restored admin row states = %v", rowStates(list, gone.ID))
}
hidden := env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp+"&filter[trashed]=only", rootToken, nil)
if listIDs(t, hidden)[gone.ID] {
t.Fatal("the trashed=only filter lists a restored admin")
}
// The editor cannot delete their own account (D-08).
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", usersPath, root.ID), rootToken, nil, "your own account")
// A bulk deactivate that selects the editor's own row is refused as a
// whole: the other row stays activated too.
other, _ := env.admin(t, "life-other", false, "")
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", rootToken,
map[string]any{"ids": []uint{other.ID, root.ID}}, "deactivate your own account")
if !env.storedUser(t, other.ID).IsActivated || !env.storedUser(t, root.ID).IsActivated {
t.Fatal("a refused bulk deactivate changed a row")
}
// Bulk activate brings the deactivated target back without a new cutoff.
before := env.storedUser(t, target.ID).TokensValidAfter
result := env.expect(t, http.StatusOK, http.MethodPost, usersPath+"/bulk/activate", rootToken, map[string]any{"ids": []uint{target.ID, other.ID}})
if data, _ := result["data"].(map[string]any); data["affected"] != float64(1) {
t.Fatalf("bulk activate result = %v", result)
}
if got := env.storedUser(t, target.ID); !got.IsActivated || got.TokensValidAfter == nil || !got.TokensValidAfter.Equal(*before) {
t.Fatalf("activated target = %+v", got)
}
}
const rolesPath = "/summercms/cabana/roles"
func TestRolesSmoke(t *testing.T) {
env := newScreensEnv(t)
_, rootToken := env.admin(t, "roles-root", true, "")
holder, _ := env.admin(t, "roles-holder", false, "")
name := "Editors " + env.stamp
created := env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, rootToken, map[string]any{
"name": name, "code": "editors-" + env.stamp, "description": "Edit things",
"permissions": map[string]any{"acme.demo.access": 1},
})
data, _ := created["data"].(map[string]any)
roleID := uint(data["id"].(float64))
var stored cabana.BackendUserRole
if err := env.gdb.First(&stored, roleID).Error; err != nil {
t.Fatal(err)
}
if stored.Permissions != `{"acme.demo.access":1}` || stored.IsSystem {
t.Fatalf("created role = %+v", stored)
}
// A code another role uses is refused on the field.
details := env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, rootToken,
map[string]any{"name": "Other " + env.stamp, "code": "editors-" + env.stamp}, "")
if !strings.Contains(fmt.Sprint(details["code"]), "already used") {
t.Fatalf("taken code details = %v", details)
}
// Assign it through the Administrators form, then the role cannot go.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken,
map[string]any{"login": holder.Login, "email": holder.Email, "role": roleID})
if got := env.storedUser(t, holder.ID); got.RoleID == nil || *got.RoleID != roleID {
t.Fatalf("assigned role = %v", got.RoleID)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil,
"“"+name+"” is assigned to 1 administrator.")
var developer cabana.BackendUserRole
if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil {
t.Fatal(err)
}
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken, nil,
"System roles cannot be deleted.")
details = env.expectRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken,
map[string]any{"name": developer.Name, "code": "developer-renamed"}, "")
if !strings.Contains(fmt.Sprint(details["code"]), "cannot be changed") {
t.Fatalf("system code details = %v", details)
}
// Name and description of a system role stay editable.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", rolesPath, developer.ID), rootToken,
map[string]any{"name": developer.Name, "code": developer.Code, "description": developer.Description})
settings := env.expect(t, http.StatusOK, http.MethodGet, "/settings", rootToken, nil)
var codes []string
entries, _ := settings["data"].([]any)
for _, entry := range entries {
item, _ := entry.(map[string]any)
if code, _ := item["code"].(string); code == "administrators" || code == "roles" {
codes = append(codes, code)
}
}
if fmt.Sprint(codes) != "[administrators roles]" {
t.Fatalf("superuser settings order = %v", codes)
}
// An unused role is deleted.
env.expect(t, http.StatusOK, http.MethodPut, fmt.Sprintf("%s/%d", usersPath, holder.ID), rootToken,
map[string]any{"login": holder.Login, "email": holder.Email, "role": developer.ID})
env.expect(t, http.StatusOK, http.MethodDelete, fmt.Sprintf("%s/%d", rolesPath, roleID), rootToken, nil)
}