333 lines
16 KiB
Go
333 lines
16 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/role"
|
|
"encoding/json"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRolesAPIDeleting(t *testing.T) {
|
|
rows := []struct {
|
|
name string
|
|
caller uint32
|
|
target string
|
|
want string
|
|
}{
|
|
{"Owner deletes Intern", raOwner, "Intern", "204"},
|
|
{"Admin deletes Intern", raAdmin, "Intern", "204"},
|
|
{"Admin deletes Contributor", raAdmin, "Contributor", "409 role_has_members"},
|
|
{"Owner deletes Writer", raOwner, "Writer", "409 role_built_in"},
|
|
{"Owner deletes Admin", raOwner, "Admin", "409 role_built_in"},
|
|
{"Owner deletes Owner", raOwner, "Owner", "403 role_locked"},
|
|
{"Admin deletes Owner", raAdmin, "Owner", "403 role_locked"},
|
|
{"Admin deletes 999", raAdmin, "Missing", "404 role_not_found"},
|
|
{"Role manager deletes Role manager", raRoleManager, "Role manager", "403 own_role"},
|
|
{"Role manager deletes Content lead", raRoleManager, "Content lead", "escalation"},
|
|
{"Role manager deletes Intern", raRoleManager, "Intern", "204"},
|
|
}
|
|
for _, c := range rows {
|
|
t.Run("Deleting a role: "+c.name, func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec := world.do(t, c.caller, http.MethodDelete, world.path(c.target), "")
|
|
assertRolesAPIResult(t, rec, c.want)
|
|
_, exists := world.roles.roles[world.ids[c.target]]
|
|
if c.want == "204" {
|
|
if exists || rec.Body.Len() != 0 {
|
|
t.Fatal("the role must be gone and the body empty")
|
|
}
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodGet, world.path(c.target), ""), "404 role_not_found")
|
|
return
|
|
}
|
|
if c.target != "Missing" && !exists {
|
|
t.Fatal("a refused delete deletes nothing")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("a delete never touches content", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.seedArticle(7, true)
|
|
article := world.articles.articles[7]
|
|
article.CreatedBy = raContributorOne
|
|
world.articles.articles[7] = article
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "204")
|
|
if world.articles.articles[7].CreatedBy != raContributorOne {
|
|
t.Fatal("content keeps its created_by")
|
|
}
|
|
})
|
|
|
|
t.Run("a member appearing meanwhile answers role_has_members", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "409 role_has_members")
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIReassigning(t *testing.T) {
|
|
target := func(w *rolesAPIWorld, name string) string {
|
|
return `{"target_role_id": ` + strconv.Itoa(int(w.ids[name])) + `}`
|
|
}
|
|
rows := []struct {
|
|
name string
|
|
caller uint32
|
|
source string
|
|
body func(*rolesAPIWorld) string
|
|
want string
|
|
}{
|
|
{"Admin moves Contributor to Writer", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
|
|
{"Admin moves Intern to Writer", raAdmin, "Intern", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
|
|
{"Role manager moves to Admin", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Admin") }, "escalation"},
|
|
{"Role manager moves to Intern", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "200"},
|
|
{"Admin moves to Owner", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Owner") }, "400 invalid_reassign"},
|
|
{"Admin moves to the same role", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Contributor") }, "400 invalid_reassign"},
|
|
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "409 target_role_not_found"},
|
|
{"Admin sends no target", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{}` }, "400"},
|
|
{"Admin moves from Writer", raAdmin, "Writer", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "409 role_built_in"},
|
|
{"Admin moves from Owner", raAdmin, "Owner", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 role_locked"},
|
|
{"Admin moves from 999", raAdmin, "Missing", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "404 role_not_found"},
|
|
{"Role manager moves from their own role", raRoleManager, "Role manager", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 own_role"},
|
|
}
|
|
for _, c := range rows {
|
|
t.Run("Moving members: "+c.name, func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec := world.do(t, c.caller, http.MethodPost, world.path(c.source)+"/reassign", c.body(world))
|
|
if c.want == "400" {
|
|
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
|
|
return
|
|
}
|
|
assertRolesAPIResult(t, rec, c.want)
|
|
if c.want != "200" && len(world.members("Contributor")) != 4 {
|
|
t.Fatal("nobody may be moved by a refusal")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Moving members, then deleting", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec := world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer"))
|
|
var moved struct {
|
|
Moved int `json:"moved"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &moved); err != nil || rec.Code != http.StatusOK || moved.Moved != 4 {
|
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if got := world.members("Writer"); !reflect.DeepEqual(got, []uint32{raWriter, 41, 42, 43, 44}) {
|
|
t.Fatalf("unexpected Writer members %v", got)
|
|
}
|
|
account := world.users.users[41]
|
|
if account.TokenVersion != 0 || account.Password != "" || account.IsVerified != true {
|
|
t.Fatalf("only role_id may change: %+v", account)
|
|
}
|
|
assertRolesAPIResult(t, world.do(t, 41, http.MethodGet, "/v1/auth/me", ""), "200")
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Contributor"), ""), "204")
|
|
actions := world.recorder.actions()
|
|
if !reflect.DeepEqual(actions, []string{"role.reassign", "role.delete"}) {
|
|
t.Fatalf("unexpected events %v", actions)
|
|
}
|
|
})
|
|
|
|
t.Run("Moving nobody records no event", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec := world.do(t, raAdmin, http.MethodPost, world.path("Intern")+"/reassign", target(world, "Writer"))
|
|
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"moved":0}` || len(world.recorder.events) != 0 {
|
|
t.Fatalf("unexpected %d %q events=%d", rec.Code, rec.Body.String(), len(world.recorder.events))
|
|
}
|
|
})
|
|
|
|
t.Run("A repository failure moves nobody, answers 500 and records a failed event", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.roles.fail.failNext("ReassignMembers")
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer")), "500 Internal server error")
|
|
if len(world.members("Contributor")) != 4 || len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
|
|
t.Fatalf("unexpected state, events=%+v", world.recorder.events)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIResetting(t *testing.T) {
|
|
edited := func(t *testing.T, world *rolesAPIWorld, name string) {
|
|
t.Helper()
|
|
body := rolesAPIUpdateBody(name, "custom", 1, "content.read=all")
|
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path(name), body), "200")
|
|
world.recorder.events = nil
|
|
}
|
|
|
|
for _, name := range []string{"Writer", "Admin"} {
|
|
t.Run("Resetting a built-in role to its defaults: Owner resets "+name+", edited", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
edited(t, world, name)
|
|
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path(name)+"/reset", `{"version": 2}`), http.StatusOK)
|
|
want := role.Role{Grants: role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants}.EffectiveGrants()
|
|
if body.Version != 3 || body.Name != name || !body.BuiltIn || body.Locked || body.Description != "" || !reflect.DeepEqual(body.Grants, want) {
|
|
t.Fatalf("unexpected role %+v", body)
|
|
}
|
|
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
|
|
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Resetting a built-in role to its defaults: a pristine role is a no-op, then a second reset too", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 1}`), http.StatusOK)
|
|
if body.Version != 1 || len(world.recorder.events) != 0 {
|
|
t.Fatalf("unexpected %+v", body)
|
|
}
|
|
edited(t, world, "Writer")
|
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
|
|
again := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 3}`), http.StatusOK)
|
|
if again.Version != 3 {
|
|
t.Fatalf("a second reset is a no-op, got version %d", again.Version)
|
|
}
|
|
})
|
|
|
|
t.Run("Resetting a built-in role to its defaults: members see the seeded grants on their next request", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
edited(t, world, "Writer")
|
|
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusForbidden)
|
|
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
|
|
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusOK)
|
|
})
|
|
|
|
refusals := []struct {
|
|
name string
|
|
caller uint32
|
|
target string
|
|
version string
|
|
want string
|
|
}{
|
|
{"Owner, Writer, stale", raOwner, "Writer", "1", "409 role_conflict"},
|
|
{"Owner, Owner", raOwner, "Owner", "1", "403 role_locked"},
|
|
{"Owner, Contributor", raOwner, "Contributor", "1", "400 not_built_in"},
|
|
{"Owner, 999", raOwner, "Missing", "1", "404 role_not_found"},
|
|
{"Admin, Writer", raAdmin, "Writer", "2", "gate:roles.edit_owner_only"},
|
|
{"Viewer, Writer", raViewer, "Writer", "2", "gate:roles.manage"},
|
|
}
|
|
for _, c := range refusals {
|
|
t.Run("Resetting a built-in role to its defaults: "+c.name, func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
edited(t, world, "Writer")
|
|
assertRolesAPIResult(t, world.do(t, c.caller, http.MethodPost, world.path(c.target)+"/reset", `{"version": `+c.version+`}`), c.want)
|
|
if len(world.recorder.events) != 0 {
|
|
t.Fatal("a refused reset records nothing")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Resetting a built-in role to its defaults: a missing version is a validation error", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
assertAPIKeyStatus(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{}`), http.StatusBadRequest)
|
|
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{`), http.StatusBadRequest, apperr.BodyErrorMessage)
|
|
})
|
|
|
|
t.Run("Resetting a built-in role to its defaults: the API key of the Owner is refused", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
edited(t, world, "Writer")
|
|
raw, _ := world.issueKey(raOwner, "owner key")
|
|
assertRolesAPIResult(t, world.withKey(t, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIActivity(t *testing.T) {
|
|
t.Run("Activity events for roles", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
created := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "Reviewer", "grants": `+rolesAPIGrantsJSON("content.read=all", "content.update=own", "media.view=all")+`}`), http.StatusCreated)
|
|
path := "/v1/role/" + strconv.Itoa(int(created.ID))
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, path, rolesAPIUpdateBody("Reviewers", "", 1, "content.read=all", "content.update=all", "content.delete=own")), "200")
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, path, ""), "204")
|
|
|
|
events := world.recorder.events
|
|
if !reflect.DeepEqual(world.recorder.actions(), []string{"role.create", "role.edit", "role.delete"}) {
|
|
t.Fatalf("unexpected events %v", world.recorder.actions())
|
|
}
|
|
for _, event := range events {
|
|
if event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess || event.Target.Type != "role" || event.Target.ID != strconv.Itoa(int(created.ID)) {
|
|
t.Fatalf("unexpected event %+v", event)
|
|
}
|
|
}
|
|
edit := events[1]
|
|
if edit.Target.Name != "Reviewers" ||
|
|
!reflect.DeepEqual(edit.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
|
|
!reflect.DeepEqual(edit.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
|
|
!reflect.DeepEqual(edit.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
|
|
t.Fatalf("unexpected edit params %v", edit.Params)
|
|
}
|
|
if events[2].Params[audit.ParamMembers] != 0 || events[2].Target.Name != "Reviewers" {
|
|
t.Fatalf("unexpected delete event %+v", events[2])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIAuthMe(t *testing.T) {
|
|
t.Run("/auth/me exposes typed grants", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec, body := world.me(t, raEventsEditor)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected %d", rec.Code)
|
|
}
|
|
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
|
|
if !reflect.DeepEqual(body.Access.Permissions, want) {
|
|
t.Fatalf("expected %v, got %v", want, body.Access.Permissions)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRolesAPIContentTypes(t *testing.T) {
|
|
createEvents := `{"key": "events", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`
|
|
|
|
t.Run("Deleting a content type removes its typed grants", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), "204")
|
|
editor := world.role("Events editor")
|
|
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
|
|
t.Fatalf("unexpected role %v version %d", editor.Grants, editor.Version)
|
|
}
|
|
if world.role("Contributor").Version != 1 {
|
|
t.Fatal("a role without typed rows keeps its version")
|
|
}
|
|
_, body := world.me(t, raEventsEditor)
|
|
if !reflect.DeepEqual(body.Access.Permissions, grantRows("content.read=all", "content.update:venues=all")) {
|
|
t.Fatalf("unexpected permissions %v", body.Access.Permissions)
|
|
}
|
|
})
|
|
|
|
t.Run("A refused content type delete keeps the typed grants", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
world.entries.entries[1] = entryOfType(raEventsTypeID)
|
|
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), http.StatusConflict, apperr.ContentTypeHasEntriesErrorMessage)
|
|
if world.role("Events editor").Version != 1 {
|
|
t.Fatal("the role must be unchanged")
|
|
}
|
|
})
|
|
|
|
t.Run("A content type created with the key of a deleted one starts clean", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
delete(world.types.types, raEventsTypeID)
|
|
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
|
|
rec := world.do(t, raAdmin, http.MethodPost, "/v1/content-type", createEvents)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if len(world.roles.roles[stale.ID].Grants) != 0 {
|
|
t.Fatal("the stale row must be removed")
|
|
}
|
|
})
|
|
|
|
t.Run("Content type keys do not change", func(t *testing.T) {
|
|
world := newRolesAPIWorld(t)
|
|
rec := world.do(t, raAdmin, http.MethodPut, "/v1/content-type/events", `{"key": "other", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
if world.types.types[raEventsTypeID].Key != "events" || world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
|
|
t.Fatal("an update never rewrites the key nor the typed rows")
|
|
}
|
|
})
|
|
}
|