Files
sm-sitemap-plugin/import_test.go
Jakub Zych 1bb89ce006 fix(17): T-17-15 refuse an unsafe frontend_url on save and import
- 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
2026-10-13 00:59:24 +02:00

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