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

+3 -3
View File
@@ -130,7 +130,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: strings.Repeat("a", example.before)}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), strings.Repeat("b", example.after), article.BodyFormatHTML); err != nil {
t.Fatal(err)
}
@@ -149,7 +149,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
repo.articles[8] = article.Article{ID: 8, Title: "Spring release", Body: "héllo"}
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors(), newMockImageRepo())
if _, err := uc.UpdateBody(producerOwnerContext(), 8, "héllo wörld", article.BodyFormatHTML); err != nil {
if _, err := uc.UpdateBody(producerOwnerContext(), 8, ownerCaller(1), "héllo wörld", article.BodyFormatHTML); err != nil {
t.Fatal(err)
}
@@ -189,7 +189,7 @@ func TestActivityLogProducersArticleChanges(t *testing.T) {
h := newActivityHarness(t)
uc := useCases.NewCreateArticleUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, h.recorder, plainSites(), plainAuthors())
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Title: "ab", Body: "Hello"})
_, err := uc.CreateArticle(producerOwnerContext(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "ab", Body: "Hello"})
if !errors.Is(err, article.ErrInvalidTitle) {
t.Fatalf("err = %v", err)
+2 -2
View File
@@ -175,8 +175,8 @@ func seedStubKey(repo *mockAPIKeyRepo, raw string, mutate func(*apikey.APIKey))
}
var (
apiKeyOwnScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeOwn}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeOwn}})
apiKeyAllScope = role.NewGrantSet([]role.Grant{{Key: role.KeyAPIKeysView, Scope: role.ScopeAll}, {Key: role.KeyAPIKeysRevoke, Scope: role.ScopeAll}})
apiKeyOwnScope = unlockedGrantSet(role.KeyAPIKeysView+"=own", role.KeyAPIKeysRevoke+"=own")
apiKeyAllScope = unlockedGrantSet(role.KeyAPIKeysView+"=all", role.KeyAPIKeysRevoke+"=all")
)
func apiKeyUnitUsers() *mockUserRepo {
+1 -1
View File
@@ -48,13 +48,13 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
+3 -2
View File
@@ -19,7 +19,8 @@ import (
)
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
routerUsers := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(routerUsers)
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors()),
@@ -27,7 +28,6 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
@@ -35,6 +35,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Group(func(r chi.Router) {
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
+1 -1
View File
@@ -28,7 +28,6 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
@@ -36,6 +35,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/", articleHandler.ListArticles)
r.Group(func(r chi.Router) {
r.Use(authtoken.Authenticator)
+9 -4
View File
@@ -24,6 +24,7 @@ type mockArticleRepo struct {
credits map[uint32][]uint32
authors *mockAuthorRepo
reportSlugRace bool
raceCreates int
createAlwaysRaces bool
afterFindByBody func()
updateBodyIDs []uint32
@@ -298,6 +299,10 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
return article.Article{}, err
}
if m.createAlwaysRaces {
m.raceCreates++
if m.raceCreates > 100 {
return article.Article{}, errors.New("create budget exceeded")
}
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
@@ -443,7 +448,7 @@ func TestCreateArticle(t *testing.T) {
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title", Body: "Content"}
res, err := uc.CreateArticle(testCtx, cmd)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
@@ -454,7 +459,7 @@ func TestCreateArticle(t *testing.T) {
})
t.Run("Short Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Ab", Body: "Content"}
cmd := useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Ab", Body: "Content"}
_, err := uc.CreateArticle(testCtx, cmd)
if !errors.Is(err, article.ErrInvalidTitle) {
t.Errorf("Expected ErrInvalidTitle")
@@ -469,14 +474,14 @@ func TestGetOnlineArticle(t *testing.T) {
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Article Online", func(t *testing.T) {
_, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
_, err := uc.GetArticle(1, article.BodyFormatHTML, useCases.ContentCaller{})
if err != nil {
t.Errorf("Expected article to be found")
}
})
t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
_, err := uc.GetArticle(2, article.BodyFormatHTML, useCases.ContentCaller{})
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("Offline article should return not found")
}
+2 -2
View File
@@ -31,13 +31,13 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {
@@ -90,7 +90,7 @@ func TestArticleTitleRunesInUseCase(t *testing.T) {
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
title := strings.Repeat("\U0001F600", tc.runeCount)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
if tc.created {
if err != nil {
+1 -1
View File
@@ -25,13 +25,13 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/", articleHandler.ListArticles)
})
})
+2 -2
View File
@@ -579,7 +579,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
repo := newMockArticleRepo()
seedSortArticles(repo)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 {
t.Fatalf("unexpected %v %+v", err, sorted)
}
@@ -595,7 +595,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
seedPaginationArticles(repo, 3, 0)
repo.fail.failNextWith(method, sentinel)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25})
if !errors.Is(err, sentinel) {
t.Fatalf("%s: expected sentinel, got %v", method, err)
}
@@ -353,11 +353,10 @@ func TestBuiltinTypeHTTP(t *testing.T) {
}
})
t.Run("Reading one built-in type does not collide with a custom type of the same key", func(t *testing.T) {
t.Run("Reading one built-in type does not collide with a custom type", func(t *testing.T) {
env := newSupportEnv(t)
env.createType(t, "builtin", dishFields())
env.createType(t, "article", dishFields())
for _, key := range []string{"builtin", "article"} {
for _, key := range []string{"builtin"} {
rec := env.admin(t, http.MethodGet, "/v1/content-type/"+key, "")
expectStatusAndBody(t, rec, http.StatusOK, "")
if got := objectKeys(decodeContentObject(t, rec)); !reflect.DeepEqual(got, supportContentTypeKeys) {
@@ -369,8 +368,8 @@ func TestBuiltinTypeHTTP(t *testing.T) {
t.Errorf("expected the built-in article, got %v", item["name"])
}
listed := decodeContentArray(t, env.admin(t, http.MethodGet, "/v1/content-type", ""))
if len(listed) != 2 {
t.Errorf("expected 2 custom types, got %d", len(listed))
if len(listed) != 1 {
t.Errorf("expected 1 custom type, got %d", len(listed))
}
})
@@ -410,3 +410,7 @@ func expectStatusAndBody(t *testing.T, rec *httptest.ResponseRecorder, status in
t.Errorf("expected body %q, got %q", body, rec.Body.String())
}
}
func (e *contentTypeEnv) giveMemberEntryAccess() {
e.users.users[ctMemberId] = user.User{ID: ctMemberId, Username: "pat", IsVerified: true, RoleID: adminRoleID}
}
+4 -2
View File
@@ -475,6 +475,7 @@ func TestUniqueEntryValues(t *testing.T) {
func TestReadingAndChangingEntries(t *testing.T) {
t.Run("Getting an entry", func(t *testing.T) {
env := newContentTypeEnv(t)
env.giveMemberEntryAccess()
env.createType(t, "recipe", recipeFields())
env.createType(t, "dish", dishFields())
env.createEntry(t, "recipe", ctAdminId, `{"title": "Pad thai", "difficulty": "easy"}`)
@@ -509,6 +510,7 @@ func TestReadingAndChangingEntries(t *testing.T) {
for _, example := range examples {
t.Run(example.method+" "+example.path, func(t *testing.T) {
env := newContentTypeEnv(t)
env.giveMemberEntryAccess()
env.createType(t, "recipe", recipeFields())
rec := env.as(t, ctMemberId, example.method, example.path, example.body)
@@ -554,6 +556,7 @@ func TestReadingAndChangingEntries(t *testing.T) {
func TestListingEntries(t *testing.T) {
seed := func(t *testing.T) *contentTypeEnv {
env := newContentTypeEnv(t)
env.giveMemberEntryAccess()
env.createType(t, "recipe", recipeFields())
env.createType(t, "dish", dishFields())
for index := 1; index <= 30; index++ {
@@ -662,10 +665,9 @@ func TestEntryRouteAccess(t *testing.T) {
t.Run("Built-in resources are not reachable through the generic engine", func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "article", dishFields())
rec := env.admin(t, http.MethodGet, "/v1/content-type/article/entry", "")
expectStatusAndBody(t, rec, http.StatusOK, `{"items":[],"total":0,"page":1,"size":25}`)
expectStatusAndBody(t, rec, http.StatusNotFound, apperr.ContentTypeNotFoundErrorMessage)
})
}
+5 -5
View File
@@ -66,7 +66,7 @@ func TestCreateEntryUseCase(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, UserID: 2})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, Caller: ownerCaller(2)})
if !errors.Is(err, entry.ErrInvalidEntry) || fixture.entries.createCalls != 0 {
t.Errorf("data %v: err %v, creates %d", data, err, fixture.entries.createCalls)
@@ -78,11 +78,11 @@ func TestCreateEntryUseCase(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
data := func() map[string]any { return map[string]any{"title": "Pad thai", "difficulty": "easy"} }
if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2}); err != nil {
if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), Caller: ownerCaller(2)}); err != nil {
t.Fatalf("first create: %v", err)
}
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), Caller: ownerCaller(2)})
if !errors.Is(err, entry.ErrUniqueViolation) || fixture.entries.createCalls != 1 {
t.Errorf("err %v, creates %d", err, fixture.entries.createCalls)
@@ -93,7 +93,7 @@ func TestCreateEntryUseCase(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, UserID: 2})
created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, Caller: ownerCaller(2)})
if err != nil || !created.CreatedAt.Equal(fixture.now()) || !created.UpdatedAt.Equal(fixture.now()) || created.CreatedBy != 2 {
t.Errorf("unexpected %+v %v", created, err)
@@ -564,7 +564,7 @@ func TestEntryPagingAndEquality(t *testing.T) {
{1, 25, entry.Sort{}, entry.ErrInvalidSort},
}
for _, c := range cases {
_, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "recipe", Page: c.page, Size: c.size, Sort: c.sort})
_, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "recipe", Page: c.page, Size: c.size, Sort: c.sort})
if !errors.Is(err, c.err) {
t.Errorf("page %d size %d sort %v: %v", c.page, c.size, c.sort, err)
}
+1 -1
View File
@@ -29,7 +29,6 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
@@ -37,6 +36,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Group(func(r chi.Router) {
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
+6 -6
View File
@@ -107,7 +107,7 @@ func TestMediaFieldUseCases(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(999)}, UserID: 2})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(999)}, Caller: ownerCaller(2)})
expectFieldError(t, err, "thumbnail", contenttype.RuleType)
if fixture.entries.createCalls != 0 {
@@ -119,7 +119,7 @@ func TestMediaFieldUseCases(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"thumbnail": float64(999)}, UserID: 2})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"thumbnail": float64(999)}, Caller: ownerCaller(2)})
expectFieldError(t, err, "title", entry.RuleRequired)
})
@@ -129,7 +129,7 @@ func TestMediaFieldUseCases(t *testing.T) {
fixture.images.fail.failNext("Get")
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(7)}, UserID: 2})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(7)}, Caller: ownerCaller(2)})
if !errors.Is(err, errRepositoryFailure) || fixture.entries.createCalls != 0 {
t.Errorf("err %v", err)
@@ -157,7 +157,7 @@ func TestMediaFieldUseCases(t *testing.T) {
fixture.entries.createCalls = 0
update := useCases.NewUpdateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "thumbnail": example.value}, UserID: 3})
_, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "thumbnail": example.value}, Caller: ownerCaller(3)})
if example.rule == "" {
if err != nil || fixture.entries.updateCalls != 1 {
@@ -194,7 +194,7 @@ func TestMediaFieldUseCases(t *testing.T) {
spy := &mfImageSpyRepo{mockImageRepo: fixture.images}
list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries, spy)
page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "event", Page: 1, Size: 25, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
page, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "event", Page: 1, Size: 25, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
if err != nil || spy.gets != 2 || len(page.Items) != 3 {
t.Fatalf("err %v lookups %d", err, spy.gets)
@@ -217,7 +217,7 @@ func TestMediaFieldUnknownDimensions(t *testing.T) {
}
get := useCases.NewGetEntryUseCase(fixture.types, fixture.entries, fixture.images)
found, err := get.Get("event", created.ID)
found, err := get.Get("event", created.ID, ownerCaller(1))
if err != nil {
t.Fatalf("get: %v", err)
+1 -1
View File
@@ -131,7 +131,6 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
hasher := hashing.NewBcryptHasher()
loginHandler := auth.NewLoginHandler(
@@ -145,6 +144,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/{id}", articleHandler.GetArticle)
})
r.Route("/auth", func(r chi.Router) {
+1 -1
View File
@@ -47,13 +47,13 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Group(func(r chi.Router) {
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
+1 -1
View File
@@ -29,13 +29,13 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
+1 -1
View File
@@ -190,7 +190,7 @@ func TestRelationFieldTypeContentTypes(t *testing.T) {
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
for range 12 {
env.createEntry(t, "event", ctMemberId, `{"title":"A"}`)
env.createEntry(t, "event", ctAdminId, `{"title":"A"}`)
}
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("venue", "Venue", "venue", "one_to_many", example.required)}
+8 -8
View File
@@ -92,13 +92,13 @@ func TestRelationFieldUseCases(t *testing.T) {
create := useCases.NewCreateEntryUseCase(env.types, env.entries, images, env.now)
update := useCases.NewUpdateEntryUseCase(env.types, env.entries, images, env.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, Caller: ownerCaller(1)})
expectFieldError(t, err, "link", contenttype.RuleType)
stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, Caller: ownerCaller(1)})
if err != nil || env.entries.createCalls != 1 {
t.Fatalf("create: %v", err)
}
_, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
_, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, Caller: ownerCaller(1)})
expectFieldError(t, err, "link", contenttype.RuleType)
if env.entries.updateCalls != 0 || env.entries.createCalls != 1 {
t.Errorf("writes: %d %d", env.entries.updateCalls, env.entries.createCalls)
@@ -141,7 +141,7 @@ func TestRelationFieldUseCases(t *testing.T) {
env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A", "link": float64(3)}}
env.entries.failGetOn = 2
if _, err := get.Get("event", 9); !errors.Is(err, errRepositoryFailure) {
if _, err := get.Get("event", 9, ownerCaller(1)); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err %v", err)
}
})
@@ -169,7 +169,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
now := func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }
images := newMockImageRepo()
created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, UserID: 1})
created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, Caller: ownerCaller(1)})
if err != nil || len(created.Relations) != 0 {
t.Fatalf("create: %v %v", err, created.Relations)
}
@@ -179,7 +179,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID); err != nil {
if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID, ownerCaller(1)); err != nil {
t.Fatalf("get: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 1 {
@@ -188,7 +188,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
types.getByKeyCalls = 0
entries.getCalls = 0
updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, UserID: 1})
updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, Caller: ownerCaller(1)})
if err != nil || len(updated.Relations) != 0 {
t.Fatalf("update: %v", err)
}
@@ -198,7 +198,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil {
if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil {
t.Fatalf("list: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 0 {
@@ -25,7 +25,7 @@ func TestRelationFieldListQueryClampsPaging(t *testing.T) {
}
for _, example := range cases {
t.Run(example.name, func(t *testing.T) {
page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"})
page, err := list.ListPage(useCases.ListEntriesCommand{Caller: ownerCaller(1), ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"})
if err != nil {
t.Fatalf("err %v", err)
}
@@ -43,6 +43,6 @@ func TestRelationFieldTargetWithoutFieldsIsMissing(t *testing.T) {
env.types.types[venue.ID] = venue
create := useCases.NewCreateEntryUseCase(env.types, env.entries, newMockImageRepo(), func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) })
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, Caller: ownerCaller(1)})
expectFieldError(t, err, "link", contenttype.RuleType)
}
+15 -16
View File
@@ -197,19 +197,19 @@ func TestValidateGrants(t *testing.T) {
})
}
func TestRoleAllowsKindAndOwnerOnly(t *testing.T) {
func TestRoleGrantSetKindAndOwnerOnly(t *testing.T) {
t.Run("A toggle grant at scope own does not count", func(t *testing.T) {
if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) {
if roleWithGrant("schema.view", role.ScopeOwn).GrantSet().Holds("schema.view") {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) {
if roleWithGrant("system.apply_updates", role.ScopeAll).Allows("system.apply_updates", true) {
if roleWithGrant("system.apply_updates", role.ScopeAll).GrantSet().Holds("system.apply_updates") {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) {
if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) {
if !role.BuiltInRoles()[0].GrantSet().Holds("system.apply_updates") {
t.Errorf("expected true")
}
})
@@ -247,34 +247,33 @@ func TestParseTypedKey(t *testing.T) {
})
}
func TestRoleAllowsFor(t *testing.T) {
func TestGrantSetTypedDecisions(t *testing.T) {
t.Run("A per-type override denies despite the wildcard", func(t *testing.T) {
r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}}
if r.AllowsFor("content.update", "blog", false) {
set := unlockedGrantSet("content.update=all", "content.update:blog=own")
if set.Permits("content.update", "blog", 1, 2) {
t.Errorf("override should deny a non-owned entry")
}
if !r.AllowsFor("content.update", "blog", true) {
if !set.Permits("content.update", "blog", 1, 1) {
t.Errorf("override should allow an owned entry")
}
if !r.AllowsFor("content.update", "news", false) {
if !set.Permits("content.update", "news", 1, 2) {
t.Errorf("other types fall back to the wildcard")
}
})
t.Run("A per-type override grants without a wildcard", func(t *testing.T) {
r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}}
if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) {
set := unlockedGrantSet("content.read:blog=all")
if !set.Permits("content.read", "blog", 1, 2) || set.Permits("content.read", "news", 1, 2) {
t.Errorf("override should apply to blog only")
}
})
t.Run("An empty type key behaves like Allows", func(t *testing.T) {
r := roleWithGrant("content.read", role.ScopeAll)
if !r.AllowsFor("content.read", "", false) {
t.Run("An empty type key reads the wildcard", func(t *testing.T) {
if !roleWithGrant("content.read", role.ScopeAll).GrantSet().Permits("content.read", "", 1, 2) {
t.Errorf("expected true")
}
})
t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) {
r := role.BuiltInRoles()[0]
if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) {
set := role.BuiltInRoles()[0].GrantSet()
if set.Permits("content.read", "Bad Key", 1, 1) || set.Permits("schema.view", "blog", 1, 1) {
t.Errorf("expected false")
}
})
+295
View File
@@ -0,0 +1,295 @@
package test
import (
"RenewCMS/internal/domain/role"
"errors"
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"reflect"
"slices"
"strconv"
"strings"
"testing"
)
func TestGrantSetScopeFor(t *testing.T) {
longKey := strings.Repeat("a", 101)
cases := []struct {
grants []string
key string
typeKey string
want role.Scope
}{
{[]string{"content.update=all"}, "content.update", "", role.ScopeAll},
{[]string{"content.update=all"}, "content.update", "blog", role.ScopeAll},
{[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn},
{[]string{"content.update=all", "content.update:blog=own"}, "content.update", "news", role.ScopeAll},
{[]string{"content.update=all", "content.update:blog=none"}, "content.update", "blog", role.ScopeNone},
{[]string{"content.update=own", "content.update:blog=all"}, "content.update", "blog", role.ScopeAll},
{[]string{"content.update:blog=all"}, "content.update", "blog", role.ScopeAll},
{[]string{"content.update:blog=all"}, "content.update", "news", role.ScopeNone},
{[]string{"content.update:blog=all"}, "content.update", "", role.ScopeNone},
{[]string{"content.create=all", "content.create:blog=none"}, "content.create", "blog", role.ScopeNone},
{[]string{"content.create=all", "content.create:blog=all"}, "content.create", "blog", role.ScopeAll},
{[]string{"content.read=all"}, "content.read", "Bad Key", role.ScopeNone},
{[]string{"content.read=all"}, "content.read", longKey, role.ScopeNone},
{[]string{"schema.view=all"}, "schema.view", "blog", role.ScopeNone},
{[]string{"schema.view=all"}, "schema.view", "", role.ScopeAll},
{[]string{"content.explode=all"}, "content.explode", "", role.ScopeNone},
{nil, "content.read", "", role.ScopeNone},
}
for _, c := range cases {
name := strings.Join(c.grants, ", ") + " | " + c.key + " | " + c.typeKey
if len(name) > 90 {
name = name[:90]
}
t.Run(name, func(t *testing.T) {
if got := unlockedGrantSet(c.grants...).ScopeFor(c.key, c.typeKey); got != c.want {
t.Errorf("expected %s, got %s", c.want, got)
}
})
}
t.Run("a zero GrantSet resolves everything to none", func(t *testing.T) {
var empty role.GrantSet
if empty.ScopeFor("content.read", "") != role.ScopeNone || empty.ScopeFor("content.read", "blog") != role.ScopeNone || empty.Holds("content.read") || empty.HoldsAll("content.read") || empty.HoldsFor("content.read", "blog") {
t.Error("a zero set must hold nothing")
}
})
t.Run("HoldsFor is true for own and all of the typed scope only", func(t *testing.T) {
set := unlockedGrantSet("content.update=all", "content.update:blog=own", "content.update:news=none")
if !set.HoldsFor("content.update", "blog") || !set.HoldsFor("content.update", "other") || set.HoldsFor("content.update", "news") {
t.Errorf("unexpected HoldsFor results")
}
})
}
func TestGrantSetPermits(t *testing.T) {
cases := []struct {
scope role.Scope
caller uint32
createdBy uint32
want bool
}{
{role.ScopeAll, 9, 21, true},
{role.ScopeAll, 9, 0, true},
{role.ScopeOwn, 21, 21, true},
{role.ScopeOwn, 9, 21, false},
{role.ScopeOwn, 21, 0, false},
{role.ScopeOwn, 0, 0, false},
{role.ScopeNone, 21, 21, false},
}
for _, c := range cases {
name := string(c.scope) + " caller " + strconv.Itoa(int(c.caller)) + " created_by " + strconv.Itoa(int(c.createdBy))
t.Run(name, func(t *testing.T) {
set := unlockedGrantSet("content.update:blog=" + string(c.scope))
if got := set.Permits("content.update", "blog", c.caller, c.createdBy); got != c.want {
t.Errorf("expected %v, got %v", c.want, got)
}
})
}
t.Run("a typed scope replaces the wildcard for Permits", func(t *testing.T) {
set := unlockedGrantSet("content.update=all", "content.update:blog=own")
if set.Permits("content.update", "blog", 9, 21) || !set.Permits("content.update", "news", 9, 21) {
t.Errorf("unexpected Permits results")
}
})
}
func TestRoleGrantSetNormalisation(t *testing.T) {
duplicates := []struct {
rows []string
key string
typeKey string
want role.Scope
}{
{[]string{"content.update=own", "content.update=all"}, "content.update", "", role.ScopeOwn},
{[]string{"content.update=all", "content.update=own"}, "content.update", "", role.ScopeOwn},
{[]string{"content.update:blog=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn},
{[]string{"content.update=all", "content.update:blog=own"}, "content.update", "blog", role.ScopeOwn},
{[]string{"content.read=all", "content.read=none"}, "content.read", "", role.ScopeNone},
}
for _, c := range duplicates {
t.Run("duplicates resolve to the most restrictive: "+strings.Join(c.rows, ", "), func(t *testing.T) {
forward := unlockedGrantSet(c.rows...)
reversed := slices.Clone(c.rows)
slices.Reverse(reversed)
backward := unlockedGrantSet(reversed...)
if forward.ScopeFor(c.key, c.typeKey) != c.want || backward.ScopeFor(c.key, c.typeKey) != c.want {
t.Errorf("expected %s both ways, got %s and %s", c.want, forward.ScopeFor(c.key, c.typeKey), backward.ScopeFor(c.key, c.typeKey))
}
})
}
t.Run("a toggle key stored at own is none", func(t *testing.T) {
if got := unlockedGrantSet("schema.view=own").ScopeFor("schema.view", ""); got != role.ScopeNone {
t.Errorf("expected none, got %s", got)
}
})
t.Run("an unknown scope string is none", func(t *testing.T) {
if got := unlockedGrantSet("content.read=everything").ScopeFor("content.read", ""); got != role.ScopeNone {
t.Errorf("expected none, got %s", got)
}
})
t.Run("a typed key on a non-content key or with an invalid type key is dropped", func(t *testing.T) {
set := unlockedGrantSet("schema.view:blog=all", "content.read:Bad Key=all", "content.read=all")
if set.ScopeFor("schema.view", "blog") != role.ScopeNone || set.ScopeFor("content.read", "Bad Key") != role.ScopeNone || set.ScopeFor("content.read", "x") != role.ScopeAll {
t.Errorf("unexpected scopes")
}
})
}
func TestRoleGrantSetOwnerOnlyKeys(t *testing.T) {
ownerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"}
t.Run("owner-only keys are dropped on a role that is not locked", func(t *testing.T) {
for _, key := range ownerOnly {
set := role.Role{Locked: false, Grants: grantRows(key + "=all")}.GrantSet()
if set.ScopeFor(key, "") != role.ScopeNone || set.Holds(key) || set.HoldsAll(key) {
t.Errorf("%s must not be effective on an unlocked role", key)
}
}
})
t.Run("owner-only keys are kept on a locked role", func(t *testing.T) {
for _, key := range ownerOnly {
set := role.Role{Locked: true, Grants: grantRows(key + "=all")}.GrantSet()
if set.ScopeFor(key, "") != role.ScopeAll {
t.Errorf("%s must be effective on a locked role", key)
}
}
})
t.Run("the built-in roles keep their catalogue", func(t *testing.T) {
builtIns := role.BuiltInRoles()
owner, admin := builtIns[0].GrantSet(), builtIns[1].GrantSet()
for _, permission := range role.Catalogue() {
if owner.ScopeFor(permission.Key, "") != role.ScopeAll {
t.Errorf("Owner lacks %s", permission.Key)
}
if permission.OwnerOnly && admin.ScopeFor(permission.Key, "") != role.ScopeNone {
t.Errorf("Admin holds %s", permission.Key)
}
}
})
}
func TestValidateGrantsRows(t *testing.T) {
cases := []struct {
rows []string
want error
}{
{[]string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant},
{[]string{"content.update:blog=all", "content.update:blog=own"}, role.ErrDuplicateGrant},
{[]string{"content.update=all", "content.update:blog=own"}, nil},
{[]string{"content.update:blog=none"}, nil},
{[]string{"content.update=none"}, role.ErrInvalidScope},
{[]string{"content.create:blog=none"}, nil},
{[]string{"content.create:blog=own"}, role.ErrInvalidScope},
{[]string{"content.update:Bad Key=all"}, role.ErrUnknownPermission},
{[]string{"schema.view:blog=all"}, role.ErrUnknownPermission},
{[]string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant},
}
for _, c := range cases {
t.Run(strings.Join(c.rows, ", "), func(t *testing.T) {
err := role.ValidateGrants(false, grantRows(c.rows...))
if c.want == nil && err != nil || c.want != nil && !errors.Is(err, c.want) {
t.Errorf("expected %v, got %v", c.want, err)
}
})
}
}
func TestRoleHasNoParallelDecisionHelpers(t *testing.T) {
roleType := reflect.TypeOf(role.Role{})
for _, name := range []string{"Allows", "AllowsFor", "scopeOf"} {
if _, present := roleType.MethodByName(name); present {
t.Errorf("Role must not have %s", name)
}
}
for _, source := range roleDomainSources(t) {
if strings.Contains(source.text, "func NewGrantSet") || strings.Contains(source.text, "scopeOf(") {
t.Errorf("%s still holds a parallel implementation", source.path)
}
}
}
type goSource struct {
path string
text string
}
func roleDomainSources(t *testing.T) []goSource {
t.Helper()
files, err := filepath.Glob("../internal/domain/role/*.go")
if err != nil || len(files) == 0 {
t.Fatalf("no role domain files: %v", err)
}
sources := []goSource{}
for _, file := range files {
sources = append(sources, goSource{path: file, text: readTestFile(t, file)})
}
return sources
}
func TestRoleKeyConstantsMatchTheCatalogue(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "../internal/domain/role/roleKeys.go", nil, 0)
if err != nil {
t.Fatal(err)
}
constants := map[string]string{}
ast.Inspect(file, func(node ast.Node) bool {
spec, ok := node.(*ast.ValueSpec)
if !ok {
return true
}
for index, name := range spec.Names {
literal, isLiteral := spec.Values[index].(*ast.BasicLit)
if !isLiteral || !name.IsExported() {
continue
}
value, _ := strconv.Unquote(literal.Value)
constants[name.Name] = value
}
return true
})
catalogueKeys := map[string]bool{}
for _, permission := range role.Catalogue() {
catalogueKeys[permission.Key] = true
}
constantValues := map[string]bool{}
for name, value := range constants {
constantValues[value] = true
if !catalogueKeys[value] {
t.Errorf("constant %s = %q is not a catalogue key", name, value)
}
if want := constantNameOf(value); name != want {
t.Errorf("constant for %q is named %s, want %s", value, name, want)
}
}
for key := range catalogueKeys {
if !constantValues[key] {
t.Errorf("catalogue key %q has no constant", key)
}
}
if len(constants) != len(catalogueKeys) {
t.Errorf("expected %d constants, got %d", len(catalogueKeys), len(constants))
}
}
func constantNameOf(key string) string {
group, action, _ := strings.Cut(key, ".")
words := append([]string{group}, strings.Split(action, "_")...)
name := "Key"
for _, word := range words {
if word == "apikeys" {
name += "APIKeys"
continue
}
name += strings.ToUpper(word[:1]) + word[1:]
}
return name
}
+682
View File
@@ -0,0 +1,682 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/user"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"testing"
)
const articleNotFound = apperr.ArticleNotFoundErrorMessage
func TestContentScopeReadingArticles(t *testing.T) {
t.Run("Reading articles shows what the caller may see", func(t *testing.T) {
rows := []struct {
caller uint32
id uint32
want scopeWant
}{
{csAnonymous, 12, okWant(200)},
{csAnonymous, 10, notFoundWant(articleNotFound)},
{csUnverified, 10, notFoundWant(articleNotFound)},
{csWriter, 10, okWant(200)},
{csWriter, 14, okWant(200)},
{csReaderOwn, 16, okWant(200)},
{csReaderOwn, 12, okWant(200)},
{csReaderOwn, 10, notFoundWant(articleNotFound)},
{csReaderOwn, 14, notFoundWant(articleNotFound)},
{csNoContent, 12, okWant(200)},
{csNoContent, 10, notFoundWant(articleNotFound)},
{csTypedB, 10, notFoundWant(articleNotFound)},
{csTypedB, 12, okWant(200)},
{csOrphan, 12, okWant(200)},
{csOrphan, 10, notFoundWant(articleNotFound)},
}
for _, row := range rows {
t.Run(callerLabel(row.caller)+" GET article "+strconv.Itoa(int(row.id))+" -> "+row.want.label(), func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, row.caller, http.MethodGet, "/v1/article/"+strconv.Itoa(int(row.id)), ""), row.want)
})
}
})
t.Run("Reading an article with an API key follows the creator's role", func(t *testing.T) {
world := newContentScopeWorld(t)
writerKey, _ := world.issueKey(csWriter, "ci")
readerKey, _ := world.issueKey(csReaderOwn, "ci")
assertScopeWant(t, world.withKey(t, http.MethodGet, "/v1/article/10", "", writerKey), okWant(200))
assertScopeWant(t, world.withKey(t, http.MethodGet, "/v1/article/10", "", readerKey), notFoundWant(articleNotFound))
})
t.Run("Public article routes never answer 403", func(t *testing.T) {
world := newContentScopeWorld(t)
for _, path := range []string{"/v1/article", "/v1/article/12", "/v1/article/12/seo"} {
rec := world.do(t, csNoContent, http.MethodGet, path, "")
if rec.Code != http.StatusOK {
t.Errorf("%s answered %d", path, rec.Code)
}
}
world.roles.fail.failNext("Get")
rec := world.do(t, csWriter, http.MethodGet, "/v1/article/12", "")
if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" {
t.Errorf("expected 500 on a role repository failure, got %d %q", rec.Code, rec.Body.String())
}
world.userRepo.failGet = errors.New("user store down")
rec = world.do(t, csWriter, http.MethodGet, "/v1/article/12", "")
if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" {
t.Errorf("expected 500 on a user repository failure, got %d %q", rec.Code, rec.Body.String())
}
world.userRepo.failGet = user.ErrUserNotFound
rec = world.do(t, csWriter, http.MethodGet, "/v1/article/12", "")
if rec.Code != http.StatusOK {
t.Errorf("a vanished user reads as anonymous, got %d", rec.Code)
}
world.userRepo.failGet = nil
world.resetCounters()
world.do(t, csAnonymous, http.MethodGet, "/v1/article", "")
world.do(t, csAnonymous, http.MethodGet, "/v1/article/12", "")
world.do(t, csAnonymous, http.MethodGet, "/v1/article/12/seo", "")
if world.roleRepo.touched() != 0 {
t.Errorf("an anonymous request must not touch the role repository, got %d calls", world.roleRepo.touched())
}
})
}
func TestContentScopeArticleList(t *testing.T) {
rows := []struct {
caller uint32
query string
ids []uint32
total int
all, on, dr int
}{
{csAnonymous, "", scopeIDs(12, 15), 2, 2, 2, 0},
{csNoContent, "", scopeIDs(12, 15), 2, 2, 2, 0},
{csWriter, "", scopeIDs(10, 11, 12, 13, 14, 15, 16, 17), 8, 8, 2, 6},
{csReaderOwn, "", scopeIDs(12, 15, 16), 3, 3, 2, 1},
{csReaderOwn, "?status=draft", scopeIDs(16), 1, 3, 2, 1},
{csReaderOwn, "?status=online", scopeIDs(12, 15), 2, 3, 2, 1},
{csContributor, "?status=draft", scopeIDs(10, 11, 13, 14, 16, 17), 6, 8, 2, 6},
}
t.Run("The article list is the visible set", func(t *testing.T) {
for _, row := range rows {
t.Run(callerLabel(row.caller)+" GET /v1/article"+row.query, func(t *testing.T) {
world := newContentScopeWorld(t)
body := decodeScopeList(t, world.do(t, row.caller, http.MethodGet, "/v1/article"+row.query, ""))
if !sameScopeIDs(body.ids(), row.ids) || body.Total != row.total {
t.Errorf("expected ids %v total %d, got %v total %d", row.ids, row.total, body.ids(), body.Total)
}
if body.Counts.All != row.all || body.Counts.Online != row.on || body.Counts.Draft != row.dr {
t.Errorf("expected counts %d/%d/%d, got %+v", row.all, row.on, row.dr, body.Counts)
}
})
}
})
t.Run("Own scope pages over the visible set", func(t *testing.T) {
world := newContentScopeWorld(t)
body := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article?page=2&size=5", ""))
if len(body.Items) != 0 || body.Total != 3 || body.Page != 2 || body.Size != 5 {
t.Errorf("unexpected page %+v", body)
}
first := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article?page=1&size=5&q=Article+16", ""))
if !sameScopeIDs(first.ids(), scopeIDs(16)) || first.Total != 1 {
t.Errorf("expected the search to run over visible articles, got %v total %d", first.ids(), first.Total)
}
})
t.Run("Suggestions and the seo filter consider only the visible articles", func(t *testing.T) {
world := newContentScopeWorld(t)
hidden := world.do(t, csReaderOwn, http.MethodGet, "/v1/article?seo=suggestions", "")
visible := decodeScopeList(t, hidden)
for _, id := range visible.ids() {
if id != 12 && id != 15 && id != 16 {
t.Errorf("article %d leaked through the seo filter", id)
}
}
everything := decodeScopeList(t, world.do(t, csWriter, http.MethodGet, "/v1/article", ""))
reader := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, "/v1/article", ""))
if reader.Counts.Suggestions > reader.Counts.All || reader.Counts.Suggestions >= everything.Counts.Suggestions && everything.Counts.Suggestions != 0 {
t.Errorf("suggestions must count visible articles only: reader %d, writer %d", reader.Counts.Suggestions, everything.Counts.Suggestions)
}
if visible.Total != reader.Counts.Suggestions {
t.Errorf("the seo filter total %d must equal counts.suggestions %d", visible.Total, reader.Counts.Suggestions)
}
})
}
func TestContentScopeArticleSEO(t *testing.T) {
t.Run("Reading an article's SEO follows the same visibility", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csAnonymous, http.MethodGet, "/v1/article/12/seo", "", okWant(200)},
{csAnonymous, http.MethodGet, "/v1/article/10/seo", "", notFoundWant(articleNotFound)},
{csReaderOwn, http.MethodGet, "/v1/article/16/seo", "", okWant(200)},
{csReaderOwn, http.MethodGet, "/v1/article/10/seo", "", notFoundWant(articleNotFound)},
{csWriter, http.MethodGet, "/v1/article/10/seo", "", okWant(200)},
})
})
t.Run("The SEO check is a read of a visible article", func(t *testing.T) {
const body = `{"excerpt": "A short summary"}`
runScopeRows(t, []scopeRow{
{csNoContent, http.MethodPost, "/v1/article/12/seo/check", body, refusedWant("content.read")},
{csReaderOwn, http.MethodPost, "/v1/article/16/seo/check", body, okWant(200)},
{csReaderOwn, http.MethodPost, "/v1/article/12/seo/check", body, okWant(200)},
{csReaderOwn, http.MethodPost, "/v1/article/10/seo/check", body, notFoundWant(articleNotFound)},
{csWriter, http.MethodPost, "/v1/article/10/seo/check", body, okWant(200)},
{csWriter, http.MethodPost, "/v1/article/999/seo/check", body, notFoundWant(articleNotFound)},
})
})
}
func TestContentScopeArticleWrites(t *testing.T) {
t.Run("Creating an article needs content.create for the article type", func(t *testing.T) {
rows := []struct {
caller uint32
want scopeWant
createdBy uint32
}{
{csContributor, okWant(201), csContributor},
{csReaderOwn, okWant(201), csReaderOwn},
{csWriter, okWant(201), csWriter},
{csTypedA, okWant(201), csTypedA},
{csPublisherOwn, refusedWant("content.create"), 0},
{csTypedB, refusedWant("content.create"), 0},
{csNoContent, refusedWant("content.create"), 0},
}
for _, row := range rows {
t.Run(callerLabel(row.caller)+" -> "+row.want.label(), func(t *testing.T) {
world := newContentScopeWorld(t)
before := len(world.articles.articles)
rec := world.do(t, row.caller, http.MethodPost, "/v1/article", `{"title": "A new draft"}`)
assertScopeWant(t, rec, row.want)
if row.want.key != "" {
if len(world.articles.articles) != before || len(world.recorder.events) != 0 {
t.Errorf("a refused create must store nothing and record nothing")
}
return
}
var created struct {
ID uint32 `json:"id"`
IsOnline bool `json:"is_online"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &created)
if created.IsOnline || world.articles.articles[created.ID].CreatedBy != row.createdBy {
t.Errorf("expected a draft created by %d, got online=%v created_by=%d", row.createdBy, created.IsOnline, world.articles.articles[created.ID].CreatedBy)
}
})
}
})
t.Run("A new article is always a draft whatever the role", func(t *testing.T) {
world := newContentScopeWorld(t)
rec := world.do(t, csOwner, http.MethodPost, "/v1/article", `{"title": "Owner draft", "is_online": true}`)
assertScopeWant(t, rec, okWant(201))
if strings.Contains(rec.Body.String(), `"is_online":true`) {
t.Errorf("a new article must be a draft: %s", rec.Body.String())
}
})
t.Run("Updating and deleting an article follow the scope of the article", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch, okWant(200)},
{csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch, refusedWant("content.update")},
{csContributor, http.MethodPatch, "/v1/article/14", csArticlePatch, refusedWant("content.update")},
{csContributor, http.MethodPatch, "/v1/article/999", csArticlePatch, notFoundWant(articleNotFound)},
{csContributor, http.MethodPatch, "/v1/article/11", `{"nothing": 1}`, scopeWant{status: 400, message: apperr.BodyErrorMessage}},
{csContributor, http.MethodDelete, "/v1/article/10", "", refusedWant("content.delete")},
{csContributor, http.MethodDelete, "/v1/article/999", "", refusedWant("content.delete")},
{csWriter, http.MethodPatch, "/v1/article/13", csArticlePatch, okWant(200)},
{csWriter, http.MethodDelete, "/v1/article/11", "", refusedWant("content.delete")},
{csEditor, http.MethodPatch, "/v1/article/13", csArticlePatch, okWant(200)},
{csEditor, http.MethodDelete, "/v1/article/13", "", refusedWant("content.delete")},
{csAdmin, http.MethodDelete, "/v1/article/13", "", okWant(204)},
{csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch, okWant(200)},
{csPublisherOwn, http.MethodPatch, "/v1/article/17", csArticlePatch, refusedWant("content.update")},
{csNoContent, http.MethodPatch, "/v1/article/12", csArticlePatch, refusedWant("content.update")},
})
})
t.Run("A refused update changes nothing and leaves no trace", func(t *testing.T) {
world := newContentScopeWorld(t)
snapshot := world.articles.articles[11]
body := `{"body": "hijacked", "site_id": 2, "author_ids": [7], "slug": "stolen"}`
assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", body), refusedWant("content.update"))
if !sameArticle(world.articles.articles[11], snapshot) {
t.Errorf("article 11 changed: %+v", world.articles.articles[11])
}
if len(world.recorder.events) != 0 {
t.Errorf("a refused update must record nothing, got %v", world.recorder.actions())
}
})
t.Run("An allowed own update keeps created_by unchanged", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
if world.articles.articles[10].CreatedBy != csContributor {
t.Errorf("created_by changed to %d", world.articles.articles[10].CreatedBy)
}
})
t.Run("An own-scope user can delete only their own article", func(t *testing.T) {
world := newContentScopeWorld(t)
world.seedScopedArticle(18, false, csEditor)
assertScopeWant(t, world.do(t, csEditor, http.MethodDelete, "/v1/article/18", ""), okWant(204))
if _, present := world.articles.articles[18]; present || strings.Join(world.recorder.actions(), ",") != "article.delete" {
t.Errorf("expected article 18 gone and one article.delete event, got %v", world.recorder.actions())
}
assertScopeWant(t, world.do(t, csEditor, http.MethodDelete, "/v1/article/12", ""), refusedWant("content.delete"))
if _, present := world.articles.articles[12]; !present {
t.Errorf("article 12 must still be stored")
}
})
}
func sameArticle(left article.Article, right article.Article) bool {
leftBody, _ := json.Marshal(left)
rightBody, _ := json.Marshal(right)
return string(leftBody) == string(rightBody)
}
func TestContentScopePublish(t *testing.T) {
t.Run("Publish is its own permission", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csWriter, http.MethodPatch, "/v1/article/11/publish", "", refusedWant("content.publish")},
{csWriter, http.MethodPatch, "/v1/article/12/unpublish", "", refusedWant("content.publish")},
{csContributor, http.MethodPatch, "/v1/article/10/publish", "", refusedWant("content.publish")},
{csEditor, http.MethodPatch, "/v1/article/11/publish", "", okWant(200)},
{csEditor, http.MethodPatch, "/v1/article/12/unpublish", "", okWant(200)},
{csPublisherOwn, http.MethodPatch, "/v1/article/17/publish", "", okWant(200)},
{csPublisherOwn, http.MethodPatch, "/v1/article/17/unpublish", "", okWant(200)},
{csPublisherOwn, http.MethodPatch, "/v1/article/10/publish", "", refusedWant("content.publish")},
{csPublisherOwn, http.MethodPatch, "/v1/article/999/publish", "", notFoundWant(articleNotFound)},
{csAdmin, http.MethodPatch, "/v1/article/10/publish", "", okWant(200)},
{csNoContent, http.MethodPatch, "/v1/article/12/publish", "", refusedWant("content.publish")},
})
})
t.Run("Publishing records one event and a refusal records none", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csEditor, http.MethodPatch, "/v1/article/11/publish", ""), okWant(200))
assertScopeWant(t, world.do(t, csEditor, http.MethodPatch, "/v1/article/12/unpublish", ""), okWant(200))
if got := strings.Join(world.recorder.actions(), ","); got != "article.publish,article.unpublish" {
t.Errorf("unexpected events %q", got)
}
if !world.articles.articles[11].IsOnline || world.articles.articles[12].IsOnline {
t.Errorf("unexpected states")
}
})
t.Run("A refused publish leaves no trace", func(t *testing.T) {
world := newContentScopeWorld(t)
snapshot := world.articles.articles[11]
assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/11/publish", ""), refusedWant("content.publish"))
if len(world.recorder.events) != 0 || !sameArticle(snapshot, world.articles.articles[11]) {
t.Errorf("a refused publish must leave no trace")
}
})
t.Run("Saving a draft without the publish permission keeps it a draft", func(t *testing.T) {
world := newContentScopeWorld(t)
rec := world.do(t, csWriter, http.MethodPatch, "/v1/article/11", `{"body": "new text"}`)
assertScopeWant(t, rec, okWant(200))
if !strings.Contains(rec.Body.String(), "new text") || world.articles.articles[11].IsOnline {
t.Errorf("expected the new text and a draft, got %s", rec.Body.String())
}
})
t.Run("Update never changes the status", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/11", `{"body": "x", "is_online": true}`), okWant(200))
assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/12", csArticlePatch), okWant(200))
if world.articles.articles[11].IsOnline || !world.articles.articles[12].IsOnline {
t.Errorf("update must not change the status")
}
})
}
func TestContentScopeEntries(t *testing.T) {
const entriesPath = "/v1/content-type/events/entry"
t.Run("Entries follow the read scope", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csReaderOwn, http.MethodGet, entriesPath + "/6", "", okWant(200)},
{csReaderOwn, http.MethodGet, entriesPath + "/1", "", notFoundWant(apperr.EntryNotFoundErrorMessage)},
{csReaderOwn, http.MethodGet, entriesPath + "/3", "", notFoundWant(apperr.EntryNotFoundErrorMessage)},
{csNoContent, http.MethodGet, entriesPath, "", refusedWant("content.read")},
{csNoContent, http.MethodGet, entriesPath + "/1", "", refusedWant("content.read")},
{csNoContent, http.MethodGet, "/v1/content-type/nope/entry", "", refusedWant("content.read")},
{csContributor, http.MethodGet, "/v1/content-type/nope/entry", "", notFoundWant(apperr.ContentTypeNotFoundErrorMessage)},
{csAdmin, http.MethodGet, entriesPath + "/2", "", okWant(200)},
})
})
t.Run("Entry lists show the caller's entries", func(t *testing.T) {
world := newContentScopeWorld(t)
all := decodeScopeList(t, world.do(t, csContributor, http.MethodGet, entriesPath, ""))
if !sameScopeIDs(all.ids(), scopeIDs(1, 2, 3, 4, 6, 7, 8)) || all.Total != 7 {
t.Errorf("expected seven entries, got %v total %d", all.ids(), all.Total)
}
own := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath, ""))
if !sameScopeIDs(own.ids(), scopeIDs(6)) || own.Total != 1 {
t.Errorf("expected entry 6 only, got %v total %d", own.ids(), own.Total)
}
paged := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?page=1&size=5", ""))
if !sameScopeIDs(paged.ids(), scopeIDs(6)) || paged.Total != 1 || paged.Page != 1 || paged.Size != 5 {
t.Errorf("unexpected paged own list %+v", paged)
}
none := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?q=Entry+1", ""))
if len(none.Items) != 0 || none.Total != 0 {
t.Errorf("a search under own must run over own entries only, got %v total %d", none.ids(), none.Total)
}
found := decodeScopeList(t, world.do(t, csReaderOwn, http.MethodGet, entriesPath+"?q=Entry+6", ""))
if !sameScopeIDs(found.ids(), scopeIDs(6)) || found.Total != 1 {
t.Errorf("expected the own entry to match, got %v total %d", found.ids(), found.Total)
}
})
t.Run("Entries follow the create, update and delete scope", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csContributor, http.MethodPost, entriesPath, csEntryBody, okWant(201)},
{csNoContent, http.MethodPost, entriesPath, csEntryBody, refusedWant("content.create")},
{csContributor, http.MethodPut, entriesPath + "/1", csEntryBody, okWant(200)},
{csContributor, http.MethodPut, entriesPath + "/2", csEntryBody, refusedWant("content.update")},
{csContributor, http.MethodPut, entriesPath + "/3", csEntryBody, refusedWant("content.update")},
{csContributor, http.MethodPut, entriesPath + "/999", csEntryBody, notFoundWant(apperr.EntryNotFoundErrorMessage)},
{csContributor, http.MethodPut, "/v1/content-type/nope/entry/1", csEntryBody, notFoundWant(apperr.ContentTypeNotFoundErrorMessage)},
{csContributor, http.MethodPut, entriesPath + "/2", `{"data": {"title": ""}}`, refusedWant("content.update")},
{csContributor, http.MethodDelete, entriesPath + "/1", "", refusedWant("content.delete")},
{csWriter, http.MethodPut, entriesPath + "/2", csEntryBody, okWant(200)},
{csWriter, http.MethodDelete, entriesPath + "/2", "", refusedWant("content.delete")},
{csEditor, http.MethodDelete, entriesPath + "/8", "", okWant(204)},
{csEditor, http.MethodDelete, entriesPath + "/2", "", refusedWant("content.delete")},
{csAdmin, http.MethodDelete, entriesPath + "/2", "", okWant(204)},
{csOwner, http.MethodPut, "/v1/content-type/venues/entry/5", csEntryBody, okWant(200)},
})
})
t.Run("Entry writes keep the owner and record the updater", func(t *testing.T) {
world := newContentScopeWorld(t)
created := world.do(t, csContributor, http.MethodPost, entriesPath, csEntryBody)
var body struct {
ID uint32 `json:"id"`
CreatedBy uint32 `json:"created_by"`
}
_ = json.Unmarshal(created.Body.Bytes(), &body)
if body.CreatedBy != csContributor || world.entries.entries[body.ID].CreatedBy != csContributor {
t.Errorf("expected created_by 21, got %+v", body)
}
assertScopeWant(t, world.do(t, csContributor, http.MethodPut, entriesPath+"/1", csEntryBody), okWant(200))
stored := world.entries.entries[1]
if stored.CreatedBy != csContributor || stored.UpdatedBy != csContributor {
t.Errorf("expected created_by and updated_by 21, got %+v", stored)
}
})
t.Run("A refused entry write leaves the entry untouched", func(t *testing.T) {
world := newContentScopeWorld(t)
before := world.entries.entries[2]
assertScopeWant(t, world.do(t, csContributor, http.MethodPut, entriesPath+"/2", csEntryBody), refusedWant("content.update"))
assertScopeWant(t, world.do(t, csContributor, http.MethodDelete, entriesPath+"/1", ""), refusedWant("content.delete"))
if world.entries.updateCalls != 0 || world.entries.entries[2].UpdatedBy != before.UpdatedBy {
t.Errorf("entry 2 must be unchanged")
}
if _, present := world.entries.entries[1]; !present {
t.Errorf("entry 1 must still be stored")
}
})
}
func TestContentScopeTypedGrants(t *testing.T) {
t.Run("Typed grants replace the wildcard for their type", func(t *testing.T) {
runScopeRows(t, []scopeRow{
{csTypedA, http.MethodPatch, "/v1/article/10", csArticlePatch, refusedWant("content.update")},
{csTypedA, http.MethodPut, "/v1/content-type/events/entry/4", csEntryBody, okWant(200)},
{csTypedA, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody, refusedWant("content.update")},
{csTypedA, http.MethodPut, "/v1/content-type/venues/entry/5", csEntryBody, okWant(200)},
{csTypedB, http.MethodGet, "/v1/content-type/events/entry", "", okWant(200)},
{csTypedB, http.MethodGet, "/v1/content-type/venues/entry", "", refusedWant("content.read")},
{csTypedB, http.MethodPost, "/v1/content-type/events/entry", csEntryBody, okWant(201)},
{csTypedB, http.MethodPost, "/v1/content-type/venues/entry", csEntryBody, refusedWant("content.create")},
{csTypedB, http.MethodPut, "/v1/content-type/events/entry/7", csEntryBody, okWant(200)},
{csTypedB, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody, refusedWant("content.update")},
{csTypedB, http.MethodGet, "/v1/article/10", "", notFoundWant(articleNotFound)},
{csTypedB, http.MethodGet, "/v1/article/12", "", okWant(200)},
{csTypedB, http.MethodPost, "/v1/article", `{"title": "A new draft"}`, refusedWant("content.create")},
})
})
t.Run("An unknown type key falls under the wildcard or is refused", func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
assertScopeWant(t, world.do(t, csTypedB, http.MethodGet, "/v1/content-type/venues/entry", ""), refusedWant("content.read"))
assertScopeWant(t, world.do(t, csTypedB, http.MethodGet, "/v1/content-type/nope/entry", ""), refusedWant("content.read"))
if world.typeRepo.lookups != 0 {
t.Errorf("a refused read must not look the content type up, got %d lookups", world.typeRepo.lookups)
}
assertScopeWant(t, world.do(t, csContributor, http.MethodGet, "/v1/content-type/nope/entry", ""), notFoundWant(apperr.ContentTypeNotFoundErrorMessage))
})
}
func TestContentScopeContentTypeKeys(t *testing.T) {
t.Run("Creating a content type with a built-in key is refused", func(t *testing.T) {
for _, key := range []string{"article", "author", "site", "media", "user"} {
t.Run(key, func(t *testing.T) {
world := newContentScopeWorld(t)
before := len(world.typeStore.types)
body := `{"key": "` + key + `", "name": "Taken", "fields": [{"key": "title", "label": "Title", "type": "text"}]}`
assertScopeWant(t, world.do(t, csAdmin, http.MethodPost, "/v1/content-type", body), scopeWant{status: 409, message: apperr.ContentTypeKeyTakenErrorMessage})
if len(world.typeStore.types) != before {
t.Errorf("nothing may be stored")
}
})
}
})
t.Run("A custom key is still created", func(t *testing.T) {
world := newContentScopeWorld(t)
body := `{"key": "podcasts", "name": "Podcasts", "fields": [{"key": "title", "label": "Title", "type": "text"}]}`
assertScopeWant(t, world.do(t, csAdmin, http.MethodPost, "/v1/content-type", body), okWant(201))
})
}
func TestContentScopeRoles(t *testing.T) {
t.Run("Admin and Owner pass every content check", func(t *testing.T) {
for _, caller := range []uint32{csAdmin, csOwner} {
world := newContentScopeWorld(t)
list := decodeScopeList(t, world.do(t, caller, http.MethodGet, "/v1/article", ""))
if list.Counts.All != 8 || list.Counts.Online != 2 || list.Counts.Draft != 6 {
t.Errorf("caller %d: unexpected counts %+v", caller, list.Counts)
}
assertScopeWant(t, world.do(t, caller, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
assertScopeWant(t, world.do(t, caller, http.MethodPatch, "/v1/article/11/publish", ""), okWant(200))
assertScopeWant(t, world.do(t, caller, http.MethodPut, "/v1/content-type/events/entry/2", csEntryBody), okWant(200))
assertScopeWant(t, world.do(t, caller, http.MethodDelete, "/v1/content-type/events/entry/3", ""), okWant(204))
assertScopeWant(t, world.do(t, caller, http.MethodDelete, "/v1/article/14", ""), okWant(204))
}
})
t.Run("Writer is effective on content", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/12", csArticlePatch), okWant(200))
if !world.articles.articles[12].IsOnline {
t.Errorf("editing an online article must keep it online")
}
assertScopeWant(t, world.do(t, csWriter, http.MethodPost, "/v1/article", `{"title": "Writer draft"}`), okWant(201))
assertScopeWant(t, world.do(t, csWriter, http.MethodPost, "/v1/content-type/events/entry", csEntryBody), okWant(201))
assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/article/10", ""), refusedWant("content.delete"))
assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/content-type/events/entry/1", ""), refusedWant("content.delete"))
assertScopeWant(t, world.do(t, csWriter, http.MethodPatch, "/v1/article/10/publish", ""), refusedWant("content.publish"))
})
t.Run("A session follows the role live for content", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), refusedWant("content.update"))
world.setGrants(csContributor, "content.read=all", "content.update=all")
assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), okWant(200))
world.setGrants(csContributor, "content.read=all")
assertScopeWant(t, world.do(t, csContributor, http.MethodPatch, "/v1/article/11", csArticlePatch), refusedWant("content.update"))
})
t.Run("An API key follows its creator's role and owns what its creator owns", func(t *testing.T) {
world := newContentScopeWorld(t)
raw, _ := world.issueKey(csContributor, "ci")
created := world.withKey(t, http.MethodPost, "/v1/article", `{"title": "Key draft"}`, raw)
assertScopeWant(t, created, okWant(201))
var body struct {
ID uint32 `json:"id"`
}
_ = json.Unmarshal(created.Body.Bytes(), &body)
if world.articles.articles[body.ID].CreatedBy != csContributor {
t.Errorf("expected created_by 21, got %d", world.articles.articles[body.ID].CreatedBy)
}
assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(body.ID)), csArticlePatch, raw), okWant(200))
assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11", csArticlePatch, raw), refusedWant("content.update"))
assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11/publish", "", raw), refusedWant("content.publish"))
changed := world.users.users[csContributor]
changed.RoleID = world.roleIDs[csEditor]
world.users.users[csContributor] = changed
assertScopeWant(t, world.withKey(t, http.MethodPatch, "/v1/article/11/publish", "", raw), okWant(200))
})
t.Run("A user whose role cannot be resolved is refused on gated content routes", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csOrphan, http.MethodPost, "/v1/article", `{"title": "A new draft"}`), refusedWant("content.create"))
assertScopeWant(t, world.do(t, csOrphan, http.MethodPatch, "/v1/article/10", csArticlePatch), refusedWant("content.update"))
assertScopeWant(t, world.do(t, csOrphan, http.MethodGet, "/v1/content-type/events/entry", ""), refusedWant("content.read"))
world.roles.fail.failNext("Get")
rec := world.do(t, csContributor, http.MethodPatch, "/v1/article/10", csArticlePatch)
if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Internal server error" {
t.Errorf("expected 500, got %d %q", rec.Code, rec.Body.String())
}
})
t.Run("Verification and token checks still come first", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csUnverified, http.MethodPatch, "/v1/article/10", csArticlePatch), scopeWant{status: 403, message: apperr.VerificationRequiredErrorMessage})
stale := world.users.users[csWriter]
stale.TokenVersion = 4
world.users.users[csWriter] = stale
for _, route := range [][3]string{{http.MethodPatch, "/v1/article/10", csArticlePatch}, {http.MethodDelete, "/v1/article/10", ""}, {http.MethodPost, "/v1/article", `{"title": "A new draft"}`}, {http.MethodGet, "/v1/content-type/events/entry", ""}, {http.MethodPut, "/v1/content-type/events/entry/1", csEntryBody}} {
assertScopeWant(t, world.do(t, csWriter, route[0], route[1], route[2]), scopeWant{status: 401, message: apperr.UnauthorizedErrorMessage})
assertScopeWant(t, world.do(t, csAnonymous, route[0], route[1], route[2]), scopeWant{status: 401, message: apperr.UnauthorizedErrorMessage})
}
})
}
func TestContentScopeRequestLoads(t *testing.T) {
routes := [][3]string{
{http.MethodPatch, "/v1/article/11", csArticlePatch},
{http.MethodGet, "/v1/content-type/events/entry", ""},
{http.MethodGet, "/v1/article/12", ""},
}
for _, route := range routes {
t.Run("A session request loads the user and the role once: "+route[0]+" "+route[1], func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
rec := world.do(t, csWriter, route[0], route[1], route[2])
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
}
})
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
world := newContentScopeWorld(t)
raw, _ := world.issueKey(csWriter, "ci")
world.resetCounters()
rec := world.withKey(t, route[0], route[1], route[2], raw)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
}
})
}
t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) {
world := newContentScopeWorld(t)
legacy := world.users.users[csOwner]
legacy.RoleID = 0
world.users.users[csOwner] = legacy
world.resetCounters()
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 {
t.Errorf("unexpected role loads %+v", world.roleRepo)
}
})
t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
rec, _ := world.me(t, csWriter)
if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 {
t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN)
}
})
}
func TestContentScopeOwnerOnlyAndMe(t *testing.T) {
t.Run("Owner-only keys are effective only on a locked role", func(t *testing.T) {
world := newContentScopeWorld(t)
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/system/update/check", ""), refusedWant("system.apply_updates"))
_, me := world.me(t, csHandPlaced)
for _, key := range permissionKeys(me.Access.Permissions) {
if key == "system.apply_updates" {
t.Errorf("/auth/me must not list the ineffective key")
}
}
_, owner := world.me(t, csOwner)
if len(owner.Access.Permissions) != 34 {
t.Errorf("the Owner must hold all 34 keys, got %d", len(owner.Access.Permissions))
}
})
t.Run("The other owner-only keys are refused on an unlocked role too", func(t *testing.T) {
world := newContentScopeWorld(t)
world.setGrants(csHandPlaced, "settings.delete_site=all", "roles.edit_owner_only=all", "system.edit_retention=all", "system.transfer_ownership=all")
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodDelete, "/v1/site/2", ""), refusedWant("settings.delete_site"))
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPatch, "/v1/settings", `{}`), refusedWant("system.edit_retention"))
})
t.Run("/auth/me lists the effective grants", func(t *testing.T) {
world := newContentScopeWorld(t)
world.setGrants(csHandPlaced, "content.update=own", "content.update=all", "settings.delete_site=all", "content.update:blog=all")
_, me := world.me(t, csHandPlaced)
got := map[string]string{}
for _, grant := range me.Access.Permissions {
got[grant.Key] = string(grant.Scope)
}
if got["content.update"] != "own" || len(got) != 1 {
t.Errorf("expected only content.update at own, got %v", got)
}
})
}
func TestContentScopeMedia(t *testing.T) {
t.Run("Media routes keep their scope behaviour", func(t *testing.T) {
world := newContentScopeWorld(t)
world.setGrants(csHandPlaced, "media.delete=own", "media.view=all", "media.upload=all")
assertScopeWant(t, world.do(t, csWriter, http.MethodDelete, "/v1/media/1", ""), refusedWant("media.delete"))
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodDelete, "/v1/media/1", ""), refusedWant("media.delete"))
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/media/merge", `{}`), refusedWant("media.delete"))
assertScopeWant(t, world.do(t, csHandPlaced, http.MethodPost, "/v1/media/purge", ""), refusedWant("media.delete"))
world.setGrants(csHandPlaced, "media.delete=all")
if rec := world.do(t, csHandPlaced, http.MethodDelete, "/v1/media/1", ""); rec.Code == http.StatusForbidden {
t.Errorf("media.delete=all must pass the gate, got %d", rec.Code)
}
if rec := world.do(t, csWriter, http.MethodGet, "/v1/media/1/usage", ""); rec.Code == http.StatusForbidden {
t.Errorf("Writer must pass the usage gate, got %d", rec.Code)
}
if rec := world.do(t, csWriter, http.MethodPost, "/v1/media", ""); rec.Code == http.StatusForbidden {
t.Errorf("Writer must pass the upload gate, got %d", rec.Code)
}
})
}
+90
View File
@@ -0,0 +1,90 @@
package test
import (
"RenewCMS/internal/domain/role"
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
)
func nonTestGoFiles(t *testing.T, roots ...string) []string {
t.Helper()
files := []string{}
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() && strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go") {
files = append(files, path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
if len(files) == 0 {
t.Fatalf("no Go files under %v", roots)
}
return files
}
func parsedGoFile(t *testing.T, path string) *ast.File {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if err != nil {
t.Fatalf("cannot parse %s: %v", path, err)
}
return file
}
func isCreatedBySelector(expression ast.Expr) bool {
selector, ok := expression.(*ast.SelectorExpr)
return ok && selector.Sel.Name == "CreatedBy"
}
func TestOwnAndAllHasOneImplementation(t *testing.T) {
t.Run("No use case, handler or middleware compares CreatedBy for an authorization decision", func(t *testing.T) {
for _, path := range nonTestGoFiles(t, "../api", "../internal/infrastructure/useCases") {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
comparison, ok := node.(*ast.BinaryExpr)
if ok && (comparison.Op == token.EQL || comparison.Op == token.NEQ) && (isCreatedBySelector(comparison.X) || isCreatedBySelector(comparison.Y)) {
t.Errorf("%s compares CreatedBy directly", path)
}
return true
})
}
})
t.Run("A GrantSet is opaque outside the role domain", func(t *testing.T) {
if reflect.TypeOf(role.GrantSet{}).Kind() != reflect.Struct {
t.Errorf("GrantSet must be a struct so no caller can index it")
}
})
t.Run("The router and the middleware hold no catalogue key literal", func(t *testing.T) {
keys := map[string]bool{}
for _, permission := range role.Catalogue() {
keys[permission.Key] = true
}
for _, path := range nonTestGoFiles(t, "../api/middleware", "../api/router.go") {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
if value, err := strconv.Unquote(literal.Value); err == nil && keys[value] {
t.Errorf("%s holds the literal %q, use the role.Key constant", path, value)
}
return true
})
}
})
}
+70
View File
@@ -0,0 +1,70 @@
package test
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/claims"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"os"
"strings"
"testing"
)
func grantRows(rows ...string) []role.Grant {
grants := make([]role.Grant, 0, len(rows))
for _, row := range rows {
key, scope, found := strings.Cut(row, "=")
if !found {
panic("grant row without a scope: " + row)
}
grants = append(grants, role.Grant{Key: key, Scope: role.Scope(scope)})
}
return grants
}
func unlockedGrantSet(rows ...string) role.GrantSet {
return role.Role{Grants: grantRows(rows...)}.GrantSet()
}
func requireMissingKey(t *testing.T, err error, key string) {
t.Helper()
if !errors.Is(err, role.ErrMissingPermission) || missingKeyOf(t, err) != key {
t.Fatalf("expected missing permission %q, got %v", key, err)
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func ownerCaller(userID uint32) useCases.ContentCaller {
return useCases.ContentCaller{UserID: userID, Grants: role.BuiltInRoles()[0].GrantSet()}
}
func callerWith(userID uint32, rows ...string) useCases.ContentCaller {
return useCases.ContentCaller{UserID: userID, Grants: unlockedGrantSet(rows...)}
}
func contentAccess(getUser *useCases.GetUserUseCase) func(http.Handler) http.Handler {
adminGrants := role.BuiltInRoles()[1].GrantSet()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = access.WithCurrentUser(r)
if userID, ok := claims.UserId(r); ok {
account, err := access.LoadUser(getUser, r, userID)
version, hasVersion := claims.TokenVersion(r)
if err == nil && account.IsVerified && (!hasVersion || version == account.TokenVersion) {
r = r.WithContext(access.WithCaller(r.Context(), access.Caller{UserID: userID, Grants: adminGrants}))
}
}
next.ServeHTTP(w, r)
})
}
}
+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)
}
})
}
}
+374
View File
@@ -0,0 +1,374 @@
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
listGrantsN 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) ListGrants(roleID uint32) ([]role.Grant, error) {
c.listGrantsN++
return c.Repository.ListGrants(roleID)
}
func (c *contentScopeRoleRepo) touched() int {
return c.gets + c.getByNames + c.listGrantsN
}
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, 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.listGrantsN = 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
}
@@ -340,7 +340,7 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
{grantsOf("media.view"), http.MethodPost, "/v1/media", "media.upload"},
{grantsOf("apikeys.create"), http.MethodGet, "/v1/api-key", "apikeys.view"},
{grantsOf("settings.edit_general"), http.MethodPatch, "/v1/media/purge-settings", ""},
{grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", ""},
{grantsOf("system.apply_updates"), http.MethodPost, "/v1/system/update/check", "system.apply_updates"},
{nil, http.MethodGet, "/v1/author", "authors.view"},
}
for _, row := range rows {
@@ -479,13 +479,19 @@ func TestRolesPermissionsEnforcementHTTP(t *testing.T) {
}
})
t.Run("Articles and entries are not gated in this slice", func(t *testing.T) {
t.Run("Articles and entries are gated by the content keys since the content scope slice", func(t *testing.T) {
e := newEnforcementEnv(t)
e.customRoleUser(enforcementCustomID)
for _, route := range [][2]string{{http.MethodPost, "/v1/article"}, {http.MethodDelete, "/v1/article/1"}, {http.MethodGet, "/v1/content-type/events/entry"}, {http.MethodPost, "/v1/content-type/events/entry"}} {
if e.gateAs(t, enforcementCustomID, route[0], route[1], `{}`).blocked() {
t.Errorf("%s %s is gated", route[0], route[1])
}
rows := []struct {
method, path, key string
}{
{http.MethodPost, "/v1/article", "content.create"},
{http.MethodDelete, "/v1/article/1", "content.delete"},
{http.MethodGet, "/v1/content-type/events/entry", "content.read"},
{http.MethodPost, "/v1/content-type/events/entry", "content.create"},
}
for _, row := range rows {
assertGate(t, e.gateAs(t, enforcementCustomID, row.method, row.path, `{}`), row.key)
}
})
}
@@ -249,7 +249,7 @@ func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) {
repo.fail.failNext("Get")
uc := useCases.NewRevokeAPIKeyUseCase(repo, apiKeyUnitClock, discardRecorder)
err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.NewGrantSet(nil))
err := uc.RevokeAPIKey(testCtx, 2, stored.ID, role.GrantSet{})
if !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
@@ -272,7 +272,7 @@ func TestRolesPermissionsEnforcementAPIKeyScope(t *testing.T) {
t.Run("A list with no apikeys.view grant is refused by the use case as well", func(t *testing.T) {
uc := useCases.NewListAPIKeysUseCase(newMockAPIKeyRepo(), apiKeyUnitUsers(), apiKeyUnitClock)
if _, err := uc.ListAPIKeys(2, role.NewGrantSet(nil)); !errors.Is(err, role.ErrMissingPermission) {
if _, err := uc.ListAPIKeys(2, role.GrantSet{}); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected a missing permission, got %v", err)
}
})
@@ -305,8 +305,8 @@ func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) {
{"own edits its linked author", authorsEditOwnGrants, 4, author.Patch{Bio: &bio}, nil, false},
{"own cannot edit another author", authorsEditOwnGrants, 5, author.Patch{Bio: &bio}, author.ErrAuthorRights, false},
{"own cannot unlink", authorsEditOwnGrants, 4, author.Patch{UserID: author.Nullable[uint32]{Set: true}}, author.ErrAuthorRights, false},
{"none is refused", role.NewGrantSet(nil), 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
{"none is refused before any lookup", role.NewGrantSet(nil), 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
{"none is refused", role.GrantSet{}, 4, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
{"none is refused before any lookup", role.GrantSet{}, 999, author.Patch{Bio: &bio}, role.ErrMissingPermission, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
@@ -329,8 +329,8 @@ func TestRolesPermissionsEnforcementAuthorScope(t *testing.T) {
t.Run("user_email on authors needs users.view", func(t *testing.T) {
for name, grants := range map[string]role.GrantSet{
"with users.view": role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)}),
"without users.view": role.NewGrantSet(nil),
"with users.view": unlockedGrantSet(role.KeyUsersView + "=all"),
"without users.view": role.GrantSet{},
} {
t.Run(name, func(t *testing.T) {
authors := plainAuthors()
@@ -76,7 +76,7 @@ func TestRolesPermissionsEnforcementAuthorLinking(t *testing.T) {
authors.seedLinked(3, "Dangling", "dangling", 99)
users := newMockUserRepo()
users.users[5] = user.User{ID: 5, Email: "five@example.com"}
grants := role.NewGrantSet([]role.Grant{scopedGrant(role.KeyUsersView, role.ScopeAll)})
grants := unlockedGrantSet(role.KeyUsersView + "=all")
page, err := useCases.NewListAuthorsUseCase(authors, users).List(useCases.ListAuthorsCommand{Page: 1, Size: 25, Grants: grants})
+11 -16
View File
@@ -73,17 +73,17 @@ func TestCheckPermissionUseCase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if grants.Scope("authors.edit") != role.ScopeOwn || grants.Scope("users.view") != role.ScopeNone {
if grants.ScopeFor("authors.edit", "") != role.ScopeOwn || grants.ScopeFor("users.view", "") != role.ScopeNone {
t.Errorf("unexpected grants %v", grants)
}
})
t.Run("A custom role is judged by its grants only, owner-only keys included", func(t *testing.T) {
t.Run("A custom role is judged by its grants only, owner-only keys excluded", func(t *testing.T) {
check, roles := newCheck()
custom, _ := roles.Create(role.Role{Name: "Hand made", Version: 1, Grants: []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}})
account := user.User{ID: 20, RoleID: custom.ID}
if _, err := check.Require(account, "system.apply_updates"); err != nil {
t.Errorf("expected the hand-placed owner-only key to be honoured, got %v", err)
if _, err := check.Require(account, "system.apply_updates"); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected the hand-placed owner-only key to be ignored on an unlocked role, got %v", err)
}
if _, err := check.Require(account, "schema.view"); !errors.Is(err, role.ErrMissingPermission) {
t.Errorf("expected no implied keys, got %v", err)
@@ -122,11 +122,6 @@ func TestCheckPermissionUseCase(t *testing.T) {
if err == nil || errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected a plain failure, got %v", err)
}
roles.fail.failNext("ListGrants")
_, err = check.Require(owner, "users.view")
if err == nil || errors.Is(err, role.ErrMissingPermission) {
t.Fatalf("expected a plain failure, got %v", err)
}
})
t.Run("Grant changes apply to the next call with no cache", func(t *testing.T) {
@@ -146,13 +141,13 @@ func TestCheckPermissionUseCase(t *testing.T) {
func TestGrantSet(t *testing.T) {
t.Run("an absent key is none and a nil set holds nothing", func(t *testing.T) {
var empty role.GrantSet
if empty.Scope("users.view") != role.ScopeNone || empty.Holds("users.view") || empty.HoldsAll("users.view") {
if empty.ScopeFor("users.view", "") != role.ScopeNone || empty.Holds("users.view") || empty.HoldsAll("users.view") {
t.Error("a nil set must hold nothing")
}
})
t.Run("Holds is true for own and all, HoldsAll only for all", func(t *testing.T) {
set := role.NewGrantSet([]role.Grant{{Key: "a", Scope: role.ScopeOwn}, {Key: "b", Scope: role.ScopeAll}, {Key: "c", Scope: role.ScopeNone}})
if !set.Holds("a") || set.HoldsAll("a") || !set.Holds("b") || !set.HoldsAll("b") || set.Holds("c") {
set := unlockedGrantSet("content.update=own", "content.delete=all", "content.read=none")
if !set.Holds("content.update") || set.HoldsAll("content.update") || !set.Holds("content.delete") || !set.HoldsAll("content.delete") || set.Holds("content.read") {
t.Errorf("unexpected %v", set)
}
})
@@ -165,8 +160,8 @@ func TestGrantSet(t *testing.T) {
}
var (
activityViewAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeAll}})
activityViewOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyActivityView, Scope: role.ScopeOwn}})
authorsEditOwnGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeOwn}})
authorsEditAllGrants = role.NewGrantSet([]role.Grant{{Key: role.KeyAuthorsEdit, Scope: role.ScopeAll}})
activityViewAllGrants = unlockedGrantSet(role.KeyActivityView + "=all")
activityViewOwnGrants = unlockedGrantSet(role.KeyActivityView + "=own")
authorsEditOwnGrants = unlockedGrantSet(role.KeyAuthorsEdit + "=own")
authorsEditAllGrants = unlockedGrantSet(role.KeyAuthorsEdit + "=all")
)
+9 -5
View File
@@ -18,7 +18,7 @@ func roleWithGrant(key string, scope role.Scope) role.Role {
return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}}
}
func TestRoleAllows(t *testing.T) {
func TestRoleGrantSetDecisions(t *testing.T) {
cases := []struct {
key string
scope role.Scope
@@ -35,14 +35,18 @@ func TestRoleAllows(t *testing.T) {
}
for _, c := range cases {
t.Run(string(c.scope)+" "+c.key, func(t *testing.T) {
if got := roleWithGrant(c.key, c.scope).Allows(c.key, c.owned); got != c.allowed {
createdBy := uint32(2)
if c.owned {
createdBy = 1
}
if got := roleWithGrant(c.key, c.scope).GrantSet().Permits(c.key, "", 1, createdBy); got != c.allowed {
t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got)
}
})
}
t.Run("A key absent from the role is not allowed", func(t *testing.T) {
if (role.Role{}).Allows("content.read", true) {
if (role.Role{}).GrantSet().Permits("content.read", "", 1, 1) {
t.Errorf("expected false")
}
})
@@ -50,7 +54,7 @@ func TestRoleAllows(t *testing.T) {
t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) {
everything := role.BuiltInRoles()[0]
everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll})
if everything.Allows("content.explode", true) {
if everything.GrantSet().Permits("content.explode", "", 1, 1) {
t.Errorf("expected false")
}
if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) {
@@ -388,7 +392,7 @@ func TestNewArticlesRecordTheirCreator(t *testing.T) {
t.Run("New articles record their creator", func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", CreatedBy: 7})
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", Caller: ownerCaller(7)})
if err != nil {
t.Fatal(err)
}
+4 -1
View File
@@ -23,6 +23,9 @@ type seoAlwaysRacingArticleRepo struct {
func (r *seoAlwaysRacingArticleRepo) Create(a article.Article) (article.Article, error) {
r.createCalls++
if r.createCalls > 100 {
return article.Article{}, errors.New("create budget exceeded")
}
return article.Article{}, article.ErrSlugTaken
}
@@ -69,7 +72,7 @@ func TestSeoDataCleanerSlugRaces(t *testing.T) {
racing := &seoAlwaysRacingArticleRepo{mockArticleRepo: e.articles}
create := useCases.NewCreateArticleUseCase(racing, &fakeBodyRenderer{}, e.audit.recorder, e.sites, e.authors)
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Title: "Racing", Body: "x"})
_, err := create.CreateArticle(t.Context(), useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Racing", Body: "x"})
if !errors.Is(err, article.ErrSlugTaken) || racing.createCalls != 5 {
t.Errorf("err %v after %d attempts", err, racing.createCalls)
+2 -2
View File
@@ -40,8 +40,8 @@ func TestSeoFilterPerformance(t *testing.T) {
}
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db))
filtered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
unfiltered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, Sort: article.DefaultSort()}
filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()}
slowestFiltered, page := slowestOfThree(t, list, filtered)
slowestUnfiltered, _ := slowestOfThree(t, list, unfiltered)
t.Logf("GET /v1/article/?seo=suggestions over %d articles in one site: slowest of 3 runs %d ms (budget %d ms), total %d, counts %+v", seoPerformanceArticles, slowestFiltered.Milliseconds(), seoPerformanceBudget.Milliseconds(), page.Total, page.Counts)
+1 -1
View File
@@ -21,7 +21,7 @@ func TestSeoOutputListPagingNeverPanics(t *testing.T) {
run := func(page int, size int) useCases.ArticlePage {
t.Helper()
result, err := e.container.ListArticlesUseCase.ListArticlesPage(useCases.ListArticlesCommand{
Verified: true, Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"},
Caller: ownerCaller(1), Page: page, Size: size, SEO: article.SEOStatusReady, Sort: article.Sort{Field: "title", Direction: "asc"},
})
if err != nil {
t.Fatalf("page %d size %d: %v", page, size, err)
+3 -3
View File
@@ -93,7 +93,7 @@ func TestSiteDomainRules(t *testing.T) {
func TestSiteUseCaseRules(t *testing.T) {
t.Run("Creating an article assigns it to the default site", func(t *testing.T) {
repo := newMockArticleRepo()
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
created, err := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()).CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
if err != nil || created.SiteID != 1 {
t.Errorf("created %+v, %v", created, err)
}
@@ -104,7 +104,7 @@ func TestSiteUseCaseRules(t *testing.T) {
sites := newMockSiteRepo(repo)
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors())
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title"})
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Valid Title"})
if !errors.Is(err, site.ErrSiteNotFound) || repo.createCalled {
t.Errorf("err = %v, create called = %v", err, repo.createCalled)
@@ -119,7 +119,7 @@ func TestSiteUseCaseRules(t *testing.T) {
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, sites, plainAuthors(), newMockImageRepo())
missing := uint32(9)
_, err := uc.Update(testCtx, 1, useCases.UpdateArticleCommand{SiteID: &missing})
_, err := uc.Update(testCtx, 1, ownerCaller(1), useCases.UpdateArticleCommand{SiteID: &missing})
if !errors.Is(err, site.ErrSiteNotFound) || repo.articles[1].SiteID != 1 {
t.Errorf("err = %v, stored %+v", err, repo.articles[1])
+1 -1
View File
@@ -28,7 +28,6 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
loginHandler := auth.NewLoginHandler(
token,
@@ -51,6 +50,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(getUserUseCase))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
+17 -17
View File
@@ -33,7 +33,7 @@ func TestCreateArticleTitleLengthLimit(t *testing.T) {
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
title := strings.Repeat("a", tc.length)
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: title, Body: "Some body text"})
result, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: title, Body: "Some body text"})
if tc.expectValid {
if err != nil {
@@ -65,7 +65,7 @@ func TestCreateArticlePropagatesRepositoryFailure(t *testing.T) {
repo.fail.failNext("Create")
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Backyard Renovation Update", Body: "Some body text"})
_, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Caller: ownerCaller(1), Title: "Backyard Renovation Update", Body: "Some body text"})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
@@ -86,7 +86,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(1, article.BodyFormatHTML)
result, err := uc.GetArticle(1, article.BodyFormatHTML, ownerCaller(1))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -98,7 +98,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetArticle(2, article.BodyFormatHTML)
result, err := uc.GetArticle(2, article.BodyFormatHTML, ownerCaller(1))
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -110,7 +110,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetArticle(9, article.BodyFormatHTML)
_, err := uc.GetArticle(9, article.BodyFormatHTML, ownerCaller(1))
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -119,7 +119,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
result, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
result, err := uc.GetArticle(1, article.BodyFormatHTML, useCases.ContentCaller{})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -131,7 +131,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_2", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
_, err := uc.GetArticle(2, article.BodyFormatHTML, useCases.ContentCaller{})
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -140,7 +140,7 @@ func TestGetArticleAndGetOnlineArticle(t *testing.T) {
t.Run("GetOnlineArticle_9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
_, err := uc.GetOnlineArticle(9, article.BodyFormatHTML)
_, err := uc.GetArticle(9, article.BodyFormatHTML, useCases.ContentCaller{})
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -210,7 +210,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
t.Run("ListArticlesPage verified", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -222,7 +222,7 @@ func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -245,7 +245,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateBody(testCtx, 1, "New body", article.BodyFormatHTML)
result, err := uc.UpdateBody(testCtx, 1, ownerCaller(1), "New body", article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -257,7 +257,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateBody_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateBody(testCtx, 9, "New body", article.BodyFormatHTML)
_, err := uc.UpdateBody(testCtx, 9, ownerCaller(1), "New body", article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -266,7 +266,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id1", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
result, err := uc.UpdateIsOnline(testCtx, 1, true, article.BodyFormatHTML)
result, err := uc.UpdateIsOnline(testCtx, 1, ownerCaller(1), true, article.BodyFormatHTML)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -278,7 +278,7 @@ func TestUpdateArticleDelegatesEachOperation(t *testing.T) {
t.Run("UpdateIsOnline_id9", func(t *testing.T) {
repo := newRepo()
uc := useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo())
_, err := uc.UpdateIsOnline(testCtx, 9, true, article.BodyFormatHTML)
_, err := uc.UpdateIsOnline(testCtx, 9, ownerCaller(1), true, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -291,7 +291,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(testCtx, 1)
err := uc.DeleteArticle(testCtx, 1, ownerCaller(1))
if err != nil {
t.Errorf("expected no error, got %v", err)
}
@@ -302,7 +302,7 @@ func TestDeleteArticleReportsMissingArticle(t *testing.T) {
repo.articles[1] = article.Article{ID: 1}
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(testCtx, 9)
err := uc.DeleteArticle(testCtx, 9, ownerCaller(1))
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("expected article.ErrArticleNotFound, got %v", err)
}
@@ -315,7 +315,7 @@ func TestDeleteArticlePropagatesRepositoryFailure(t *testing.T) {
repo.fail.failNext("Delete")
uc := useCases.NewDeleteArticleUseCase(repo, discardRecorder)
err := uc.DeleteArticle(testCtx, 1)
err := uc.DeleteArticle(testCtx, 1, ownerCaller(1))
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("expected repository failure, got %v", err)
}
+1 -1
View File
@@ -26,13 +26,13 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Get("/", articleHandler.ListArticles)
r.Get("/{id}", articleHandler.GetArticle)
r.Group(func(r chi.Router) {
+1 -1
View File
@@ -218,13 +218,13 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo()),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(token))
r.Use(contentAccess(getUserUseCase))
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Route("/article", func(r chi.Router) {