refac: debt on role backend
This commit is contained in:
1 parent
de142f6c5f
commit
a043f7f41a
130 files changed
+3591
-512
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user