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}` }, "400 invalid_reassign"}, {"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") } }) }