330 lines
12 KiB
Go
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())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|