Files
RenewCMS/test/roles_content_scope_world_test.go
T

375 lines
11 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"strings"
"testing"
"time"
)
const (
csOwner uint32 = 3
csAdmin uint32 = 5
csWriter uint32 = 9
csUnverified uint32 = 12
csOrphan uint32 = 13
csHandPlaced uint32 = 20
csContributor uint32 = 21
csReaderOwn uint32 = 22
csEditor uint32 = 23
csPublisherOwn uint32 = 24
csTypedA uint32 = 25
csTypedB uint32 = 26
csNoContent uint32 = 27
csAnonymous uint32 = 0
csEventsTypeID uint32 = 1
csVenuesTypeID uint32 = 2
csEntryBody = `{"data": {"title": "Updated"}}`
csArticlePatch = `{"body": "x"}`
)
type contentScopeRecorder struct {
events []audit.Event
}
var _ audit.Recorder = &contentScopeRecorder{}
func (r *contentScopeRecorder) Record(_ context.Context, event audit.Event) {
r.events = append(r.events, event)
}
func (r *contentScopeRecorder) actions() []string {
actions := []string{}
for _, event := range r.events {
actions = append(actions, event.Action)
}
return actions
}
type contentScopeRoleRepo struct {
role.Repository
gets int
getByNames int
lists int
}
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
c.gets++
return c.Repository.Get(id)
}
func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) {
c.getByNames++
return c.Repository.GetByName(name)
}
func (c *contentScopeRoleRepo) List() ([]role.Role, error) {
c.lists++
return c.Repository.List()
}
func (c *contentScopeRoleRepo) touched() int {
return c.gets + c.getByNames + c.lists
}
type contentScopeUserRepo struct {
user.Repository
gets int
failGet error
}
func (c *contentScopeUserRepo) Get(id uint32) (user.User, error) {
c.gets++
if c.failGet != nil {
return user.User{}, c.failGet
}
return c.Repository.Get(id)
}
type contentScopeTypeRepo struct {
contenttype.Repository
lookups int
}
func (c *contentScopeTypeRepo) GetByKey(key string) (contenttype.ContentType, error) {
c.lookups++
return c.Repository.GetByKey(key)
}
type contentScopeWorld struct {
*apiKeyEnv
recorder *contentScopeRecorder
roleRepo *contentScopeRoleRepo
userRepo *contentScopeUserRepo
typeRepo *contentScopeTypeRepo
typeStore *mockContentTypeRepo
entries *mockEntryRepo
roleIDs map[uint32]uint32
}
type customRoleSpec struct {
user uint32
name string
grants []string
}
var contentScopeCustomRoles = []customRoleSpec{
{csContributor, "Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}},
{csReaderOwn, "Reader own", []string{"content.read=own", "content.create=all"}},
{csEditor, "Editor", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=own"}},
{csPublisherOwn, "Publisher own", []string{"content.read=all", "content.publish=own"}},
{csTypedA, "Typed A", []string{"content.read=all", "content.create=all", "content.update=all", "content.update:events=own", "content.update:article=none"}},
{csTypedB, "Typed B", []string{"content.read:events=all", "content.create:events=all", "content.update:events=own"}},
{csNoContent, "No content", []string{"schema.view=all"}},
{csHandPlaced, "Hand placed", []string{"system.apply_updates=all"}},
}
type articleSeed struct {
id uint32
online bool
createdBy uint32
}
var contentScopeArticles = []articleSeed{
{10, false, 21}, {11, false, 9}, {12, true, 3}, {13, false, 3}, {14, false, 0}, {15, true, 21}, {16, false, 22}, {17, false, 24},
}
type entrySeed struct {
id uint32
typeID uint32
createdBy uint32
}
var contentScopeEntries = []entrySeed{
{1, csEventsTypeID, 21}, {2, csEventsTypeID, 9}, {3, csEventsTypeID, 0}, {4, csEventsTypeID, 25},
{5, csVenuesTypeID, 3}, {6, csEventsTypeID, 22}, {7, csEventsTypeID, 26}, {8, csEventsTypeID, 23},
}
func newContentScopeWorld(t *testing.T) *contentScopeWorld {
t.Helper()
recorder := &contentScopeRecorder{}
env := newAPIKeyEnvRecording(t, recorder)
world := &contentScopeWorld{
apiKeyEnv: env,
recorder: recorder,
roleRepo: &contentScopeRoleRepo{Repository: env.roles},
userRepo: &contentScopeUserRepo{Repository: env.users},
typeStore: newMockContentTypeRepo(),
entries: newMockEntryRepo(),
roleIDs: map[uint32]uint32{},
}
world.typeRepo = &contentScopeTypeRepo{Repository: world.typeStore}
world.seedUsers()
world.seedContent()
world.wire()
return world
}
func (w *contentScopeWorld) seedUsers() {
w.addUser(csOwner, ownerRoleID, true)
w.addUser(csAdmin, adminRoleID, true)
w.addUser(csWriter, writerRoleID, true)
w.addUser(csUnverified, adminRoleID, false)
w.addUser(csOrphan, 99, true)
for _, spec := range contentScopeCustomRoles {
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
w.roleIDs[spec.user] = created.ID
w.addUser(spec.user, created.ID, true)
}
}
func (w *contentScopeWorld) seedContent() {
now := time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC)
for _, seed := range contentScopeArticles {
w.seedScopedArticle(seed.id, seed.online, seed.createdBy)
}
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
w.typeStore.types[csEventsTypeID] = contenttype.ContentType{ID: csEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
w.typeStore.types[csVenuesTypeID] = contenttype.ContentType{ID: csVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
w.typeStore.nextID = 10
for _, seed := range contentScopeEntries {
w.entries.entries[seed.id] = entry.Entry{ID: seed.id, ContentTypeID: seed.typeID, Data: map[string]any{"title": "Entry " + strconv.Itoa(int(seed.id))}, CreatedBy: seed.createdBy, UpdatedBy: seed.createdBy, CreatedAt: now, UpdatedAt: now}
}
w.entries.nextID = 100
}
func (w *contentScopeWorld) seedScopedArticle(id uint32, online bool, createdBy uint32) {
w.articles.articles[id] = article.Article{ID: id, Title: "Article " + strconv.Itoa(int(id)), Body: "x", Slug: "article-" + strconv.Itoa(int(id)), IsOnline: online, SiteID: 1, CreatedBy: createdBy, UpdatedAt: time.Date(2026, time.October, 1, 0, 0, 0, 0, time.UTC)}
}
func (w *contentScopeWorld) wire() {
clock := func() time.Time { return w.now }
c := w.container
c.GetUserUseCase = useCases.NewGetUserUseCase(w.userRepo)
c.CheckPermissionUseCase = useCases.NewCheckPermissionUseCase(w.roleRepo)
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roleRepo, w.userRepo)
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
c.UpdateEntryUseCase = useCases.NewUpdateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
c.DeleteEntryUseCase = useCases.NewDeleteEntryUseCase(w.typeRepo, w.entries)
w.rebuildRouter()
}
func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
}
func (w *contentScopeWorld) resetCounters() {
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.lists = 0, 0, 0
w.userRepo.gets = 0
w.typeRepo.lookups = 0
}
func (w *contentScopeWorld) setGrants(userID uint32, rows ...string) {
stored := w.roles.roles[w.roleIDs[userID]]
stored.Grants = grantRows(rows...)
w.roles.roles[stored.ID] = stored
}
type scopeWant struct {
status int
key string
message string
}
func okWant(status int) scopeWant {
return scopeWant{status: status}
}
func refusedWant(key string) scopeWant {
return scopeWant{status: http.StatusForbidden, key: key}
}
func notFoundWant(message string) scopeWant {
return scopeWant{status: http.StatusNotFound, message: message}
}
func (w scopeWant) label() string {
if w.key != "" {
return "403 " + w.key
}
text := strconv.Itoa(w.status)
if w.message != "" {
text += " " + w.message
}
return text
}
func assertScopeWant(t *testing.T, rec *httptest.ResponseRecorder, want scopeWant) {
t.Helper()
if want.key != "" {
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.MissingPermissionErrorMessage+"\n" {
t.Fatalf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String())
}
if header := rec.Header().Get(apperr.MissingPermissionHeader); header != want.key {
t.Fatalf("expected %s %q, got %q", apperr.MissingPermissionHeader, want.key, header)
}
return
}
if rec.Code != want.status {
t.Fatalf("expected %d, got %d %q", want.status, rec.Code, rec.Body.String())
}
if rec.Header().Get(apperr.MissingPermissionHeader) != "" {
t.Fatalf("unexpected %s header on a non-refusal", apperr.MissingPermissionHeader)
}
if want.message != "" && strings.TrimSpace(rec.Body.String()) != want.message {
t.Fatalf("expected body %q, got %q", want.message, rec.Body.String())
}
}
type scopeRow struct {
caller uint32
method string
path string
body string
want scopeWant
}
func callerLabel(caller uint32) string {
if caller == csAnonymous {
return "no cookie"
}
return "user " + strconv.Itoa(int(caller))
}
func runScopeRows(t *testing.T, rows []scopeRow) {
t.Helper()
for _, row := range rows {
t.Run(callerLabel(row.caller)+" "+row.method+" "+row.path+" "+row.body+" -> "+row.want.label(), func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, row.caller, row.method, row.path, row.body), row.want)
})
}
}
type scopeListBody struct {
Items []struct {
ID uint32 `json:"id"`
} `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts struct {
All int `json:"all"`
Online int `json:"online"`
Draft int `json:"draft"`
Suggestions int `json:"suggestions"`
} `json:"counts"`
}
func decodeScopeList(t *testing.T, rec *httptest.ResponseRecorder) scopeListBody {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d %q", rec.Code, rec.Body.String())
}
var body scopeListBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body
}
func (b scopeListBody) ids() []uint32 {
ids := []uint32{}
for _, item := range b.Items {
ids = append(ids, item.ID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func scopeIDs(values ...uint32) []uint32 {
return values
}
func sameScopeIDs(left []uint32, right []uint32) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}