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