- 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
306 lines
15 KiB
Go
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)
|
|
}
|