feat(12.1-02): permissioneditor field in radio or checkbox mode

- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
  controller serves the options per request through
  cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
  mode's set and 403 for a changed locked code; stored codes that are not
  offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
  mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:44:50 +02:00
parent a1c6bb1ce6
commit f50d9b8f10
38 changed files with 1300 additions and 34 deletions

View File

@@ -386,3 +386,170 @@ func TestPresetSchema(t *testing.T) {
})
}
}
// TestPermissionEditorSmoke drives `type: permissioneditor` through the
// assembled router on PostgreSQL (D-16; T-12.1-13): options per administrator,
// the code and value checks, the locked guard, kept unknown codes and the
// boot rules.
func TestPermissionEditorSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
legacy := `{"legacy.code":1,"reports.export":1}`
id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &legacy})
record := fmt.Sprintf("%s/%d", rosterPeople, id)
stored := func(t *testing.T) map[string]int {
t.Helper()
person := rosterLoad(t, gdb, id)
out := map[string]int{}
if person.Permissions != nil {
if err := json.Unmarshal([]byte(*person.Permissions), &out); err != nil {
t.Fatalf("stored permissions %q: %v", *person.Permissions, err)
}
}
return out
}
same := func(t *testing.T, got, want map[string]int) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("permissions = %v, want %v", got, want)
}
}
const unknown = "The permissions field contains an unknown permission."
const invalid = "The permissions field contains an invalid value."
const shape = "The permissions field must be an object of permission codes."
t.Run("the schema carries localized options, locked only for the limited admin", func(t *testing.T) {
view, raw := rosterFormSchema(t, env, "bearer")
if !strings.Contains(raw, `"name":"permissions","type":"permissioneditor","label":"Permissions","tab":"Permissions","context":"update","mode":"radio","permissionOptions":[{"code":"posts.edit","label":"Edit posts","tab":"Content","comment":"Change the text of any post."},{"code":"posts.publish","label":"Publish posts","tab":"Content"},{"code":"reports.export","label":"Export reports","tab":"Reports"},{"code":"misc.beta","label":"Try beta features"}]`) {
t.Fatalf("permission field is not in the schema: %s", raw)
}
if strings.Contains(raw, `"locked"`) {
t.Fatalf("an option is locked for the full admin: %s", raw)
}
_ = view
limited, raw := rosterFormSchema(t, env, "limited")
if !strings.Contains(raw, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) || strings.Count(raw, `"locked":true`) != 1 {
t.Fatalf("limited admin's options: %s", raw)
}
// The cached schema was not mutated by either request.
for _, field := range limited.Fields {
if field.Type == "permissioneditor" && len(field.PermissionOptions) != 4 {
t.Fatalf("options = %+v", field.PermissionOptions)
}
}
})
t.Run("show returns the stored codes as an object", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, record, "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"reports.export":1}`) {
t.Fatalf("show: %s", rec.Body.String())
}
blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true})
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/%d", rosterPeople, blank), "", "bearer")
if !strings.Contains(rec.Body.String(), `"permissions":{}`) {
t.Fatalf("show without stored permissions: %s", rec.Body.String())
}
})
t.Run("an update stores offered codes, drops inherit and keeps a stored code that is not offered", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0,"reports.export":1}}`, "bearer")
want := map[string]int{"legacy.code": 1, "posts.edit": 1, "posts.publish": -1, "reports.export": 1}
same(t, stored(t), want)
got, _ := rosterRecord(t, rec.Body.Bytes()).Data["permissions"].(map[string]any)
if len(got) != 4 || got["posts.publish"] != float64(-1) || got["legacy.code"] != float64(1) {
t.Fatalf("update answered %v", got)
}
// An offered code that is left out goes back to inherit.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.edit":1,"reports.export":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
// A save without the field leaves the column alone.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"name":"Perm B"}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.edit": 1, "reports.export": 1})
})
t.Run("an unknown code, a value outside the set and a non-object are 422", func(t *testing.T) {
before := stored(t)
for body, message := range map[string]string{
`{"permissions":{"posts.edit":1,"admin.root":1}}`: unknown,
// A stored code that is not offered cannot be submitted either.
`{"permissions":{"legacy.code":1}}`: unknown,
`{"permissions":{"posts.edit":2}}`: invalid,
`{"permissions":{"posts.edit":-2}}`: invalid,
`{"permissions":{"posts.edit":"1"}}`: shape,
`{"permissions":{"posts.edit":1.5}}`: shape,
`{"permissions":{"posts.edit":true}}`: shape,
`{"permissions":{"posts.edit":{"a":1}}}`: shape,
`{"permissions":["posts.edit"]}`: shape,
`{"permissions":"posts.edit"}`: shape,
`{"permissions":null}`: shape,
} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, record, body, "bearer")
rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message)
}
same(t, stored(t), before)
})
t.Run("a changed locked code is 403 for the limited admin and nothing is written", func(t *testing.T) {
before := stored(t)
const locked = "You cannot change this permission."
// Removing it (leaving it out), denying it and renaming at the same time.
for _, body := range []string{
`{"name":"Sneaky","permissions":{"posts.edit":1}}`,
`{"name":"Sneaky","permissions":{"posts.edit":1,"reports.export":-1}}`,
`{"name":"Sneaky","permissions":{"reports.export":0}}`,
} {
rec := env.expect(t, http.StatusForbidden, http.MethodPut, record, body, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
}
same(t, stored(t), before)
if person := rosterLoad(t, gdb, id); person.Name != "Perm B" {
t.Fatalf("a refused save renamed the person: %q", person.Name)
}
// Granting it where it is not stored is refused too.
bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true})
rec := env.expect(t, http.StatusForbidden, http.MethodPut, fmt.Sprintf("%s/%d", rosterPeople, bare), `{"permissions":{"reports.export":1}}`, "limited")
rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", locked)
if person := rosterLoad(t, gdb, bare); person.Permissions != nil {
t.Fatalf("a refused save wrote %q", *person.Permissions)
}
// The limited admin may change the other codes while the locked one
// keeps its stored value.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1,"reports.export":1}}`, "limited")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1, "reports.export": 1})
// The full admin may change it.
env.expect(t, http.StatusOK, http.MethodPut, record, `{"permissions":{"posts.publish":1}}`, "bearer")
same(t, stored(t), map[string]int{"legacy.code": 1, "posts.publish": 1})
})
t.Run("a field hidden on create is not written by a create", func(t *testing.T) {
rec := env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"Fresh","password":"long-enough-1","password_confirmation":"long-enough-1","permissions":{"posts.edit":1}}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes())
newID, _ := created.Data["id"].(float64)
if person := rosterLoad(t, gdb, uint(newID)); person.Permissions != nil {
t.Fatalf("create wrote permissions %q", *person.Permissions)
}
if got, ok := created.Data["permissions"].(map[string]any); !ok || len(got) != 0 {
t.Fatalf("create answered permissions %v", created.Data["permissions"])
}
})
t.Run("boot rules", func(t *testing.T) {
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
"field permissions: mode must be radio or checkbox on type: permissioneditor", rosterFieldsFile)
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
"mode must be radio or checkbox on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
"default is not valid on type: permissioneditor")
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
"mode is only valid on type: fileupload, datepicker or permissioneditor")
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
t.Fatalf("mode: checkbox did not boot: %v", err)
}
// A controller without the provider cannot have the field.
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
if err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("error = %v, want %q", err, want)
}
})
}