Files
RenewCMS/test/seo_data_site_http_test.go

335 lines
12 KiB
Go

package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/site"
"net/http"
"reflect"
"strings"
"testing"
)
func TestSeoDataSiteFields(t *testing.T) {
const validator = "validator"
validSite := func(extra string) string {
return `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"` + extra + `}`
}
t.Run("The owner sets the site SEO fields", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
got := decodeSiteBody(t, rec)
want := before
want.SiteName, want.TitleTemplate, want.DefaultDescription = "Renew Shop", "%title% - %site%", "A shop about renewal."
if !reflect.DeepEqual(got, want) || e.sites.sites[siteShopID].SiteName != "Renew Shop" {
t.Errorf("got %+v, expected %+v", got, want)
}
})
t.Run("A site created without the new fields has the defaults", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`)
created := decodeSiteBody(t, rec)
if rec.Code != http.StatusCreated || created.SiteName != "" || created.TitleTemplate != "%title% | %site%" || created.DefaultDescription != "" {
t.Errorf("status %d site %+v", rec.Code, created)
}
})
t.Run("A site is created with the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(`, "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."`))
created := decodeSiteBody(t, rec)
stored := e.sites.sites[created.ID]
if rec.Code != http.StatusCreated || created.SiteName != "Renew Shop" || created.TitleTemplate != "%title% - %site%" || created.DefaultDescription != "A shop about renewal." {
t.Fatalf("status %d site %+v", rec.Code, created)
}
if stored.SiteName != "Renew Shop" || stored.TitleTemplate != "%title% - %site%" || stored.DefaultDescription != "A shop about renewal." {
t.Errorf("stored %+v", stored)
}
})
t.Run("Creation validates the new fields like editing", func(t *testing.T) {
cases := []struct {
name string
extra string
status int
text string
}{
{"template with title", `, "title_template": "%title%"`, 201, ""},
{"template without title", `, "title_template": "just text"`, 400, "invalid_title_template"},
{"template with site only", `, "title_template": "%site%"`, 400, "invalid_title_template"},
{"empty template", `, "title_template": ""`, 400, "invalid_title_template"},
{"template 201", `, "title_template": "%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
{"description 320", `, "default_description": "` + strings.Repeat("a", 320) + `"`, 201, ""},
{"description 321", `, "default_description": "` + strings.Repeat("a", 321) + `"`, 400, validator},
{"site name 100", `, "site_name": "` + strings.Repeat("a", 100) + `"`, 201, ""},
{"site name 101", `, "site_name": "` + strings.Repeat("a", 101) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
sitesBefore := len(e.sites.sites)
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(c.extra))
switch {
case c.status == http.StatusCreated:
if rec.Code != c.status {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
if c.status != http.StatusCreated && len(e.sites.sites) != sitesBefore {
t.Errorf("a site was added")
}
})
}
})
t.Run("The list carries the new fields", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "")
var items []map[string]any
for _, item := range decodeSiteList(t, rec) {
items = append(items, map[string]any{"site_name": item.SiteName, "title_template": item.TitleTemplate})
}
if rec.Code != http.StatusOK || len(items) != 2 || !strings.Contains(rec.Body.String(), `"site_name":""`) || !strings.Contains(rec.Body.String(), `"title_template":"%title% | %site%"`) || !strings.Contains(rec.Body.String(), `"default_description":""`) {
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
}
})
t.Run("Absent fields are untouched", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.TitleTemplate, shop.DefaultDescription = "%title% :: %site%", "Kept."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew"}`)
want := shop
want.SiteName = "Renew"
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
t.Errorf("status %d stored %+v", rec.Code, e.sites.sites[siteShopID])
}
})
t.Run("The template must contain %title%", func(t *testing.T) {
cases := []struct {
name string
template string
status int
text string
}{
{"title only", `"%title%"`, 200, ""},
{"title and site", `"%title% | %site%"`, 200, ""},
{"site first", `"%site% - %title%"`, 200, ""},
{"prefix", `"News: %title%"`, 200, ""},
{"site only", `"%site%"`, 400, "invalid_title_template"},
{"plain text", `"just text"`, 400, "invalid_title_template"},
{"upper case placeholder", `"%TITLE%"`, 400, "invalid_title_template"},
{"empty", `""`, 400, "invalid_title_template"},
{"201 characters", `"%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"title_template": `+c.template+`}`)
switch {
case c.status == http.StatusOK:
if rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
case c.text == validator:
expectStatusAndValidatorText(t, rec, c.status)
default:
expectStatusAndText(t, rec, c.status, c.text)
}
})
}
})
t.Run("Other site SEO fields are validated", func(t *testing.T) {
cases := []struct {
name string
body string
status int
}{
{"description 321", `{"default_description": "` + strings.Repeat("a", 321) + `"}`, 400},
{"description 320", `{"default_description": "` + strings.Repeat("a", 320) + `"}`, 200},
{"description empty", `{"default_description": ""}`, 200},
{"site name 101", `{"site_name": "` + strings.Repeat("a", 101) + `"}`, 400},
{"site name empty", `{"site_name": ""}`, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != c.status {
t.Errorf("status %d: %.120s", rec.Code, rec.Body.String())
}
if c.status == http.StatusBadRequest {
expectStatusAndValidatorText(t, rec, c.status)
}
})
}
})
t.Run("A refused site update changes no other field", func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.sites[siteShopID]
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "title_template": "nope"}`)
expectStatusAndText(t, rec, http.StatusBadRequest, "invalid_title_template")
if e.sites.sites[siteShopID].Name != before.Name {
t.Errorf("the name changed to %q", e.sites.sites[siteShopID].Name)
}
})
t.Run("The existing site fields keep their names", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5, "default_robots": "noindex"}`)
got := decodeSiteBody(t, rec)
if rec.Code != http.StatusOK || got.DefaultSocialImageID == nil || *got.DefaultSocialImageID != 5 || got.DefaultRobots != "noindex" {
t.Errorf("status %d site %+v", rec.Code, got)
}
})
t.Run("Other roles and keys are refused", func(t *testing.T) {
const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session."
cases := []struct {
name string
method string
path string
send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult
text string
}{
{"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText},
{"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText},
{"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText},
{"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText},
{"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newSeoEnv(t)
before := e.sites.snapshot()
body := `{"site_name": "Hijack"}`
if c.method == http.MethodPost {
body = validSite(`, "site_name": "Hijack"`)
}
rec := c.send(e, t, c.method, c.path, body)
if rec.code != http.StatusForbidden || rec.text != c.text || !reflect.DeepEqual(e.sites.snapshot(), before) {
t.Errorf("status %d body %q", rec.code, rec.text)
}
})
}
})
t.Run("No credentials is unauthorised", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, 0, http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status %d", rec.Code)
}
})
t.Run("The role is checked before the body", func(t *testing.T) {
e := newSeoEnv(t)
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json")
expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.")
})
t.Run("A no-op site edit records nothing", func(t *testing.T) {
e := newSeoEnv(t)
shop := e.sites.sites[siteShopID]
shop.SiteName, shop.DefaultDescription = "Renew", "A shop."
e.sites.sites[siteShopID] = shop
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew", "title_template": "%title% | %site%", "default_description": "A shop."}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
e.noEvent(t)
})
t.Run("A site SEO edit is recorded", func(t *testing.T) {
cases := []struct{ body, field, from, to string }{
{`{"site_name": "Renew"}`, "site_name", "", "Renew"},
{`{"title_template": "%title% - %site%"}`, "title_template", "%title% | %site%", "%title% - %site%"},
{`{"default_description": "A shop."}`, "default_description", "", "A shop."},
}
for _, c := range cases {
t.Run(c.field, func(t *testing.T) {
e := newSeoEnv(t)
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
event := e.single(t)
assertProducerEvent(t, event, audit.ActionSiteEdit, audit.CategorySites, audit.OutcomeSuccess)
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
if got := changesJSON(t, event); got != want {
t.Errorf("changes = %s, expected %s", got, want)
}
})
}
})
t.Run("The site defaults are the documented ones", func(t *testing.T) {
if site.DefaultTitleTemplate != "%title% | %site%" {
t.Errorf("template = %q", site.DefaultTitleTemplate)
}
})
}
type seoResult struct {
code int
text string
}
func seoAsUser(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
rec := e.as(t, userID, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}
func seoAsKeyOf(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
raw, _ := e.issueKey(userID, "Deploy bot")
rec := e.asKey(t, raw, method, path, body)
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
}
}