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 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) } } }) }