refac: debt on role backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 23:34:35 +01:00
1 parent de142f6c5f
commit a043f7f41a
130 files changed
+3591 -512

No files matched your search

+525
View File
@@ -0,0 +1,525 @@
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"
"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.listGrantsN != 0 {
t.Errorf("unexpected loads %+v", counting)
}
counting.gets = 0
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
t.Fatal(err)
}
if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 {
t.Errorf("unexpected legacy 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).Get(user.User{ID: 30, RoleID: custom.ID})
if err != nil {
t.Fatal(err)
}
if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn {
t.Errorf("unexpected permissions %+v", access.Permissions)
}
if counting.gets != 1 || counting.listGrantsN != 0 {
t.Errorf("unexpected loads %+v", counting)
}
})
t.Run("The Owner holds the 34 keys", func(t *testing.T) {
access, err := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()).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, 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)
}
})
}
}