Files
sm-seo-plugin/addresses_test.go
Jakub Zych 4cb0fbfebb feat: URL normalisation, strict URLs and the one-hop redirect decision
- Normalize: https, www or bare host, double slashes and index files,
  trailing slash add/remove, optional ASCII lowercasing (defaults change
  nothing but scheme, slashes and index files)
- strict URLs through signpost MatchPath: a path that names a record
  without being its address 301s to the record's address
- resolve runs normalisation, redirects, strict URLs, then SEO; the
  location is the final address, so a visitor makes one hop
- P1 warns when a record's canonical URL redirects
2026-10-27 11:41:45 +01:00

204 lines
8.8 KiB
Go

package seo_test
import (
"context"
"net/http"
"net/url"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// blogPlugin is acme.blog: posts under /blog/:category/:slug with a
// translated slug in pl (/pl/blog/:category/:slug), kept in memory. It
// stands in for a content type whose URL has more segments than the
// record's own slug (D-20c).
type blogPlugin struct{}
type blogPost struct {
id, category, slug, plSlug string
}
var blogPosts = []blogPost{{id: "1", category: "news", slug: "hello", plSlug: "witaj"}}
func (blogPlugin) ID() string { return "acme.blog" }
func (blogPlugin) Requires() []string { return nil }
func (blogPlugin) Register(*backpack.App) error { return nil }
func (blogPlugin) Boot(*backpack.App) error { return nil }
func (blogPlugin) MenuItemTypes() []pact.MenuItemType {
return []pact.MenuItemType{{
Key: "acme-post",
Label: "Posts",
DefaultPattern: "/blog/:category/:slug",
Placeholders: []string{"category", "slug"},
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return nil, nil
},
Content: &pact.ContentDescriptor{
Model: "acme_blog_posts",
Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) {
for _, p := range blogPosts {
if p.id == in.ID {
return &pact.ContentRecord{ID: p.id, Published: true, Locales: []pact.ContentLocale{
{Locale: "en", URL: "/blog/" + p.category + "/" + p.slug, Title: "Hello"},
{Locale: "pl", URL: "/pl/blog/" + p.category + "/" + p.plSlug, Title: "Witaj"},
}}, nil
}
}
return nil, nil
},
Match: func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) {
path, locale := in.Path, "en"
if rest, ok := strings.CutPrefix(path, "/pl/"); ok {
path, locale = "/"+rest, "pl"
}
values, ok := signpost.MatchPattern(in.Pattern, path)
if !ok {
return nil, nil
}
for _, p := range blogPosts {
own := p.slug
if locale == "pl" {
own = p.plSlug
}
if values["slug"] == p.slug || values["slug"] == p.plSlug {
return &pact.ContentMatchResult{ID: p.id, Locale: locale, Exact: values["category"] == p.category && values["slug"] == own}, nil
}
}
return nil, nil
},
},
}}
}
var _ pact.HasMenuItemTypes = blogPlugin{}
func init() {
party.Register(blogPlugin{})
}
// TestNormalize checks the address rules (D-20b, RESEARCH Pitfall 13):
// pure, no database.
func TestNormalize(t *testing.T) {
keep := classes.NormalizeOptions{Host: models.HostKeep, Slash: models.SlashKeep}
cases := []struct {
name string
in string
opts classes.NormalizeOptions
want string
changed bool
}{
{"http to https", "http://www.example.test/a", keep, "https://www.example.test/a", true},
{"clean", "https://www.example.test/a", keep, "https://www.example.test/a", false},
{"clean path", "/pages/a?page=2", keep, "/pages/a?page=2", false},
{"double slashes", "/a//b///c", keep, "/a/b/c", true},
{"index file", "/blog/index.html", keep, "/blog", true},
{"root index", "/index.php", keep, "/", true},
{"fragment", "/a#top", keep, "/a", false},
{"www added", "https://example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", true},
{"www kept", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostWWW}, "https://www.example.test/a", false},
{"www dropped", "https://www.example.test/a", classes.NormalizeOptions{Host: models.HostBare}, "https://example.test/a", true},
{"slash added", "/a", classes.NormalizeOptions{Slash: models.SlashAdd}, "/a/", true},
{"slash not added to a file", "/sitemap.xml", classes.NormalizeOptions{Slash: models.SlashAdd}, "/sitemap.xml", false},
{"slash removed", "/a/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/a", true},
{"root kept (add)", "/", classes.NormalizeOptions{Slash: models.SlashAdd}, "/", false},
{"root kept (remove)", "/", classes.NormalizeOptions{Slash: models.SlashDrop}, "/", false},
{"lowercase", "/Blog/Post", classes.NormalizeOptions{Lowercase: true}, "/blog/post", true},
{"lowercase off", "/Blog/Post", keep, "/Blog/Post", false},
{"polish letters kept", "/%C5%81%C3%B3d%C5%BA/Abc", classes.NormalizeOptions{Lowercase: true}, "/%C5%81%C3%B3d%C5%BA/abc", true},
{"percent case is the same address", "/%c5%81odz", classes.NormalizeOptions{Lowercase: true}, "/%C5%81odz", false},
{"all at once", "http://example.test//Blog//index.html", classes.NormalizeOptions{Host: models.HostWWW, Slash: models.SlashAdd, Lowercase: true}, "https://www.example.test/blog/", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
u, err := url.Parse(c.in)
if err != nil {
t.Fatal(err)
}
got, changed := classes.Normalize(u, c.opts)
if got.String() != c.want || changed != c.changed {
t.Fatalf("Normalize(%s) = %s, %v; want %s, %v", c.in, got, changed, c.want, c.changed)
}
if u.String() != c.in && !strings.Contains(c.in, "#") {
t.Fatalf("Normalize modified its input: %s", u)
}
})
}
// The Polish case decodes to the same letters it started with.
u, _ := url.Parse("/%C5%81%C3%B3d%C5%BA")
if got, _ := classes.Normalize(u, classes.NormalizeOptions{Lowercase: true}); got.Path != "/Łódź" {
t.Fatalf("decoded Polish path = %q, want /Łódź", got.Path)
}
}
// TestStrictURL checks strict URLs (D-20c): a path that names a record
// with a wrong segment or another locale's slug 301s to the record's own
// address, and only its own address answers ok.
func TestStrictURL(t *testing.T) {
env := bootPlugins(t, []string{"acme.pages", "acme.blog", "golem15.seo"})
env.expectAction(t, "/blog/news/hello", "ok")
env.expectRedirect(t, "/blog/wrong-category/hello", 301, siteURL+"/blog/news/hello")
env.expectRedirect(t, "/blog/wrong-category/hello?page=2", 301, siteURL+"/blog/news/hello?page=2")
env.expectRedirect(t, "/pl/blog/news/hello", 301, siteURL+"/pl/blog/news/witaj")
env.expectRedirect(t, "/blog/news/witaj", 301, siteURL+"/blog/news/hello")
got := env.expectAction(t, "/pl/blog/news/witaj", "ok")
if got["locale"] != "pl" {
t.Fatalf("pl address = %v, want locale pl", got)
}
env.expectAction(t, "/blog/news/missing", "not_found")
token := env.admin(t, true)
env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true})
env.expectAction(t, "/pages/a", "ok")
env.expectRedirect(t, "/pages/a/", 301, siteURL+"/pages/a")
}
// TestOneHop checks that the resolve answer is the redirect decision
// (D-20a, D-20b, P-21, D-06): normalisation, redirects and strict URLs
// reach the final address in one hop, a runtime cycle stops with
// not_found, and P1 warns about a canonical that redirects.
func TestOneHop(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "Alpha", "slug": "a", "published": true})
if err := env.db.Exec(`UPDATE golem15_seo_settings SET url_slash = 'add', url_lowercase = TRUE WHERE id = 1`).Error; err != nil {
t.Fatal(err)
}
env.mustRedirect(t, "/old-page", "exact", "/pages/a", 301)
env.expectRedirect(t, "http://www.example.test//OLD-PAGE", 301, siteURL+"/pages/a/")
env.expectRedirect(t, "/Old-Page/index.html", 301, siteURL+"/pages/a/")
env.mustRedirect(t, "/x2", "exact", "/old-page", 301)
env.expectRedirect(t, "/X2", 301, siteURL+"/pages/a/")
env.expectAction(t, "/pages/a/", "ok")
env.expectRedirect(t, "/pages/a", 301, siteURL+"/pages/a/")
env.expectRedirect(t, "/Pages/A", 301, siteURL+"/pages/a/")
env.expectAction(t, "/", "ok")
got := env.resolve(t, "https://attacker.example/pages/a/")
if got["action"] != "ok" {
t.Fatalf("a foreign host = %v, want its path resolved on the site", got)
}
// A cycle seeded past the save-time check stops after the hop cap.
env.seedRedirect(t, "/c1", "exact", "/c2", 301)
env.seedRedirect(t, "/c2", "exact", "/c1", 301)
if got := env.resolve(t, "/c1/"); got["action"] != "not_found" || got["status"] != float64(404) {
t.Fatalf("runtime cycle = %v, want not_found", got)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "Alpha", "slug": "a", "published": true, "seo_canonical": map[string]any{"en": "/old-page"},
})
status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil).Body.String()
if !strings.Contains(status, "The canonical URL “/old-page” redirects to “"+siteURL+"/pages/a/”") {
t.Fatalf("P1 = %s, want status.canonical_redirected", status)
}
}