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.
237 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|