335 lines
12 KiB
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())}
|
|
}
|
|
}
|