package seo_test import ( "encoding/json" "errors" "net/http" "sort" "strings" "testing" "git.golem15.com/golem15/sm-seo-plugin/classes" "git.golem15.com/golem15/sm-seo-plugin/models" "gorm.io/gorm" ) // redirectRow is a stored redirect as the tests read it. type redirectRow struct { ID uint Source string Match string Target string Status int Query string IgnoreCase bool TrailingSlash bool Origin string } // redirects returns every stored redirect in id order. func (e *testEnv) redirects(t *testing.T) []redirectRow { t.Helper() var rows []redirectRow if err := e.db.Raw(`SELECT id, source, match, target, status, query, ignore_case, trailing_slash, origin FROM golem15_seo_redirects ORDER BY id`).Scan(&rows).Error; err != nil { t.Fatal(err) } return rows } // redirectTo returns the target of the exact redirect of source, "" when // there is none. func redirectTo(rows []redirectRow, source string) string { for _, r := range rows { if r.Source == source && r.Match == "exact" { return r.Target } } return "" } // seedRedirect stores a redirect directly. func (e *testEnv) seedRedirect(t *testing.T, source, match, target string, status int) { t.Helper() if err := e.db.Exec(`INSERT INTO golem15_seo_redirects (source, match, target, status, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW())`, source, match, target, status).Error; err != nil { t.Fatal(err) } } // saveNotices returns the save notices of an admin save response. func saveNotices(t *testing.T, body []byte) []struct{ Field, Message string } { t.Helper() var out struct { Meta struct { Notices []struct { Field string `json:"field"` Message string `json:"message"` } `json:"notices"` } `json:"meta"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatal(err) } var notices []struct{ Field, Message string } for _, n := range out.Meta.Notices { notices = append(notices, struct{ Field, Message string }{n.Field, n.Message}) } return notices } // TestSlugChange301 is the plan's tracer (SC-3, D-18): an editor changes a // published page's slug in the admin; inside the save a 301 from the old // address to the new one is created, an earlier redirect to the old // address is pointed straight at the new one, the editor gets the // redirects.auto_created notice, and the resolve answer redirects the old // address in one hop. func TestSlugChange301(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}) env.seedRedirect(t, "/pages/x", "exact", "/pages/a", 301) rec := env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{ "title": "Alpha", "slug": "b", "published": true, }) var found bool for _, n := range saveNotices(t, rec.Body.Bytes()) { if n.Field == "slug" && strings.Contains(n.Message, "“/pages/a”") && strings.Contains(n.Message, "“/pages/b”") && strings.Contains(n.Message, "1 earlier") { found = true } } if !found { t.Fatalf("update notices lack redirects.auto_created on slug: %s", rec.Body.String()) } got := env.resolve(t, "/pages/a") if got["action"] != "redirect" || got["status"] != float64(301) || got["location"] != siteURL+"/pages/b" { t.Fatalf("resolve /pages/a = %v, want redirect 301 to %s/pages/b", got, siteURL) } if got := env.resolve(t, "/pages/b"); got["action"] != "ok" { t.Fatalf("resolve /pages/b = %v, want ok", got) } rows := env.redirects(t) if target := redirectTo(rows, "/pages/x"); target != "/pages/b" { t.Fatalf("earlier redirect /pages/x -> %q, want flattened to /pages/b (%+v)", target, rows) } if target := redirectTo(rows, "/pl/pages/a"); target != "/pl/pages/b" { t.Fatalf("pl redirect /pl/pages/a -> %q, want /pl/pages/b (%+v)", target, rows) } for _, r := range rows { if r.Source == "/pages/a" && r.Origin != "slug" { t.Fatalf("address-change redirect origin = %q, want slug", r.Origin) } } if got := env.resolve(t, "/pages/x"); got["location"] != siteURL+"/pages/b" { t.Fatalf("resolve /pages/x = %v, want one hop to /pages/b", got) } } // saveRedirect stores r through the service in its own transaction. func (e *testEnv) saveRedirect(t *testing.T, r models.Redirect) (classes.RedirectCheck, error) { t.Helper() svc := e.seoPlugin(t).Service() var check classes.RedirectCheck err := e.db.Transaction(func(tx *gorm.DB) error { c, err := svc.SaveRedirect(t.Context(), tx, &r) check = c return err }) return check, err } // mustRedirect stores r and fails on a refusal. func (e *testEnv) mustRedirect(t *testing.T, source, match, target string, status int, opts ...func(*models.Redirect)) classes.RedirectCheck { t.Helper() r := models.NewRedirect() r.Source, r.Match, r.Target, r.Status = source, match, target, status for _, o := range opts { o(&r) } check, err := e.saveRedirect(t, r) if err != nil { t.Fatalf("save %s -> %s: %v", source, target, err) } return check } // refusedOn asserts that saving r is refused with key on field. func (e *testEnv) refusedOn(t *testing.T, r models.Redirect, field, key string) { t.Helper() _, err := e.saveRedirect(t, r) var problems *classes.ProblemsError if !errors.As(err, &problems) { t.Fatalf("save %+v: err = %v, want a refusal on %s", r, err, field) } for _, p := range problems.Problems[field] { if p.Key == key { return } } t.Fatalf("save %+v: problems = %+v, want %s on %s", r, problems.Problems, key, field) } // expectRedirect asserts that target resolves to a redirect to location. func (e *testEnv) expectRedirect(t *testing.T, target string, status int, location string) { t.Helper() got := e.resolve(t, target) if got["action"] != "redirect" || got["status"] != float64(status) || got["location"] != location { t.Fatalf("resolve %s = %v, want redirect %d to %s", target, got, status, location) } } // expectAction asserts the resolve action of target. func (e *testEnv) expectAction(t *testing.T, target, action string) map[string]any { t.Helper() got := e.resolve(t, target) if got["action"] != action { t.Fatalf("resolve %s = %v, want %s", target, got, action) } return got } // TestRedirectEngine checks the redirect engine through the service and // the resolve answer (D-16, D-17, D-19, T-32-16, T-32-17): matching // options, order, statuses, refusals, loops, chains and the back-and-forth // address change. func TestRedirectEngine(t *testing.T) { env := bootEnv(t) svc := env.seoPlugin(t).Service() ignoreCase := func(r *models.Redirect) { r.IgnoreCase = true } noSlash := func(r *models.Redirect) { r.TrailingSlash = false } pass := func(r *models.Redirect) { r.Query = models.QueryPass } env.mustRedirect(t, "/old", "exact", "/new", 301) env.expectRedirect(t, "/old", 301, siteURL+"/new") env.expectRedirect(t, "/old/", 301, siteURL+"/new") env.expectRedirect(t, "/old?a=1", 301, siteURL+"/new") env.expectAction(t, "/OLD", "not_found") env.mustRedirect(t, "/Case", "exact", "/case-new", 302, ignoreCase) env.expectRedirect(t, "/CASE", 302, siteURL+"/case-new") env.mustRedirect(t, "/strict", "exact", "/strict-new", 301, noSlash) env.expectAction(t, "/strict/", "not_found") env.mustRedirect(t, "/p-old", "exact", "/p-new", 301, pass) env.expectRedirect(t, "/p-old?a=1&b=2", 301, siteURL+"/p-new?a=1&b=2") env.mustRedirect(t, "/q?id=5", "exact", "/q5", 301, pass) env.expectRedirect(t, "/q?id=5", 301, siteURL+"/q5") env.expectAction(t, "/q?id=6", "not_found") env.mustRedirect(t, "/w/*", "pattern", "/nw/*", 301) env.expectRedirect(t, "/w/x/y", 301, siteURL+"/nw/x/y") env.mustRedirect(t, `^/p/(\d+)$`, "regex", "/post/$1", 301) env.expectRedirect(t, "/p/12", 301, siteURL+"/post/12") env.expectAction(t, "/p/abc", "not_found") env.mustRedirect(t, "/w/exact", "exact", "/exact-wins", 301) env.mustRedirect(t, `^/w/.*$`, "regex", "/regex-loses", 301) env.expectRedirect(t, "/w/exact", 301, siteURL+"/exact-wins") env.expectRedirect(t, "/w/z", 301, siteURL+"/nw/z") env.mustRedirect(t, "/gone", "exact", "", 410) got := env.expectAction(t, "/gone", "gone") if got["status"] != float64(410) || got["location"] != nil { t.Fatalf("gone answer = %v, want 410 without a location", got) } newRule := func(source, match, target string, status int) models.Redirect { r := models.NewRedirect() r.Source, r.Match, r.Target, r.Status = source, match, target, status return r } env.refusedOn(t, newRule("/g2", "exact", "/x", 410), "target", "golem15.seo::lang.redirects.target_gone") env.refusedOn(t, newRule("/g3", "exact", "", 301), "target", "golem15.seo::lang.redirects.target_required") env.refusedOn(t, newRule("/g4", "exact", "elsewhere", 301), "target", "golem15.seo::lang.redirects.target_invalid") env.refusedOn(t, newRule(`^/(`, "regex", "/x", 301), "source", "golem15.seo::lang.redirects.regex_invalid") env.refusedOn(t, newRule("^/"+strings.Repeat("a", 520), "regex", "/x", 301), "source", "golem15.seo::lang.redirects.regex_invalid") env.refusedOn(t, newRule("/old", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_taken") env.refusedOn(t, newRule("old", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_invalid") env.refusedOn(t, newRule("/a*", "exact", "/other", 301), "source", "golem15.seo::lang.redirects.source_invalid") // Loops are refused; chains are saved with a notice (D-19). env.mustRedirect(t, "/la", "exact", "/lb", 301) _, err := env.saveRedirect(t, newRule("/lb", "exact", "/la", 301)) var loop *classes.LoopError if !errors.As(err, &loop) || loop.Check.PathText() != "/lb → /la → /lb" { t.Fatalf("B -> A after A -> B: err = %v, want a loop /lb → /la → /lb", err) } if !strings.Contains(svc.LoopMessage(t.Context(), loop.Check), "/lb → /la → /lb") { t.Fatalf("loop message = %q", svc.LoopMessage(t.Context(), loop.Check)) } _, err = env.saveRedirect(t, newRule("/grow/*", "pattern", "/grow/new/*", 301)) if !errors.As(err, &loop) { t.Fatalf("/grow/* -> /grow/new/*: err = %v, want a loop (pattern probe)", err) } _, err = env.saveRedirect(t, newRule(`^/rx/(\d+)$`, "regex", "/rx/$1", 301)) if !errors.As(err, &loop) { t.Fatalf("regex onto itself: err = %v, want a loop (regex probe)", err) } env.mustRedirect(t, "/cb", "exact", "/cc", 301) check := env.mustRedirect(t, "/ca", "exact", "/cb", 301) if !check.Chain || check.Final != "/cc" || check.Hops != 2 || check.PathText() != "/ca → /cb → /cc" { t.Fatalf("A -> B with B -> C: check = %+v, want a chain to /cc", check) } check = env.mustRedirect(t, "/cz", "exact", "/ca", 301) if !check.Chain || check.Final != "/cc" || check.Hops != 3 { t.Fatalf("Z -> A -> B -> C: check = %+v, want a 3-hop chain to /cc", check) } env.expectRedirect(t, "/cz", 301, siteURL+"/cc") check = env.mustRedirect(t, "/lone", "exact", "/somewhere", 301) if check.Chain || check.Loop { t.Fatalf("a single redirect: check = %+v, want neither chain nor loop", check) } // Slug back and forth (RESEARCH Pitfall 11): A→B, B→A, then A→C ends // with exactly A→C and B→C. for _, step := range [][2]string{{"/fa", "/fb"}, {"/fb", "/fa"}, {"/fa", "/fc"}} { if err := env.db.Transaction(func(tx *gorm.DB) error { _, err := svc.AddressChanged(t.Context(), tx, classes.AddressChange{Old: step[0], New: step[1]}) return err }); err != nil { t.Fatal(err) } } var back []string for _, r := range env.redirects(t) { if strings.HasPrefix(r.Source, "/f") { back = append(back, r.Source+"->"+r.Target) } } sort.Strings(back) if strings.Join(back, ",") != "/fa->/fc,/fb->/fc" { t.Fatalf("back and forth = %v, want exactly /fa->/fc and /fb->/fc", back) } // A capture never turns an internal target into another host // (T-32-16); an absolute literal keeps its own host. env.mustRedirect(t, `^/r/(.*)$`, "regex", "/$1", 301) got = env.resolve(t, "/r//evil.example/x") if loc, _ := got["location"].(string); got["action"] != "redirect" || !strings.HasPrefix(loc, siteURL+"/") || strings.HasPrefix(loc, siteURL+"//") { t.Fatalf("protocol-relative capture = %v, want a path on the site", got) } env.mustRedirect(t, `^/ext/(.*)$`, "regex", "https://partner.example/$1", 301) env.expectRedirect(t, "/ext/x", 301, "https://partner.example/x") env.seedRedirect(t, `^/ext2(.*)$`, "regex", "https://partner.example$1", 301) if got := env.resolve(t, "/ext2@evil.example"); got["action"] == "redirect" { t.Fatalf("capture into the host = %v, want no redirect", got) } env.mustRedirect(t, "/qp/*", "pattern", "/nq/*", 301, pass) got = env.resolve(t, "/qp/a?next=//evil.example") if loc, _ := got["location"].(string); !strings.HasPrefix(loc, siteURL+"/nq/a?") { t.Fatalf("passed query = %v, want it appended to the query only", got) } }