package test import ( "RenewCMS/internal/domain/site" "encoding/json" "net/http" "strings" "testing" ) const journalPattern = "https://journal.example.com/{lang}/{slug}" func patternBody(value string) string { encoded, _ := json.Marshal(value) return `{"public_url_pattern":` + string(encoded) + `}` } func newPatternEnv(t *testing.T) *localizationEnv { t.Helper() e := newLocalizationEnv(t) e.sites.seed(siteMainID, "Main", "main", true) return e } func TestSitePublicURLPattern(t *testing.T) { t.Run("Reading the pattern", func(t *testing.T) { e := newPatternEnv(t) stored := e.sites.sites[siteMainID] pattern := journalPattern stored.PublicURLPattern = &pattern e.sites.sites[siteMainID] = stored rec := e.as(t, readerUserID, http.MethodGet, "/v1/site/", "") if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } var items []map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatal(err) } if string(items[0]["public_url_pattern"]) != `"`+journalPattern+`"` { t.Errorf("pattern = %s", items[0]["public_url_pattern"]) } for _, key := range []string{"id", "name", "key", "base_url", "default_social_image_id", "default_robots", "locale", "is_default", "article_count", "site_name", "title_template", "default_description"} { if _, present := items[0][key]; !present { t.Errorf("key %q missing", key) } } }) writes := []struct { name string raw string want *string start string }{ {"a pattern with lang and slug", patternBody(journalPattern), localeOf(journalPattern), ""}, {"a pattern with a path", patternBody("https://journal.example.com/{lang}/articles/{slug}"), localeOf("https://journal.example.com/{lang}/articles/{slug}"), ""}, {"a local http pattern", patternBody("http://localhost:3000/{slug}"), localeOf("http://localhost:3000/{slug}"), ""}, {"a repeated placeholder", patternBody("https://journal.example.com/{slug}/{slug}"), localeOf("https://journal.example.com/{slug}/{slug}"), ""}, {"null", `{"public_url_pattern":null}`, nil, journalPattern}, {"an empty string", patternBody(""), nil, journalPattern}, {"a padded pattern is trimmed", patternBody(" https://journal.example.com/{slug} "), localeOf("https://journal.example.com/{slug}"), ""}, } for _, c := range writes { t.Run("Writing the pattern/"+c.name, func(t *testing.T) { e := newPatternEnv(t) if c.start != "" { stored := e.sites.sites[siteMainID] stored.PublicURLPattern = localeOf(c.start) e.sites.sites[siteMainID] = stored } rec := e.owner(t, http.MethodPatch, "/v1/site/1", c.raw) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } if got := decodeSiteBody(t, rec).PublicURLPattern; !sameText(got, c.want) { t.Errorf("returned = %v", got) } if !sameText(e.sites.sites[siteMainID].PublicURLPattern, c.want) { t.Errorf("stored = %v", e.sites.sites[siteMainID].PublicURLPattern) } }) } refusals := []struct { value string code string }{ {"ftp://journal.example.com/{lang}/{slug}", "invalid_scheme"}, {"journal.example.com/{slug}", "invalid_scheme"}, {"https:///{slug}", "invalid_host"}, {"https://journal.example.com/{language}/{slug}", "unknown_placeholder"}, {"https://journal.example.com/{lang", "unknown_placeholder"}, {"https://journal.example.com/my pages/{lang}/{slug}", "not_encoded"}, {"https://journal.example.com/é/{slug}", "not_encoded"}, {"https://journal.example.com/{lang}", "missing_slug"}, {"https://example.com/" + strings.Repeat("a", 494) + "{slug}", "invalid_pattern"}, {"ftp://x.example.com/{language} here", "invalid_scheme"}, {"https://x.example.com/{language} here", "unknown_placeholder"}, {"https://x.example.com/here there", "not_encoded"}, } for _, c := range refusals { t.Run("Patterns that are refused/"+c.code+" "+truncate(c.value), func(t *testing.T) { e := newPatternEnv(t) stored := e.sites.sites[siteMainID] stored.PublicURLPattern = localeOf(journalPattern) e.sites.sites[siteMainID] = stored rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(c.value)) expectSiteStatusAndBody(t, rec, http.StatusUnprocessableEntity, c.code) if !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) { t.Errorf("the stored pattern changed") } }) } t.Run("A pattern of 513 characters", func(t *testing.T) { long := "https://example.com/" + strings.Repeat("a", 513-len("https://example.com/")-len("{slug}")) + "{slug}" if len(long) != 513 { t.Fatalf("fixture length %d", len(long)) } e := newPatternEnv(t) expectSiteStatusAndBody(t, e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(long)), http.StatusUnprocessableEntity, "invalid_pattern") }) t.Run("A pattern of 512 characters is accepted", func(t *testing.T) { exact := "https://example.com/" + strings.Repeat("a", 512-len("https://example.com/")-len("{slug}")) + "{slug}" e := newPatternEnv(t) if rec := e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(exact)); rec.Code != http.StatusOK { t.Errorf("status %d: %s", rec.Code, rec.Body) } }) t.Run("Only an owner can write", func(t *testing.T) { cases := []struct { name string user uint32 status int }{ {"an anonymous client", 0, http.StatusUnauthorized}, {"a reader", readerUserID, http.StatusForbidden}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newPatternEnv(t) rec := e.as(t, c.user, http.MethodPatch, "/v1/site/1", patternBody(journalPattern)) if rec.Code != c.status || e.sites.sites[siteMainID].PublicURLPattern != nil { t.Errorf("status %d, stored %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern) } }) } }) t.Run("Saving the pattern changes nothing else", func(t *testing.T) { e := newPatternEnv(t) before := e.sites.sites[siteMainID] e.owner(t, http.MethodPatch, "/v1/site/1", patternBody(journalPattern)) after := e.sites.sites[siteMainID] after.PublicURLPattern = nil if after != before { t.Errorf("site changed: %+v vs %+v", after, before) } }) t.Run("Existing site behaviour is unchanged", func(t *testing.T) { e := newPatternEnv(t) stored := e.sites.sites[siteMainID] stored.PublicURLPattern = localeOf(journalPattern) e.sites.sites[siteMainID] = stored rec := e.owner(t, http.MethodPatch, "/v1/site/1", `{"name":"Renamed"}`) if rec.Code != http.StatusOK || !sameText(e.sites.sites[siteMainID].PublicURLPattern, localeOf(journalPattern)) { t.Errorf("status %d, pattern %v", rec.Code, e.sites.sites[siteMainID].PublicURLPattern) } }) } func truncate(value string) string { if len(value) > 40 { return value[:40] } return value } func TestPublicURLPatternNormalization(t *testing.T) { t.Run("a nil pattern stays unset", func(t *testing.T) { if normalized, err := site.NormalizePublicURLPattern(nil); normalized != nil || err != nil { t.Errorf("got %v, %v", normalized, err) } }) t.Run("a pattern without a host is refused as a host error", func(t *testing.T) { if _, err := site.NormalizePublicURLPattern(localeOf("https:///{slug}")); err != site.ErrPublicURLHost { t.Errorf("err = %v", err) } }) t.Run("percent encoded sequences are accepted and a stray percent is not", func(t *testing.T) { if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/%C3%A9/{slug}")); err != nil { t.Errorf("encoded: %v", err) } if _, err := site.NormalizePublicURLPattern(localeOf("https://a.example.com/100%/{slug}")); err != site.ErrPublicURLNotEncoded { t.Errorf("stray: %v", err) } }) t.Run("a pattern ending in a percent sequence is judged without panicking", func(t *testing.T) { cases := []struct { name string pattern string want error }{ {"a lone percent at the end", "https://a.example.com/{slug}%", site.ErrPublicURLNotEncoded}, {"a percent and one digit at the end", "https://a.example.com/{slug}%4", site.ErrPublicURLNotEncoded}, {"a percent and a non hex digit at the end", "https://a.example.com/{slug}%4G", site.ErrPublicURLNotEncoded}, {"a valid sequence at the end", "https://a.example.com/{slug}%41", nil}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if _, err := site.NormalizePublicURLPattern(localeOf(c.pattern)); err != c.want { t.Errorf("err = %v, want %v", err, c.want) } }) } }) }