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, user.RoleOwner, ownerRoleID, true) w.addUser(csAdmin, user.RoleAdmin, adminRoleID, true) w.addUser(csWriter, "", writerRoleID, true) w.addUser(csUnverified, user.RoleAdmin, 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) 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 }