- TestFieldTriggerBoot (-short): every compileTrigger and checkTriggers refusal names controller, field and trigger; disable and hide reach the localized schema; settings and relation forms refuse the key - TestPermissionRecordLocks (-short): the save rule in radio and checkbox modes, no lock lookup on create, meta only for stored records with a provider - TestRolesCodeDeclaredLocks (Postgres): locked_by_code grants on a role, a save without them, locked_self on the own record
273 lines
12 KiB
Go
273 lines
12 KiB
Go
package cabana_test
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
)
|
|
|
|
// roleURL is the record path of a role.
|
|
func roleURL(id uint) string { return fmt.Sprintf("%s/%d", rolesPath, id) }
|
|
|
|
// roleBody is a role save body.
|
|
func roleBody(role cabana.BackendUserRole, extra map[string]any) map[string]any {
|
|
body := map[string]any{"name": role.Name, "code": role.Code, "description": role.Description}
|
|
for key, value := range extra {
|
|
body[key] = value
|
|
}
|
|
return body
|
|
}
|
|
|
|
// roleExists reports whether a role row with id exists.
|
|
func (e *screensEnv) roleExists(t *testing.T, id uint) bool {
|
|
t.Helper()
|
|
var n int64
|
|
if err := e.gdb.Raw(`SELECT count(*) FROM backend_user_roles WHERE id = ?`, id).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n == 1
|
|
}
|
|
|
|
// TestRolesLifecycle (D-16, D-17): role codes are unique; a system role (is_system
|
|
// or one code-declared grants attach to) keeps its code and cannot be
|
|
// deleted; a role any administrator holds, deleted ones included, cannot be
|
|
// deleted, alone or in a bulk selection.
|
|
func TestRolesLifecycle(t *testing.T) {
|
|
declared := fmt.Sprintf("plugrole-%d", time.Now().UnixNano())
|
|
env := newScreensEnv(t, grantPlugin{id: "acme.granter", grants: []pact.Permission{{Code: "acme.granter.use", Roles: []string{declared}}}})
|
|
super := env.seedAdmin(t, adminSeed{Name: "roles-super", Superuser: true})
|
|
token := env.login(t, super.Login)
|
|
|
|
// Create with the slug the form's preset makes from the name.
|
|
name := "Editors " + env.stamp
|
|
code := "editors-" + env.stamp
|
|
created := dataOf(env.expect(t, http.StatusCreated, http.MethodPost, rolesPath, token,
|
|
map[string]any{"name": name, "code": code, "description": "Edit things"}))
|
|
createdID := uint(created["id"].(float64))
|
|
t.Cleanup(func() { env.gdb.Exec(`DELETE FROM backend_user_roles WHERE id = ?`, createdID) })
|
|
var stored cabana.BackendUserRole
|
|
if err := env.gdb.First(&stored, createdID).Error; err != nil || stored.Code != code || stored.IsSystem {
|
|
t.Fatalf("created role = %+v, %v", stored, err)
|
|
}
|
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPost, rolesPath, token,
|
|
map[string]any{"name": "Other " + env.stamp, "code": code}, "code", enCodeTaken, false)
|
|
before := env.roleRow(t, createdID)
|
|
other := env.seedRole(t, roleSeed{Name: "roles-other"})
|
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(other.ID), token,
|
|
roleBody(other, map[string]any{"code": code}), "code", enCodeTaken, false)
|
|
if env.roleRow(t, createdID) != before {
|
|
t.Fatal("a refused code change touched the created role")
|
|
}
|
|
|
|
// System roles: is_system, and a role a plugin's code-declared grant
|
|
// attaches to.
|
|
var developer cabana.BackendUserRole
|
|
if err := env.gdb.Where("code = ?", "developer").First(&developer).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pluginRole := env.seedRole(t, roleSeed{Name: "roles-declared", Code: declared})
|
|
for _, role := range []cabana.BackendUserRole{developer, pluginRole} {
|
|
rowBefore := env.roleRow(t, role.ID)
|
|
env.expectFieldRefusal(t, http.StatusUnprocessableEntity, http.MethodPut, roleURL(role.ID), token,
|
|
roleBody(role, map[string]any{"code": role.Code + "-renamed"}), "code", enCodeLocked, false)
|
|
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(role.ID), token, nil, enDeleteSystem)
|
|
if env.roleRow(t, role.ID) != rowBefore {
|
|
t.Fatalf("a refused change touched system role %s", role.Code)
|
|
}
|
|
}
|
|
// A system role's name and description stay editable.
|
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(pluginRole.ID), token, roleBody(pluginRole, map[string]any{"description": "Changed"}))
|
|
|
|
// A held role, one holder active and one soft-deleted.
|
|
held := env.seedRole(t, roleSeed{Name: "roles-held"})
|
|
env.seedAdmin(t, adminSeed{Name: "roles-holder", Role: held.ID})
|
|
env.seedAdmin(t, adminSeed{Name: "roles-gone", Role: held.ID, Deleted: true})
|
|
env.expectRefusal(t, http.StatusForbidden, http.MethodDelete, roleURL(held.ID), token, nil,
|
|
"The role “"+held.Name+"” is assigned to 2 administrators.")
|
|
if !env.roleExists(t, held.ID) {
|
|
t.Fatal("a refused delete removed the role")
|
|
}
|
|
|
|
// A bulk delete of an unused role and the held one is refused as a whole.
|
|
unused := env.seedRole(t, roleSeed{Name: "roles-unused"})
|
|
env.expectRefusal(t, http.StatusForbidden, http.MethodPost, rolesPath+"/bulk-delete", token,
|
|
map[string]any{"ids": []uint{unused.ID, held.ID}}, "is assigned to 2 administrators")
|
|
if !env.roleExists(t, unused.ID) || !env.roleExists(t, held.ID) {
|
|
t.Fatal("a refused bulk delete removed a role")
|
|
}
|
|
// The unused role alone deletes.
|
|
env.expect(t, http.StatusOK, http.MethodDelete, roleURL(unused.ID), token, nil)
|
|
if env.roleExists(t, unused.ID) {
|
|
t.Fatal("the unused role was not deleted")
|
|
}
|
|
}
|
|
|
|
// TestRolesPermissionCeiling (D-07): a non-superuser holding
|
|
// backend.manage_roles can neither add nor remove a code it does not hold on
|
|
// any role; codes it holds toggle.
|
|
func TestRolesPermissionCeiling(t *testing.T) {
|
|
env := newScreensEnv(t)
|
|
editor := env.seedAdmin(t, adminSeed{Name: "rceil-editor", RolePermissions: map[string]int{cabana.PermissionManageRoles: 1, pingPermission: 1}})
|
|
token := env.login(t, editor.Login)
|
|
|
|
field := schemaFields(env.expect(t, http.StatusOK, http.MethodGet, rolesPath+"/schema/form", token, nil))["permissions"]
|
|
locked := permissionLocked(field)
|
|
if len(locked) < 4 {
|
|
t.Fatalf("permission options = %v", locked)
|
|
}
|
|
for code, isLocked := range locked {
|
|
held := code == cabana.PermissionManageRoles || code == pingPermission
|
|
if isLocked == held {
|
|
t.Fatalf("option %s locked=%v, want %v", code, isLocked, !held)
|
|
}
|
|
}
|
|
|
|
strong := env.seedRole(t, roleSeed{Name: "rceil-strong", Permissions: map[string]int{"acme.demo.access": 1}})
|
|
empty := env.seedRole(t, roleSeed{Name: "rceil-empty"})
|
|
strong, empty = env.reloadRole(t, strong.ID), env.reloadRole(t, empty.ID)
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
role cabana.BackendUserRole
|
|
permissions map[string]any
|
|
}{
|
|
{"add a locked code", empty, map[string]any{"acme.demo.access": 1}},
|
|
{"add a locked code next to a held one", empty, map[string]any{"acme.demo.access": 1, pingPermission: 1}},
|
|
{"remove a locked code", strong, map[string]any{}},
|
|
{"remove a locked code, add a held one", strong, map[string]any{pingPermission: 1}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
before := env.roleRow(t, tc.role.ID)
|
|
env.expectFieldRefusal(t, http.StatusForbidden, http.MethodPut, roleURL(tc.role.ID), token,
|
|
roleBody(tc.role, map[string]any{"permissions": tc.permissions}), "permissions", enPermissionLocked, false)
|
|
if env.roleRow(t, tc.role.ID) != before {
|
|
t.Fatal("a refused permission change touched the role")
|
|
}
|
|
})
|
|
}
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token,
|
|
roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1, pingPermission: 1}}))
|
|
if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || got[pingPermission] != 1 || len(got) != 2 {
|
|
t.Fatalf("strong role after adding the held code = %v", got)
|
|
}
|
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token,
|
|
roleBody(strong, map[string]any{"permissions": map[string]any{"acme.demo.access": 1}}))
|
|
if got := decodeJSON(t, env.reloadRole(t, strong.ID).Permissions); got["acme.demo.access"] != 1 || len(got) != 1 {
|
|
t.Fatalf("strong role after removing the held code = %v", got)
|
|
}
|
|
// Without the permissions key the stored grants stay.
|
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(strong.ID), token, roleBody(strong, map[string]any{"description": "Renamed"}))
|
|
if got := env.reloadRole(t, strong.ID); got.Description != "Renamed" || got.Permissions != `{"acme.demo.access":1}` {
|
|
t.Fatalf("strong role after a description change = %+v", got)
|
|
}
|
|
}
|
|
|
|
// reloadRole reads a role row.
|
|
func (e *screensEnv) reloadRole(t *testing.T, id uint) cabana.BackendUserRole {
|
|
t.Helper()
|
|
var role cabana.BackendUserRole
|
|
if err := e.gdb.First(&role, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return role
|
|
}
|
|
|
|
// permissionLocks returns a record response's meta.permissionLocks for the
|
|
// permissions field as code to [value, note].
|
|
func permissionLocks(body map[string]any) map[string][2]any {
|
|
out := map[string][2]any{}
|
|
meta, _ := body["meta"].(map[string]any)
|
|
locks, _ := meta["permissionLocks"].(map[string]any)
|
|
list, _ := locks["permissions"].([]any)
|
|
for _, item := range list {
|
|
lock, _ := item.(map[string]any)
|
|
code, _ := lock["code"].(string)
|
|
out[code] = [2]any{lock["value"], lock["note"]}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestRolesCodeDeclaredLocks (D-21 for D-16 and D-08): a role's code-declared
|
|
// grants travel as record locks with the locked_by_code note and survive a
|
|
// save that leaves them out; an administrator's own record locks every
|
|
// offered code with the locked_self note.
|
|
func TestRolesCodeDeclaredLocks(t *testing.T) {
|
|
env := newScreensEnv(t, grantPlugin{id: "acme.reporter", grants: []pact.Permission{{Code: "acme.screens.report", Roles: []string{"developer"}}}})
|
|
super := env.seedAdmin(t, adminSeed{Name: "locks-super", Superuser: true})
|
|
token := env.login(t, super.Login)
|
|
developer := env.reloadRoleByCode(t, "developer")
|
|
t.Cleanup(func() {
|
|
env.gdb.Exec(`UPDATE backend_user_roles SET description = ?, permissions = ? WHERE id = ?`, developer.Description, developer.Permissions, developer.ID)
|
|
})
|
|
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, roleURL(developer.ID), token, nil)
|
|
locks := permissionLocks(show)
|
|
for _, code := range []string{"acme.screens.report", "acme.demo.access"} {
|
|
if got, ok := locks[code]; !ok || got[0] != float64(1) || got[1] != enLockedByCode {
|
|
t.Fatalf("lock of %s = %v (present %v), all %v", code, got, ok, locks)
|
|
}
|
|
}
|
|
if _, ok := locks[cabana.PermissionManageUsers]; ok {
|
|
t.Fatalf("a code the role does not get from a plugin is locked: %v", locks)
|
|
}
|
|
|
|
// The admin's payload leaves the record-locked codes out.
|
|
stored, _ := dataOf(show)["permissions"].(map[string]any)
|
|
payload := map[string]any{}
|
|
for code, value := range stored {
|
|
if _, locked := locks[code]; !locked {
|
|
payload[code] = value
|
|
}
|
|
}
|
|
payload[pingPermission] = 1
|
|
env.expect(t, http.StatusOK, http.MethodPut, roleURL(developer.ID), token,
|
|
roleBody(developer, map[string]any{"description": "Changed " + env.stamp, "permissions": payload}))
|
|
saved := env.reloadRoleByCode(t, "developer")
|
|
if saved.Description != "Changed "+env.stamp || decodeJSON(t, saved.Permissions)[pingPermission] != 1 {
|
|
t.Fatalf("developer after the save = %+v", saved)
|
|
}
|
|
if strings.Contains(saved.Permissions, "acme.screens.report") {
|
|
t.Fatalf("a code-declared grant was written to the stored JSON: %s", saved.Permissions)
|
|
}
|
|
|
|
// The delegate's own record: every offered code record-locked.
|
|
delegate := env.seedAdmin(t, adminSeed{Name: "locks-delegate", RolePermissions: manageUsers, Permissions: map[string]int{pingPermission: -1}})
|
|
delegateToken := env.login(t, delegate.Login)
|
|
offered := permissionLocked(schemaFields(env.expect(t, http.StatusOK, http.MethodGet, usersPath+"/schema/form", delegateToken, nil))["permissions"])
|
|
own := permissionLocks(env.expect(t, http.StatusOK, http.MethodGet, userURL(delegate.ID), delegateToken, nil))
|
|
if len(own) != len(offered) || len(own) == 0 {
|
|
t.Fatalf("own record locks %v, offered %v", own, offered)
|
|
}
|
|
for code := range offered {
|
|
got, ok := own[code]
|
|
want := float64(0)
|
|
if code == pingPermission {
|
|
want = -1
|
|
}
|
|
if !ok || got[0] != want || got[1] != enLockedSelf {
|
|
t.Fatalf("own lock of %s = %v (present %v)", code, got, ok)
|
|
}
|
|
}
|
|
// Another administrator's record carries no locks for the delegate.
|
|
other := env.seedAdmin(t, adminSeed{Name: "locks-other"})
|
|
if locks := permissionLocks(env.expect(t, http.StatusOK, http.MethodGet, userURL(other.ID), delegateToken, nil)); len(locks) != 0 {
|
|
t.Fatalf("locks on another record = %v", locks)
|
|
}
|
|
}
|
|
|
|
// reloadRoleByCode reads the role with code.
|
|
func (e *screensEnv) reloadRoleByCode(t *testing.T, code string) cabana.BackendUserRole {
|
|
t.Helper()
|
|
var role cabana.BackendUserRole
|
|
if err := e.gdb.Where("code = ?", code).First(&role).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return role
|
|
}
|