Files
sm-seo-plugin/classes/redirect_graph_test.go
Jakub Zych c2ba33ee94 fix: the runtime cycle log is keyed by rules and bounded (WR-13)
redirectHopsLogged was a never-evicted map keyed by the anonymous
request's URL with its query, so requests to a looping address with fresh
query strings grew it without limit and logged each one. The log is now
keyed by the cycle's rule ids, repeats at most once an hour and starts
over at 1024 keys.
2026-10-27 15:32:29 +01:00

237 lines
9.8 KiB
Go

package classes
import (
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/models"
)
// rd is an exact 301 rule with defaults.
func rd(id uint, source, target string) models.Redirect {
r := models.NewRedirect()
r.ID, r.Source, r.Target = id, source, target
return r
}
func rdMatch(id uint, match, source, target string) models.Redirect {
r := rd(id, source, target)
r.Match = match
return r
}
const rdSite = "https://example.test"
// TestRedirectGraph: the redirect graph rules of D-18/D-19 over rule lists
// (the database writes of AddressChanged are covered by TestSlugChange301
// and TestAddressChangeFromEvent in the plugin package).
func TestRedirectGraph(t *testing.T) {
t.Run("a source with a query wins over the path-only source", func(t *testing.T) {
for _, rules := range [][]models.Redirect{
{rd(1, "/products", "/shop"), rd(2, "/products?id=5", "/shop/item-5")},
{rd(1, "/products?id=5", "/shop/item-5"), rd(2, "/products", "/shop")},
} {
set := NewRedirectSet(rules)
hit, ok, err := set.Match("/products", "id=5")
if err != nil || !ok || hit.Target != "/shop/item-5" {
t.Fatalf("rules %v: /products?id=5 = %+v, %v, %v; want /shop/item-5", rules, hit, ok, err)
}
hit, ok, err = set.Match("/products", "id=6")
if err != nil || !ok || hit.Target != "/shop" {
t.Fatalf("rules %v: /products?id=6 = %+v, %v, %v; want /shop", rules, hit, ok, err)
}
}
check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/products", "/shop")}, rd(0, "/products?id=5", "/shop/item-5"))
if err != nil || check.Loop || check.Chain {
t.Fatalf("check = %+v, %v", check, err)
}
})
t.Run("a chain A to B to C is a chain notice from A to C", func(t *testing.T) {
stored := []models.Redirect{rd(1, "/b", "/c")}
check, err := checkRedirectRules(rdSite, stored, rd(0, "/a", "/b"))
if err != nil || check.Loop || !check.Chain || check.Hops != 2 || check.Source != "/a" || check.Final != "/c" || check.PathText() != "/a → /b → /c" {
t.Fatalf("check = %+v, %v", check, err)
}
notice := ChainNotice(check)
if len(notice) != 1 || notice[0].Params["count"] != "2" || notice[0].Params["final"] != "/c" || notice[0].Params["source"] != "/a" {
t.Fatalf("notice = %+v", notice)
}
})
t.Run("a rule that another rule leads into is the second hop of a chain", func(t *testing.T) {
stored := []models.Redirect{rd(1, "/a", "/b")}
check, err := checkRedirectRules(rdSite, stored, rd(0, "/b", "/c"))
if err != nil || !check.Chain || check.Hops != 2 || check.Source != "/a" || check.Final != "/c" {
t.Fatalf("check = %+v, %v", check, err)
}
})
t.Run("the flattened graph A to C, B to C is one hop each", func(t *testing.T) {
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")})
for _, from := range []string{"/a", "/b"} {
ch, err := set.Follow(rdSite, from, "", MaxRedirectHops)
if err != nil || len(ch.Hops) != 1 || ch.Final != "/c" || ch.Loop {
t.Fatalf("%s: chain = %+v, %v", from, ch, err)
}
}
for _, r := range []models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")} {
others := []models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")}
if check, err := checkRedirectRules(rdSite, others, r); err != nil || check.Chain || check.Loop {
t.Fatalf("%s: check = %+v, %v", r.Source, check, err)
}
}
})
t.Run("back and forth A to B to A to C ends as A to C and B to C", func(t *testing.T) {
// After A→B, B→A (the address returned) and then A→C, the graph
// AddressChanged leaves is A→C and B→C: no rule shadows the live
// address and every old address reaches C in one hop.
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "/c"), rd(2, "/b", "/c")})
for _, from := range []string{"/a", "/b"} {
if ch, _ := set.Follow(rdSite, from, "", MaxRedirectHops); len(ch.Hops) != 1 || ch.Final != "/c" {
t.Fatalf("%s: chain = %+v", from, ch)
}
}
if _, ok, _ := set.Match("/c", ""); ok {
t.Fatal("the live address is shadowed")
}
// Back and forth without the cleanup would be a loop.
check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/a", "/b")}, rd(0, "/b", "/a"))
if err != nil || !check.Loop {
t.Fatalf("A→B then B→A = %+v, %v; want a loop", check, err)
}
})
loops := []struct {
name string
stored []models.Redirect
rule models.Redirect
path string
}{
{"exact self loop", nil, rd(0, "/a", "/a"), "/a → /a"},
{"exact two-rule loop", []models.Redirect{rd(1, "/b", "/a")}, rd(0, "/a", "/b"), "/a → /b → /a"},
{"exact three-rule loop", []models.Redirect{rd(1, "/b", "/c"), rd(2, "/c", "/a")}, rd(0, "/a", "/b"), "/a → /b → /c → /a"},
{"exact loop through the site's absolute URL", []models.Redirect{rd(1, "/b", rdSite+"/a")}, rd(0, "/a", "/b"), "/a → /b → /a"},
{"exact loop through case and trailing slash", []models.Redirect{rd(1, "/b", "/A/")}, func() models.Redirect { r := rd(0, "/a", "/b"); r.IgnoreCase = true; return r }(), ""},
{"pattern probe loop", []models.Redirect{rd(1, "/new/seoprobe", "/old/x")}, rdMatch(0, models.RedirectPattern, "/old/*", "/new/*"), ""},
{"pattern onto itself", nil, rdMatch(0, models.RedirectPattern, "/docs/*", "/docs/*"), ""},
{"regex probe loop", []models.Redirect{rdMatch(1, models.RedirectPattern, "/b/*", "/a/*")}, rdMatch(0, models.RedirectRegex, `^/a/(\d+)$`, "/b/$1"), `^/a/(\d+)$ → /b/0 → /a/0`},
{"regex onto itself", nil, rdMatch(0, models.RedirectRegex, `^/x/([a-z]+)$`, "/x/$1"), ""},
}
for _, tc := range loops {
t.Run("loop refused: "+tc.name, func(t *testing.T) {
check, err := checkRedirectRules(rdSite, tc.stored, tc.rule)
if err != nil || !check.Loop {
t.Fatalf("check = %+v, %v; want a loop", check, err)
}
if tc.path != "" && check.PathText() != tc.path {
t.Fatalf("path = %q, want %q", check.PathText(), tc.path)
}
if msg := (&LoopError{Check: check}).Error(); !strings.Contains(msg, "loop") {
t.Fatalf("LoopError = %q", msg)
}
})
}
t.Run("a 410 rule ends every chain and is never a loop", func(t *testing.T) {
gone := rd(0, "/a", "")
gone.Status = models.Status410
if check, err := checkRedirectRules(rdSite, []models.Redirect{rd(1, "/b", "/a")}, gone); err != nil || check.Loop || check.Chain {
t.Fatalf("check = %+v, %v", check, err)
}
set := NewRedirectSet([]models.Redirect{rd(1, "/b", "/a"), func() models.Redirect { g := gone; g.ID = 2; return g }()})
ch, err := set.Follow(rdSite, "/b", "", MaxRedirectHops)
if err != nil || !ch.Gone || ch.Loop || len(ch.Hops) != 2 {
t.Fatalf("chain = %+v, %v", ch, err)
}
})
t.Run("an external target ends the chain", func(t *testing.T) {
set := NewRedirectSet([]models.Redirect{rd(1, "/a", "https://other.test/a"), rd(2, "/b", "/a")})
ch, err := set.Follow(rdSite, "/b", "", MaxRedirectHops)
if err != nil || ch.Loop || len(ch.Hops) != 2 || ch.Final != "https://other.test/a" {
t.Fatalf("chain = %+v, %v", ch, err)
}
})
t.Run("the runtime stops after 10 hops", func(t *testing.T) {
var rules []models.Redirect
for i := 0; i < 15; i++ {
rules = append(rules, rd(uint(i+1), "/p"+strconv.Itoa(i), "/p"+strconv.Itoa(i+1)))
}
set := NewRedirectSet(rules)
ch, err := set.Follow(rdSite, "/p0", "", MaxRedirectHops)
if err != nil || !ch.Loop || len(ch.Hops) != MaxRedirectHops || MaxRedirectHops != 10 {
t.Fatalf("chain = %d hops, loop %v, %v", len(ch.Hops), ch.Loop, err)
}
// Reaching the cap counts as a loop; a chain one hop shorter ends.
capped, _ := set.Follow(rdSite, "/p5", "", MaxRedirectHops)
if !capped.Loop || len(capped.Hops) != 10 {
t.Fatalf("ten hops: %d hops, loop %v", len(capped.Hops), capped.Loop)
}
short, err := set.Follow(rdSite, "/p6", "", MaxRedirectHops)
if err != nil || short.Loop || len(short.Hops) != 9 || short.Final != "/p15" {
t.Fatalf("nine hops: %d hops, loop %v, final %s", len(short.Hops), short.Loop, short.Final)
}
// A new rule that makes a chain longer than the cap is a loop.
check, err := checkRedirectRules(rdSite, rules[1:], rd(0, "/p0", "/p1"))
if err != nil || !check.Loop {
t.Fatalf("an 11-hop chain = %+v, %v; want refused", check, err)
}
})
t.Run("probes", func(t *testing.T) {
for _, tc := range []struct {
rule models.Redirect
path string
ok bool
}{
{rd(0, "/a?x=1", "/b"), "/a", true},
{rdMatch(0, models.RedirectPattern, "/old/*/x", "/n"), "/old/seoprobe/x", true},
{rdMatch(0, models.RedirectRegex, `^/a/[0-9]{2}/(x|y)$`, "/n"), "/a/00/x", true},
{rdMatch(0, models.RedirectRegex, `^/a/\w+$`, "/n"), "/a/a", true},
{rdMatch(0, models.RedirectRegex, `^a$`, "/n"), "", false},
{rdMatch(0, models.RedirectRegex, `(`, "/n"), "", false},
{rdMatch(0, models.RedirectRegex, `^/a\?q$`, "/n"), "", false},
} {
path, _, ok := redirectProbe(tc.rule)
if ok != tc.ok || (ok && path != tc.path) {
t.Errorf("redirectProbe(%s %q) = %q %v, want %q %v", tc.rule.Match, tc.rule.Source, path, ok, tc.path, tc.ok)
}
}
})
}
// TestCycleLogBounded: the runtime cycle log is keyed by the cycle's rules,
// not the request URL, and never holds more than maxCycleLog keys (WR-13).
func TestCycleLogBounded(t *testing.T) {
set := NewRedirectSet([]models.Redirect{rd(1, "/la", "/lb"), rd(2, "/lb", "/la")})
ch, err := set.Follow(rdSite, "/la", "", MaxRedirectHops)
if err != nil || !ch.Loop {
t.Fatalf("chain = %+v, %v; want a loop", ch, err)
}
cycleLog.Lock()
cycleLog.seen = nil
cycleLog.Unlock()
for i := 0; i < 5000; i++ {
logRedirectCycle(rdSite+"/la?x="+strconv.Itoa(i), ch)
}
cycleLog.Lock()
n := len(cycleLog.seen)
cycleLog.Unlock()
if n != 1 {
t.Fatalf("cycle log keys after one cycle from 5000 URLs = %d, want 1", n)
}
for i := 0; i < maxCycleLog+10; i++ {
logRedirectCycle("/x", Chain{Hops: []RedirectHit{{Rule: rd(uint(100+i), "/a", "/b")}}})
}
cycleLog.Lock()
n = len(cycleLog.seen)
cycleLog.Unlock()
if n > maxCycleLog {
t.Fatalf("cycle log keys = %d, want at most %d", n, maxCycleLog)
}
}