- 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
665 lines
27 KiB
Go
665 lines
27 KiB
Go
package sitemap_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
sitemap "git.golem15.com/golem15/sm-sitemap-plugin"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/console"
|
|
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/signpost"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// testTypesPluginID is the fake content plugin that registers menu item
|
|
// types for the sitemap smoke tests. The real Journal and sitemap
|
|
// combination is proven by the host application's tests.
|
|
const testTypesPluginID = "acme.sitemaptypes"
|
|
|
|
// acmeTypes is a content plugin with one menu item type, acme-post, whose
|
|
// reference "7" resolves to a fixed post with two alternates.
|
|
type acmeTypes struct{}
|
|
|
|
func (acmeTypes) ID() string { return testTypesPluginID }
|
|
func (acmeTypes) Requires() []string { return nil }
|
|
func (acmeTypes) Register(*backpack.App) error { return nil }
|
|
func (acmeTypes) Boot(*backpack.App) error { return nil }
|
|
func (acmeTypes) MenuItemTypes() []pact.MenuItemType {
|
|
mtime := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
|
|
return []pact.MenuItemType{acmeList(), {
|
|
Key: "acme-post",
|
|
Label: "Acme post",
|
|
DefaultPattern: "/news/:slug",
|
|
References: func(context.Context) ([]pact.MenuItemReference, error) {
|
|
return []pact.MenuItemReference{{Value: "7", Label: "Hello"}}, nil
|
|
},
|
|
Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
if in.Reference != "7" {
|
|
return nil, nil
|
|
}
|
|
return &pact.ResolvedMenuItem{
|
|
Title: "Hello",
|
|
URL: "/news/hello",
|
|
MTime: &mtime,
|
|
Alternates: []pact.MenuItemAlternate{
|
|
{Locale: "pl", URL: "/news/hello"},
|
|
{Locale: "en", URL: "/en/news/hello"},
|
|
},
|
|
}, nil
|
|
},
|
|
}}
|
|
}
|
|
|
|
// acmeList is a list type of the fake plugin: no URL of its own, one post
|
|
// with two alternates as its item.
|
|
func acmeList() pact.MenuItemType {
|
|
return pact.MenuItemType{
|
|
Key: "acme-list",
|
|
Label: "Acme posts",
|
|
DefaultPattern: "/news/:slug",
|
|
PatternFrom: "acme-post",
|
|
DynamicItems: true,
|
|
Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
|
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{
|
|
Title: "A",
|
|
URL: "/news/a",
|
|
Alternates: []pact.MenuItemAlternate{
|
|
{Locale: "pl", URL: "/news/a"},
|
|
{Locale: "en", URL: "/en/news/a"},
|
|
},
|
|
}}}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
var _ pact.HasMenuItemTypes = acmeTypes{}
|
|
|
|
func init() {
|
|
party.Register(acmeTypes{})
|
|
}
|
|
|
|
// sitemapEnv is a booted application with the sitemap and the fake types.
|
|
type sitemapEnv struct {
|
|
h http.Handler
|
|
app *backpack.App
|
|
gdb *gorm.DB
|
|
}
|
|
|
|
// bootSitemap boots the application. The sitemap cache is off
|
|
// (golem15.sitemap.cache_seconds 0): the smoke tests change listeners and
|
|
// registry content between requests. overrides set further config keys.
|
|
func bootSitemap(t *testing.T, overrides ...map[string]any) *sitemapEnv {
|
|
t.Helper()
|
|
sqlDB := freshDB(t)
|
|
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: sitemap-smoke\nlocale: en\nfallback_locale: en\nurl: https://api.example.test\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := compass.Open(compass.Options{
|
|
Dir: dir,
|
|
Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=sitemap-smoke-test-only-hs256-secret-key"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := map[string]any{
|
|
"http.body_limits.default_bytes": 1048576,
|
|
"http.body_limits.upload_bytes": 8388608,
|
|
"mail.driver": "log",
|
|
"mail.from": "noreply@example.test",
|
|
sitemap.CacheConfigKey: 0,
|
|
}
|
|
for _, o := range overrides {
|
|
for key, value := range o {
|
|
settings[key] = value
|
|
}
|
|
}
|
|
for key, value := range settings {
|
|
if err := cfg.Set(key, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugins, err := party.Activate(app, []string{testTypesPluginID, "golem15.sitemap"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := surf.Assemble(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &sitemapEnv{h: h, app: app, gdb: gdb}
|
|
}
|
|
|
|
func (e *sitemapEnv) get(t *testing.T, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
req.Host = "api.example.test"
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func (e *sitemapEnv) storeDefinition(t *testing.T, items []models.DefinitionItem) models.Definition {
|
|
t.Helper()
|
|
theme := models.DefinitionTheme
|
|
def := models.Definition{Theme: &theme}
|
|
def.SetItems(items)
|
|
if err := e.gdb.Create(&def).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return def
|
|
}
|
|
|
|
func text(s string) *string { return &s }
|
|
|
|
// TestSitemapCacheAndThrottle pins WR-05: with the cache on, a repeated GET
|
|
// is served from memory (a listener added later does not run), a save of
|
|
// the definition is served at once, and the 61st request of a client within
|
|
// a minute is a 429.
|
|
func TestSitemapCacheAndThrottle(t *testing.T) {
|
|
e := bootSitemap(t, map[string]any{sitemap.CacheConfigKey: 60})
|
|
def := e.storeDefinition(t, []models.DefinitionItem{
|
|
{Type: models.ItemTypeURL, URL: text("/"), Changefreq: text("always"), Priority: text("1.0")},
|
|
{Type: "acme-post", Reference: text("7"), URLPattern: text(""), Nesting: true, Changefreq: text("weekly"), Priority: text("0.5")},
|
|
})
|
|
requests := 0
|
|
get := func() *httptest.ResponseRecorder {
|
|
requests++
|
|
return e.get(t, "/sitemap.xml")
|
|
}
|
|
first := get()
|
|
if first.Code != http.StatusOK || !strings.Contains(first.Body.String(), "<loc>https://api.example.test/</loc>") {
|
|
t.Fatalf("first GET: %d %s", first.Code, first.Body.String())
|
|
}
|
|
e.app.Events.Listen("acme.skipall", func(_ context.Context, ev *classes.BeforeAddItem) error {
|
|
ev.Skip = true
|
|
return nil
|
|
})
|
|
if again := get(); again.Code != http.StatusOK || again.Body.String() != first.Body.String() {
|
|
t.Fatalf("a repeated GET was generated again:\n%s", again.Body.String())
|
|
}
|
|
def.SetItems(append(def.Items(), models.DefinitionItem{Type: models.ItemTypeURL, URL: text("/x")}))
|
|
if err := e.gdb.Save(&def).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if saved := get(); saved.Code != http.StatusOK || strings.Contains(saved.Body.String(), "<url>") {
|
|
t.Fatalf("a saved definition was served from the cache:\n%s", saved.Body.String())
|
|
}
|
|
for requests < 60 {
|
|
if rec := get(); rec.Code != http.StatusOK {
|
|
t.Fatalf("request %d: %d", requests, rec.Code)
|
|
}
|
|
}
|
|
if rec := get(); rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("request %d: %d, want 429", requests, rec.Code)
|
|
}
|
|
if rec := e.get(t, "/sitemap.xsl"); rec.Code != http.StatusOK {
|
|
t.Fatalf("/sitemap.xsl is throttled with the sitemap: %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
// TestSitemapTracer stores a definition with a url item and a registry
|
|
// item, GETs /sitemap.xml and compares the body with the PHP golden.
|
|
func TestSitemapTracer(t *testing.T) {
|
|
e := bootSitemap(t)
|
|
|
|
if rec := e.get(t, "/sitemap.xml"); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("no definition: %d, want 404", rec.Code)
|
|
}
|
|
|
|
def := e.storeDefinition(t, nil)
|
|
rec := e.get(t, "/sitemap.xml")
|
|
if rec.Code != http.StatusOK || rec.Body.Len() != 0 || rec.Header().Get("Content-Type") != "application/xml" {
|
|
t.Fatalf("empty definition: %d %q %q", rec.Code, rec.Header().Get("Content-Type"), rec.Body.String())
|
|
}
|
|
|
|
def.SetItems([]models.DefinitionItem{
|
|
{Type: models.ItemTypeURL, URL: text("/"), Changefreq: text("always"), Priority: text("1.0")},
|
|
{Type: "acme-post", Reference: text("7"), URLPattern: text(""), Nesting: true, Changefreq: text("weekly"), Priority: text("0.5")},
|
|
})
|
|
if err := e.gdb.Save(&def).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec = e.get(t, "/sitemap.xml")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /sitemap.xml: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/xml" {
|
|
t.Fatalf("Content-Type = %q", ct)
|
|
}
|
|
want, err := os.ReadFile(filepath.Join("classes", "testdata", "tracer.golden"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := rec.Body.String(); got != string(want) {
|
|
t.Fatalf("/sitemap.xml differs from the PHP golden\n got: %q\nwant: %q", got, want)
|
|
}
|
|
|
|
// The stored data keeps the PHP item keys.
|
|
var raw string
|
|
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, def.ID).Scan(&raw).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":null},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":null,"reference":"7","cmsPage":null,"urlPattern":""}]`
|
|
if raw != wantData {
|
|
t.Fatalf("stored data = %s", raw)
|
|
}
|
|
}
|
|
|
|
func writeFile(t *testing.T, dir, name, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func testOutput() (bonfire.Output, *bytes.Buffer) {
|
|
var buf bytes.Buffer
|
|
return bonfire.NewOutput(strings.NewReader(""), &buf, &buf), &buf
|
|
}
|
|
|
|
// TestSitemapImportSmoke copies a Winter export through sitemap:import-winter,
|
|
// then checks the frontend_url rewrite, the stylesheet route, a
|
|
// BeforeAddItem listener and sitemap:import.
|
|
func TestSitemapImportSmoke(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()
|
|
data := `[{"changefreq":"always","priority":"1.0","nesting":"0","type":"url","url":"/","code":null,"reference":null,"cmsPage":null,"urlPattern":null,"exists":false,"fillable":["changefreq"]},` +
|
|
`{"changefreq":"monthly","priority":"0.5","nesting":"0","type":"static-page","url":null,"reference":"about","cmsPage":null,"urlPattern":null},` +
|
|
`{"changefreq":"weekly","priority":"0.4","nesting":"1","type":"blog-posts","url":null,"reference":null,"cmsPage":"blog/post","urlPattern":"/news/:slug"},` +
|
|
`{"changefreq":"weekly","priority":"0.4","nesting":"0","type":"acme-post","url":null,"reference":"99","cmsPage":null,"urlPattern":null},` +
|
|
`{"changefreq":"daily","priority":"0.7","nesting":"false","type":"cms-page","url":"","reference":"news","cmsPage":"","urlPattern":""}]`
|
|
quotedRaw, _ := json.Marshal(data)
|
|
quoted := string(quotedRaw)
|
|
defs := writeFile(t, dir, "definitions.jsonl", `{"id": 3, "theme": "site-theme", "data": `+quoted+`, "created_at": "2026-01-01 10:00:00", "updated_at": "2026-01-02 10:00:00"}`+"\n")
|
|
settings := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test/","frontend_default_locale":"pl","enable_priority_tiers":"0","priority_homepage":"0.9","legacy_key":"x"}`)
|
|
|
|
out, buf := testOutput()
|
|
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{
|
|
DefinitionsFile: defs, SettingsFile: settings, Maps: map[string]string{"blog-posts": "acme-list"},
|
|
PageURLs: map[string]string{"news": "/news"},
|
|
}, out)
|
|
if err != nil {
|
|
t.Fatalf("import-winter: %v\n%s", err, buf.String())
|
|
}
|
|
if report.Kept != 3 || report.Dropped != 2 || report.Remapped != 2 || !report.Settings {
|
|
t.Fatalf("report = %+v\n%s", report, buf.String())
|
|
}
|
|
for _, want := range []string{
|
|
"item 1 (type 'static-page', reference 'about'): dropped (static and CMS page types are not supported)",
|
|
"item 2 (type 'blog-posts'): type 'blog-posts' mapped to 'acme-list'",
|
|
"item 2 (type 'blog-posts'): cmsPage 'blog/post' dropped",
|
|
"item 3 (type 'acme-post', reference '99'): dropped (reference '99' is not among the current references of 'acme-post')",
|
|
"item 4 (type 'cms-page', reference 'news'): copied as type 'url' with url '/news'",
|
|
"setting 'legacy_key' ignored",
|
|
"theme 'site-theme'",
|
|
} {
|
|
if !strings.Contains(buf.String(), want) {
|
|
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
|
}
|
|
}
|
|
var def models.Definition
|
|
if err := e.gdb.Where("theme = ?", models.DefinitionTheme).First(&def).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if items := def.Items(); len(items) != 3 || items[0].Type != "url" || items[1].Type != "acme-list" || models.Text(items[1].URLPattern) != "/news/:slug" ||
|
|
items[2].Type != "url" || models.Text(items[2].URL) != "/news" || items[2].Reference != nil {
|
|
t.Fatalf("copied items = %+v", items)
|
|
}
|
|
var st models.Settings
|
|
if err := e.gdb.First(&st, 1).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if models.Text(st.FrontendURL) != "https://www.example.test/" || st.EnablePriorityTiers || st.Priority(models.PriorityHomepage) != "0.9" || st.Priority(models.PriorityBlog) != "0.6" {
|
|
t.Fatalf("copied settings = %+v", st)
|
|
}
|
|
|
|
// A second copy without --force is refused.
|
|
out, buf = testOutput()
|
|
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{DefinitionsFile: defs}, out); err == nil || !strings.Contains(err.Error(), "--force") {
|
|
t.Fatalf("second import: %v\n%s", err, buf.String())
|
|
}
|
|
|
|
// The frontend URL replaces the backend URL in loc, hreflang and the
|
|
// stylesheet href; the list type expands per alternate.
|
|
rec := e.get(t, "/sitemap.xml")
|
|
body := rec.Body.String()
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-cache, private" {
|
|
t.Fatalf("GET /sitemap.xml: %d %v", rec.Code, rec.Header())
|
|
}
|
|
for _, want := range []string{
|
|
`href="https://www.example.test/sitemap.xsl"`,
|
|
`<loc>https://www.example.test/</loc>`,
|
|
`<loc>https://www.example.test/news</loc>`,
|
|
`<loc>https://www.example.test/news/a</loc>`,
|
|
`<loc>https://www.example.test/en/news/a</loc>`,
|
|
`hreflang="en" href="https://www.example.test/en/news/a"/>`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("sitemap lacks %s:\n%s", want, body)
|
|
}
|
|
}
|
|
if strings.Contains(body, "api.example.test") {
|
|
t.Errorf("the backend URL is left in the sitemap:\n%s", body)
|
|
}
|
|
|
|
// /sitemap.xsl answers the PHP file.
|
|
rec = e.get(t, "/sitemap.xsl")
|
|
want, err := os.ReadFile(filepath.Join("assets", "sitemap.xsl"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/xsl; charset=UTF-8" || !bytes.Equal(rec.Body.Bytes(), want) {
|
|
t.Fatalf("GET /sitemap.xsl: %d %q (%d bytes)", rec.Code, rec.Header().Get("Content-Type"), rec.Body.Len())
|
|
}
|
|
|
|
// A BeforeAddItem listener that sets Skip drops its URL.
|
|
e.app.Events.Listen("acme.skip", func(_ context.Context, ev *classes.BeforeAddItem) error {
|
|
if ev.Info.URL == "/en/news/a" {
|
|
ev.Skip = true
|
|
}
|
|
return nil
|
|
})
|
|
body = e.get(t, "/sitemap.xml").Body.String()
|
|
if strings.Contains(body, "<loc>https://www.example.test/en/news/a</loc>") || !strings.Contains(body, "<loc>https://www.example.test/news/a</loc>") {
|
|
t.Fatalf("the skipped URL is still written:\n%s", body)
|
|
}
|
|
|
|
// sitemap:import replaces the url items and keeps the dynamic ones first.
|
|
manifest := writeFile(t, dir, "urls.json", `[{"url":"/x","changefreq":"daily"},{"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)
|
|
}
|
|
if err := e.gdb.Where("theme = ?", models.DefinitionTheme).First(&def).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items := def.Items()
|
|
if len(items) != 3 || items[0].Type != "acme-list" || models.Text(items[1].URL) != "/x" || models.Text(items[1].Priority) != "0.5" || models.Text(items[2].Changefreq) != "monthly" {
|
|
t.Fatalf("imported items = %+v", items)
|
|
}
|
|
if !strings.Contains(buf.String(), "Imported 2 URLs into sitemap definition for theme 'default'.") || !strings.Contains(buf.String(), "Kept 1 existing non-URL (dynamic) items.") {
|
|
t.Fatalf("sitemap:import output:\n%s", buf.String())
|
|
}
|
|
bad := writeFile(t, dir, "bad.json", `[{"url":"/x"},{"changefreq":"daily"}]`)
|
|
if err := console.ImportFile(ctx, e.gdb, bad, "", true, out); err == nil || err.Error() != "Entry 1 is missing a 'url' field" {
|
|
t.Fatalf("missing url: %v", err)
|
|
}
|
|
|
|
// T-17-15: a frontend URL written straight into the row (one stored
|
|
// before the save check existed) is not applied; the sitemap keeps the
|
|
// backend URL and none of the value reaches it.
|
|
for _, value := range []string{"javascript:alert(document.domain)//", `https://x"/><y a="`} {
|
|
if err := e.gdb.Model(&models.Settings{}).Where("id = ?", 1).Update("frontend_url", value).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := e.get(t, "/sitemap.xml")
|
|
body := rec.Body.String()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /sitemap.xml with frontend_url %q: %d %s", value, rec.Code, body)
|
|
}
|
|
if lower := strings.ToLower(body); strings.Contains(lower, "javascript") || strings.Contains(lower, "<y a=") {
|
|
t.Errorf("frontend_url %q reached the sitemap:\n%s", value, body)
|
|
}
|
|
if !strings.Contains(body, `href="https://api.example.test/sitemap.xsl"`) || !strings.Contains(body, "<loc>https://api.example.test/x</loc>") {
|
|
t.Errorf("frontend_url %q: the sitemap is not on the backend URL:\n%s", value, body)
|
|
}
|
|
if strings.Contains(body, "www.example.test") {
|
|
t.Errorf("frontend_url %q: the earlier frontend URL is left in the sitemap:\n%s", value, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
var adminSeq atomic.Uint64
|
|
|
|
// call sends an admin API request with an optional bearer token and JSON
|
|
// body.
|
|
func (e *sitemapEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req := httptest.NewRequest(method, cabana.DefaultAdminPrefix+"/api/v1"+path, reader)
|
|
req.Host = "api.example.test"
|
|
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1)
|
|
req.Header.Set("Accept-Language", "en")
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// admin signs in a new administrator whose role grants permissions.
|
|
func (e *sitemapEnv) admin(t *testing.T, permissions ...string) string {
|
|
t.Helper()
|
|
stamp := fmt.Sprintf("%d%d", time.Now().UnixNano()%1_000_000_000, adminSeq.Add(1))
|
|
login, password := "admin-"+stamp, "admin-secret-"+stamp
|
|
hash, err := bouncer.HashPassword(10, password)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
grants := map[string]int{}
|
|
for _, code := range permissions {
|
|
grants[code] = 1
|
|
}
|
|
raw, _ := json.Marshal(grants)
|
|
var roleID uint
|
|
if err := e.gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at) VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`,
|
|
"Role "+stamp, "role-"+stamp, string(raw)).Scan(&roleID).Error; err != nil || roleID == 0 {
|
|
t.Fatalf("backend role: id=%d err=%v", roleID, err)
|
|
}
|
|
if err := e.gdb.Exec(`INSERT INTO backend_users (login, email, password, is_activated, is_superuser, role_id, created_at, updated_at) VALUES (?, ?, ?, TRUE, FALSE, ?, NOW(), NOW())`,
|
|
login, login+"@example.test", hash, roleID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rec := e.call(t, http.MethodPost, "/auth/login", "", map[string]string{"login": login, "password": password})
|
|
var body struct {
|
|
Data struct {
|
|
AccessToken string `json:"access_token"`
|
|
} `json:"data"`
|
|
}
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.AccessToken == "" {
|
|
t.Fatalf("admin login: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
return body.Data.AccessToken
|
|
}
|
|
|
|
// TestSitemapAdminSmoke drives the definition singleton, the item repeater
|
|
// with its dependent reference, the settings form and the permission.
|
|
func TestSitemapAdminSmoke(t *testing.T) {
|
|
e := bootSitemap(t)
|
|
const perm = "golem15.sitemap.access_definitions"
|
|
token := e.admin(t, perm)
|
|
const base = "/golem15/sitemap/definitions"
|
|
|
|
// The singleton route creates the definition once.
|
|
var ids []float64
|
|
for range 2 {
|
|
rec := e.call(t, http.MethodGet, base+"/singleton", token, nil)
|
|
var body struct {
|
|
Data struct {
|
|
ID float64 `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Data.ID == 0 {
|
|
t.Fatalf("GET singleton: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
ids = append(ids, body.Data.ID)
|
|
}
|
|
var n int64
|
|
e.gdb.Model(&models.Definition{}).Count(&n)
|
|
if ids[0] != ids[1] || n != 1 {
|
|
t.Fatalf("singleton ids %v, %d rows", ids, n)
|
|
}
|
|
id := fmt.Sprintf("%d", int(ids[0]))
|
|
|
|
// The form schema compiles: a singleton with the type options and the
|
|
// dependent reference.
|
|
rec := e.call(t, http.MethodGet, base+"/schema/form", token, nil)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"singleton":true`) || !strings.Contains(rec.Body.String(), `"acme-post"`) || !strings.Contains(rec.Body.String(), `"dependsOn":"type"`) {
|
|
t.Fatalf("form schema: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
rec = e.call(t, http.MethodGet, base+"/fields/data.reference/dependent-options?values%5Btype%5D=acme-post", token, nil)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"value":"7"`) {
|
|
t.Fatalf("dependent options: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Saving rows stores PHP-keyed item JSON.
|
|
rows := []map[string]any{
|
|
{"type": "url", "url": "/", "reference": "", "urlPattern": "", "priority": "1.0", "changefreq": "always", "nesting": false},
|
|
{"type": "acme-post", "url": "", "reference": "7", "urlPattern": "/n/:slug", "priority": "0.5", "changefreq": "weekly", "nesting": true},
|
|
}
|
|
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT rows: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var raw string
|
|
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const wantData = `[{"changefreq":"always","priority":"1.0","nesting":false,"type":"url","url":"/","reference":null,"cmsPage":null,"urlPattern":""},{"changefreq":"weekly","priority":"0.5","nesting":true,"type":"acme-post","url":"","reference":"7","cmsPage":null,"urlPattern":"/n/:slug"}]`
|
|
if raw != wantData {
|
|
t.Fatalf("stored data = %s", raw)
|
|
}
|
|
|
|
// A reference not offered for its row type is a 422 on the row.
|
|
rows[1]["reference"] = "99"
|
|
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": rows})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "data.1.reference") {
|
|
t.Fatalf("PUT unknown reference: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// A missing reference for a type that takes one (WR-03), a javascript:
|
|
// URL and a protocol-relative pattern (CR-01) are 422s on their rows.
|
|
for _, c := range []struct {
|
|
row int
|
|
key, value string
|
|
field string
|
|
message string
|
|
}{
|
|
{1, "reference", "", "data.1.reference", "golem15.sitemap::lang.item.reference_required"},
|
|
{1, "reference", " ", "data.1.reference", ""},
|
|
{0, "url", "javascript://%0aalert(1)%0a//x", "data.0.url", "golem15.sitemap::lang.errors.url_scheme"},
|
|
{1, "urlPattern", "//evil.test/:slug", "data.1.urlPattern", "golem15.sitemap::lang.errors.url_pattern"},
|
|
} {
|
|
bad := []map[string]any{{}, {}}
|
|
for i := range rows {
|
|
for k, v := range rows[i] {
|
|
bad[i][k] = v
|
|
}
|
|
}
|
|
bad[1]["reference"] = "7"
|
|
bad[c.row][c.key] = c.value
|
|
rec = e.call(t, http.MethodPut, base+"/"+id, token, map[string]any{"data": bad})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+c.field+`"`) {
|
|
t.Fatalf("PUT %s=%q: %d %s", c.key, c.value, rec.Code, rec.Body.String())
|
|
}
|
|
if c.message != "" && !strings.Contains(rec.Body.String(), c.message) {
|
|
t.Fatalf("PUT %s=%q: want %s: %s", c.key, c.value, c.message, rec.Body.String())
|
|
}
|
|
}
|
|
if err := e.gdb.Raw(`SELECT data FROM golem15_sitemap_definitions WHERE id = ?`, id).Scan(&raw).Error; err != nil || raw != wantData {
|
|
t.Fatalf("a refused save changed the data: %s %v", raw, err)
|
|
}
|
|
|
|
// The settings form saves the frontend URL.
|
|
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "https://www.example.test", "enable_priority_tiers": true})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT settings: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var st models.Settings
|
|
if err := e.gdb.First(&st, 1).Error; err != nil || models.Text(st.FrontendURL) != "https://www.example.test" {
|
|
t.Fatalf("settings = %+v %v", st, err)
|
|
}
|
|
|
|
// T-17-15: a frontend URL that is not a plain http(s) URL is a 422 on
|
|
// frontend_url and the stored row keeps its value.
|
|
for _, value := range []string{"javascript:alert(document.domain)//", `https://x"/><y a="`, "https://www.example.test "} {
|
|
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": value, "enable_priority_tiers": true})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"frontend_url"`) || !strings.Contains(rec.Body.String(), "golem15.sitemap::lang.errors.frontend_url") {
|
|
t.Fatalf("PUT settings frontend_url=%q: %d %s", value, rec.Code, rec.Body.String())
|
|
}
|
|
var kept models.Settings
|
|
if err := e.gdb.First(&kept, 1).Error; err != nil || models.Text(kept.FrontendURL) != "https://www.example.test" {
|
|
t.Fatalf("a refused save of frontend_url=%q changed the settings: %+v %v", value, kept, err)
|
|
}
|
|
}
|
|
|
|
rec = e.call(t, http.MethodGet, "/settings", token, nil)
|
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"controller":"golem15.sitemap.definitions"`) {
|
|
t.Fatalf("settings index: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// An empty frontend URL saves: the sitemap keeps the backend URL.
|
|
rec = e.call(t, http.MethodPut, "/settings/sitemap_settings", token, map[string]any{"frontend_url": "", "enable_priority_tiers": true})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("PUT settings with an empty frontend_url: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var cleared models.Settings
|
|
if err := e.gdb.First(&cleared, 1).Error; err != nil || models.Text(cleared.FrontendURL) != "" {
|
|
t.Fatalf("settings after an empty frontend_url = %+v %v", cleared, err)
|
|
}
|
|
|
|
// Without the permission every route answers 403.
|
|
other := e.admin(t)
|
|
for _, c := range []struct{ method, path string }{
|
|
{http.MethodGet, base + "/singleton"},
|
|
{http.MethodPut, base + "/" + id},
|
|
{http.MethodGet, "/settings/sitemap_settings"},
|
|
{http.MethodPut, "/settings/sitemap_settings"},
|
|
} {
|
|
if rec := e.call(t, c.method, c.path, other, map[string]any{}); rec.Code != http.StatusForbidden {
|
|
t.Errorf("%s %s without permission: %d %s", c.method, c.path, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|