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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user