- 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
674 lines
33 KiB
Go
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")
|
|
}
|
|
}
|