- golem15.seo.redirects list and form (UI-SPEC C8): chain rows marked, status, match, origin and chain filters, the navigation item lands here - a field-less extension on the controller refuses a loop with redirects.loop on target, answers the chain notice and records history - the status dropdown posts text, read through RedirectStatus
419 lines
17 KiB
Go
419 lines
17 KiB
Go
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, models.RedirectStatus(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, models.RedirectStatus(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)
|
|
}
|
|
}
|
|
|
|
// redirectCount is the number of stored redirects.
|
|
func (e *testEnv) redirectCount(t *testing.T) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := e.db.Raw(`SELECT COUNT(*) FROM golem15_seo_redirects`).Scan(&n).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// createdID reads the id of a create response.
|
|
func createdID(t *testing.T, body []byte) string {
|
|
t.Helper()
|
|
var out struct {
|
|
Data struct {
|
|
ID json.Number `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil || out.Data.ID == "" {
|
|
t.Fatalf("create body %s: %v", body, err)
|
|
}
|
|
return out.Data.ID.String()
|
|
}
|
|
|
|
// TestLoopRefused checks the redirects screen (SC-3, D-19, UI-SPEC C8): a
|
|
// loop is refused from the admin with redirects.loop on target and nothing
|
|
// saved, on create and on update; a chain is saved with the chain notice
|
|
// on target and marked in the list; field refusals answer 422.
|
|
func TestLoopRefused(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
api := adminAPI("/golem15/seo/redirects")
|
|
|
|
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/la", "match": "exact", "target": "/lb", "status": "301"})
|
|
before := env.redirectCount(t)
|
|
rec := env.call(t, http.MethodPost, api, token, map[string]any{"source": "/lb", "match": "exact", "target": "/la", "status": "301"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"target"`) ||
|
|
!strings.Contains(rec.Body.String(), "This redirect would create a loop: /lb → /la → /lb. Nothing was saved.") {
|
|
t.Fatalf("loop create = %d %s, want 422 redirects.loop on target", rec.Code, rec.Body.String())
|
|
}
|
|
if after := env.redirectCount(t); after != before {
|
|
t.Fatalf("redirects after a refused loop = %d, want %d", after, before)
|
|
}
|
|
|
|
other := createdID(t, env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/lc", "target": "/elsewhere", "status": "302"}).Body.Bytes())
|
|
rec = env.call(t, http.MethodPut, api+"/"+other, token, map[string]any{"source": "/lb", "match": "exact", "target": "/la", "status": "301"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "would create a loop") {
|
|
t.Fatalf("loop update = %d %s, want 422", rec.Code, rec.Body.String())
|
|
}
|
|
var stored redirectRow
|
|
if err := env.db.Raw(`SELECT id, source, target, status FROM golem15_seo_redirects WHERE id = ?`, other).Scan(&stored).Error; err != nil || stored.Source != "/lc" || stored.Status != 302 {
|
|
t.Fatalf("redirect after a refused update = %+v err=%v, want it unchanged", stored, err)
|
|
}
|
|
|
|
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/cb", "target": "/cc"})
|
|
rec = env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/ca", "target": "/cb"})
|
|
chainID := createdID(t, rec.Body.Bytes())
|
|
var notice bool
|
|
for _, n := range saveNotices(t, rec.Body.Bytes()) {
|
|
if n.Field == "target" && strings.Contains(n.Message, "/ca → /cb → /cc") && strings.Contains(n.Message, "“/cc”") {
|
|
notice = true
|
|
}
|
|
}
|
|
if !notice {
|
|
t.Fatalf("chain save notices = %s, want redirects.chain_notice on target naming /cc", rec.Body.String())
|
|
}
|
|
var list struct {
|
|
Meta struct {
|
|
RowStates map[string][]string `json:"row_states"`
|
|
} `json:"meta"`
|
|
}
|
|
if err := json.Unmarshal(env.expect(t, http.StatusOK, http.MethodGet, api, token, nil).Body.Bytes(), &list); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if states := list.Meta.RowStates[chainID]; len(states) != 1 || states[0] != "negative" {
|
|
t.Fatalf("row states = %v, want the chain row %s negative", list.Meta.RowStates, chainID)
|
|
}
|
|
|
|
rec = env.call(t, http.MethodPost, api, token, map[string]any{"source": "/g", "status": "301"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Enter the address to redirect to") {
|
|
t.Fatalf("missing target = %d %s, want 422 target_required", rec.Code, rec.Body.String())
|
|
}
|
|
rec = env.call(t, http.MethodPost, api, token, map[string]any{"source": `^/(`, "match": "regex", "target": "/x"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "This is not a valid regular expression: missing closing )") {
|
|
t.Fatalf("invalid regex = %d %s, want 422 regex_invalid with the error", rec.Code, rec.Body.String())
|
|
}
|
|
env.expect(t, http.StatusCreated, http.MethodPost, api, token, map[string]any{"source": "/gone-page", "status": "410"})
|
|
if got := env.resolve(t, "/gone-page"); got["action"] != "gone" {
|
|
t.Fatalf("410 from the admin = %v, want gone", got)
|
|
}
|
|
if rows := env.revisions(t, "redirect"); len(rows) < 4 {
|
|
t.Fatalf("redirect history rows = %d, want every admin save recorded", len(rows))
|
|
}
|
|
}
|