package cabana_test import ( "encoding/json" "fmt" "net/http" "reflect" "strings" "testing" "gorm.io/gorm" ) const ( permUnknown = "The permissions field contains an unknown permission." permInvalid = "The permissions field contains an invalid value." permShape = "The permissions field must be an object of permission codes." permLocked = "You cannot change this permission." ) // rosterPermissions reads a person's stored permission object. func rosterPermissions(t *testing.T, gdb *gorm.DB, id uint) map[string]int { t.Helper() out := map[string]int{} if raw := rosterLoad(t, gdb, id).Permissions; raw != nil { if err := json.Unmarshal([]byte(*raw), &out); err != nil { t.Fatalf("stored permissions %q: %v", *raw, err) } } return out } // newRosterCheckboxEnv is the roster fixture with the permission editor in // checkbox mode. func newRosterCheckboxEnv(t *testing.T) (*rosterEnv, *gorm.DB) { t.Helper() return newRosterEnvWith(t, func(p *rosterPlugin) { p.fsys = rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")}) }) } // TestPermissionEditorModes: radio accepts 1 and -1, checkbox accepts 1 // only, and 0 means "no value" in both (D-16; T-12.1-13). func TestPermissionEditorModes(t *testing.T) { t.Run("radio", func(t *testing.T) { env, gdb := newRosterEnv(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Radio", Active: true}) env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1,"posts.publish":-1,"misc.beta":0}}`, "bearer") if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1, "posts.publish": -1}) { t.Fatalf("stored = %v, want 1 and -1 kept and 0 left out", got) } // 0 for a stored code removes it. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":0,"posts.publish":-1}}`, "bearer") if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.publish": -1}) { t.Fatalf("stored = %v", got) } for _, value := range []string{"2", "-2", "100"} { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":`+value+`}}`, "bearer") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid) } _, raw := rosterFormSchema(t, env, "bearer") if !strings.Contains(raw, `"mode":"radio"`) { t.Fatalf("schema mode: %s", raw) } }) t.Run("checkbox", func(t *testing.T) { env, gdb := newRosterCheckboxEnv(t) id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Check", Active: true}) env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1,"posts.publish":0}}`, "bearer") if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) { t.Fatalf("stored = %v", got) } // A denial is not a checkbox value. for _, value := range []string{"-1", "2"} { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.publish":`+value+`}}`, "bearer") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid) } if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) { t.Fatalf("a refused save changed the stored set: %v", got) } // An empty object unchecks everything that is offered. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{}}`, "bearer") if got := rosterPermissions(t, gdb, id); len(got) != 0 { t.Fatalf("stored after unchecking everything = %v", got) } _, raw := rosterFormSchema(t, env, "bearer") if !strings.Contains(raw, `"mode":"checkbox"`) { t.Fatalf("schema mode: %s", raw) } }) } // TestPermissionEditorUnknownCode: a code the controller does not offer is // 422, also when it is stored on the record, and the value must be an object // of integers. func TestPermissionEditorUnknownCode(t *testing.T) { env, gdb := newRosterEnv(t) stored := `{"legacy.code":1,"posts.edit":1}` id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &stored}) for body, message := range map[string]string{ `{"permissions":{"admin.root":1}}`: permUnknown, `{"permissions":{"posts.edit":1,"admin.root":0}}`: permUnknown, `{"permissions":{"legacy.code":1}}`: permUnknown, `{"permissions":{"POSTS.EDIT":1}}`: permUnknown, `{"permissions":{"":1}}`: permUnknown, `{"permissions":{"posts.edit":"1"}}`: permShape, `{"permissions":{"posts.edit":1.5}}`: permShape, `{"permissions":{"posts.edit":null}}`: permShape, `{"permissions":{"posts.edit":99999999999}}`: permShape, `{"permissions":{"posts.edit":[1]}}`: permShape, `{"permissions":[]}`: permShape, `{"permissions":1}`: permShape, `{"name":"Renamed","permissions":{"admin.root":1}}`: permUnknown, } { rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), body, "bearer") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", message) } if got := rosterLoad(t, gdb, id); got.Name != "Perm" || got.Permissions == nil || *got.Permissions != stored { t.Fatalf("a refused save wrote: %+v", got) } } // TestPermissionEditorLocked: a locked code's stored and submitted value must // be equal for the administrator it is locked for; otherwise 403 and nothing // is written. func TestPermissionEditorLocked(t *testing.T) { env, gdb := newRosterEnv(t) stored := `{"reports.export":1}` holder := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Holder", Active: true, Permissions: &stored}) bare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bare", Active: true}) refused := func(id uint, body string) { t.Helper() rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), body, "limited") rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", permLocked) } refused(holder, `{"permissions":{}}`) refused(holder, `{"permissions":{"reports.export":-1}}`) refused(holder, `{"name":"Sneaky","permissions":{"reports.export":0,"posts.edit":1}}`) refused(bare, `{"permissions":{"reports.export":1}}`) refused(bare, `{"permissions":{"reports.export":-1}}`) if got := rosterLoad(t, gdb, holder); got.Name != "Holder" || *got.Permissions != stored { t.Fatalf("a refused save wrote: %+v", got) } if got := rosterLoad(t, gdb, bare); got.Permissions != nil { t.Fatalf("a refused save wrote %q", *got.Permissions) } // The stored value sent back unchanged passes, with other codes changed. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"permissions":{"reports.export":1,"posts.edit":-1}}`, "limited") env.expect(t, http.StatusOK, http.MethodPut, rosterPath(bare, ""), `{"permissions":{"posts.edit":1}}`, "limited") if got := rosterPermissions(t, gdb, holder); !reflect.DeepEqual(got, map[string]int{"reports.export": 1, "posts.edit": -1}) { t.Fatalf("stored = %v", got) } // A save without the field is not checked. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"name":"Holder B"}`, "limited") // The administrator it is not locked for changes it. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(holder, ""), `{"permissions":{}}`, "bearer") if got := rosterPermissions(t, gdb, holder); len(got) != 0 { t.Fatalf("the full admin's change was not stored: %v", got) } // Known property of the contract: a locked code whose stored value is // outside the mode's set cannot be sent back, so every save that carries // the field is refused for that administrator; a save without the field // still passes. The value has to be repaired by an administrator the code // is not locked for. t.Run("a locked code stored outside the mode's set", func(t *testing.T) { env, gdb := newRosterCheckboxEnv(t) denied := `{"reports.export":-1}` id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Legacy", Active: true, Permissions: &denied}) rec := env.expect(t, http.StatusForbidden, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1}}`, "limited") rosterErrorDetail(t, rec.Body.Bytes(), "forbidden", "permissions", permLocked) rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, rosterPath(id, ""), `{"permissions":{"reports.export":-1}}`, "limited") rosterErrorDetail(t, rec.Body.Bytes(), "validation_failed", "permissions", permInvalid) env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Legacy B"}`, "limited") if got := rosterLoad(t, gdb, id); got.Name != "Legacy B" || *got.Permissions != denied { t.Fatalf("stored = %+v", got) } env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"permissions":{"posts.edit":1}}`, "bearer") if got := rosterPermissions(t, gdb, id); !reflect.DeepEqual(got, map[string]int{"posts.edit": 1}) { t.Fatalf("the full admin's repair stored %v", got) } }) } // TestPermissionEditorKeepsUnoffered: stored codes the controller does not // offer survive every save, are shown, and cannot be invented. func TestPermissionEditorKeepsUnoffered(t *testing.T) { env, gdb := newRosterEnv(t) stored := `{"legacy.code":1,"legacy.denied":-1,"posts.edit":1}` id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Legacy", Active: true, Permissions: &stored}) rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPath(id, ""), "", "bearer") if !strings.Contains(rec.Body.String(), `"permissions":{"legacy.code":1,"legacy.denied":-1,"posts.edit":1}`) { t.Fatalf("show = %s", rec.Body.String()) } for _, body := range []string{`{"permissions":{}}`, `{"permissions":{"posts.publish":1}}`, `{"permissions":{"posts.publish":0}}`} { env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), body, "bearer") got := rosterPermissions(t, gdb, id) if got["legacy.code"] != 1 || got["legacy.denied"] != -1 { t.Fatalf("after %s the stored codes that are not offered are %v", body, got) } if _, kept := got["posts.edit"]; kept { t.Fatalf("after %s an offered code that was left out is still stored: %v", body, got) } } // A record without stored permissions shows an empty object, never null. blank := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Blank", Active: true}) rec = env.expect(t, http.StatusOK, http.MethodGet, rosterPath(blank, ""), "", "bearer") if !strings.Contains(rec.Body.String(), `"permissions":{}`) { t.Fatalf("show without stored permissions = %s", rec.Body.String()) } } // TestPermissionEditorOptionsPerRequest: options, with their locked flag and // localized texts, are asked from the controller for each request and are // never written into the cached schema. func TestPermissionEditorOptionsPerRequest(t *testing.T) { env, _ := newRosterEnv(t) for range 2 { _, limited := rosterFormSchema(t, env, "limited") if strings.Count(limited, `"locked":true`) != 1 || !strings.Contains(limited, `{"code":"reports.export","label":"Export reports","tab":"Reports","locked":true}`) { t.Fatalf("limited admin's options = %s", limited) } _, full := rosterFormSchema(t, env, "bearer") if strings.Contains(full, `"locked"`) { t.Fatalf("an option is locked for the full admin after a limited request: %s", full) } } pl := rosterLocale(env, http.MethodGet, rosterPeople+"/schema/form", "", "pl") if !strings.Contains(pl.Body.String(), `{"code":"posts.edit","label":"Edycja wpisów","tab":"Treści","comment":"Zmiana treści dowolnego wpisu."}`) { t.Fatalf("pl options = %s", pl.Body.String()) } _, en := rosterFormSchema(t, env, "bearer") if !strings.Contains(en, `"label":"Edit posts","tab":"Content"`) { t.Fatalf("the cached schema kept another locale's labels: %s", en) } } // TestPermissionEditorProviderError: an error from the provider is the // generic 500 on every route that asks it, and nothing is written. func TestPermissionEditorProviderError(t *testing.T) { env, gdb := newRosterEnv(t) stored := `{"posts.edit":1}` id := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Perm", Active: true, Permissions: &stored}) opaque := func(what, method, rel, body string) { t.Helper() rec := env.expect(t, http.StatusInternalServerError, method, rel, body, "bearer") if got := rosterError(t, rec); got.Code != "error" || len(got.Details) != 0 || strings.Contains(rec.Body.String(), "hunter2") || strings.Contains(rec.Body.String(), "permission table") || strings.Contains(rec.Body.String(), "permission column") { t.Fatalf("%s = %s", what, rec.Body.String()) } } env.knobs.permissionOptions.Store(true) opaque("the form schema", http.MethodGet, rosterPeople+"/schema/form", "") opaque("an update with the field", http.MethodPut, rosterPath(id, ""), `{"name":"Changed","permissions":{"posts.publish":1}}`) // A save that does not carry the field does not ask for the options. env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), `{"name":"Perm B"}`, "bearer") env.knobs.permissionOptions.Store(false) env.knobs.permissionValues.Store(true) opaque("show", http.MethodGet, rosterPath(id, ""), "") opaque("an update that reads the stored values", http.MethodPut, rosterPath(id, ""), `{"name":"Changed","permissions":{"posts.publish":1}}`) env.knobs.permissionValues.Store(false) if got := rosterLoad(t, gdb, id); got.Name != "Perm B" || *got.Permissions != stored { t.Fatalf("a failed save wrote: %+v", got) } env.expect(t, http.StatusOK, http.MethodPut, rosterPath(id, ""), fmt.Sprintf(`{"permissions":%s}`, stored), "bearer") }