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 user with role_id 0 is denied without any role load", func(t *testing.T) { world := newContentScopeWorld(t) bare := world.users.users[csOwner] bare.RoleID = 0 world.users.users[csOwner] = bare world.resetCounters() rec := world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch) if rec.Code != http.StatusForbidden || rec.Body.String() != "missing_permission\n" { t.Errorf("expected 403 missing_permission, got %d %q", rec.Code, rec.Body.String()) } if world.roleRepo.touched() != 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) } }) }