527 lines
24 KiB
Go
527 lines
24 KiB
Go
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).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()).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)
|
|
}
|
|
})
|
|
}
|
|
}
|