Files
sm-sitemap-plugin/classes/generator_test.go
Jakub Zych c42bc265c8 fix(17): CR-01 refuse non-http sitemap URLs on save, import and output
A url item stored as javascript://%0aalert(1)%0a//x passed Url::to and
reached <loc>, which sitemap.xsl renders as a link on the site origin.

- The definitions form refuses, per row, a url that is not a path or an
  http(s) URL (classes.AllowedItemURL) and a urlPattern that is not a
  path (signpost.ValidItemPattern), keyed data.<i>.url / .urlPattern.
- Generator.addItem drops a loc or alternate that is not http(s).
- sitemap:import-winter drops such url items and clears such patterns
  with a report line; sitemap:import refuses such an entry.
2026-10-12 21:28:28 +02:00

184 lines
7.2 KiB
Go

package classes_test
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
)
func urlItems(paths ...string) []models.DefinitionItem {
out := make([]models.DefinitionItem, 0, len(paths))
for _, p := range paths {
out = append(out, models.DefinitionItem{Type: models.ItemTypeURL, URL: text(p), Changefreq: text("daily"), Priority: text("0.5")})
}
return out
}
// TestHooks pins the typed successors of the PHP events (D-14): a
// BeforeAddItem listener that sets Skip drops the URL before it is counted,
// the first skipping listener stops the rest, AddItem listeners see the
// written loc and their Extra is written before the url closing tag in
// order, and a listener error stops the generation.
func TestHooks(t *testing.T) {
bus := festival.New()
var seenBefore, seenAdd, afterSkip []string
bus.Listen("acme.first", func(_ context.Context, e *classes.BeforeAddItem) error {
seenBefore = append(seenBefore, e.Info.URL)
if strings.HasSuffix(e.Info.URL, "/skip") {
e.Skip = true
}
return nil
})
bus.Listen("acme.second", func(_ context.Context, e *classes.BeforeAddItem) error {
afterSkip = append(afterSkip, e.Info.URL)
return nil
})
bus.Listen("acme.extra", func(_ context.Context, e *classes.AddItem) error {
seenAdd = append(seenAdd, e.Info.URL)
e.Extra = append(e.Extra, "<image:image><image:loc>"+classes.EscapeText(e.Info.URL+"?a&b")+"</image:loc></image:image>")
return nil
})
bus.Listen("acme.more", func(_ context.Context, e *classes.AddItem) error {
e.Extra = append(e.Extra, "<x/>")
return nil
})
gen := &classes.Generator{Root: "https://h.test", Events: bus}
xml, err := gen.Generate(context.Background(), urlItems("/a", "/skip", "/b"))
if err != nil {
t.Fatal(err)
}
if strings.Join(seenBefore, ",") != "https://h.test/a,https://h.test/skip,https://h.test/b" {
// The url item's URL is filled through Url::to before the event.
t.Fatalf("BeforeAddItem saw %v", seenBefore)
}
if strings.Join(afterSkip, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("a listener after the skipping one ran for the skipped URL: %v", afterSkip)
}
if strings.Contains(xml, "/skip") {
t.Fatalf("the skipped URL is written:\n%s", xml)
}
if strings.Join(seenAdd, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("AddItem saw %v", seenAdd)
}
want := "<url><loc>https://h.test/a</loc><changefreq>daily</changefreq><priority>0.5</priority><image:image><image:loc>https://h.test/a?a&amp;b</image:loc></image:image><x/></url>"
if !strings.Contains(xml, want) {
t.Fatalf("extra not written before the closing tag:\n%s", xml)
}
// A skip on the exact URL the event carries.
bus2 := festival.New()
var second []string
bus2.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/skip")
return nil
})
bus2.Listen("acme.later", func(_ context.Context, e *classes.BeforeAddItem) error {
second = append(second, e.Info.URL)
return nil
})
gen = &classes.Generator{Root: "https://h.test", Events: bus2}
xml, err = gen.Generate(context.Background(), urlItems("/a", "/skip"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(xml, "/skip<") || strings.Join(second, ",") != "https://h.test/a" {
t.Fatalf("UntilHandled did not stop at the skipping listener: %v\n%s", second, xml)
}
// Without listeners or a bus nothing changes.
gen = &classes.Generator{Root: "https://h.test"}
if xml, err := gen.Generate(context.Background(), urlItems("/a")); err != nil || !strings.Contains(xml, "<loc>https://h.test/a</loc>") {
t.Fatalf("no bus: %v\n%s", err, xml)
}
// A listener error stops the generation.
failing := festival.New()
failing.Listen("acme.fail", func(_ context.Context, e *classes.AddItem) error { return errors.New("listener failed") })
gen = &classes.Generator{Root: "https://h.test", Events: failing}
if _, err := gen.Generate(context.Background(), urlItems("/a")); err == nil || !strings.Contains(err.Error(), "listener failed") {
t.Fatalf("listener error = %v", err)
}
}
// TestURLCap pins the protocol limit: of 50,001 URLs the 50,000th is
// written and the 50,001st is not; skipped and empty URLs are not counted;
// no items is an empty document.
func TestURLCap(t *testing.T) {
paths := make([]string, classes.MaxURLs+1)
for i := range paths {
paths[i] = "/u/" + strconv.Itoa(i+1)
}
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
t.Fatalf("%d url elements, want %d", n, classes.MaxURLs)
}
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "<loc>https://api.example.test/u/50001</loc>") {
t.Fatal("the 50000th URL must be written and the 50001st must not")
}
// Skipped URLs do not count toward the cap.
bus := festival.New()
bus.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/u/1")
return nil
})
gen = &classes.Generator{Root: "https://api.example.test", Events: bus}
xml, err = gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if strings.Count(xml, "<url>") != classes.MaxURLs || !strings.Contains(xml, "/u/50001</loc>") {
t.Fatal("a skipped URL took a place under the cap")
}
// A generator is reusable: a second run starts its count again.
if again, err := gen.Generate(context.Background(), urlItems("/x")); err != nil || strings.Count(again, "<url>") != 1 {
t.Fatalf("second run = %v, %d urls", err, strings.Count(again, "<url>"))
}
if empty, err := (&classes.Generator{Root: "https://h"}).Generate(context.Background(), nil); err != nil || empty != "" {
t.Fatalf("no items = %q %v", empty, err)
}
}
// TestOnlyHTTPLocs pins the CR-01 output filter: a URL that Url::to keeps
// but that is not http or https (a javascript: URL FILTER_VALIDATE_URL
// accepts, mailto:, a bare fragment) is not written and not counted. A
// protocol-relative URL comes out of Url::to on the root host.
func TestOnlyHTTPLocs(t *testing.T) {
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), urlItems(
"javascript://%0aalert(1)%0a//x",
"JavaScript://example.test/%0aalert(1)",
"//evil.test/x",
"mailto:a@example.test",
"#top",
"/ok",
"https://other.example.test/page",
))
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != 3 {
t.Fatalf("%d url elements, want 3:\n%s", n, xml)
}
if !strings.Contains(xml, "<loc>https://api.example.test/evil.test/x</loc>") {
t.Fatalf("the protocol-relative URL did not stay on the root host:\n%s", xml)
}
if strings.Contains(strings.ToLower(xml), "javascript") || strings.Contains(xml, "mailto:") || strings.Contains(xml, "#top") {
t.Fatalf("a non-http URL was written:\n%s", xml)
}
if !strings.Contains(xml, "<loc>https://api.example.test/ok</loc>") || !strings.Contains(xml, "<loc>https://other.example.test/page</loc>") {
t.Fatalf("an http(s) URL is missing:\n%s", xml)
}
}