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

306 lines
15 KiB
Go

package cabana_test
import (
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
)
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"])
}
}
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)
// The permissions field's trigger still names is_superuser (D-21: a
// controlling field missing from the schema counts as off); the switch
// itself must be gone.
schemaData, _ := schema["data"].(map[string]any)
schemaFields, _ := schemaData["fields"].([]any)
if len(schemaFields) == 0 {
t.Fatalf("form schema = %v", schema["data"])
}
for _, item := range schemaFields {
if field, _ := item.(map[string]any); field["name"] == "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)
}
}
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)
}