test(19.6.1-03): trigger boot rules and per-record permission locks
- 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
This commit is contained in:
115
modules/cabana/field_trigger_test.go
Normal file
115
modules/cabana/field_trigger_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestFieldTriggerBoot (D-21): every refusal of compileTrigger and
|
||||
// checkTriggers stops boot with an error naming the controller, the field
|
||||
// and the trigger key; valid disable and hide triggers reach the localized
|
||||
// schema unchanged.
|
||||
func TestFieldTriggerBoot(t *testing.T) {
|
||||
base := "fields:\n is_admin:\n type: switch\n agree:\n type: checkbox\n title:\n type: text\n"
|
||||
notes := func(trigger string) string {
|
||||
return base + " notes:\n type: textarea\n trigger:\n" + trigger
|
||||
}
|
||||
full := func(action, field, condition string) string {
|
||||
return notes(" action: " + action + "\n field: " + field + "\n condition: " + condition + "\n")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, fields string
|
||||
want []string
|
||||
}{
|
||||
{"missing action", notes(" field: is_admin\n condition: checked\n"), []string{"action is required"}},
|
||||
{"missing field", notes(" action: hide\n condition: checked\n"), []string{"field is required"}},
|
||||
{"missing condition", notes(" action: hide\n field: is_admin\n"), []string{"condition is required"}},
|
||||
{"extra sub-key", notes(" action: hide\n field: is_admin\n condition: checked\n value: 1\n"), []string{"unknown key value"}},
|
||||
{"not a mapping", base + " notes:\n type: textarea\n trigger: is_admin\n", []string{"trigger must be a mapping"}},
|
||||
{"unknown action", full("show", "is_admin", "checked"), []string{"action show is not supported"}},
|
||||
{"unknown condition", full("hide", "is_admin", "value[1]"), []string{"condition value[1] is not supported"}},
|
||||
{"field is not a name", full("hide", "is-admin", "checked"), []string{`field "is-admin" is not a field name`}},
|
||||
{"self reference", base + " notes:\n type: checkbox\n trigger:\n action: hide\n field: notes\n condition: checked\n", []string{"names the field itself"}},
|
||||
{"unknown controlling field", full("hide", "active", "checked"), []string{"trigger field active is not a field of this form"}},
|
||||
{"text controlling field", full("disable", "title", "unchecked"), []string{"trigger field title must be a switch or checkbox", "type text"}},
|
||||
{"in a repeater row", base + " rows:\n type: repeater\n form:\n fields:\n flag:\n type: checkbox\n notes:\n type: text\n trigger:\n action: hide\n field: flag\n condition: checked\n", []string{"trigger is not supported in a repeater row"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := compileTriggerForm(tc.fields)
|
||||
if err == nil {
|
||||
t.Fatalf("compiled; want an error containing %v", tc.want)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range append([]string{"acme.trig.items", "notes", "trigger"}, tc.want...) {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("err = %v, want it to contain %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("duplicate sub-key", func(t *testing.T) {
|
||||
// The YAML decoder refuses a duplicate key before compileTrigger.
|
||||
_, err := compileTriggerForm(notes(" action: hide\n action: disable\n field: is_admin\n condition: checked\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.trig.items") || !strings.Contains(err.Error(), `"action" already defined`) {
|
||||
t.Fatalf("duplicate sub-key = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid triggers reach the localized schema", func(t *testing.T) {
|
||||
fields := base +
|
||||
" notes:\n type: textarea\n trigger:\n action: disable\n field: is_admin\n condition: checked\n" +
|
||||
" summary:\n type: text\n trigger:\n action: hide\n field: agree\n condition: unchecked\n"
|
||||
schema, err := compileTriggerForm(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, err := schema.Localize(context.Background(), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]FieldTrigger{
|
||||
"notes": {Action: "disable", Field: "is_admin", Condition: "checked"},
|
||||
"summary": {Action: "hide", Field: "agree", Condition: "unchecked"},
|
||||
}
|
||||
for _, field := range view.Fields {
|
||||
expected, ok := want[field.Name]
|
||||
switch {
|
||||
case ok && (field.Trigger == nil || *field.Trigger != expected):
|
||||
t.Fatalf("%s trigger = %+v, want %+v", field.Name, field.Trigger, expected)
|
||||
case !ok && field.Trigger != nil:
|
||||
t.Fatalf("%s has trigger %+v", field.Name, field.Trigger)
|
||||
}
|
||||
delete(want, field.Name)
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Fatalf("fields missing from the view: %v", want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a settings form refuses trigger at boot", func(t *testing.T) {
|
||||
fsys := fstest.MapFS{"models/settings/fields.yaml": &fstest.MapFile{Data: []byte(
|
||||
"fields:\n enabled:\n type: switch\n extra:\n type: text\n trigger:\n action: hide\n field: enabled\n condition: checked\n")}}
|
||||
item := pact.SettingsItem{Code: "demo", Label: "Demo", Form: "models/settings/fields.yaml", Model: "DemoSettings", NewModel: func() any { return &ucSettings{} }}
|
||||
_, err := compileSetting("acme.unit", item, fsys)
|
||||
if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "trigger") {
|
||||
t.Fatalf("settings form with a trigger = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a relation form refuses trigger", func(t *testing.T) {
|
||||
field := FormField{Name: "notes", Type: "text", Trigger: &FieldTrigger{Action: "hide", Field: "flag", Condition: "checked"}}
|
||||
if err := controllerFormOnly(field); err == nil || !strings.Contains(err.Error(), "trigger") {
|
||||
t.Fatalf("controllerFormOnly = %v", err)
|
||||
}
|
||||
field.Trigger = nil
|
||||
if err := controllerFormOnly(field); err != nil {
|
||||
t.Fatalf("controllerFormOnly without a trigger = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
161
modules/cabana/record_locks_test.go
Normal file
161
modules/cabana/record_locks_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// modeLockEditor is a permission editor in mode radio or checkbox over a.x
|
||||
// and a.y. With locks, a stored record (ID set) has a.x record-locked at
|
||||
// lockValue; a new record has no locks, as cabana's own providers do.
|
||||
type modeLockEditor struct {
|
||||
requestLocked bool
|
||||
lockValue int
|
||||
stored map[string]int
|
||||
asked int
|
||||
}
|
||||
|
||||
func (*modeLockEditor) ID() string { return "acme.locks.items" }
|
||||
func (*modeLockEditor) ModelName() string { return `Acme\Locks\Models\Item` }
|
||||
func (*modeLockEditor) ConfigDir() string { return "controllers/items" }
|
||||
|
||||
func (c *modeLockEditor) AdminPermissionOptions(context.Context, string) ([]PermissionOption, error) {
|
||||
return []PermissionOption{{Code: "a.x", Label: "X", Locked: c.requestLocked}, {Code: "a.y", Label: "Y"}}, nil
|
||||
}
|
||||
|
||||
func (c *modeLockEditor) AdminPermissionValues(_ context.Context, _ string, record any) (map[string]int, error) {
|
||||
out := map[string]int{}
|
||||
for code, n := range record.(*lockRecord).Permissions {
|
||||
out[code] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *modeLockEditor) AdminSetPermissionValues(_ context.Context, _ string, record any, values map[string]int) error {
|
||||
c.stored = values
|
||||
record.(*lockRecord).Permissions = values
|
||||
return nil
|
||||
}
|
||||
|
||||
// modeLocking adds the record-lock provider.
|
||||
type modeLocking struct{ *modeLockEditor }
|
||||
|
||||
func (c modeLocking) AdminPermissionRecordLocks(_ context.Context, _ string, record any) ([]PermissionRecordLock, error) {
|
||||
c.asked++
|
||||
if record.(*lockRecord).ID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []PermissionRecordLock{{Code: "a.x", Value: c.lockValue, Note: permissionLockedByCodeKey}}, nil
|
||||
}
|
||||
|
||||
func modeController(c *modeLockEditor, mode string, locks bool) *CompiledController {
|
||||
cc := &CompiledController{
|
||||
Form: &FormSchema{Fields: []FormField{{Name: "permissions", Type: permissionFieldType, Mode: mode}}},
|
||||
permissions: map[string]string{"permissions": mode},
|
||||
}
|
||||
if locks {
|
||||
cc.Controller = modeLocking{c}
|
||||
} else {
|
||||
cc.Controller = c
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// TestPermissionRecordLocks (D-21): the record-lock save rule in both editor
|
||||
// modes, and meta.permissionLocks only for a stored record of a controller
|
||||
// with the provider.
|
||||
func TestPermissionRecordLocks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
lockValue int
|
||||
other int
|
||||
}{{"checkbox", 1, 0}, {"radio", -1, 1}} {
|
||||
lift := func(values map[string]int) []permissionValue {
|
||||
return []permissionValue{{field: "permissions", mode: mode.name, values: values}}
|
||||
}
|
||||
stored := func() *lockRecord {
|
||||
return &lockRecord{ID: 3, Permissions: map[string]int{"a.x": mode.lockValue, "a.y": 1}}
|
||||
}
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
requestLocked bool
|
||||
body map[string]int
|
||||
refused bool
|
||||
want map[string]int
|
||||
}{
|
||||
{name: "absent locked code keeps the stored value", body: map[string]int{"a.y": 1}, want: map[string]int{"a.x": mode.lockValue, "a.y": 1}},
|
||||
{name: "an equal locked value passes", body: map[string]int{"a.x": mode.lockValue}, want: map[string]int{"a.x": mode.lockValue}},
|
||||
{name: "a different locked value is refused", body: map[string]int{"a.x": mode.other, "a.y": 1}, refused: true},
|
||||
{name: "request-locked and record-locked, absent", requestLocked: true, body: map[string]int{"a.y": 1}, want: map[string]int{"a.x": mode.lockValue, "a.y": 1}},
|
||||
{name: "request-locked and record-locked, different", requestLocked: true, body: map[string]int{"a.x": mode.other}, refused: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
editor := &modeLockEditor{requestLocked: tc.requestLocked, lockValue: mode.lockValue}
|
||||
record := stored()
|
||||
err := applyPermissionValues(ctx, modeController(editor, mode.name, true), record, lift(tc.body), true)
|
||||
if tc.refused {
|
||||
var forbidden *ForbiddenError
|
||||
if !errors.As(err, &forbidden) || forbidden.Details["permissions"] == nil {
|
||||
t.Fatalf("err = %v, want a ForbiddenError on permissions", err)
|
||||
}
|
||||
if got, _ := forbidden.Details["permissions"].([]string); len(got) != 1 || got[0] != permissionLockedKey {
|
||||
t.Fatalf("details = %v", forbidden.Details)
|
||||
}
|
||||
if editor.stored != nil {
|
||||
t.Fatalf("stored %v after a refusal", editor.stored)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(editor.stored) != len(tc.want) {
|
||||
t.Fatalf("stored = %v, want %v", editor.stored, tc.want)
|
||||
}
|
||||
for code, n := range tc.want {
|
||||
if editor.stored[code] != n {
|
||||
t.Fatalf("stored = %v, want %v", editor.stored, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("record locks are not asked on create", func(t *testing.T) {
|
||||
editor := &modeLockEditor{lockValue: mode.lockValue}
|
||||
locking := modeController(editor, mode.name, true)
|
||||
if err := applyPermissionValues(ctx, locking, &lockRecord{}, lift(map[string]int{"a.x": mode.other}), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if editor.asked != 0 {
|
||||
t.Fatalf("locks asked %d times on create", editor.asked)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("meta carries permissionLocks only for a stored record with a provider", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
locks bool
|
||||
id uint
|
||||
expect bool
|
||||
}{{"provider, stored record", true, 3, true}, {"provider, new record", true, 0, false}, {"no provider", false, 3, false}} {
|
||||
editor := &modeLockEditor{lockValue: -1}
|
||||
record := &lockRecord{ID: tc.id, Permissions: map[string]int{"a.x": -1}}
|
||||
result, err := projectFullRecord(ctx, nil, modeController(editor, "radio", tc.locks), record)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
raw, _ := json.Marshal(result.Meta)
|
||||
has := strings.Contains(string(raw), `"permissionLocks":{"permissions":[{"code":"a.x","value":-1,"note":"backend::lang.permissioneditor.locked_by_code"}]}`)
|
||||
if has != tc.expect || (!tc.expect && strings.Contains(string(raw), "permissionLocks")) {
|
||||
t.Fatalf("%s: meta JSON = %s", tc.name, raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package cabana_test
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -176,3 +177,96 @@ func (e *screensEnv) reloadRole(t *testing.T, id uint) cabana.BackendUserRole {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user