(page-1)*perPage overflowed for a large page and sliced out of range. Export compares the page with the page count before multiplying, and the list endpoints cap the page number.
186 lines
9.8 KiB
Go
186 lines
9.8 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const bom = "\ufeff"
|
|
|
|
// transferAction calls the import and export screen's widget action.
|
|
func (e *testEnv) transferAction(t *testing.T, token string, delimiter string, payload map[string]any) map[string]any {
|
|
t.Helper()
|
|
rec := e.expect(t, http.StatusOK, http.MethodPost, adminAPI("/golem15/seo/transfer/widgets/_transfer"), token, map[string]any{
|
|
"record_id": 1,
|
|
"values": map[string]any{"csv_delimiter": delimiter},
|
|
"payload": payload,
|
|
})
|
|
var out struct {
|
|
Data struct {
|
|
Data map[string]any `json:"data"`
|
|
Message string `json:"message"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
|
}
|
|
if out.Data.Data == nil {
|
|
out.Data.Data = map[string]any{}
|
|
}
|
|
out.Data.Data["_message"] = out.Data.Message
|
|
return out.Data.Data
|
|
}
|
|
|
|
// summary reads an import answer's summary as counts.
|
|
func summary(t *testing.T, body map[string]any) map[string]int {
|
|
t.Helper()
|
|
raw, _ := body["summary"].(map[string]any)
|
|
out := map[string]int{}
|
|
for k, v := range raw {
|
|
f, _ := v.(float64)
|
|
out[k] = int(f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestCSVTransfer checks D-13 import and export (P-27, T-32-24/25/29):
|
|
// the export is UTF-8 with a BOM, the chosen delimiter and formula cells
|
|
// prefixed with an apostrophe, and importing it back changes nothing;
|
|
// missing columns and unreadable files are refused; a looping row is
|
|
// refused and blocks the apply; a records row without a record becomes a
|
|
// URL rule; the admin's apply token is refused once the data changed and
|
|
// applies one import batch when fresh; size caps hold on both paths.
|
|
func TestCSVTransfer(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
|
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/e1", "target": "/t1", "note": "=cmd"})
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/e2", "target": "/t2", "status": 302})
|
|
|
|
exp := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/export?dataset=redirects&delimiter=%3B", agent, nil)
|
|
csv, _ := exp["csv"].(string)
|
|
if !strings.HasPrefix(csv, bom+"source;match;target;status;query;ignore_case;trailing_slash;note") {
|
|
t.Fatalf("export = %q, want a BOM and the semicolon header", csv)
|
|
}
|
|
if !strings.Contains(csv, "'=cmd") || strings.Contains(csv, ";=cmd") {
|
|
t.Fatalf("export = %q, want the formula cell prefixed with an apostrophe", csv)
|
|
}
|
|
if !strings.HasPrefix(fmt.Sprint(exp["filename"]), "seo-redirects-") {
|
|
t.Fatalf("filename = %v", exp["filename"])
|
|
}
|
|
|
|
// A huge page number is an empty page, not a panic (WR-14); so is a
|
|
// list page.
|
|
far := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/export?dataset=redirects&page=9223372036854777", agent, nil)
|
|
if c, _ := far["csv"].(string); strings.Contains(c, "/e1") {
|
|
t.Fatalf("export of a huge page = %v, want no rows", far)
|
|
}
|
|
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?page=9223372036854775807&per_page=200", agent, nil); len(l["data"].([]any)) != 0 {
|
|
t.Fatalf("list of a huge page = %v, want no rows", l)
|
|
}
|
|
|
|
preview := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": csv, "delimiter": ";", "dry_run": true})
|
|
if s := summary(t, preview); s["unchanged"] != 2 || s["new"] != 0 || s["changed"] != 0 || s["refused"] != 0 {
|
|
t.Fatalf("re-import of the export = %v, want 2 unchanged", preview)
|
|
}
|
|
|
|
// Refusals of the file as a whole.
|
|
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "target;status\n/x;301\n", "delimiter": ";", "dry_run": true})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"missing_column"`) || !strings.Contains(rec.Body.String(), "source") {
|
|
t.Fatalf("missing column = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": "source;target\n\"/x;/y\n", "delimiter": ";", "dry_run": true})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"unreadable"`) {
|
|
t.Fatalf("unreadable = %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "nope", "csv": "a\n"}); rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("unknown dataset = %d, want 422", rec.Code)
|
|
}
|
|
|
|
// A looping row is refused and nothing is applied.
|
|
looping := "source,target\n/new1,/t1\n/e1x,/e1\n/t1,/new1\n"
|
|
preview = env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": looping, "delimiter": ",", "dry_run": true})
|
|
if s := summary(t, preview); s["refused"] != 1 || s["new"] != 2 {
|
|
t.Fatalf("looping preview = %v", preview)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(preview["rows"]), "Creates a redirect loop") {
|
|
t.Fatalf("looping rows = %v, want transfer.row_loop", preview["rows"])
|
|
}
|
|
before := env.redirectCount(t)
|
|
rec = env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": looping, "delimiter": ","})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"error":"import_refused"`) {
|
|
t.Fatalf("looping apply = %d %s, want 422 import_refused", rec.Code, rec.Body.String())
|
|
}
|
|
if n := env.redirectCount(t); n != before {
|
|
t.Fatalf("redirects after a refused import = %d, want %d", n, before)
|
|
}
|
|
|
|
// Records address pages by URL; no record means a URL rule.
|
|
records := "url;locale;title;robots\n/pages/a;en;Imported;noindex\n/no-record;;Rule title;\n"
|
|
preview = env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "records", "csv": records, "delimiter": ";", "dry_run": true})
|
|
if s := summary(t, preview); s["changed"] != 1 || s["new"] != 1 {
|
|
t.Fatalf("records preview = %v", preview)
|
|
}
|
|
if !strings.Contains(fmt.Sprint(preview["rows"]), "No record at this address") {
|
|
t.Fatalf("records rows = %v, want transfer.row_rule", preview["rows"])
|
|
}
|
|
applied := env.mcpExpect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "records", "csv": records, "delimiter": ";"})
|
|
if applied["applied"] != true || applied["count"] != float64(2) {
|
|
t.Fatalf("records apply = %v", applied)
|
|
}
|
|
if got := env.resolve(t, "/no-record"); got["action"] != "ok" || got["seo"].(map[string]any)["title"] != "Rule title" {
|
|
t.Fatalf("resolve /no-record = %v, want the imported rule", got)
|
|
}
|
|
|
|
// The admin screen: a stale token is refused, a fresh one applies.
|
|
if fields := env.formSchema(t, token, "golem15/seo/transfer"); len(fields) < 2 {
|
|
t.Fatalf("transfer form = %v", fields)
|
|
}
|
|
file := "source;target;status\n/admin1;/pages/a;301\n/e2;/t2-new;302\n"
|
|
prev := env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": file})
|
|
tok, _ := prev["token"].(string)
|
|
if tok == "" || summary(t, prev)["new"] != 1 || summary(t, prev)["changed"] != 1 {
|
|
t.Fatalf("admin preview = %v", prev)
|
|
}
|
|
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/admin1", "target": "/elsewhere"})
|
|
stale := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": tok})
|
|
if stale["error"] != "stale" || !strings.Contains(fmt.Sprint(stale["message"]), "The data changed after the preview") {
|
|
t.Fatalf("stale apply = %v, want transfer.stale", stale)
|
|
}
|
|
forged := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": tok + "x"})
|
|
if forged["error"] == nil {
|
|
t.Fatalf("forged token = %v, want a refusal", forged)
|
|
}
|
|
prev = env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": file})
|
|
done := env.transferAction(t, token, ";", map[string]any{"op": "apply", "token": prev["token"]})
|
|
if done["applied"] != true || !strings.Contains(fmt.Sprint(done["_message"]), "Applied 2 changes as one batch") {
|
|
t.Fatalf("apply = %v", done)
|
|
}
|
|
var vias []string
|
|
if err := env.db.Raw(`SELECT DISTINCT via FROM golem15_seo_revisions WHERE batch_id = ?`, done["batch_id"]).Scan(&vias).Error; err != nil || len(vias) != 1 || vias[0] != "import" {
|
|
t.Fatalf("import batch vias = %v err=%v, want import", vias, err)
|
|
}
|
|
var origin string
|
|
if err := env.db.Raw(`SELECT origin FROM golem15_seo_redirects WHERE source = '/e2'`).Scan(&origin).Error; err != nil || origin != "mcp" {
|
|
t.Fatalf("an updated redirect keeps its origin: %q err=%v", origin, err)
|
|
}
|
|
|
|
// Export through the screen, and the size caps.
|
|
out := env.transferAction(t, token, ",", map[string]any{"op": "export", "dataset": "rules"})
|
|
if c := fmt.Sprint(out["csv"]); !strings.HasPrefix(c, bom+"url,match,title") || !strings.Contains(c, "/no-record") {
|
|
t.Fatalf("admin export = %v", out)
|
|
}
|
|
big := "source;target\n" + strings.Repeat("/a-long-source-address;/a-long-target-address\n", 1350)
|
|
if tooLarge := env.transferAction(t, token, ";", map[string]any{"op": "preview", "dataset": "redirects", "csv": big}); tooLarge["error"] != "too_large" {
|
|
t.Fatalf("admin import over 60 KB = %v, want too_large", tooLarge)
|
|
}
|
|
huge := "source;target\n" + strings.Repeat("/a-long-source-address;/a-long-target-address\n", 21000)
|
|
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/import", agent, map[string]any{"dataset": "redirects", "csv": huge, "dry_run": true}); rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("MCP import over 900 KB = %d, want 413", rec.Code)
|
|
}
|
|
}
|