683 lines
35 KiB
Go
683 lines
35 KiB
Go
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.lists != 0 {
|
|
t.Errorf("expected at most 1 user Get, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
|
|
}
|
|
})
|
|
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.lists != 0 {
|
|
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("A legacy owner is resolved by one GetByName and no List call", 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.lists != 0 {
|
|
t.Errorf("unexpected role loads %+v", world.roleRepo)
|
|
}
|
|
})
|
|
|
|
t.Run("GET /auth/me does not call List", func(t *testing.T) {
|
|
world := newContentScopeWorld(t)
|
|
world.resetCounters()
|
|
rec, _ := world.me(t, csWriter)
|
|
if rec.Code != http.StatusOK || world.roleRepo.lists != 0 {
|
|
t.Errorf("unexpected %d and %d List calls", rec.Code, world.roleRepo.lists)
|
|
}
|
|
})
|
|
}
|
|
|
|
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" || got["content.update:blog"] != "all" || len(got) != 2 {
|
|
t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), 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)
|
|
}
|
|
})
|
|
}
|