package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/contenttype" "RenewCMS/internal/domain/entry" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "reflect" "testing" "time" ) func TestContentScopeCheckPermissionUseCase(t *testing.T) { newCheck := func() (*useCases.CheckPermissionUseCase, *contentScopeRoleRepo, *mockRoleRepo) { roles := newSeededMockRoleRepo() counting := &contentScopeRoleRepo{Repository: roles} return useCases.NewCheckPermissionUseCase(counting), counting, roles } owner := user.User{ID: 3, RoleID: ownerRoleID} admin := user.User{ID: 5, RoleID: adminRoleID} writer := user.User{ID: 9, RoleID: writerRoleID} t.Run("RequireFor judges the typed scope", func(t *testing.T) { check, _, roles := newCheck() typed, _ := roles.Create(role.Role{Name: "Typed", Version: 1, Grants: grantRows("content.update=all", "content.update:blog=none", "content.read:blog=own")}) account := user.User{ID: 30, RoleID: typed.ID} cases := []struct { name string account user.User key string typeKey string allowed bool }{ {"owner holds update for any type", owner, "content.update", "blog", true}, {"admin holds publish for article", admin, "content.publish", "article", true}, {"writer lacks publish", writer, "content.publish", "article", false}, {"writer holds update for events", writer, "content.update", "events", true}, {"a none row refuses despite the wildcard", account, "content.update", "blog", false}, {"the wildcard applies to another type", account, "content.update", "news", true}, {"a typed own row without a wildcard holds", account, "content.read", "blog", true}, {"a typed row does not apply to another type", account, "content.read", "news", false}, {"an invalid type key is refused", owner, "content.read", "Bad Key", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := check.RequireFor(c.account, c.key, c.typeKey) if c.allowed && err != nil { t.Fatalf("expected pass, got %v", err) } if !c.allowed { requireMissingKey(t, err, c.key) } }) } }) t.Run("An unresolvable role is refused and a failing repository is not a refusal", func(t *testing.T) { check, _, roles := newCheck() _, err := check.RequireFor(user.User{ID: 13, RoleID: 99}, "content.read", "events") requireMissingKey(t, err, "content.read") roles.fail.failNext("Get") _, err = check.RequireFor(owner, "content.read", "events") if err == nil || errors.Is(err, role.ErrMissingPermission) { t.Errorf("expected a plain failure, got %v", err) } }) t.Run("A grant change applies to the next call", func(t *testing.T) { check, _, roles := newCheck() if _, err := check.RequireFor(writer, "content.delete", "article"); !errors.Is(err, role.ErrMissingPermission) { t.Fatalf("expected a refusal first, got %v", err) } stored := roles.roles[writerRoleID] stored.Grants = append(stored.Grants, role.Grant{Key: "content.delete", Scope: role.ScopeOwn}) roles.roles[writerRoleID] = stored if _, err := check.RequireFor(writer, "content.delete", "article"); err != nil { t.Fatalf("expected a pass after the edit, got %v", err) } }) t.Run("One check loads the role once and never lists grants", func(t *testing.T) { check, counting, _ := newCheck() if _, err := check.Require(writer, "content.read"); err != nil { t.Fatal(err) } if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 { t.Errorf("unexpected loads %+v", counting) } counting.gets = 0 if _, err := check.Require(user.User{ID: 4, RoleID: adminRoleID}, "content.read"); err != nil { t.Fatal(err) } if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 { t.Errorf("unexpected loads %+v", counting) } }) t.Run("Grants gives an empty set for an unresolvable role and an error for a failure", func(t *testing.T) { check, _, roles := newCheck() empty, err := check.Grants(user.User{ID: 13, RoleID: 99}) if err != nil || empty.Holds("content.read") { t.Errorf("expected an empty set, got %v %v", empty, err) } roles.fail.failNext("Get") if _, err := check.Grants(owner); err == nil { t.Errorf("expected the failure") } }) } func TestContentScopeGetMyAccessUseCase(t *testing.T) { t.Run("The effective grants are normalised and read in one load", func(t *testing.T) { roles := newSeededMockRoleRepo() counting := &contentScopeRoleRepo{Repository: roles} custom, _ := roles.Create(role.Role{Name: "Messy", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all", "schema.view=own")}) access, err := useCases.NewGetMyAccessUseCase(counting, newMockUserRepo()).Get(user.User{ID: 30, RoleID: custom.ID}) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(access.Permissions, grantRows("content.update=own", "content.update:blog=all")) { t.Errorf("unexpected permissions %+v (typed entries superseded by roles_permissions_roles_api)", access.Permissions) } if counting.gets != 1 || counting.lists != 0 { t.Errorf("unexpected loads %+v", counting) } }) t.Run("The Owner holds the 34 keys", func(t *testing.T) { access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo(), newMockUserRepo()).Get(user.User{ID: 3, RoleID: ownerRoleID}) if err != nil || len(access.Permissions) != 34 { t.Errorf("expected 34 permissions, got %d (%v)", len(access.Permissions), err) } }) } type articleCase struct { name string caller useCases.ContentCaller id uint32 err error missing string forbidden bool } func seededScopeArticles() *mockArticleRepo { repo := newMockArticleRepo() for _, seed := range contentScopeArticles { repo.articles[seed.id] = article.Article{ID: seed.id, Title: "Article", Body: "x", IsOnline: seed.online, SiteID: 1, CreatedBy: seed.createdBy} } return repo } func TestContentScopeArticleUseCases(t *testing.T) { newRepos := func() (*mockArticleRepo, *mockSiteRepo, *mockAuthorRepo, *mockImageRepo) { repo := seededScopeArticles() return repo, plainSites(), newMockAuthorRepo(repo), newMockImageRepo() } t.Run("GetArticle shows online articles and the articles the caller may read", func(t *testing.T) { repo, _, _, _ := newRepos() get := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}) cases := []struct { name string caller useCases.ContentCaller id uint32 found bool }{ {"anonymous reads an online article", useCases.ContentCaller{}, 12, true}, {"anonymous cannot read a draft", useCases.ContentCaller{}, 10, false}, {"read all reads any draft", callerWith(9, "content.read=all"), 10, true}, {"read own reads an own draft", callerWith(22, "content.read=own"), 16, true}, {"read own cannot read a foreign draft", callerWith(22, "content.read=own"), 10, false}, {"read own cannot read an unattributed draft", callerWith(22, "content.read=own"), 14, false}, {"a typed read on another type does not show drafts", callerWith(26, "content.read:events=all"), 10, false}, {"a typed none row hides drafts despite the wildcard", callerWith(9, "content.read=all", "content.read:article=none"), 10, false}, {"caller 0 owns nothing", callerWith(0, "content.read=own"), 14, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := get.GetArticle(c.id, article.BodyFormatHTML, c.caller) if c.found && err != nil || !c.found && !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("found=%v, got %v", c.found, err) } }) } }) t.Run("ListArticlesPage returns the visible set with matching counts", func(t *testing.T) { repo, sites, _, _ := newRepos() list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, sites) cases := []struct { name string caller useCases.ContentCaller status string ids []uint32 counts article.Counts }{ {"anonymous", useCases.ContentCaller{}, "", scopeIDs(12, 15), article.Counts{All: 2, Online: 2}}, {"read all", callerWith(9, "content.read=all"), "", scopeIDs(10, 11, 12, 13, 14, 15, 16, 17), article.Counts{All: 8, Online: 2, Draft: 6}}, {"read own", callerWith(22, "content.read=own"), "", scopeIDs(12, 15, 16), article.Counts{All: 3, Online: 2, Draft: 1}}, {"read own, drafts", callerWith(22, "content.read=own"), "draft", scopeIDs(16), article.Counts{All: 3, Online: 2, Draft: 1}}, {"read own, online", callerWith(22, "content.read=own"), "online", scopeIDs(12, 15), article.Counts{All: 3, Online: 2, Draft: 1}}, {"typed none", callerWith(9, "content.read=all", "content.read:article=none"), "", scopeIDs(12, 15), article.Counts{All: 2, Online: 2}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { page, err := list.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: c.caller, Page: 1, Size: 25, Status: c.status}) if err != nil { t.Fatal(err) } got := []uint32{} for _, item := range page.Items { got = append(got, item.ID) } sortIDs(got) page.Counts.Suggestions = 0 if !sameScopeIDs(got, c.ids) || page.Total != len(c.ids) || page.Counts != c.counts { t.Errorf("expected %v %+v, got %v total %d %+v", c.ids, c.counts, got, page.Total, page.Counts) } }) } }) t.Run("Update, publish and delete decide by the scope of the stored article", func(t *testing.T) { cases := []struct { name string call func(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error key string caller useCases.ContentCaller id uint32 allowed bool }{ {"update own", updateCall, "content.update", callerWith(21, "content.update=own"), 10, true}, {"update foreign", updateCall, "content.update", callerWith(21, "content.update=own"), 11, false}, {"update unattributed", updateCall, "content.update", callerWith(21, "content.update=own"), 14, false}, {"update all", updateCall, "content.update", callerWith(9, "content.update=all"), 14, true}, {"update refused by a typed none row", updateCall, "content.update", callerWith(25, "content.update=all", "content.update:article=none"), 10, false}, {"publish own", publishCall, "content.publish", callerWith(24, "content.publish=own"), 17, true}, {"publish foreign", publishCall, "content.publish", callerWith(24, "content.publish=own"), 10, false}, {"publish is not implied by update", publishCall, "content.publish", callerWith(9, "content.update=all"), 10, false}, {"delete own", deleteCall, "content.delete", callerWith(23, "content.delete=own"), 13 + 100, true}, {"delete foreign", deleteCall, "content.delete", callerWith(23, "content.delete=own"), 13, false}, {"delete is not implied by update", deleteCall, "content.delete", callerWith(9, "content.update=all"), 11, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { repo, sites, authors, images := newRepos() repo.articles[113] = article.Article{ID: 113, Title: "Owned", CreatedBy: 23, SiteID: 1} before := len(repo.articles) snapshot := repo.articles[c.id] err := c.call(repo, sites, authors, images, c.caller, c.id) if c.allowed { if err != nil { t.Fatalf("expected success, got %v", err) } return } requireMissingKey(t, err, c.key) if len(repo.articles) != before || !sameArticle(repo.articles[c.id], snapshot) { t.Errorf("a refused call must change nothing") } }) } }) t.Run("A refused update performs no site, author or image lookup and records nothing", func(t *testing.T) { repo, sites, authors, images := newRepos() sites.fail.failNext("Get") authors.fail.failNext("Get") images.fail.failNext("Get") recorder := &contentScopeRecorder{} update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, recorder, sites, authors, images) missingSite := uint32(5) _, err := update.Update(testCtx, 11, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{SiteID: &missingSite, AuthorIDs: &[]uint32{7}}) requireMissingKey(t, err, "content.update") if sites.fail.method != "Get" || authors.fail.method != "Get" || len(recorder.events) != 0 { t.Errorf("a refused update must not look anything up or record an event") } }) t.Run("An unknown id is not found even under own scope", func(t *testing.T) { repo, sites, authors, images := newRepos() update := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images) body := "x" _, err := update.Update(testCtx, 999, callerWith(21, "content.update=own"), useCases.UpdateArticleCommand{Body: &body}) if !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("expected not found, got %v", err) } }) t.Run("CreateArticle needs content.create for the article type and stores the caller", func(t *testing.T) { repo, sites, authors, _ := newRepos() create := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors) created, err := create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(25, "content.create=all", "content.update:article=none")}) if err != nil || created.CreatedBy != 25 || created.IsOnline { t.Fatalf("expected a draft created by 25, got %+v %v", created, err) } before := len(repo.articles) _, err = create.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "A new draft", Caller: callerWith(26, "content.create:events=all")}) requireMissingKey(t, err, "content.create") if len(repo.articles) != before || repo.createCalled && before != len(repo.articles) { t.Errorf("nothing may be stored") } }) t.Run("The SEO check and resolve follow the read visibility", func(t *testing.T) { repo, sites, _, images := newRepos() check := useCases.NewCheckArticleSEOUseCase(repo, sites, images) resolve := useCases.NewResolveArticleSEOUseCase(repo, sites, images) reader := callerWith(22, "content.read=own") if _, err := check.Check(16, reader, useCases.CheckArticleSEOCommand{}); err != nil { t.Errorf("own draft: %v", err) } if _, err := check.Check(10, reader, useCases.CheckArticleSEOCommand{}); !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("foreign draft: %v", err) } _, err := check.Check(12, callerWith(27, "schema.view=all"), useCases.CheckArticleSEOCommand{}) requireMissingKey(t, err, "content.read") if _, err := resolve.Resolve(10, useCases.ContentCaller{}); !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("anonymous draft: %v", err) } if _, err := resolve.Resolve(10, reader); !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("foreign draft: %v", err) } if _, err := resolve.Resolve(16, reader); err != nil { t.Errorf("own draft: %v", err) } }) } func sortIDs(ids []uint32) { for i := range ids { for j := i + 1; j < len(ids); j++ { if ids[j] < ids[i] { ids[i], ids[j] = ids[j], ids[i] } } } } func updateCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { body := "new" _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).Update(testCtx, id, caller, useCases.UpdateArticleCommand{Body: &body}) return err } func publishCall(repo *mockArticleRepo, sites *mockSiteRepo, authors *mockAuthorRepo, images *mockImageRepo, caller useCases.ContentCaller, id uint32) error { _, err := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, authors, images).UpdateIsOnline(testCtx, id, caller, true, article.BodyFormatHTML) return err } func deleteCall(repo *mockArticleRepo, _ *mockSiteRepo, _ *mockAuthorRepo, _ *mockImageRepo, caller useCases.ContentCaller, id uint32) error { return useCases.NewDeleteArticleUseCase(repo, discardRecorder).DeleteArticle(testCtx, id, caller) } func TestContentScopeEntryUseCases(t *testing.T) { type entryWorld struct { types *mockContentTypeRepo entries *mockEntryRepo lookups *contentScopeTypeRepo } newWorld := func() entryWorld { world := newContentScopeWorld(t) return entryWorld{types: world.typeStore, entries: world.entries, lookups: world.typeRepo} } clock := func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) } data := map[string]any{"title": "Updated"} t.Run("Get shows only the entries the caller may read", func(t *testing.T) { w := newWorld() get := useCases.NewGetEntryUseCase(w.lookups, w.entries, newMockImageRepo()) if _, err := get.Get("events", 6, callerWith(22, "content.read=own")); err != nil { t.Errorf("own entry: %v", err) } for _, id := range []uint32{1, 3} { if _, err := get.Get("events", id, callerWith(22, "content.read=own")); !errors.Is(err, entry.ErrEntryNotFound) { t.Errorf("entry %d: expected not found, got %v", id, err) } } w.lookups.lookups = 0 _, err := get.Get("events", 1, callerWith(27, "schema.view=all")) requireMissingKey(t, err, "content.read") if w.lookups.lookups != 0 { t.Errorf("a refused read must not look the content type up") } }) t.Run("ListPage filters by scope before totals, search and paging", func(t *testing.T) { w := newWorld() list := useCases.NewListEntriesUseCase(w.lookups, w.entries, newMockImageRepo()) sortByID := entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc} own, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(22, "content.read=own")}) if err != nil || own.Total != 1 || len(own.Items) != 1 || own.Items[0].ID != 6 { t.Fatalf("expected entry 6 only, got %+v %v", own, err) } all, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(21, "content.read=all")}) if err != nil || all.Total != 7 || len(all.Items) != 5 { t.Fatalf("expected 7 entries over 5 rows, got %+v %v", all, err) } searched, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "events", Page: 1, Size: 5, Sort: sortByID, Query: "Entry 1", Caller: callerWith(22, "content.read=own")}) if err != nil || searched.Total != 0 { t.Fatalf("a search under own covers own entries only, got %+v %v", searched, err) } w.lookups.lookups = 0 _, err = list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "nope", Page: 1, Size: 5, Sort: sortByID, Caller: callerWith(26, "content.read:events=all")}) requireMissingKey(t, err, "content.read") if w.lookups.lookups != 0 { t.Errorf("a refused list must not look the content type up") } }) t.Run("Create stores the caller as creator and needs content.create for the type", func(t *testing.T) { w := newWorld() create := useCases.NewCreateEntryUseCase(w.lookups, w.entries, newMockImageRepo(), clock) created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "events", Data: data, Caller: callerWith(26, "content.create:events=all")}) if err != nil || created.CreatedBy != 26 || created.UpdatedBy != 26 { t.Fatalf("expected an entry by 26, got %+v %v", created, err) } before := len(w.entries.entries) _, err = create.Create(useCases.CreateEntryCommand{ContentTypeKey: "venues", Data: data, Caller: callerWith(26, "content.create:events=all")}) requireMissingKey(t, err, "content.create") if len(w.entries.entries) != before { t.Errorf("nothing may be stored") } }) t.Run("Update checks the scope after the load and before the validation", func(t *testing.T) { w := newWorld() update := useCases.NewUpdateEntryUseCase(w.lookups, w.entries, newMockImageRepo(), clock) contributor := callerWith(21, "content.update=own") _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 2, Data: map[string]any{"title": ""}, Caller: contributor}) requireMissingKey(t, err, "content.update") var fieldErr entry.FieldError if errors.As(err, &fieldErr) { t.Errorf("the scope must be refused before the validation") } if _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 999, Data: data, Caller: contributor}); !errors.Is(err, entry.ErrEntryNotFound) { t.Errorf("an unknown id is not found, got %v", err) } if _, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "nope", ID: 1, Data: data, Caller: contributor}); !errors.Is(err, contenttype.ErrContentTypeNotFound) { t.Errorf("an unknown type is not found, got %v", err) } stored, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "events", ID: 1, Data: data, Caller: contributor}) if err != nil || stored.CreatedBy != 21 || stored.UpdatedBy != 21 { t.Errorf("expected an own update keeping the owner, got %+v %v", stored, err) } if w.entries.updateCalls != 1 { t.Errorf("only the allowed update may write, got %d", w.entries.updateCalls) } }) t.Run("Delete decides by the scope of the stored entry", func(t *testing.T) { w := newWorld() remove := useCases.NewDeleteEntryUseCase(w.lookups, w.entries) editor := callerWith(23, "content.delete=own") requireMissingKey(t, remove.Delete("events", 2, editor), "content.delete") if _, present := w.entries.entries[2]; !present { t.Errorf("entry 2 must be stored") } if err := remove.Delete("events", 8, editor); err != nil { t.Errorf("own entry: %v", err) } if err := remove.Delete("events", 999, editor); !errors.Is(err, entry.ErrEntryNotFound) { t.Errorf("unknown entry: %v", err) } w.lookups.lookups = 0 requireMissingKey(t, remove.Delete("events", 8, callerWith(9, "content.update=all")), "content.delete") if w.lookups.lookups != 0 { t.Errorf("a refused delete must not look anything up") } }) } func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) { for _, key := range []string{"article", "author", "site", "media", "user"} { t.Run(key, func(t *testing.T) { types := newMockContentTypeRepo() create := useCases.NewCreateContentTypeUseCase(types, newMockRoleRepo(), time.Now) _, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}}) if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 { t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls) } }) } } func TestContentScopeCreateArticleAuthorLookupFailure(t *testing.T) { authors := plainAuthors() boom := errors.New("author store down") authors.fail.failNextWith("GetByUserID", boom) articles := newMockArticleRepo() create := useCases.NewCreateArticleUseCase(articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), authors) caller := useCases.ContentCaller{UserID: 9, Grants: role.Role{Grants: grantRows("content.create=all")}.GrantSet()} _, err := create.CreateArticle(context.Background(), useCases.CreateArticleCommand{Title: "Hello", Body: "x", Caller: caller}) if !errors.Is(err, boom) { t.Fatalf("expected the author lookup error, got %v", err) } if len(articles.articles) != 0 { t.Errorf("no article may be stored, got %d", len(articles.articles)) } } func TestContentScopeListArticlesSiteLookupFailure(t *testing.T) { boom := errors.New("site store down") key := "default" grantsFor := map[string]role.GrantSet{ "all": role.Role{Grants: grantRows("content.read=all")}.GrantSet(), "own": role.Role{Grants: grantRows("content.read=own")}.GrantSet(), "none": role.Role{Grants: grantRows("schema.view=all")}.GrantSet(), } for name, grants := range grantsFor { t.Run(name, func(t *testing.T) { sites := plainSites() sites.fail.failNextWith("GetByKey", boom) list := useCases.NewListArticlesUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, sites) _, err := list.ListArticlesPage(useCases.ListArticlesCommand{Page: 1, Size: 10, SiteKey: &key, Caller: useCases.ContentCaller{UserID: 9, Grants: grants}}) if !errors.Is(err, boom) { t.Fatalf("expected the site lookup error, got %v", err) } }) } }