diff --git a/modules/cabana/field_trigger_test.go b/modules/cabana/field_trigger_test.go new file mode 100644 index 00000000..5c9aba8d --- /dev/null +++ b/modules/cabana/field_trigger_test.go @@ -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) + } + }) +} diff --git a/modules/cabana/record_locks_test.go b/modules/cabana/record_locks_test.go new file mode 100644 index 00000000..be586ad2 --- /dev/null +++ b/modules/cabana/record_locks_test.go @@ -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) + } + } + }) +} diff --git a/modules/cabana/roles_postgres_test.go b/modules/cabana/roles_postgres_test.go index 273ca015..d5bd96a0 100644 --- a/modules/cabana/roles_postgres_test.go +++ b/modules/cabana/roles_postgres_test.go @@ -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 +}