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()) } }) } }) }