Files
summercms/modules/cabana/administrators_postgres_test.go
Jakub Zych b684bb7d8d test(19.6.1-03): save-path privilege rule tests
- TestAdministratorsSuperuserHidden (D-03, scoped-token superusers)
- TestAdministratorsSuperuserProjected (Pitfall 1)
- TestAdministratorsPermissionCeiling and TestAdministratorsRoleCeiling (D-06, D-07)
- TestAdministratorsSelfEdit (D-08), TestAdministratorsPasswordAndIdentity (D-11)
- TestAdministratorsOutranked (D-19), TestAdministratorsOwnPassword (D-20)
- TestRoleCeiling (-short) against grantsCovered
2026-10-22 12:01:06 +02:00

674 lines
33 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// manageUsers is the role grant of a delegated administrator.
var manageUsers = map[string]int{cabana.PermissionManageUsers: 1}
// newAdminBody is a create body with a valid, confirmed password.
func newAdminBody(login string, extra map[string]any) map[string]any {
body := map[string]any{
"login": login, "email": login + "@example.test", "first_name": "New", "last_name": "Admin",
"password": "pass-1234", "password_confirmation": "pass-1234",
}
for key, value := range extra {
body[key] = value
}
return body
}
// cleanupLogin soft-deletes and demotes an administrator a test created
// through the API, by login.
func (e *screensEnv) cleanupLogin(t *testing.T, login string) {
t.Helper()
t.Cleanup(func() {
e.gdb.Exec(`UPDATE backend_users SET is_superuser = FALSE, deleted_at = COALESCE(deleted_at, NOW()) WHERE login = ?`, login)
})
}
// countLogin counts the administrators with login, deleted ones included.
func (e *screensEnv) countLogin(t *testing.T, login string) int64 {
t.Helper()
var n int64
if err := e.gdb.Raw(`SELECT count(*) FROM backend_users WHERE login = ?`, login).Scan(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
// TestAdministratorsSuperuserFlag (D-05): only an administrator acting as a
// superuser may send the superuser switch, on create and on update, whatever
// its value; a superuser grants and removes the flag.
func TestAdministratorsSuperuserFlag(t *testing.T) {
env := newScreensEnv(t)
super := env.seedAdmin(t, adminSeed{Name: "flag-super", Superuser: true})
superToken := env.login(t, super.Login)
delegate := env.seedAdmin(t, adminSeed{Name: "flag-delegate", RolePermissions: manageUsers})
delegateToken := env.login(t, delegate.Login)
target := env.seedAdmin(t, adminSeed{Name: "flag-target"})
for _, flag := range []bool{true, false} {
login := env.unique("flag-create")
env.cleanupLogin(t, login)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPost, usersPath, delegateToken,
newAdminBody(login, map[string]any{"is_superuser": flag}), "is_superuser", enSuperuserForbidden, true)
if n := env.countLogin(t, login); n != 0 {
t.Fatalf("a refused create (is_superuser=%v) left %d rows", flag, n)
}
before := env.userRow(t, target.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "first_name": "Changed", "is_superuser": flag},
"is_superuser", enSuperuserForbidden, true)
env.unchanged(t, target.ID, before, "a refused is_superuser update")
}
// A superuser grants the flag on create...
login := env.unique("flag-granted")
env.cleanupLogin(t, login)
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken,
newAdminBody(login, map[string]any{"is_superuser": true}))
id := uint(dataOf(created)["id"].(float64))
if got := env.storedUser(t, id); !got.IsSuperuser {
t.Fatalf("created superuser = %+v", got)
}
// ...and removes it from that other superuser through update.
env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken,
map[string]any{"login": login, "email": login + "@example.test", "is_superuser": false})
if got := env.storedUser(t, id); got.IsSuperuser {
t.Fatal("the superuser flag was not removed")
}
// The same superuser grants it on update.
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), superToken,
map[string]any{"login": target.Login, "email": target.Email, "is_superuser": true})
if got := env.storedUser(t, target.ID); !got.IsSuperuser {
t.Fatal("the superuser flag was not granted on update")
}
}
// listSchemaKeys returns a list schema response's column keys and filter
// names.
func listSchemaKeys(body map[string]any) (map[string]bool, map[string]bool) {
columns, filters := map[string]bool{}, map[string]bool{}
data := dataOf(body)
items, _ := data["columns"].([]any)
for _, item := range items {
column, _ := item.(map[string]any)
key, _ := column["key"].(string)
columns[key] = true
}
items, _ = data["filters"].([]any)
for _, item := range items {
filter, _ := item.(map[string]any)
name, _ := filter["name"].(string)
filters[name] = true
}
return columns, filters
}
// TestAdministratorsSuperuserHidden (D-03): an administrator who does not act
// as a superuser never sees a superuser, in the list, any lookup or the
// schemas; a superuser's scoped API token does not act as a superuser.
func TestAdministratorsSuperuserHidden(t *testing.T) {
env := newScreensEnv(t)
super := env.seedAdmin(t, adminSeed{Name: "hidden-super", Superuser: true})
superToken := env.login(t, super.Login)
delegate := env.seedAdmin(t, adminSeed{Name: "hidden-delegate", RolePermissions: manageUsers})
delegateToken := env.login(t, delegate.Login)
plain := env.seedAdmin(t, adminSeed{Name: "hidden-plain"})
for _, query := range []string{
"?search=" + env.stamp,
"?search=" + super.Login,
"?search=" + env.stamp + "&filter[is_superuser]=1",
"?search=" + env.stamp + "&filter[activated]=1",
"?search=" + env.stamp + "&filter[trashed]=hide",
} {
rec := env.call(t, http.MethodGet, usersPath+query, delegateToken, nil)
if rec.Code == http.StatusOK {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if listIDs(t, body)[super.ID] {
t.Fatalf("list %s shows the superuser to a delegate", query)
}
} else if rec.Code >= 500 {
t.Fatalf("list %s status=%d", query, rec.Code)
}
}
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, delegateToken, nil)); !ids[plain.ID] || !ids[delegate.ID] {
t.Fatalf("delegate list = %v, want %d and %d", ids, plain.ID, delegate.ID)
}
before := env.userRow(t, super.ID)
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, userURL(super.ID), delegateToken, nil, "")
env.expectRefusal(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/hint?id=%d", usersPath, super.ID), delegateToken, nil, "")
env.expectRefusal(t, http.StatusNotFound, http.MethodPost, userURL(super.ID, "actions", "deactivate"), delegateToken, map[string]any{}, "")
env.expectRefusal(t, http.StatusNotFound, http.MethodPut, userURL(super.ID), delegateToken,
map[string]any{"login": super.Login, "email": super.Email, "first_name": "Hijacked"}, "")
// The framework's delete answers a missing id with deleted 0; a hidden
// superuser gets exactly that answer, so it reveals nothing.
hiddenDelete := env.call(t, http.MethodDelete, userURL(super.ID), delegateToken, nil)
missingDelete := env.call(t, http.MethodDelete, userURL(2000000000), delegateToken, nil)
if hiddenDelete.Code != missingDelete.Code || hiddenDelete.Body.String() != missingDelete.Body.String() {
t.Fatalf("delete of a hidden superuser %d %s, of a missing id %d %s", hiddenDelete.Code, hiddenDelete.Body.String(), missingDelete.Code, missingDelete.Body.String())
}
// A bulk selection never resolves to the superuser.
if rec := env.call(t, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken, map[string]any{"ids": []uint{super.ID}}); rec.Code >= 500 || rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), `"affected":1`) {
t.Fatalf("bulk deactivate of a hidden superuser status=%d body=%s", rec.Code, rec.Body.String())
}
env.unchanged(t, super.ID, before, "lookups of a hidden superuser")
form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))
if _, ok := form["is_superuser"]; ok {
t.Fatal("the delegate's form schema has is_superuser")
}
columns, filters := listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", delegateToken, nil))
if columns["is_superuser"] || filters["is_superuser"] || !columns["login"] || !filters["role"] {
t.Fatalf("delegate list schema columns=%v filters=%v", columns, filters)
}
// A superuser sees all three, and the other superuser.
form = schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil))
if _, ok := form["is_superuser"]; !ok {
t.Fatal("the superuser's form schema lacks is_superuser")
}
columns, filters = listSchemaKeys(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/list", superToken, nil))
if !columns["is_superuser"] || !filters["is_superuser"] {
t.Fatalf("superuser list schema columns=%v filters=%v", columns, filters)
}
other := env.seedAdmin(t, adminSeed{Name: "hidden-other", Superuser: true})
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, superToken, nil)); !ids[other.ID] || !ids[super.ID] {
t.Fatalf("superuser list = %v, want both superusers", ids)
}
// A superuser's scoped token does not act as a superuser; a full one does.
scoped, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{pingScope}, nil)
if err != nil {
t.Fatal(err)
}
full, err := cabana.IssueTestAPIToken(env.gdb, super.ID, []string{"*"}, nil)
if err != nil {
t.Fatal(err)
}
ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, scoped, nil))
if ids[other.ID] || !ids[plain.ID] {
t.Fatalf("scoped token list = %v, want the plain admin and no superuser", ids)
}
if _, ok := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", scoped, nil))["is_superuser"]; ok {
t.Fatal("the scoped token's form schema has is_superuser")
}
before = env.userRow(t, plain.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(plain.ID), scoped,
map[string]any{"login": plain.Login, "email": plain.Email, "is_superuser": true}, "is_superuser", enSuperuserForbidden, true)
env.unchanged(t, plain.ID, before, "a scoped token's is_superuser update")
if ids := listIDs(t, env.expect(t, http.StatusOK, http.MethodGet, usersPath+"?search="+env.stamp, full, nil)); !ids[other.ID] {
t.Fatalf("full token list = %v, want the other superuser", ids)
}
}
// editablePayload is the save body the admin builds from a record GET: every
// editable field of the form with its loaded value, the passwords blank.
func editablePayload(record map[string]any, form map[string]map[string]any) map[string]any {
body := map[string]any{"password": "", "password_confirmation": ""}
for name := range form {
if name == "password" || name == "password_confirmation" || name == "last_login" {
continue
}
if value, ok := record[name]; ok {
body[name] = value
}
}
return body
}
// TestAdministratorsSuperuserProjected (Pitfall 1): a superuser reads another
// superuser's stored flag, and saving the untouched form keeps it.
func TestAdministratorsSuperuserProjected(t *testing.T) {
env := newScreensEnv(t)
super := env.seedAdmin(t, adminSeed{Name: "proj-super", Superuser: true})
superToken := env.login(t, super.Login)
other := env.seedAdmin(t, adminSeed{Name: "proj-other", Superuser: true, Permissions: map[string]int{pingPermission: 1}})
record := dataOf(env.expect(t, http.StatusOK, http.MethodGet, userURL(other.ID), superToken, nil))
if record["is_superuser"] != true {
t.Fatalf("record data is_superuser = %v", record["is_superuser"])
}
form := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", superToken, nil))
body := editablePayload(record, form)
if _, ok := body["is_superuser"]; !ok {
t.Fatalf("payload lacks is_superuser: %v", body)
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken, body)
got := env.storedUser(t, other.ID)
if !got.IsSuperuser || got.Permissions != `{"`+pingPermission+`":1}` {
t.Fatalf("after an untouched save = %+v", got)
}
}
// TestAdministratorsPermissionCeiling (D-06, D-07): a delegate may set only
// the overrides of codes it holds itself; stored values of other codes are
// kept, and an override is merged last over the role.
func TestAdministratorsPermissionCeiling(t *testing.T) {
env := newScreensEnv(t)
delegate := env.seedAdmin(t, adminSeed{Name: "ceil-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
delegateToken := env.login(t, delegate.Login)
target := env.seedAdmin(t, adminSeed{Name: "ceil-target", RolePermissions: manageUsers})
targetToken := env.login(t, target.Login)
locked := permissionLocked(schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))["permissions"])
if len(locked) < 4 {
t.Fatalf("permission options = %v", locked)
}
for code, isLocked := range locked {
held := code == cabana.PermissionManageUsers || code == pingPermission
if isLocked == held {
t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held)
}
}
for _, value := range []int{1, -1} {
for _, code := range []string{"acme.demo.access", cabana.PermissionManageRoles} {
before := env.userRow(t, target.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{code: value}},
"permissions", enPermissionLocked, false)
env.unchanged(t, target.ID, before, "a locked override")
}
}
// A stored value of a locked code is kept when the delegate saves.
keeper := env.seedAdmin(t, adminSeed{Name: "ceil-keeper", Permissions: map[string]int{"acme.demo.access": -1}})
env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken,
map[string]any{"login": keeper.Login, "email": keeper.Email, "first_name": "Kept"})
if got := env.storedUser(t, keeper.ID); got.FirstName != "Kept" || got.Permissions != `{"acme.demo.access":-1}` {
t.Fatalf("keeper after a save without permissions = %+v", got)
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(keeper.ID), delegateToken,
map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{"acme.demo.access": -1, pingPermission: 1}})
if got := decodeJSON(t, env.storedUser(t, keeper.ID).Permissions); got["acme.demo.access"] != -1 || got[pingPermission] != 1 || len(got) != 2 {
t.Fatalf("keeper overrides = %v", got)
}
before := env.userRow(t, keeper.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(keeper.ID), delegateToken,
map[string]any{"login": keeper.Login, "email": keeper.Email, "permissions": map[string]any{pingPermission: 1}},
"permissions", enPermissionLocked, false)
env.unchanged(t, keeper.ID, before, "dropping a locked stored override")
// Overrides of held codes save and are merged last over the role.
if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusOK {
t.Fatalf("target list before status=%d", rec.Code)
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{cabana.PermissionManageUsers: -1}})
if rec := env.call(t, http.MethodGet, usersPath, targetToken, nil); rec.Code != http.StatusForbidden {
t.Fatalf("target list after a deny override status=%d", rec.Code)
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "permissions": map[string]any{pingPermission: 1}})
if got := decodeJSON(t, env.storedUser(t, target.ID).Permissions); got[pingPermission] != 1 || len(got) != 1 {
t.Fatalf("target overrides = %v", got)
}
show := env.expect(t, http.StatusOK, http.MethodGet, userURL(keeper.ID), targetToken, nil)
if names := recordActionNames(show); !containsName(names, "acme_ping") {
t.Fatalf("an allowed override did not reach the target: actions %v", names)
}
}
func decodeJSON(t *testing.T, raw string) map[string]int {
t.Helper()
out := map[string]int{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("permissions %q: %v", raw, err)
}
return out
}
func containsName(names []string, want string) bool {
for _, name := range names {
if name == want {
return true
}
}
return false
}
// roleOptions returns the role field's options as id to locked.
func (e *screensEnv) roleOptions(t *testing.T, token, search string) map[uint]bool {
t.Helper()
body := e.expect(t, http.StatusOK, http.MethodGet, usersPath+"/fields/role/options?per_page=100&search="+search, token, nil)
rows, _ := body["data"].([]any)
out := map[uint]bool{}
for _, row := range rows {
option, _ := row.(map[string]any)
id, _ := option["value"].(float64)
locked, _ := option["locked"].(bool)
out[uint(id)] = locked
}
return out
}
// TestAdministratorsRoleCeiling (D-07): a delegate may assign only roles
// whose grants it holds; a held role above the ceiling stays unchanged.
func TestAdministratorsRoleCeiling(t *testing.T) {
env := newScreensEnv(t)
delegate := env.seedAdmin(t, adminSeed{Name: "rc-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
delegateToken := env.login(t, delegate.Login)
above := env.seedRole(t, roleSeed{Name: "rc-above", Permissions: map[string]int{"acme.demo.access": 1}})
covered := env.seedRole(t, roleSeed{Name: "rc-covered", Permissions: map[string]int{pingPermission: 1}})
target := env.seedAdmin(t, adminSeed{Name: "rc-target"})
// Holds the above-ceiling role, with its extra grant denied: it does not
// outrank the delegate, but its role stays locked.
holder := env.seedAdmin(t, adminSeed{Name: "rc-holder", Role: above.ID, Permissions: map[string]int{"acme.demo.access": -1}})
options := env.roleOptions(t, delegateToken, env.stamp)
if locked, ok := options[above.ID]; !ok || !locked {
t.Fatalf("above-ceiling role option = %v (present %v)", locked, ok)
}
if locked, ok := options[covered.ID]; !ok || locked {
t.Fatalf("covered role option = %v (present %v)", locked, ok)
}
var developer uint
if err := env.gdb.Raw(`SELECT id FROM backend_user_roles WHERE code = 'developer'`).Scan(&developer).Error; err != nil {
t.Fatal(err)
}
if locked := env.roleOptions(t, delegateToken, "Developer")[developer]; !locked {
t.Fatal("the developer role (code-declared acme.demo.access) is not locked for the delegate")
}
before := env.userRow(t, target.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "role": above.ID}, "role", enRoleCeiling, true)
env.unchanged(t, target.ID, before, "assigning an above-ceiling role")
env.expect(t, http.StatusOK, http.MethodPut, userURL(holder.ID), delegateToken,
map[string]any{"login": holder.Login, "email": holder.Email, "first_name": "Renamed", "role": above.ID})
if got := env.storedUser(t, holder.ID); got.FirstName != "Renamed" || got.RoleID == nil || *got.RoleID != above.ID {
t.Fatalf("holder after a rename = %+v", got)
}
before = env.userRow(t, holder.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(holder.ID), delegateToken,
map[string]any{"login": holder.Login, "email": holder.Email, "role": covered.ID}, "role", enRoleCeiling, true)
env.unchanged(t, holder.ID, before, "moving off an above-ceiling role")
env.expect(t, http.StatusOK, http.MethodPut, userURL(target.ID), delegateToken,
map[string]any{"login": target.Login, "email": target.Email, "role": covered.ID})
if got := env.storedUser(t, target.ID); got.RoleID == nil || *got.RoleID != covered.ID {
t.Fatalf("target role = %v, want %d", got.RoleID, covered.ID)
}
}
// TestAdministratorsSelfEdit (D-08): an administrator edits their own name,
// login and email, never their own role, superuser flag, overrides,
// activation or existence.
func TestAdministratorsSelfEdit(t *testing.T) {
env := newScreensEnv(t)
other := env.seedRole(t, roleSeed{Name: "self-other", Permissions: manageUsers})
delegate := env.seedAdmin(t, adminSeed{Name: "self-delegate", RolePermissions: manageUsers})
delegateToken := env.login(t, delegate.Login)
super := env.seedAdmin(t, adminSeed{Name: "self-super", Superuser: true})
superToken := env.login(t, super.Login)
peer := env.seedAdmin(t, adminSeed{Name: "self-peer"})
login := env.unique("self-renamed")
env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken,
map[string]any{"login": login, "email": login + "@example.test", "first_name": "Own"})
self := env.storedUser(t, delegate.ID)
if self.Login != login || self.Email != login+"@example.test" || self.FirstName != "Own" {
t.Fatalf("own edit = %+v", self)
}
identity := map[string]any{"login": self.Login, "email": self.Email}
with := func(key string, value any) map[string]any {
body := map[string]any{key: value}
for k, v := range identity {
body[k] = v
}
return body
}
// Own role: refused after assignment and rolled back.
before := env.userRow(t, delegate.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken,
with("role", other.ID), "role", enSelfPrivileged, true)
env.unchanged(t, delegate.ID, before, "an own role change")
// Own superuser flag, for a superuser.
superBefore := env.userRow(t, super.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(super.ID), superToken,
map[string]any{"login": super.Login, "email": super.Email, "is_superuser": false}, "is_superuser", enSelfPrivileged, true)
env.unchanged(t, super.ID, superBefore, "an own superuser removal")
// Own overrides: every code is record-locked on the own record.
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(delegate.ID), delegateToken,
with("permissions", map[string]any{cabana.PermissionManageUsers: -1}), "permissions", enPermissionLocked, false)
env.unchanged(t, delegate.ID, before, "an own override change")
// An untouched own save (the record-locked overrides left out) passes.
env.expect(t, http.StatusOK, http.MethodPut, userURL(delegate.ID), delegateToken, with("first_name", "Own"))
// Own delete and own bulk deactivate.
before = env.userRow(t, delegate.ID)
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, userURL(delegate.ID), delegateToken, nil, enSelfDelete)
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk-delete", delegateToken,
map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDelete)
peerBefore := env.userRow(t, peer.ID)
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, usersPath+"/bulk/deactivate", delegateToken,
map[string]any{"ids": []uint{peer.ID, delegate.ID}}, enSelfDeactivate)
env.unchanged(t, delegate.ID, before, "an own delete or deactivate")
env.unchanged(t, peer.ID, peerBefore, "a bulk selection refused for the own row")
// Deactivate is not offered on the own preview, and does not run there.
own := env.expect(t, http.StatusOK, http.MethodGet, userURL(delegate.ID), delegateToken, nil)
if names := recordActionNames(own); containsName(names, "deactivate") {
t.Fatalf("own preview offers %v", names)
}
if rec := env.call(t, http.MethodPost, userURL(delegate.ID, "actions", "deactivate"), delegateToken, map[string]any{}); rec.Code != http.StatusConflict {
t.Fatalf("own deactivate action status=%d body=%s", rec.Code, rec.Body.String())
}
env.unchanged(t, delegate.ID, before, "an own deactivate action")
}
// TestAdministratorsPasswordAndIdentity (D-11): the password rules on create
// and update, and login and email unique ignoring case, deleted
// administrators included.
func TestAdministratorsPasswordAndIdentity(t *testing.T) {
env := newScreensEnv(t)
super := env.seedAdmin(t, adminSeed{Name: "pw-super", Superuser: true})
superToken := env.login(t, super.Login)
long := strings.Repeat("p", 73)
for _, tc := range []struct {
name, password, confirmation, want string
omit bool
}{
{name: "no password", omit: true, want: "required"},
{name: "no confirmation", password: "pass-1234", confirmation: "", want: "confirm"},
{name: "mismatch", password: "pass-1234", confirmation: "pass-5678", want: "confirm"},
{name: "too short", password: "abc", confirmation: "abc", want: "at least 4 characters"},
{name: "73 bytes", password: long, confirmation: long, want: "greater than 72"},
} {
t.Run(tc.name, func(t *testing.T) {
login := env.unique("pw-new")
env.cleanupLogin(t, login)
body := newAdminBody(login, nil)
if tc.omit {
delete(body, "password")
delete(body, "password_confirmation")
} else {
body["password"], body["password_confirmation"] = tc.password, tc.confirmation
}
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, "password", tc.want, false)
if n := env.countLogin(t, login); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
})
}
login := env.unique("pw-valid")
env.cleanupLogin(t, login)
created := env.expect(t, http.StatusCreated, http.MethodPost, usersPath, superToken, newAdminBody(login, nil))
id := uint(dataOf(created)["id"].(float64))
stored := env.storedUser(t, id)
if !strings.HasPrefix(stored.Password, "$2") || !bouncer.CheckPassword(stored.Password, "pass-1234") {
t.Fatalf("stored hash %q does not verify", stored.Password)
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(id), superToken,
map[string]any{"login": login, "email": login + "@example.test", "first_name": "Kept", "password": "", "password_confirmation": ""})
if got := env.storedUser(t, id); got.Password != stored.Password || got.FirstName != "Kept" {
t.Fatalf("a blank password changed the hash: %+v", got)
}
taken := env.seedAdmin(t, adminSeed{Name: "pw-taken"})
gone := env.seedAdmin(t, adminSeed{Name: "pw-gone", Deleted: true})
for _, tc := range []struct {
name, field, login, email, want string
}{
{"login taken", "login", taken.Login, "", enLoginTaken},
{"login taken in another case", "login", strings.ToUpper(taken.Login), "", enLoginTaken},
{"login equal to another email", "login", taken.Email, "", enLoginTaken},
{"email taken", "email", "", taken.Email, enEmailTaken},
{"email taken in another case", "email", "", strings.ToUpper(taken.Email), enEmailTaken},
{"login of a deleted admin", "login", gone.Login, "", enLoginTakenDeleted},
{"email of a deleted admin", "email", "", gone.Email, enEmailTakenDeleted},
} {
t.Run(tc.name, func(t *testing.T) {
fresh := env.unique("pw-fresh")
env.cleanupLogin(t, fresh)
body := newAdminBody(fresh, nil)
if tc.login != "" {
body["login"] = tc.login
}
if tc.email != "" {
body["email"] = tc.email
}
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, usersPath, superToken, body, tc.field, tc.want, false)
if n := env.countLogin(t, fresh); n != 0 {
t.Fatalf("a refused create left %d rows", n)
}
// The same value on update of another administrator.
before := env.userRow(t, id)
update := map[string]any{"login": login, "email": login + "@example.test"}
if tc.login != "" {
update["login"] = tc.login
}
if tc.email != "" {
update["email"] = tc.email
}
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, userURL(id), superToken, update, tc.field, tc.want, false)
env.unchanged(t, id, before, "a refused identity update")
})
}
}
// TestAdministratorsOutranked (D-19): a delegate cannot change, deactivate,
// activate, restore or delete an administrator holding a grant the delegate
// lacks; covered administrators pass.
func TestAdministratorsOutranked(t *testing.T) {
env := newScreensEnv(t)
delegate := env.seedAdmin(t, adminSeed{Name: "out-delegate", RolePermissions: map[string]int{cabana.PermissionManageUsers: 1, pingPermission: 1}})
delegateToken := env.login(t, delegate.Login)
strong := map[string]int{cabana.PermissionManageUsers: 1, "acme.demo.access": 1}
active := env.seedAdmin(t, adminSeed{Name: "out-active", RolePermissions: strong})
inactive := env.seedAdmin(t, adminSeed{Name: "out-inactive", RolePermissions: strong, Inactive: true})
deleted := env.seedAdmin(t, adminSeed{Name: "out-deleted", RolePermissions: strong, Deleted: true})
// A user-level allow outranks too (the override is merged last).
override := env.seedAdmin(t, adminSeed{Name: "out-override", Permissions: map[string]int{"acme.demo.access": 1}})
type attempt struct {
name string
method string
path string
body func(cabana.BackendUser) any
target cabana.BackendUser
}
identity := func(u cabana.BackendUser) map[string]any { return map[string]any{"login": u.Login, "email": u.Email} }
attempts := []attempt{
{"update", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any {
b := identity(u)
b["first_name"] = "Taken"
return b
}, active},
{"password", http.MethodPut, userURL(active.ID), func(u cabana.BackendUser) any {
b := identity(u)
b["password"], b["password_confirmation"] = "pass-9999", "pass-9999"
return b
}, active},
{"override update", http.MethodPut, userURL(override.ID), func(u cabana.BackendUser) any {
b := identity(u)
b["first_name"] = "Taken"
return b
}, override},
{"delete", http.MethodDelete, userURL(active.ID), nil, active},
{"bulk delete", http.MethodPost, usersPath + "/bulk-delete", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active},
{"record deactivate", http.MethodPost, userURL(active.ID, "actions", "deactivate"), func(cabana.BackendUser) any { return map[string]any{} }, active},
{"record activate", http.MethodPost, userURL(inactive.ID, "actions", "activate"), func(cabana.BackendUser) any { return map[string]any{} }, inactive},
{"record restore", http.MethodPost, userURL(deleted.ID, "actions", "restore"), func(cabana.BackendUser) any { return map[string]any{} }, deleted},
{"bulk deactivate", http.MethodPost, usersPath + "/bulk/deactivate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, active},
{"bulk activate", http.MethodPost, usersPath + "/bulk/activate", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, inactive},
{"bulk restore", http.MethodPost, usersPath + "/bulk/restore", func(u cabana.BackendUser) any { return map[string]any{"ids": []uint{u.ID}} }, deleted},
}
for _, a := range attempts {
t.Run(a.name, func(t *testing.T) {
var body any
if a.body != nil {
body = a.body(a.target)
}
before := env.userRow(t, a.target.ID)
env.expectRefusal(t, http.StatusForbidden, a.method, a.path, delegateToken, body, enOutranked)
env.unchanged(t, a.target.ID, before, a.name)
})
}
// Covered administrators pass.
covered := env.seedAdmin(t, adminSeed{Name: "out-covered", RolePermissions: map[string]int{pingPermission: 1}})
env.expect(t, http.StatusOK, http.MethodPut, userURL(covered.ID), delegateToken,
map[string]any{"login": covered.Login, "email": covered.Email, "first_name": "Covered", "password": "pass-9999", "password_confirmation": "pass-9999"})
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "deactivate"), delegateToken, map[string]any{})
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "activate"), delegateToken, map[string]any{})
env.expect(t, http.StatusOK, http.MethodDelete, userURL(covered.ID), delegateToken, nil)
env.expect(t, http.StatusOK, http.MethodPost, userURL(covered.ID, "actions", "restore"), delegateToken, map[string]any{})
if got := env.storedUser(t, covered.ID); got.FirstName != "Covered" || !got.IsActivated || got.DeletedAt.Valid || !bouncer.CheckPassword(got.Password, "pass-9999") {
t.Fatalf("covered admin = %+v", got)
}
}
// TestAdministratorsOwnPassword (D-20, backend::lang.user.own_password): an
// own password is changed in My account only; another administrator's
// password is set here.
func TestAdministratorsOwnPassword(t *testing.T) {
env := newScreensEnv(t)
delegate := env.seedAdmin(t, adminSeed{Name: "own-delegate", RolePermissions: manageUsers})
delegateToken := env.login(t, delegate.Login)
super := env.seedAdmin(t, adminSeed{Name: "own-super", Superuser: true})
superToken := env.login(t, super.Login)
other := env.seedAdmin(t, adminSeed{Name: "own-other"})
for _, who := range []struct {
user cabana.BackendUser
token string
}{{delegate, delegateToken}, {super, superToken}} {
before := env.userRow(t, who.user.ID)
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, userURL(who.user.ID), who.token,
map[string]any{"login": who.user.Login, "email": who.user.Email, "password": "pass-5678", "password_confirmation": "pass-5678"},
"password", enOwnPassword, true)
env.unchanged(t, who.user.ID, before, "an own password")
if got := env.storedUser(t, who.user.ID); !bouncer.CheckPassword(got.Password, adminTestPassword) {
t.Fatal("the own hash changed")
}
}
env.expect(t, http.StatusOK, http.MethodPut, userURL(other.ID), superToken,
map[string]any{"login": other.Login, "email": other.Email, "password": "pass-5678", "password_confirmation": "pass-5678"})
if got := env.storedUser(t, other.ID); !bouncer.CheckPassword(got.Password, "pass-5678") {
t.Fatal("another administrator's password was not set")
}
}