Files
RenewCMS/test/roles_permissions_foundation_http_test.go
T

330 lines
12 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
type accessBody struct {
Role struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
} `json:"role"`
Permissions []role.Grant `json:"permissions"`
}
type meBody struct {
ID uint32 `json:"id"`
Username string `json:"username"`
IsVerified bool `json:"is_verified"`
Role string `json:"role"`
Author any `json:"author"`
Access accessBody `json:"access"`
}
func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) {
e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID}
}
func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) {
t.Helper()
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", id)
var body meBody
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
}
return rec, body
}
func permissionKeys(grants []role.Grant) []string {
keys := []string{}
for _, grant := range grants {
keys = append(keys, grant.Key)
}
return keys
}
func TestRolesPermissionsFoundationHTTP(t *testing.T) {
t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
rec, body := e.me(t, 7)
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control"))
}
if body.ID != 7 || body.Role != "owner" || !body.IsVerified {
t.Errorf("existing fields changed: %+v", body)
}
if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked {
t.Errorf("role %+v", body.Access.Role)
}
want := []string{}
for _, permission := range role.Catalogue() {
want = append(want, permission.Key)
}
if !slices.Equal(permissionKeys(body.Access.Permissions), want) {
t.Errorf("permissions %v", permissionKeys(body.Access.Permissions))
}
for _, grant := range body.Access.Permissions {
if grant.Scope != role.ScopeAll {
t.Errorf("%s scope %s", grant.Key, grant.Scope)
}
}
})
t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(8, user.RoleAdmin, adminRoleID, true)
_, body := e.me(t, 8)
if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked {
t.Errorf("role %+v", body.Access.Role)
}
if len(body.Access.Permissions) != 29 {
t.Fatalf("expected 29 permissions, got %d", len(body.Access.Permissions))
}
scopes := grantMap(body.Access.Permissions)
for _, permission := range role.Catalogue() {
if _, present := scopes[permission.Key]; present == permission.OwnerOnly {
t.Errorf("%s presence %v", permission.Key, present)
}
}
if scopes["roles.view"] != role.ScopeAll || scopes["roles.manage"] != role.ScopeAll {
t.Errorf("roles keys missing")
}
})
t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
_, body := e.me(t, 9)
want := []role.Grant{
{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll},
{Key: "content.update", Scope: role.ScopeAll}, {Key: "schema.view", Scope: role.ScopeAll},
{Key: "media.view", Scope: role.ScopeAll}, {Key: "media.upload", Scope: role.ScopeAll},
{Key: "authors.view", Scope: role.ScopeAll}, {Key: "authors.edit", Scope: role.ScopeOwn},
{Key: "activity.view", Scope: role.ScopeOwn},
}
if !slices.Equal(body.Access.Permissions, want) {
t.Errorf("permissions %v", body.Access.Permissions)
}
})
t.Run("A user on a custom role sees that role", func(t *testing.T) {
e := newAPIKeyEnv(t)
custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{
{Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll},
}})
e.addUser(10, user.RoleAdmin, custom.ID, true)
_, body := e.me(t, 10)
want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}}
if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) {
t.Errorf("body %+v", body.Access)
}
})
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
e := newAPIKeyEnv(t)
e.addUser(11, legacy, 0, true)
_, body := e.me(t, 11)
if body.Access.Role.Name != name {
t.Errorf("%s: got %q", legacy, body.Access.Role.Name)
}
}
})
t.Run("The permissions follow the role live", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
writer := e.roles.byName("Writer")
writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn})
_, _ = e.roles.Save(writer)
_, body := e.me(t, 9)
if grantMap(body.Access.Permissions)["media.delete"] != role.ScopeOwn {
t.Errorf("permissions %v", body.Access.Permissions)
}
})
t.Run("An unverified user still gets /me, with access", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(12, user.RoleAdmin, writerRoleID, false)
rec, body := e.me(t, 12)
if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" {
t.Errorf("status %d body %+v", rec.Code, body)
}
})
t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(13, "", 99, true)
rec, _ := e.me(t, 13)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
t.Run("The existing /me failures are unchanged", func(t *testing.T) {
e := newAPIKeyEnv(t)
if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("no cookie: %d", rec.Code)
}
e.addUser(7, user.RoleOwner, ownerRoleID, true)
stale := e.users.users[7]
stale.TokenVersion = 1
e.users.users[7] = stale
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("stale token: %d %q", rec.Code, rec.Body.String())
}
delete(e.users.users, 7)
rec = e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("deleted account: %d %q", rec.Code, rec.Body.String())
}
})
}
func TestPermissionCatalogueHTTP(t *testing.T) {
type item struct {
Key string `json:"key"`
ScopeKind string `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type catalogueBody struct {
Groups []struct {
Key string `json:"key"`
Items []item `json:"items"`
} `json:"groups"`
}
t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var body catalogueBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
groups := []string{}
total := 0
for _, group := range body.Groups {
groups = append(groups, group.Key)
for _, found := range group.Items {
total++
permission, err := role.Lookup(found.Key)
if err != nil {
t.Fatalf("unknown key %s", found.Key)
}
wantKind := "scoped"
if permission.Kind == role.KindToggle {
wantKind = "toggle"
}
if found.ScopeKind != wantKind || found.OwnerOnly != permission.OwnerOnly {
t.Errorf("item %+v", found)
}
if found.PerType != (group.Key == "content") {
t.Errorf("per_type of %s: %v", found.Key, found.PerType)
}
}
}
if !slices.Equal(groups, []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}) || total != 34 {
t.Errorf("groups %v total %d", groups, total)
}
if body.Groups[0].Items[0] != (item{Key: "content.read", ScopeKind: "scoped", PerType: true}) {
t.Errorf("first item %+v", body.Groups[0].Items[0])
}
})
t.Run("The catalogue is the same for every role", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 {
t.Errorf("bodies differ")
}
})
t.Run("An API key may read the catalogue", func(t *testing.T) {
e := newAPIKeyEnv(t)
raw, _ := e.issueKey(apiKeyDevId, "reader")
if rec := e.withKey(t, http.MethodGet, "/v1/permission", "", raw); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("No cookie is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
if rec := e.anonymous(t, http.MethodGet, "/v1/permission", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A stale token is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
stored := e.users.users[7]
stored.TokenVersion = 1
e.users.users[7] = stored
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
t.Run("An unverified user is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(12, user.RoleAdmin, writerRoleID, false)
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
}
func TestExistingRoleChecksAreUntouched(t *testing.T) {
t.Run("Existing role checks are untouched in this slice", func(t *testing.T) {
cases := []struct {
name string
user uint32
method string
path string
body string
denied bool
}{
{"owner invites", apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false},
{"admin invites (allowed since roles_permissions_enforcement)", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false},
{"admin creates a content type", apiKeyDevId, http.MethodPost, "/v1/content-type", `{}`, false},
{"admin patches settings", apiKeyDevId, http.MethodPatch, "/v1/settings", `{}`, true},
{"admin checks for updates", apiKeyDevId, http.MethodPost, "/v1/system/update/check", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newAPIKeyEnv(t)
rec := e.withCookie(t, c.method, c.path, c.body, c.user)
if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n") {
t.Errorf("expected missing_permission 403, got %d %q", rec.Code, rec.Body.String())
}
if !c.denied && rec.Code == http.StatusForbidden {
t.Errorf("unexpected 403 %q", rec.Body.String())
}
})
}
})
}