- 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
540 lines
23 KiB
Go
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)
|
|
}
|