Files
sm-sitemap-plugin/import_test.go
Jakub Zych 84cb27a810 feat!: remove the PHP-parity sitemap, tiers, definitions screen and XSL
BREAKING CHANGE: /sitemap.xsl is gone (404), the definitions screen and
the Phase 17 settings tile are replaced by the settings singleton, and
priority, change frequency, priority tiers and the update manifest are
no longer written or read. The config keys golem15.sitemap.cache_seconds
and golem15.sitemap.priority_tiers are removed; site_url, lastmod_ignore
and rebuild_delay_seconds are new.

- sitemap:import and sitemap:import-winter write the additional addresses
  (and site_url, default_locale) instead of the definition
- the golem15_sitemap_definitions table stays in the database, unread
2026-11-04 11:12:51 +01:00

257 lines
12 KiB
Go

package sitemap_test
import (
"context"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/console"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// storedExtra reads the additional addresses of settings row 1.
func (e *sitemapEnv) storedExtra(t *testing.T) []models.ExtraURL {
t.Helper()
s, err := classes.LoadSettings(e.gdb)
if err != nil {
t.Fatal(err)
}
return s.Extra()
}
// extraURLs lists the addresses of rows.
func extraURLs(rows []models.ExtraURL) string {
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.URL)
}
return strings.Join(out, " ")
}
// TestImportWinter pins sitemap:import-winter on Postgres (D-32): the url
// items of a WinterCMS definition become the additional addresses (static
// and CMS page items through --page-url, types renamed by --map first),
// every other item is dropped and listed, --theme picks a definition,
// --dry-run writes nothing, stored addresses are replaced only with
// --force, and frontend_url and frontend_default_locale become site_url and
// default_locale while the PHP-parity keys are reported as ignored; missing,
// empty and malformed exports and an unsafe frontend_url are refused.
func TestImportWinter(t *testing.T) {
e := bootIndex(t)
ctx := context.Background()
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":"/","changefreq":"always","priority":"1.0"},` +
`{"type":"blog-posts","reference":null,"nesting":"1","cmsPage":"blog/post"},` +
`{"type":"acme-post","reference":"7"},` +
`{"type":"static-page","reference":"about"},` +
`{"type":"cms-page","reference":"news"},` +
`{"type":"remove-me"},` +
`{"type":"url","url":"javascript://%0aalert(1)%0a//x"},` +
`{"type":"url","url":"/"}]`
defs := writeFile(t, dir, "definitions.jsonl", row(1, "main", items)+"\n"+row(2, "other", `[{"type":"url","url":"/other"}]`))
run := func(opts console.WinterOptions) (console.WinterReport, string, error) {
out, buf := testOutput()
report, err := console.ImportWinterFiles(ctx, e.gdb, 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 := e.storedExtra(t); len(got) != 0 {
t.Fatalf("a refused import wrote %+v", got)
}
})
opts := console.WinterOptions{DefinitionsFile: defs, Theme: "main", Maps: map[string]string{"remove-me": ""}, PageURLs: map[string]string{"news": "/news"}}
wantLines := []string{
"item 1 (type 'blog-posts'): cmsPage 'blog/post' dropped (CMS pages are not supported)",
"item 1 (type 'blog-posts'): dropped (the content types list their records themselves)",
"item 2 (type 'acme-post', reference '7'): dropped (the content types list their records themselves)",
"item 3 (type 'static-page', reference 'about'): dropped (static and CMS page types are not supported)",
"item 4 (type 'cms-page', reference 'news'): copied as an additional address '/news'",
"item 5 (type 'remove-me'): type 'remove-me' mapped to ''",
"item 5 (type 'remove-me'): dropped (mapped to nothing)",
"item 6 (type 'url', url 'javascript://%0aalert(1)%0a//x'): dropped (the url is not a path or an http(s) URL)",
"item 7 (type 'url', url '/'): dropped (address '/' is already listed)",
}
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 != 2 || report.Dropped != 6 || 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: 2 items kept, 2 remapped, 6 dropped.") || !strings.Contains(out, "Dry run complete (no changes made).") {
t.Fatalf("dry run output:\n%s", out)
}
if got := e.storedExtra(t); len(got) != 0 {
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)
}
if got := extraURLs(e.storedExtra(t)); report.Kept != 2 || got != "/ /news" {
t.Fatalf("copied = %q (report %+v)", got, report)
}
if !strings.Contains(out, "Definition copied: 2 items kept, 2 remapped, 6 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 2 additional addresses; rerun with --force") {
t.Fatalf("second copy: %v\n%s", err, out)
}
dry := other
dry.DryRun = true
if _, out, err := run(dry); err != nil || !strings.Contains(out, "already has 2 additional addresses") {
t.Fatalf("dry run over addresses: %v\n%s", err, out)
}
if got := extraURLs(e.storedExtra(t)); got != "/ /news" {
t.Fatalf("a refused copy changed the addresses: %q", got)
}
other.Force = true
if _, out, err := run(other); err != nil {
t.Fatalf("--force: %v\n%s", err, out)
}
if got := extraURLs(e.storedExtra(t)); got != "/other" {
t.Fatalf("after --force = %q", 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 := extraURLs(e.storedExtra(t)); got != "/raw" {
t.Fatalf("raw data = %q", 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 := e.storedExtra(t); len(got) != 0 {
t.Fatalf("null data stored %+v", got)
}
})
t.Run("settings", func(t *testing.T) {
first := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test","frontend_default_locale":"pl","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 (not a sitemap setting of this port)") ||
!strings.Contains(out, "setting 'priority_blog' ignored (the per-type sitemaps do not use it)") || !strings.Contains(out, "Settings copied.") {
t.Fatalf("settings: %+v %v\n%s", report, err, out)
}
s, err := classes.LoadSettings(e.gdb)
if err != nil || models.Text(s.SiteURL) != "https://www.example.test" || models.Text(s.DefaultLocale) != "pl" {
t.Fatalf("copied settings = %+v %v", s, err)
}
second := writeFile(t, dir, "settings2.json", `"{\"frontend_url\":\"https://other.example.test\"}"`)
if _, out, err := run(console.WinterOptions{SettingsFile: second}); err != nil {
t.Fatalf("second settings: %v\n%s", err, out)
}
if s, _ = classes.LoadSettings(e.gdb); models.Text(s.SiteURL) != "https://other.example.test" || s.DefaultLocale != nil {
t.Fatalf("second settings = %+v", s)
}
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)
}
// T-17-15: an unsafe frontend_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 s, _ = classes.LoadSettings(e.gdb); models.Text(s.SiteURL) != "https://other.example.test" {
t.Fatalf("a refused or dry import changed the settings: %+v", s)
}
})
}
// TestImportFile pins sitemap:import: a JSON list of addresses replaces the
// additional addresses (or is appended, skipping listed ones), lastmod is
// kept, changefreq and priority are ignored, and a missing url, an unsafe
// url or a bad lastmod stops the import before anything is written.
func TestImportFile(t *testing.T) {
e := bootIndex(t)
ctx := context.Background()
dir := t.TempDir()
manifest := writeFile(t, dir, "urls.json", `[{"url":"/x","changefreq":"daily","lastmod":"2026-10-01"},{"url":"/y","priority":"0.9"}]`)
out, buf := testOutput()
if err := console.ImportFile(ctx, e.gdb, manifest, "", false, out); err != nil {
t.Fatalf("sitemap:import: %v", err)
}
got := e.storedExtra(t)
if extraURLs(got) != "/x /y" || got[0].LastMod != "2026-10-01" || got[1].LastMod != "" {
t.Fatalf("imported = %+v", got)
}
if !strings.Contains(buf.String(), "Imported 2 addresses into the sitemap's additional addresses.") {
t.Fatalf("output:\n%s", buf.String())
}
more := writeFile(t, dir, "more.json", `[{"url":"/y"},{"url":"https://www.example.test/z"}]`)
out, buf = testOutput()
if err := console.ImportFile(ctx, e.gdb, more, "main", true, out); err != nil {
t.Fatalf("--append: %v", err)
}
if got := extraURLs(e.storedExtra(t)); got != "/x /y https://www.example.test/z" {
t.Fatalf("appended = %q", got)
}
if !strings.Contains(buf.String(), "Imported 1 addresses") || !strings.Contains(buf.String(), "Kept 2 existing addresses.") || !strings.Contains(buf.String(), "Theme 'main' was given") {
t.Fatalf("--append output:\n%s", buf.String())
}
for name, c := range map[string]struct{ body, want string }{
"missing url": {`[{"url":"/a"},{"changefreq":"daily"}]`, "Entry 1 is missing a 'url' field"},
"unsafe url": {`[{"url":"javascript:alert(1)"}]`, "Entry 0 has a url that is not a path or an http(s) URL"},
"bad lastmod": {`[{"url":"/a","lastmod":"soon"}]`, "Entry 0 has a lastmod that is not a date"},
"not a list": {`5`, "Invalid JSON"},
} {
file := writeFile(t, dir, strings.ReplaceAll(name, " ", "-")+".json", c.body)
if err := console.ImportFile(ctx, e.gdb, file, "", false, out); err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("%s: %v", name, err)
}
}
if got := extraURLs(e.storedExtra(t)); got != "/x /y https://www.example.test/z" {
t.Fatalf("a refused import changed the addresses: %q", got)
}
}