- Settings.BeforeValidate answers a cabana validation error on frontend_url, so the settings save is a 422 and the row is unchanged - sitemap:import-winter stops on a frontend_url that models.AllowedFrontendURL refuses, before the dry-run branch and before any database use; the value is printed quoted - errors.frontend_url phrase in en and pl - TestSettingsBeforeValidate, TestWinterSettingsFrontendURL and the save and import refusals in the Postgres smokes
232 lines
10 KiB
Go
232 lines
10 KiB
Go
package sitemap_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/console"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
)
|
|
|
|
// TestImportWinter pins sitemap:import-winter on Postgres (D-12): type maps
|
|
// and drops with their report lines, the reference check, --theme with
|
|
// several exported definitions, --dry-run writing nothing, the refusal of a
|
|
// second copy without --force and the replacement with it, the settings
|
|
// upsert onto row 1 with PHP defaults for missing keys, and the refusals for
|
|
// missing, empty and malformed exports.
|
|
func TestImportWinter(t *testing.T) {
|
|
e := bootSitemap(t)
|
|
ctx := context.Background()
|
|
reg, ok := e.app.Lookup[*signpost.Registry]()
|
|
if !ok {
|
|
t.Fatal("signpost registry is not published")
|
|
}
|
|
dir := t.TempDir()
|
|
row := func(id int, theme string, items string) string {
|
|
quoted, _ := json.Marshal(items)
|
|
return `{"id": ` + string(rune('0'+id)) + `, "theme": "` + theme + `", "data": ` + string(quoted) + "}\n"
|
|
}
|
|
items := `[{"type":"url","url":"/"},` +
|
|
`{"type":"old-posts","reference":null,"nesting":"1"},` +
|
|
`{"type":"acme-post","reference":"7"},` +
|
|
`{"type":"acme-post","reference":"8"},` +
|
|
`{"type":"gone-type","reference":"1"},` +
|
|
`{"type":"static-page","reference":"about"},` +
|
|
`{"type":"remove-me"}]`
|
|
defs := writeFile(t, dir, "definitions.jsonl", row(1, "main", items)+"\n"+row(2, "other", `[{"type":"url","url":"/other"}]`))
|
|
stored := func(t *testing.T) []models.DefinitionItem {
|
|
t.Helper()
|
|
var rows []models.Definition
|
|
if err := e.gdb.Where("theme = ?", models.DefinitionTheme).Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("%d definitions", len(rows))
|
|
}
|
|
return rows[0].Items()
|
|
}
|
|
run := func(opts console.WinterOptions) (console.WinterReport, string, error) {
|
|
out, buf := testOutput()
|
|
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out)
|
|
return report, buf.String(), err
|
|
}
|
|
|
|
t.Run("refusals", func(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
opts console.WinterOptions
|
|
want string
|
|
}{
|
|
"no file": {console.WinterOptions{}, "give --definitions, --settings or both"},
|
|
"missing file": {console.WinterOptions{DefinitionsFile: dir + "/missing.jsonl"}, "File not found"},
|
|
"empty file": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "empty.jsonl", "\n\n")}, "holds no definition"},
|
|
"bad line": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "bad.jsonl", "{\n")}, "line 1"},
|
|
"several rows": {console.WinterOptions{DefinitionsFile: defs}, "holds 2 definitions (themes 'main', 'other'); pick one with --theme"},
|
|
"unknown theme": {console.WinterOptions{DefinitionsFile: defs, Theme: "nope"}, "no definition of theme 'nope'"},
|
|
"data not items": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "scalar.jsonl", `{"id":1,"theme":"t","data":"5"}`)}, "not a list of items"},
|
|
"bad settings": {console.WinterOptions{SettingsFile: writeFile(t, dir, "settings-bad.json", `[1]`)}, "must be a JSON object"},
|
|
} {
|
|
_, out, err := run(tc.opts)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("%s: err = %v, want %q\n%s", name, err, tc.want, out)
|
|
}
|
|
}
|
|
if got := stored(t); got != nil {
|
|
t.Fatalf("a refused import wrote %+v", got)
|
|
}
|
|
})
|
|
|
|
opts := console.WinterOptions{DefinitionsFile: defs, Theme: "main", Maps: map[string]string{"old-posts": "acme-list", "remove-me": ""}}
|
|
wantLines := []string{
|
|
"item 1 (type 'old-posts'): type 'old-posts' mapped to 'acme-list'",
|
|
"item 3 (type 'acme-post', reference '8'): dropped (reference '8' is not among the current references of 'acme-post')",
|
|
"item 4 (type 'gone-type', reference '1'): dropped (type 'gone-type' is not registered; map it with --map gone-type=<type>)",
|
|
"item 5 (type 'static-page', reference 'about'): dropped (static and CMS page types are not supported)",
|
|
"item 6 (type 'remove-me'): dropped (mapped to nothing)",
|
|
}
|
|
|
|
t.Run("dry run writes nothing", func(t *testing.T) {
|
|
dry := opts
|
|
dry.DryRun = true
|
|
report, out, err := run(dry)
|
|
if err != nil {
|
|
t.Fatalf("dry run: %v\n%s", err, out)
|
|
}
|
|
if report.Kept != 3 || report.Dropped != 4 || report.Remapped != 2 {
|
|
t.Fatalf("report = %+v\n%s", report, out)
|
|
}
|
|
for _, line := range wantLines {
|
|
if !strings.Contains(out, line) {
|
|
t.Errorf("output lacks %q:\n%s", line, out)
|
|
}
|
|
}
|
|
if !strings.Contains(out, "Dry run: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "Dry run complete (no changes made).") {
|
|
t.Fatalf("dry run output:\n%s", out)
|
|
}
|
|
if got := stored(t); got != nil {
|
|
t.Fatalf("the dry run wrote %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("copies, refuses a second copy, replaces with --force", func(t *testing.T) {
|
|
report, out, err := run(opts)
|
|
if err != nil {
|
|
t.Fatalf("import: %v\n%s", err, out)
|
|
}
|
|
got := stored(t)
|
|
if report.Kept != 3 || len(got) != 3 || got[0].Type != "url" || got[1].Type != "acme-list" || !got[1].Nesting || got[2].Type != "acme-post" || models.Text(got[2].Reference) != "7" {
|
|
t.Fatalf("copied = %+v (report %+v)", got, report)
|
|
}
|
|
if !strings.Contains(out, "Definition copied: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "theme 'main'") {
|
|
t.Fatalf("output:\n%s", out)
|
|
}
|
|
other := console.WinterOptions{DefinitionsFile: defs, Theme: "other"}
|
|
if _, out, err := run(other); err == nil || !strings.Contains(err.Error(), "already has 3 items; rerun with --force") {
|
|
t.Fatalf("second copy: %v\n%s", err, out)
|
|
}
|
|
// A dry run over existing items warns instead of failing.
|
|
dry := other
|
|
dry.DryRun = true
|
|
if _, out, err := run(dry); err != nil || !strings.Contains(out, "already has 3 items") {
|
|
t.Fatalf("dry run over items: %v\n%s", err, out)
|
|
}
|
|
if len(stored(t)) != 3 {
|
|
t.Fatal("a refused copy changed the definition")
|
|
}
|
|
other.Force = true
|
|
if _, out, err := run(other); err != nil {
|
|
t.Fatalf("--force: %v\n%s", err, out)
|
|
}
|
|
if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/other" {
|
|
t.Fatalf("after --force = %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("data held as JSON or null", func(t *testing.T) {
|
|
raw := writeFile(t, dir, "raw.jsonl", `{"id":5,"theme":"t","data":[{"type":"url","url":"/raw"}]}`)
|
|
if _, out, err := run(console.WinterOptions{DefinitionsFile: raw, Force: true}); err != nil {
|
|
t.Fatalf("raw JSON data: %v\n%s", err, out)
|
|
}
|
|
if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/raw" {
|
|
t.Fatalf("raw data = %+v", got)
|
|
}
|
|
null := writeFile(t, dir, "null.jsonl", `{"id":6,"theme":"t","data":null}`)
|
|
if report, out, err := run(console.WinterOptions{DefinitionsFile: null, Force: true}); err != nil || report.Kept != 0 {
|
|
t.Fatalf("null data: %+v %v\n%s", report, err, out)
|
|
}
|
|
if got := stored(t); len(got) != 0 {
|
|
t.Fatalf("null data stored %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("url and pattern checks", func(t *testing.T) {
|
|
bad := writeFile(t, dir, "bad-urls.jsonl", row(7, "t", `[{"type":"url","url":"javascript://%0aalert(1)%0a//x"},`+
|
|
`{"type":"url","url":"/ok"},`+
|
|
`{"type":"acme-post","reference":"7","urlPattern":"//evil.test/:slug"}]`))
|
|
report, out, err := run(console.WinterOptions{DefinitionsFile: bad, Force: true})
|
|
if err != nil {
|
|
t.Fatalf("import: %v\n%s", err, out)
|
|
}
|
|
if report.Kept != 2 || report.Dropped != 1 {
|
|
t.Fatalf("report = %+v\n%s", report, out)
|
|
}
|
|
for _, line := range []string{
|
|
"item 0 (type 'url', url 'javascript://%0aalert(1)%0a//x'): dropped (the url is not a path or an http(s) URL)",
|
|
"item 2 (type 'acme-post', reference '7'): urlPattern '//evil.test/:slug' cleared (not a path starting with a single /)",
|
|
} {
|
|
if !strings.Contains(out, line) {
|
|
t.Errorf("output lacks %q:\n%s", line, out)
|
|
}
|
|
}
|
|
got := stored(t)
|
|
if len(got) != 2 || models.Text(got[0].URL) != "/ok" || got[1].URLPattern != nil {
|
|
t.Fatalf("stored = %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("settings upsert", func(t *testing.T) {
|
|
first := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test","enable_priority_tiers":"1","priority_blog":0.7,"unknown":"x"}`)
|
|
report, out, err := run(console.WinterOptions{SettingsFile: first})
|
|
if err != nil || !report.Settings || !strings.Contains(out, "setting 'unknown' ignored") || !strings.Contains(out, "Settings copied.") {
|
|
t.Fatalf("settings: %+v %v\n%s", report, err, out)
|
|
}
|
|
second := writeFile(t, dir, "settings2.json", `"{\"frontend_url\":\"https://other.example.test\",\"enable_priority_tiers\":\"0\"}"`)
|
|
if _, out, err := run(console.WinterOptions{SettingsFile: second}); err != nil {
|
|
t.Fatalf("second settings: %v\n%s", err, out)
|
|
}
|
|
var rows []models.Settings
|
|
if err := e.gdb.Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 || rows[0].ID != 1 || models.Text(rows[0].FrontendURL) != "https://other.example.test" || rows[0].EnablePriorityTiers || rows[0].Priority(models.PriorityBlog) != "0.6" {
|
|
t.Fatalf("settings rows = %+v", rows)
|
|
}
|
|
dry := writeFile(t, dir, "settings3.json", `{"frontend_url":"https://dry.example.test"}`)
|
|
if _, out, err := run(console.WinterOptions{SettingsFile: dry, DryRun: true}); err != nil || !strings.Contains(out, "Dry run: the settings would be copied.") {
|
|
t.Fatalf("settings dry run: %v\n%s", err, out)
|
|
}
|
|
if err := e.gdb.Find(&rows).Error; err != nil || models.Text(rows[0].FrontendURL) != "https://other.example.test" {
|
|
t.Fatalf("the dry run changed the settings: %+v %v", rows, err)
|
|
}
|
|
|
|
// T-17-15: a frontend_url that is not a plain http(s) URL stops the
|
|
// import, also as a dry run, and the row keeps its value.
|
|
quoted, _ := json.Marshal(map[string]string{"frontend_url": `https://x"/><y a="`})
|
|
unsafe := writeFile(t, dir, "settings4.json", string(quoted))
|
|
for _, dryRun := range []bool{false, true} {
|
|
report, out, err := run(console.WinterOptions{SettingsFile: unsafe, DryRun: dryRun})
|
|
if err == nil || !strings.Contains(err.Error(), "frontend_url") || report.Settings {
|
|
t.Fatalf("unsafe frontend_url (dry run %v): %+v %v\n%s", dryRun, report, err, out)
|
|
}
|
|
if err := e.gdb.Find(&rows).Error; err != nil || len(rows) != 1 || models.Text(rows[0].FrontendURL) != "https://other.example.test" {
|
|
t.Fatalf("a refused import (dry run %v) changed the settings: %+v %v", dryRun, rows, err)
|
|
}
|
|
}
|
|
})
|
|
}
|