Files
RenewCMS/test/site_acceptance_test.go
T

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)
})
}