722 lines
29 KiB
Go
722 lines
29 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/article"
|
|
"RenewCMS/internal/domain/audit"
|
|
"encoding/json"
|
|
"net/http"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func capabilitiesOf(t *testing.T, response storyResponse) []string {
|
|
t.Helper()
|
|
var capabilities []string
|
|
if err := json.Unmarshal(response.fields(t)["capabilities"], &capabilities); err != nil {
|
|
t.Fatalf("capabilities: %v in %s", err, response.text())
|
|
}
|
|
return capabilities
|
|
}
|
|
|
|
func translationsOf(t *testing.T, response storyResponse) []map[string]json.RawMessage {
|
|
t.Helper()
|
|
var translations []map[string]json.RawMessage
|
|
if err := json.Unmarshal(response.fields(t)["translations"], &translations); err != nil {
|
|
t.Fatalf("translations: %v in %s", err, response.text())
|
|
}
|
|
return translations
|
|
}
|
|
|
|
func languagesIn(translations []map[string]json.RawMessage) []string {
|
|
tags := []string{}
|
|
for _, entry := range translations {
|
|
tags = append(tags, strings.Trim(string(entry["lang"]), `"`))
|
|
}
|
|
return tags
|
|
}
|
|
|
|
func disableOnJournal(w *storyWorld, tag string) {
|
|
rows := w.rows.rows[storyJournal]
|
|
for index := range rows {
|
|
if rows[index].Tag == tag {
|
|
rows[index].Enabled = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArticleStoryLifecycleHTTP(t *testing.T) {
|
|
t.Run("Capabilities of the owner", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
|
|
|
|
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, []string{"read", "edit", "publish", "delete", "create_translation"}) {
|
|
t.Errorf("capabilities %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Capabilities per caller and version", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
user uint32
|
|
id string
|
|
online11 bool
|
|
want []string
|
|
}{
|
|
{"writer1 on its own draft", storyWriter1, "12", false, []string{"read", "edit", "publish", "delete", "create_translation"}},
|
|
{"writer1 on a readable sibling of another user", storyWriter1, "11", true, []string{"read", "create_translation"}},
|
|
{"reader", storyReader, "10", false, []string{"read"}},
|
|
{"update and read all", storyUpdater, "12", false, []string{"read", "edit"}},
|
|
{"publish and read all", storyPublish, "12", false, []string{"read", "publish"}},
|
|
{"anonymous", storyAnon, "10", false, []string{"read"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
if c.online11 {
|
|
sibling := w.stored(11)
|
|
sibling.IsOnline = true
|
|
w.articles.articles[11] = sibling
|
|
}
|
|
|
|
response := w.send(c.user, http.MethodGet, "/v1/article/"+c.id, "")
|
|
|
|
if got := capabilitiesOf(t, response); !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("capabilities %v, expected %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Translations list readable versions only", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
response := w.send(storyWriter1, http.MethodGet, "/v1/article/10", "")
|
|
|
|
translations := translationsOf(t, response)
|
|
if got := languagesIn(translations); !reflect.DeepEqual(got, []string{"en", "ar", "fr"}) {
|
|
t.Errorf("translations %v", got)
|
|
}
|
|
for _, entry := range translations {
|
|
for _, key := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} {
|
|
if _, present := entry[key]; !present {
|
|
t.Errorf("entry misses %q: %v", key, entry)
|
|
}
|
|
}
|
|
}
|
|
if strings.Contains(response.text(), "Kosten") || slices.Contains(languagesIn(translations), "de") {
|
|
t.Errorf("the hidden version leaks: %s", response.text())
|
|
}
|
|
})
|
|
|
|
t.Run("Translations are ordered default language first, then by tag", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
response := w.send(storyOwner, http.MethodGet, "/v1/article/12", "")
|
|
|
|
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
|
|
t.Errorf("translations %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("Disabled languages are marked", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
disableOnJournal(w, "fr")
|
|
|
|
response := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
|
|
|
|
for _, entry := range translationsOf(t, response) {
|
|
enabled := string(entry["language_enabled"])
|
|
if lang := string(entry["lang"]); (lang == `"fr"`) == (enabled == "true") {
|
|
t.Errorf("fr must be flagged disabled and still listed: %v", entry)
|
|
}
|
|
}
|
|
if got := languagesIn(translationsOf(t, response)); !reflect.DeepEqual(got, []string{"en", "ar", "de", "fr"}) {
|
|
t.Errorf("fr must still be listed: %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("A single-version article lists itself", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
|
|
|
|
response := w.send(storyOwner, http.MethodGet, "/v1/article/20", "")
|
|
|
|
translations := translationsOf(t, response)
|
|
if len(translations) != 1 || string(translations[0]["id"]) != "20" {
|
|
t.Errorf("translations %v", translations)
|
|
}
|
|
})
|
|
|
|
t.Run("Anonymous reads expose nothing about siblings", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
response := w.send(storyAnon, http.MethodGet, "/v1/article/10", "")
|
|
|
|
fields := response.fields(t)
|
|
if string(fields["translations"]) != "[]" || string(fields["capabilities"]) != `["read"]` || string(fields["source"]) != "null" {
|
|
t.Errorf("unexpected anonymous fields: %s", response.text())
|
|
}
|
|
expected := append([]string{"lang", "story_id", "revision", "capabilities", "translations", "source", "body_format"}, legacyArticleKeys...)
|
|
for key := range fields {
|
|
if !contains(expected, key) {
|
|
t.Errorf("unexpected key %q in the public shape", key)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("The source on the read", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
source := uint32(10)
|
|
w.put(article.Article{ID: 14, Title: "Copy", CreatedBy: storyRestrict, Slug: "copy", Lang: "en", StoryID: 14, SourceArticleID: &source, SourceRevision: 4})
|
|
w.put(article.Article{ID: 15, Title: "Empty", CreatedBy: storyWriter1, Slug: "empty", Lang: "en", StoryID: 15})
|
|
|
|
response := w.send(storyOwner, http.MethodGet, "/v1/article/14", "")
|
|
if string(response.fields(t)["source"]) != `{"article_id":10,"revision":4}` {
|
|
t.Errorf("source %s", response.fields(t)["source"])
|
|
}
|
|
if string(w.send(storyOwner, http.MethodGet, "/v1/article/15", "").fields(t)["source"]) != "null" {
|
|
t.Errorf("an empty version has no source")
|
|
}
|
|
draft := w.stored(10)
|
|
draft.IsOnline = false
|
|
w.articles.articles[10] = draft
|
|
if string(w.send(storyReader, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) == "null" {
|
|
t.Errorf("the reader reads everything and sees the source")
|
|
}
|
|
if string(w.send(storyRestrict, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
|
|
t.Errorf("an unreadable source is null")
|
|
}
|
|
delete(w.articles.articles, 10)
|
|
if string(w.send(storyOwner, http.MethodGet, "/v1/article/14", "").fields(t)["source"]) != "null" {
|
|
t.Errorf("a deleted source is null")
|
|
}
|
|
})
|
|
|
|
t.Run("Publishing respects the language configuration", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
id uint32
|
|
status int
|
|
message string
|
|
online bool
|
|
}{
|
|
{"fr enabled", 12, 200, "", true},
|
|
{"es disabled, offline version", 15, 422, apperr.LanguageDisabledErrorMessage, false},
|
|
{"es disabled, already online", 16, 200, "", true},
|
|
{"unassigned language", 30, 200, "", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
|
|
w.put(article.Article{ID: 16, Title: "Online es", CreatedBy: 1, Slug: "es-online", Lang: "es", StoryID: 16, IsOnline: true})
|
|
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
|
|
|
|
response := w.send(storyPublish, http.MethodPatch, "/v1/article/"+strconv.Itoa(int(c.id))+"/publish", "")
|
|
|
|
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
|
|
t.Fatalf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
|
}
|
|
if w.stored(c.id).IsOnline != c.online {
|
|
t.Errorf("is_online = %v, expected %v", w.stored(c.id).IsOnline, c.online)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Unpublish and edit stay available in a disabled language", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
fr := w.stored(12)
|
|
fr.IsOnline = true
|
|
w.articles.articles[12] = fr
|
|
disableOnJournal(w, "fr")
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
|
|
})
|
|
|
|
t.Run("Publishing one version leaves the others alone", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
before := w.snapshot()
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
|
|
|
|
for _, id := range []uint32{10, 11, 13} {
|
|
if !articlesEqual(before[id], w.stored(id)) {
|
|
t.Errorf("article %d changed", id)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Re-enabling a language allows publishing again", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 15, Title: "Offline es", CreatedBy: 1, Slug: "es-offline", Lang: "es", StoryID: 15})
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 422, apperr.LanguageDisabledErrorMessage)
|
|
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar", "es"}, nil)
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/15/publish", "").expect(t, 200, "")
|
|
})
|
|
|
|
t.Run("Existing publish errors are unchanged", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/9999/publish", "").expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/abc/publish", "").expect(t, 400, apperr.IdUint32ErrorMessage)
|
|
w.send(storyAnon, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 401, "")
|
|
w.send(storyReader, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
|
|
})
|
|
|
|
t.Run("Deleting one version keeps the story", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
|
|
|
for _, id := range []uint32{10, 11, 13} {
|
|
if _, present := w.articles.articles[id]; !present {
|
|
t.Errorf("article %d must remain", id)
|
|
}
|
|
}
|
|
listing := w.send(storyOwner, http.MethodGet, "/v1/article/10", "")
|
|
if got := languagesIn(translationsOf(t, listing)); !reflect.DeepEqual(got, []string{"en", "ar", "de"}) {
|
|
t.Errorf("translations %v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("A deleted language can be created again", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/article/10/translations", `{"lang":"fr","title":"Couts","copy":false}`)
|
|
|
|
response.expect(t, 201, "")
|
|
})
|
|
|
|
t.Run("Deleting the last version removes the story", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "en", StoryID: 8})
|
|
|
|
w.send(storyOwner, http.MethodDelete, "/v1/article/20", "").expect(t, 204, "")
|
|
|
|
if versions, _ := w.articles.ListByStory(8); len(versions) != 0 {
|
|
t.Errorf("story 8 must be gone")
|
|
}
|
|
w.send(storyOwner, http.MethodPost, "/v1/article/20/translations", `{"lang":"fr","title":"Couts","copy":false}`).expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
|
})
|
|
|
|
t.Run("Deleting the last of several versions", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
for position, id := range []string{"10", "11", "12", "13"} {
|
|
w.send(storyOwner, http.MethodDelete, "/v1/article/"+id, "").expect(t, 204, "")
|
|
versions, _ := w.articles.ListByStory(storyStory)
|
|
if (position < 3) != (len(versions) > 0) {
|
|
t.Errorf("after deleting %s, %d versions remain", id, len(versions))
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Delete is checked per version", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
w.send(storyWriter1, http.MethodDelete, "/v1/article/11", "").expect(t, 403, apperr.MissingPermissionErrorMessage)
|
|
|
|
if _, present := w.articles.articles[11]; !present {
|
|
t.Errorf("article 11 must remain")
|
|
}
|
|
})
|
|
|
|
t.Run("A story with a hidden version", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 80, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "en", StoryID: 80})
|
|
w.put(article.Article{ID: 81, Title: "Hidden", CreatedBy: 2, Slug: "hidden", Lang: "de", StoryID: 80})
|
|
|
|
response := w.send(storyWriter1, http.MethodDelete, "/v1/article/80", "")
|
|
|
|
response.expect(t, 204, "")
|
|
if versions, _ := w.articles.ListByStory(80); len(versions) != 1 || response.text() != "" {
|
|
t.Errorf("the story must remain and the response mention nothing: %d %q", len(versions), response.text())
|
|
}
|
|
})
|
|
|
|
moveBody := `{"site":"atelier"}`
|
|
t.Run("Move a story", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 200, "")
|
|
if response.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}` {
|
|
t.Errorf("unexpected body %s", response.text())
|
|
}
|
|
for id, previous := range before {
|
|
moved := w.stored(id)
|
|
if moved.SiteID != 2 || moved.EditRevision != previous.EditRevision+1 || moved.ContentRevision != previous.ContentRevision {
|
|
t.Errorf("article %d: %+v", id, moved)
|
|
}
|
|
previous.SiteID, previous.EditRevision = moved.SiteID, moved.EditRevision
|
|
if !articlesEqual(previous, moved) {
|
|
t.Errorf("article %d changed beyond its site: %+v", id, moved)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("A move requires every language to be enabled at the destination", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
if response.header(apperr.MissingLanguagesHeader) != "ar" {
|
|
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("A language disabled at the destination counts as missing", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "ar"}, []string{"de"})
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
if !strings.Contains(response.header(apperr.MissingLanguagesHeader), "de") {
|
|
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
|
}
|
|
})
|
|
|
|
t.Run("A language disabled at the source still needs the destination", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
disableOnJournal(w, "fr")
|
|
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
|
|
|
|
w = newStoryWorld(t)
|
|
disableOnJournal(w, "fr")
|
|
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "de", "ar"}, nil)
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
if response.header(apperr.MissingLanguagesHeader) != "fr" {
|
|
t.Errorf("X-Missing-Languages %q", response.header(apperr.MissingLanguagesHeader))
|
|
}
|
|
})
|
|
|
|
t.Run("Address conflict at the destination", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 11, Title: "Kosten", CreatedBy: storyPartial, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * 60e9)})
|
|
w.put(article.Article{ID: 12, Title: "Couts", CreatedBy: storyPartial, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(60e9)})
|
|
hidden := w.stored(13)
|
|
hidden.IsOnline = false
|
|
w.articles.articles[13] = hidden
|
|
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 91})
|
|
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier ar", CreatedBy: 1, Slug: "takalif", Lang: "ar", StoryID: 92})
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
|
if response.header(apperr.ConflictingArticlesHeader) != "12" {
|
|
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("Every conflict of the story is checked", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 91, SiteID: storyAtelier, Title: "Atelier en", CreatedBy: 1, Slug: "renovation-costs", Lang: "en", StoryID: 91})
|
|
w.put(article.Article{ID: 92, SiteID: storyAtelier, Title: "Atelier fr", CreatedBy: 1, Slug: "couts", Lang: "fr", StoryID: 92})
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
|
if response.header(apperr.ConflictingArticlesHeader) != "10,12" {
|
|
t.Errorf("X-Conflicting-Articles %q", response.header(apperr.ConflictingArticlesHeader))
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("A move needs update permission on every version, readable or not", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
user uint32
|
|
offline bool
|
|
status int
|
|
message string
|
|
readable bool
|
|
}{
|
|
{"an anonymous client", storyAnon, false, 401, "", true},
|
|
{"writer1", storyWriter1, false, 403, apperr.MissingPermissionErrorMessage, true},
|
|
{"a user without content.update", storyReader, false, 403, apperr.MissingPermissionErrorMessage, true},
|
|
{"a user who can read no version", storyPartial, true, 404, "", false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
if c.offline {
|
|
for _, id := range []uint32{10, 13} {
|
|
draft := w.stored(id)
|
|
draft.IsOnline = false
|
|
w.articles.articles[id] = draft
|
|
}
|
|
}
|
|
before := w.snapshot()
|
|
|
|
response := w.send(c.user, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
if c.readable {
|
|
if response.Code != c.status || (c.message != "" && response.text() != c.message+"\n") {
|
|
t.Errorf("expected %d %q, got %d %q", c.status, c.message, response.Code, response.text())
|
|
}
|
|
} else {
|
|
response.expect(t, 404, apperr.ArticleNotFoundErrorMessage)
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Hidden versions never leak through the move", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
hidden := w.stored(13)
|
|
hidden.IsOnline = false
|
|
w.articles.articles[13] = hidden
|
|
|
|
response := w.send(storyWriter1, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
response.expect(t, 403, apperr.MissingPermissionErrorMessage)
|
|
if strings.Contains(response.text(), "11") || strings.Contains(response.text(), "13") || strings.Contains(response.Header().Get(apperr.MissingPermissionHeader), "1") {
|
|
t.Errorf("the response names a hidden version")
|
|
}
|
|
w = newStoryWorld(t)
|
|
hidden = w.stored(13)
|
|
hidden.IsOnline = false
|
|
w.articles.articles[13] = hidden
|
|
for _, id := range []uint32{10, 11, 12} {
|
|
readable := w.stored(id)
|
|
readable.CreatedBy = storyPartial
|
|
w.articles.articles[id] = readable
|
|
}
|
|
success := w.send(storyPartial, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
success.expect(t, 200, "")
|
|
if success.text() != `{"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11]}` {
|
|
t.Errorf("article_ids must list readable versions only: %s", success.text())
|
|
}
|
|
})
|
|
|
|
t.Run("Move validation", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
status int
|
|
message string
|
|
}{
|
|
{"unknown site", "/v1/story/5/move", `{"site":"nowhere"}`, 404, apperr.SiteNotFoundErrorMessage},
|
|
{"empty body", "/v1/story/5/move", `{}`, 400, apperr.BodyErrorMessage},
|
|
{"malformed JSON", "/v1/story/5/move", `{"site":`, 400, apperr.BodyErrorMessage},
|
|
{"unknown story", "/v1/story/9999/move", moveBody, 404, apperr.ArticleNotFoundErrorMessage},
|
|
{"bad story id", "/v1/story/abc/move", moveBody, 400, apperr.IdUint32ErrorMessage},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
|
|
w.send(storyOwner, http.MethodPost, c.path, c.body).expect(t, c.status, c.message)
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("Moving to the current site", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", `{"site":"journal"}`)
|
|
|
|
response.expect(t, 200, "")
|
|
w.expectUnchanged(before)
|
|
if len(w.recorder.events) != 0 {
|
|
t.Errorf("no activity event expected, got %d", len(w.recorder.events))
|
|
}
|
|
})
|
|
|
|
t.Run("The move is atomic and revalidated inside the transaction", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.articles.beforeMove = func() {
|
|
w.put(article.Article{ID: 95, SiteID: storyAtelier, Title: "Late", CreatedBy: 1, Slug: "kosten", Lang: "de", StoryID: 95})
|
|
}
|
|
before := w.snapshot()
|
|
delete(before, 95)
|
|
|
|
late := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
late.expect(t, 409, apperr.SlugConflictErrorMessage)
|
|
delete(w.articles.articles, 95)
|
|
w.expectUnchanged(before)
|
|
|
|
w.articles.moveFailsAt = 3
|
|
failed := w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody)
|
|
|
|
if failed.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", failed.Code)
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("PATCH site_id cannot split a story", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
before := w.snapshot()
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/10", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
|
|
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("PATCH site_id counts readable versions only", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 60, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 60})
|
|
w.put(article.Article{ID: 61, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden", Lang: "de", StoryID: 60})
|
|
w.put(article.Article{ID: 62, Title: "Mine", CreatedBy: storyWriter1, Slug: "mine", Lang: "fr", StoryID: 62})
|
|
w.put(article.Article{ID: 63, Title: "Not mine", CreatedBy: storyOwner, Slug: "not-mine", Lang: "de", StoryID: 62})
|
|
w.put(article.Article{ID: 64, Title: "First", CreatedBy: storyPartial, Slug: "first", Lang: "fr", StoryID: 64})
|
|
w.put(article.Article{ID: 65, Title: "Second", CreatedBy: storyPartial, Slug: "second", Lang: "de", StoryID: 64})
|
|
w.put(article.Article{ID: 66, Title: "Third hidden", CreatedBy: storyOwner, Slug: "third-hidden", Lang: "en", StoryID: 64})
|
|
|
|
denied := w.send(storyWriter1, http.MethodPatch, "/v1/article/62", `{"site_id":2}`)
|
|
denied.expect(t, 403, apperr.MissingPermissionErrorMessage)
|
|
if denied.header("X-Missing-Permission") != "content.update" || strings.Contains(denied.text(), "hidden") {
|
|
t.Errorf("the refusal must only name the permission: %v %q", denied.Header(), denied.text())
|
|
}
|
|
if w.stored(62).SiteID != storyJournal || w.stored(63).SiteID != storyJournal {
|
|
t.Errorf("a refused move must change nothing")
|
|
}
|
|
|
|
w.send(storyPartial, http.MethodPatch, "/v1/article/64", `{"site_id":2}`).expect(t, 409, apperr.StoryMoveRequiredErrorMessage)
|
|
if w.stored(64).SiteID != storyJournal {
|
|
t.Errorf("a refused move must change nothing")
|
|
}
|
|
|
|
w.send(storyPartial, http.MethodPatch, "/v1/article/60", `{"site_id":2}`).expect(t, 200, "")
|
|
if w.stored(60).SiteID != 2 || w.stored(61).SiteID != 2 {
|
|
t.Errorf("every version of the story moves together: %d %d", w.stored(60).SiteID, w.stored(61).SiteID)
|
|
}
|
|
})
|
|
|
|
t.Run("PATCH site_id with a hidden version applies the story move rules", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70})
|
|
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70})
|
|
w.put(article.Article{ID: 72, SiteID: storyAtelier, Title: "Taken", CreatedBy: 1, Slug: "hidden-de", Lang: "de", StoryID: 72})
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2}`)
|
|
|
|
response.expect(t, 409, apperr.SlugConflictErrorMessage)
|
|
if strings.Contains(response.text(), "hidden") || response.header(apperr.ConflictingArticlesHeader) != "" {
|
|
t.Errorf("the refusal must not name hidden versions: %v %q", response.Header(), response.text())
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("PATCH site_id with a hidden version raises revision by exactly 1", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3, ContentRevision: 4})
|
|
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5, ContentRevision: 6})
|
|
|
|
w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":3}`).expect(t, 200, "")
|
|
|
|
if edit, content := revisionsOf(w, 70); w.stored(70).SiteID != 2 || edit != 4 || content != 4 {
|
|
t.Errorf("site %d revision %d content_revision %d, expected 2, 4 and 4", w.stored(70).SiteID, edit, content)
|
|
}
|
|
if edit, content := revisionsOf(w, 71); w.stored(71).SiteID != 2 || edit != 6 || content != 6 {
|
|
t.Errorf("hidden version: site %d revision %d content_revision %d, expected 2, 6 and 6", w.stored(71).SiteID, edit, content)
|
|
}
|
|
})
|
|
|
|
t.Run("PATCH site_id with a stale if_revision moves nobody", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 70, Title: "Visible", CreatedBy: storyPartial, Slug: "visible", Lang: "fr", StoryID: 70, EditRevision: 3})
|
|
w.put(article.Article{ID: 71, Title: "Hidden", CreatedBy: storyOwner, Slug: "hidden-de", Lang: "de", StoryID: 70, EditRevision: 5})
|
|
before := w.snapshot()
|
|
|
|
response := w.send(storyPartial, http.MethodPatch, "/v1/article/70", `{"site_id":2,"if_revision":2}`)
|
|
|
|
if response.Code != 409 {
|
|
t.Errorf("expected 409, got %d %s", response.Code, response.text())
|
|
}
|
|
w.expectUnchanged(before)
|
|
})
|
|
|
|
t.Run("PATCH site_id on a single-version story applies the move rules", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 20, Title: "Alone", CreatedBy: storyWriter1, Slug: "alone", Lang: "fr", StoryID: 8})
|
|
w.put(article.Article{ID: 21, Title: "Alone es", CreatedBy: storyWriter1, Slug: "alone-es", Lang: "es", StoryID: 21})
|
|
w.put(article.Article{ID: 22, Title: "Alone unassigned", CreatedBy: storyWriter1, Slug: "alone-unassigned", Lang: "", StoryID: 22})
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/20", `{"site_id":2}`).expect(t, 200, "")
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/21", `{"site_id":2}`).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/22", `{"site_id":2}`).expect(t, 200, "")
|
|
|
|
if w.stored(20).SiteID != 2 || w.stored(21).SiteID != 1 || w.stored(22).SiteID != 2 {
|
|
t.Errorf("unexpected sites %d %d %d", w.stored(20).SiteID, w.stored(21).SiteID, w.stored(22).SiteID)
|
|
}
|
|
})
|
|
|
|
t.Run("Activity events carry the language", func(t *testing.T) {
|
|
w := newStoryWorld(t)
|
|
w.put(article.Article{ID: 30, Title: "Unassigned", CreatedBy: 1, Slug: "unassigned", Lang: "", StoryID: 30})
|
|
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12/publish", "").expect(t, 200, "")
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12/unpublish", "").expect(t, 200, "")
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/12", `{"body":"x"}`).expect(t, 200, "")
|
|
w.send(storyOwner, http.MethodDelete, "/v1/article/12", "").expect(t, 204, "")
|
|
w.send(storyOwner, http.MethodPatch, "/v1/article/30/publish", "").expect(t, 200, "")
|
|
|
|
for _, action := range []string{audit.ActionArticlePublish, audit.ActionArticleUnpublish, audit.ActionArticleEdit, audit.ActionArticleDelete} {
|
|
events := w.eventsOf(action)
|
|
if len(events) == 0 || events[0].Params[audit.ParamLang] != "fr" {
|
|
t.Errorf("%s must carry lang fr: %+v", action, events)
|
|
}
|
|
}
|
|
if publishes := w.eventsOf(audit.ActionArticlePublish); len(publishes) != 2 {
|
|
t.Fatalf("expected two publish events, got %d", len(publishes))
|
|
} else if _, present := publishes[1].Params[audit.ParamLang]; present {
|
|
t.Errorf("an unassigned article carries no lang")
|
|
}
|
|
|
|
w = newStoryWorld(t)
|
|
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 200, "")
|
|
moves := w.eventsOf(audit.ActionArticleEdit)
|
|
if len(moves) != 4 {
|
|
t.Fatalf("one edit event per moved version expected, got %d", len(moves))
|
|
}
|
|
for _, event := range moves {
|
|
if event.Params[audit.ParamLang] == nil || len(event.Changes) != 1 || event.Changes[0].Field != audit.FieldSite || *event.Changes[0].After != "Atelier" {
|
|
t.Errorf("unexpected move event %+v", event)
|
|
}
|
|
}
|
|
|
|
w = newStoryWorld(t)
|
|
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de"}, nil)
|
|
w.send(storyOwner, http.MethodPost, "/v1/story/5/move", moveBody).expect(t, 422, apperr.LanguageNotEnabledErrorMessage)
|
|
refusals := w.eventsOf(audit.ActionArticleEdit)
|
|
if len(refusals) != 1 || refusals[0].Outcome != audit.OutcomeFailed || refusals[0].FailureReason == nil {
|
|
t.Fatalf("one failed event expected: %+v", refusals)
|
|
}
|
|
})
|
|
}
|
|
|
|
func contains(values []string, wanted string) bool {
|
|
for _, value := range values {
|
|
if value == wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|