670 lines
23 KiB
Go
670 lines
23 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
"RenewCMS/internal/domain/site"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const (
|
|
siteOwnerOnlyBody = "Only the owner can do this."
|
|
siteSessionOnlyBody = "This action needs a real session."
|
|
)
|
|
|
|
func TestSitesReading(t *testing.T) {
|
|
t.Run("The list puts the default site first and then orders by name", func(t *testing.T) {
|
|
e := newSiteEnv(t)
|
|
e.sites.seed(4, "Zeta", "zeta", false)
|
|
e.sites.seed(5, "Alpha", "alpha", false)
|
|
e.sites.seed(6, "Main", "main", true)
|
|
e.seedArticle(1, 5, true)
|
|
e.seedArticle(2, 5, false)
|
|
|
|
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d", rec.Code)
|
|
}
|
|
listed := decodeSiteList(t, rec)
|
|
names := []string{}
|
|
for _, s := range listed {
|
|
names = append(names, s.Name)
|
|
}
|
|
if !reflect.DeepEqual(names, []string{"Main", "Alpha", "Zeta"}) {
|
|
t.Fatalf("order = %v", names)
|
|
}
|
|
if listed[1].ArticleCount != 2 || listed[0].ArticleCount != 0 {
|
|
t.Errorf("article_count = %+v", listed)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"article_count"`) {
|
|
t.Errorf("article_count missing: %s", rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("Who can read the list", func(t *testing.T) {
|
|
cases := []struct {
|
|
credentials string
|
|
send func(e *siteEnv, t *testing.T) int
|
|
status int
|
|
}{
|
|
{"the owner's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
|
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/", "").Code
|
|
}, http.StatusOK},
|
|
{"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
|
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "").Code
|
|
}, http.StatusOK},
|
|
{"a valid API key", func(e *siteEnv, t *testing.T) int {
|
|
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
|
return e.asKey(t, raw, http.MethodGet, "/v1/site/", "").Code
|
|
}, http.StatusOK},
|
|
{"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) int {
|
|
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/", "").Code
|
|
}, http.StatusForbidden},
|
|
{"no credentials", func(e *siteEnv, t *testing.T) int {
|
|
return e.anonymous(t, http.MethodGet, "/v1/site/", "").Code
|
|
}, http.StatusUnauthorized},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.credentials, func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
if got := c.send(e, t); got != c.status {
|
|
t.Errorf("status = %d, expected %d", got, c.status)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A site without image or locale returns nulls", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
|
|
|
rec := e.owner(t, http.MethodGet, "/v1/site/", "")
|
|
|
|
if !strings.Contains(rec.Body.String(), `"default_social_image_id":null`) || !strings.Contains(rec.Body.String(), `"locale":null`) {
|
|
t.Errorf("expected nulls in %s", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSitesCreating(t *testing.T) {
|
|
const shopBody = `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}`
|
|
|
|
t.Run("The owner creates a site", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
body := `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}`
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", body)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
created := decodeSiteBody(t, rec)
|
|
want := site.Site{ID: created.ID, Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr-CA"), TitleTemplate: site.DefaultTitleTemplate}
|
|
if created.ID == 0 || !reflect.DeepEqual(created, want) {
|
|
t.Errorf("created = %+v", created)
|
|
}
|
|
if stored, ok := e.sites.sites[created.ID]; !ok || stored.Key != "shop" {
|
|
t.Errorf("site not stored: %+v", e.sites.sites)
|
|
}
|
|
})
|
|
|
|
t.Run("Optional fields default", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
|
|
created := decodeSiteBody(t, e.owner(t, http.MethodPost, "/v1/site/", shopBody))
|
|
|
|
if created.DefaultRobots != "index" || created.Locale != nil || created.DefaultSocialImageID != nil || created.IsDefault {
|
|
t.Errorf("created = %+v", created)
|
|
}
|
|
})
|
|
|
|
t.Run("A new site can become the default at creation", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}`)
|
|
|
|
created := decodeSiteBody(t, rec)
|
|
if rec.Code != http.StatusCreated || !created.IsDefault || e.sites.sites[siteMainID].IsDefault {
|
|
t.Errorf("shop %+v, main %+v", created, e.sites.sites[siteMainID])
|
|
}
|
|
if defaults := e.sites.defaults(); !reflect.DeepEqual(defaults, []uint32{created.ID}) {
|
|
t.Errorf("defaults = %v", defaults)
|
|
}
|
|
})
|
|
|
|
t.Run("The base URL is normalised", func(t *testing.T) {
|
|
cases := [][2]string{
|
|
{"https://shop.example.com/", "https://shop.example.com"},
|
|
{"https://Shop.Example.com", "https://shop.example.com"},
|
|
{"http://localhost:5173/", "http://localhost:5173"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c[0], func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c[0]+`"}`)
|
|
if got := decodeSiteBody(t, rec).BaseURL; rec.Code != http.StatusCreated || got != c[1] {
|
|
t.Errorf("status %d, base_url %q, expected %q", rec.Code, got, c[1])
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The base URL must be https except loopback and carry no path", func(t *testing.T) {
|
|
cases := []struct {
|
|
sent string
|
|
status int
|
|
body string
|
|
}{
|
|
{"https://shop.example.com", 201, ""},
|
|
{"http://localhost:8080", 201, ""},
|
|
{"http://127.0.0.1:8080", 201, ""},
|
|
{"http://[::1]:8080", 201, ""},
|
|
{"http://shop.example.com", 400, "invalid_site_url"},
|
|
{"https://shop.example.com/blog", 400, "invalid_site_url"},
|
|
{"https://shop.example.com/?a=1", 400, "invalid_site_url"},
|
|
{"https://user@shop.example.com", 400, "invalid_site_url"},
|
|
{"ftp://shop.example.com", 400, "invalid_site_url"},
|
|
{"shop.example.com", 400, "invalid_site_url"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.sent, func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "`+c.sent+`"}`)
|
|
if rec.Code != c.status {
|
|
t.Fatalf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
|
|
}
|
|
if c.body != "" && bodyText(rec) != c.body {
|
|
t.Errorf("body = %q, expected %q", bodyText(rec), c.body)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The key format", func(t *testing.T) {
|
|
cases := []struct {
|
|
key string
|
|
status int
|
|
}{
|
|
{"shop", 201}, {"my-shop-2", 201}, {"ab", 201},
|
|
{"a", 400}, {"Shop", 400}, {"-shop", 400}, {"shop-", 400}, {"my--shop", 400}, {"my_shop", 400},
|
|
{strings.Repeat("a", 33), 400}, {"", 400},
|
|
{strings.Repeat("a", 32), 201},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run("key "+strconv.Quote(c.key), func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "`+c.key+`", "base_url": "https://shop.example.com"}`)
|
|
if rec.Code != c.status {
|
|
t.Errorf("status = %d, expected %d: %s", rec.Code, c.status, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A taken key is refused", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", shopBody)
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_key_taken")
|
|
if len(e.sites.sites) != 2 {
|
|
t.Errorf("a site was added: %+v", e.sites.sites)
|
|
}
|
|
})
|
|
|
|
t.Run("Other fields are validated", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
status int
|
|
token string
|
|
}{
|
|
{"name empty", `{"name": "", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
|
|
{"name 101 characters", `{"name": "` + strings.Repeat("n", 101) + `", "key": "shop", "base_url": "https://shop.example.com"}`, 400, ""},
|
|
{"default_robots follow", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "follow"}`, 400, ""},
|
|
{"locale english", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "english"}`, 400, "invalid_locale"},
|
|
{"locale fr_FR", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": "fr_FR"}`, 400, "invalid_locale"},
|
|
{"default_social_image_id 999", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_social_image_id": 999}`, 400, "image_not_found"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", c.body)
|
|
if rec.Code != c.status {
|
|
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if c.token != "" && bodyText(rec) != c.token {
|
|
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
|
|
}
|
|
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
|
|
t.Errorf("expected the validator error text, got %q", bodyText(rec))
|
|
}
|
|
if len(e.sites.sites) != 1 {
|
|
t.Errorf("a site was added: %+v", e.sites.sites)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A malformed body is refused", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", "{not json")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
|
if len(e.sites.sites) != 1 {
|
|
t.Errorf("a site was added")
|
|
}
|
|
})
|
|
|
|
t.Run("A valid locale is accepted and an empty one clears it", func(t *testing.T) {
|
|
cases := []struct {
|
|
sent string
|
|
stored *string
|
|
}{
|
|
{`"en"`, stringPointer("en")},
|
|
{`"fr-CA"`, stringPointer("fr-CA")},
|
|
{`"zh-Hant-TW"`, stringPointer("zh-Hant-TW")},
|
|
{`""`, nil},
|
|
{`null`, nil},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.sent, func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "locale": `+c.sent+`}`)
|
|
if rec.Code != http.StatusCreated || !reflect.DeepEqual(decodeSiteBody(t, rec).Locale, c.stored) {
|
|
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSitesEditing(t *testing.T) {
|
|
t.Run("The owner edits a site", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}`)
|
|
|
|
updated := decodeSiteBody(t, rec)
|
|
want := site.Site{ID: 2, Name: "Boutique", Key: "shop", BaseURL: "https://boutique.example.com", DefaultRobots: "noindex", Locale: stringPointer("fr")}
|
|
if rec.Code != http.StatusOK || !reflect.DeepEqual(updated, want) {
|
|
t.Errorf("status %d, updated %+v", rec.Code, updated)
|
|
}
|
|
})
|
|
|
|
t.Run("Absent fields are untouched", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
shop := e.sites.sites[siteShopID]
|
|
shop.Locale, shop.DefaultRobots = stringPointer("fr"), "noindex"
|
|
e.sites.sites[siteShopID] = shop
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`)
|
|
|
|
want := shop
|
|
want.Name = "Boutique"
|
|
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
|
|
t.Errorf("status %d, site %+v", rec.Code, e.sites.sites[siteShopID])
|
|
}
|
|
})
|
|
|
|
t.Run("The default social image is set and cleared", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.seedBanner(5, "banner.png", "hash")
|
|
|
|
set := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5}`)
|
|
afterSet := e.sites.sites[siteShopID].DefaultSocialImageID
|
|
cleared := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": null}`)
|
|
|
|
if set.Code != http.StatusOK || afterSet == nil || *afterSet != 5 {
|
|
t.Errorf("set: status %d, image %v", set.Code, afterSet)
|
|
}
|
|
if cleared.Code != http.StatusOK || e.sites.sites[siteShopID].DefaultSocialImageID != nil {
|
|
t.Errorf("clear: status %d, image %v", cleared.Code, e.sites.sites[siteShopID].DefaultSocialImageID)
|
|
}
|
|
})
|
|
|
|
t.Run("The key never changes", func(t *testing.T) {
|
|
cases := []struct {
|
|
key string
|
|
status int
|
|
body string
|
|
}{{"shop", 200, ""}, {"store", 400, "key_immutable"}}
|
|
for _, c := range cases {
|
|
t.Run(c.key, func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"key": "`+c.key+`"}`)
|
|
if rec.Code != c.status || (c.body != "" && bodyText(rec) != c.body) {
|
|
t.Errorf("status %d body %q", rec.Code, bodyText(rec))
|
|
}
|
|
if e.sites.sites[siteShopID].Key != "shop" {
|
|
t.Errorf("key changed to %q", e.sites.sites[siteShopID].Key)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A rejected key change leaves every other field unchanged", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "key": "store"}`)
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "key_immutable")
|
|
if e.sites.sites[siteShopID].Name != "Shop" {
|
|
t.Errorf("name changed to %q", e.sites.sites[siteShopID].Name)
|
|
}
|
|
})
|
|
|
|
t.Run("The default flag is not editable here", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"is_default": true}`)
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, "use_set_default")
|
|
if !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
|
|
t.Errorf("defaults = %v", e.sites.defaults())
|
|
}
|
|
})
|
|
|
|
t.Run("Editing is validated like creation", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
token string
|
|
}{
|
|
{"base_url http", `{"base_url": "http://shop.example.com"}`, "invalid_site_url"},
|
|
{"base_url path", `{"base_url": "https://shop.example.com/x"}`, "invalid_site_url"},
|
|
{"locale fr_FR", `{"locale": "fr_FR"}`, "invalid_locale"},
|
|
{"default_social_image_id 999", `{"default_social_image_id": 999}`, "image_not_found"},
|
|
{"name empty", `{"name": ""}`, ""},
|
|
{"default_robots follow", `{"default_robots": "follow"}`, ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
before := e.sites.snapshot()
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if c.token != "" && bodyText(rec) != c.token {
|
|
t.Errorf("body = %q, expected %q", bodyText(rec), c.token)
|
|
}
|
|
if c.token == "" && (bodyText(rec) == "" || strings.Contains(bodyText(rec), "_")) {
|
|
t.Errorf("expected the validator error text, got %q", bodyText(rec))
|
|
}
|
|
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
|
t.Errorf("site changed")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("A missing site is not found", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/999", `{"name": "X"}`)
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
|
})
|
|
|
|
t.Run("A no-op edit records nothing", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Shop", "base_url": "https://shop.example.com/", "default_robots": "index", "locale": null}`)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("A malformed id is refused", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/abc", `{"name": "X"}`)
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
|
})
|
|
}
|
|
|
|
func TestSitesDefault(t *testing.T) {
|
|
t.Run("The owner makes another site the default", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
|
|
|
got := decodeSiteBody(t, rec)
|
|
if rec.Code != http.StatusOK || got.Name != "Shop" || !got.IsDefault || e.sites.sites[siteMainID].IsDefault {
|
|
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("Setting the current default is a no-op", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/1/default", "")
|
|
|
|
if rec.Code != http.StatusOK || e.sites.setDefaultCalls != 0 {
|
|
t.Errorf("status %d, setDefault calls %d", rec.Code, e.sites.setDefaultCalls)
|
|
}
|
|
e.noEvent(t)
|
|
})
|
|
|
|
t.Run("A missing site cannot become the default", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/999/default", "")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
|
})
|
|
|
|
t.Run("The swap is atomic", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.sites.fail.failNext("SetDefault")
|
|
|
|
rec := e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
|
|
|
if rec.Code != http.StatusInternalServerError || !reflect.DeepEqual(e.sites.defaults(), []uint32{siteMainID}) {
|
|
t.Errorf("status %d, defaults %v", rec.Code, e.sites.defaults())
|
|
}
|
|
})
|
|
|
|
t.Run("New articles start on the new default", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.owner(t, http.MethodPatch, "/v1/site/2/default", "")
|
|
|
|
rec := e.owner(t, http.MethodPost, "/v1/article", `{"title": "Spring release", "body": "Hello"}`)
|
|
|
|
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"site_id":2`) {
|
|
t.Errorf("status %d, body %s", rec.Code, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSitesDeleting(t *testing.T) {
|
|
t.Run("A site without articles is deleted", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
|
|
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
if _, exists := e.sites.sites[siteShopID]; exists {
|
|
t.Errorf("site 2 still exists")
|
|
}
|
|
})
|
|
|
|
t.Run("A site with articles needs a destination", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
for id := uint32(1); id <= 3; id++ {
|
|
e.seedArticle(id, siteShopID, true)
|
|
}
|
|
before := e.sites.snapshot()
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2", "")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusConflict, "site_has_articles")
|
|
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
|
|
t.Errorf("state changed")
|
|
}
|
|
})
|
|
|
|
t.Run("The articles move in one transaction", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
|
for id := uint32(1); id <= 3; id++ {
|
|
e.seedArticle(id, siteShopID, true)
|
|
}
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
|
|
|
if rec.Code != http.StatusNoContent || e.articlesOn(siteBlogID) != 3 {
|
|
t.Errorf("status %d, articles on 3: %d", rec.Code, e.articlesOn(siteBlogID))
|
|
}
|
|
if _, exists := e.sites.sites[siteShopID]; exists {
|
|
t.Errorf("site 2 still exists")
|
|
}
|
|
})
|
|
|
|
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
|
e.seedArticle(1, siteShopID, true)
|
|
e.sites.fail.failNext("DeleteMovingArticles")
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
|
|
|
if rec.Code != http.StatusInternalServerError || e.articlesOn(siteShopID) != 1 {
|
|
t.Errorf("status %d, articles on 2: %d", rec.Code, e.articlesOn(siteShopID))
|
|
}
|
|
if _, exists := e.sites.sites[siteShopID]; !exists {
|
|
t.Errorf("site 2 vanished")
|
|
}
|
|
})
|
|
|
|
t.Run("The articles are moved by one repository operation", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
|
e.seedArticle(1, siteShopID, true)
|
|
e.articles.fail.failNext("Update")
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
|
|
|
if rec.Code != http.StatusNoContent || e.sites.deleteCalls != 1 || e.sites.deleteReassignments[0] == nil || *e.sites.deleteReassignments[0] != siteBlogID {
|
|
t.Errorf("status %d, delete calls %d", rec.Code, e.sites.deleteCalls)
|
|
}
|
|
})
|
|
|
|
t.Run("An unusable destination", func(t *testing.T) {
|
|
cases := []struct {
|
|
reassign string
|
|
status int
|
|
body string
|
|
}{
|
|
{"999", 404, "site_not_found"},
|
|
{"2", 400, "invalid_reassign"},
|
|
{"abc", 400, "invalid_reassign"},
|
|
{"-1", 400, "invalid_reassign"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.reassign, func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
for id := uint32(1); id <= 3; id++ {
|
|
e.seedArticle(id, siteShopID, true)
|
|
}
|
|
before := e.sites.snapshot()
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to="+c.reassign, "")
|
|
expectSiteStatusAndBody(t, rec, c.status, c.body)
|
|
if !reflect.DeepEqual(before, e.sites.snapshot()) || e.articlesOn(siteShopID) != 3 {
|
|
t.Errorf("state changed")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The default site cannot be deleted", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
|
|
})
|
|
|
|
t.Run("The default check comes before the articles check", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
for id := uint32(1); id <= 5; id++ {
|
|
e.seedArticle(id, siteMainID, true)
|
|
}
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/1", "")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusForbidden, "default_site")
|
|
})
|
|
|
|
t.Run("A missing site is not found", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/999", "")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusNotFound, "site_not_found")
|
|
})
|
|
|
|
t.Run("An unused reassign_to is ignored", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
e.sites.seed(siteBlogID, "Blog", "blog", false)
|
|
blogBefore := e.sites.sites[siteBlogID]
|
|
|
|
rec := e.owner(t, http.MethodDelete, "/v1/site/2?reassign_to=3", "")
|
|
|
|
if rec.Code != http.StatusNoContent || !reflect.DeepEqual(e.sites.sites[siteBlogID], blogBefore) {
|
|
t.Errorf("status %d", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSitesOnlyTheOwnerWrites(t *testing.T) {
|
|
t.Run("Other roles and keys are refused", func(t *testing.T) {
|
|
requests := []struct{ method, path, body string }{
|
|
{http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`},
|
|
{http.MethodPatch, "/v1/site/2", `{"name": "Boutique"}`},
|
|
{http.MethodPatch, "/v1/site/2/default", ""},
|
|
{http.MethodDelete, "/v1/site/2", ""},
|
|
}
|
|
for _, request := range requests {
|
|
t.Run(request.method+" "+request.path+" with a verified admin's jwt cookie", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
before := e.sites.snapshot()
|
|
rec := e.as(t, apiKeyDevId, request.method, request.path, request.body)
|
|
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
|
|
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
|
t.Errorf("a site changed")
|
|
}
|
|
})
|
|
t.Run(request.method+" "+request.path+" with a valid API key", func(t *testing.T) {
|
|
e := newSiteEnvWithShop(t)
|
|
before := e.sites.snapshot()
|
|
raw, _ := e.issueKey(apiKeyOwnerId, "Deploy bot")
|
|
rec := e.asKey(t, raw, request.method, request.path, request.body)
|
|
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteSessionOnlyBody)
|
|
if !reflect.DeepEqual(before, e.sites.snapshot()) {
|
|
t.Errorf("a site changed")
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("The role is checked before the body", func(t *testing.T) {
|
|
e := newSiteEnvWithMain(t)
|
|
|
|
rec := e.as(t, apiKeyDevId, http.MethodPost, "/v1/site/", "{not json")
|
|
|
|
expectSiteStatusAndBody(t, rec, http.StatusForbidden, siteOwnerOnlyBody)
|
|
})
|
|
}
|