feat: sitemap settings singleton with content types and additional addresses

- one settings tile opening the golem15.sitemap.settings singleton
- Files tab (site address, file size, S1 files partial), Additional
  addresses repeater, Content types tab built from signpost at boot
- golem15_sitemap_types; a type switched off leaves the files (reason type)
- a save validates (errors.*) and dispatches a rebuild after commit
This commit is contained in:
Jakub Zych
2026-11-04 11:06:29 +01:00
parent 3359f1a196
commit 3ba70bb69f
20 changed files with 1304 additions and 337 deletions

View File

@@ -10,9 +10,6 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// cabana calls the hook on a settings save through this interface.
var _ lagoon.HasBeforeValidate = (*models.Settings)(nil)
// TestAllowedFrontendURL pins the one definition of a frontend_url that may
// be saved, imported and applied (T-17-15): empty, or an absolute http(s)
// URL with a host and without whitespace, control characters, a double
@@ -67,31 +64,59 @@ func TestAllowedFrontendURL(t *testing.T) {
}
}
// TestSettingsBeforeValidate pins the save check of T-17-15: the hook cabana
// calls on a settings save passes a null, empty or accepted frontend_url and
// answers a validation error keyed frontend_url for any other value.
func TestSettingsBeforeValidate(t *testing.T) {
// TestSettingsValidate pins the save checks of the settings singleton
// (UI-SPEC C26, T-33-12): the check the controller runs before a save passes
// a null, empty or accepted site_url, a file size from 1 to 50 000 and
// additional addresses that are paths or http(s) URLs, and answers a
// validation error keyed by the field for any other value.
func TestSettingsValidate(t *testing.T) {
text := func(s string) *string { return &s }
for _, url := range []*string{nil, text(""), text("https://www.ex.pl/")} {
if err := (&models.Settings{FrontendURL: url}).BeforeValidate(nil); err != nil {
t.Errorf("BeforeValidate(%q) = %v, want nil", models.Text(url), err)
ok := func(s models.Settings) models.Settings {
if s.FileSize == 0 {
s.FileSize = 1000
}
return s
}
extra := func(urls ...string) lagoon.Jsonable[[]models.ExtraURL] {
rows := []models.ExtraURL{}
for _, u := range urls {
rows = append(rows, models.ExtraURL{URL: u})
}
return lagoon.Jsonable[[]models.ExtraURL]{Data: rows, Valid: true}
}
for name, s := range map[string]models.Settings{
"null site_url": ok(models.Settings{}),
"empty site_url": ok(models.Settings{SiteURL: text("")}),
"site_url": ok(models.Settings{SiteURL: text("https://www.ex.pl/")}),
"file size 1": {FileSize: 1},
"file size 50 000": {FileSize: 50000},
"extra addresses": ok(models.Settings{ExtraURLs: extra("/", "/o-nas?x=1&y=2", "https://www.ex.pl/kontakt")}),
} {
if err := s.Validate(); err != nil {
t.Errorf("%s: Validate = %v, want nil", name, err)
}
}
want := map[string]any{"frontend_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}
for _, url := range []string{
"javascript:alert(document.domain)//",
`https://x"/><y a="`,
"0",
" https://www.ex.pl",
for name, c := range map[string]struct {
s models.Settings
want map[string]any
}{
"javascript site_url": {ok(models.Settings{SiteURL: text("javascript:alert(document.domain)//")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"attribute site_url": {ok(models.Settings{SiteURL: text(`https://x"/><y a="`)}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"path site_url": {ok(models.Settings{SiteURL: text("/x")}), map[string]any{"site_url": []string{"golem15.sitemap::lang.errors.frontend_url"}}},
"file size 0": {models.Settings{}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
"file size 50 001": {models.Settings{FileSize: 50001}, map[string]any{"file_size": []string{"golem15.sitemap::lang.errors.file_size"}}},
"javascript address": {ok(models.Settings{ExtraURLs: extra("/", "javascript:alert(1)")}), map[string]any{"extra_urls.1.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
"protocol-relative": {ok(models.Settings{ExtraURLs: extra("//evil.test/x")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
"address with a space": {ok(models.Settings{ExtraURLs: extra("/a b")}), map[string]any{"extra_urls.0.url": []string{"golem15.sitemap::lang.errors.url_scheme"}}},
} {
err := (&models.Settings{FrontendURL: &url}).BeforeValidate(nil)
err := c.s.Validate()
var invalid *cabana.ValidationError
if !errors.As(err, &invalid) {
t.Errorf("BeforeValidate(%q) = %v, want a *cabana.ValidationError", url, err)
t.Errorf("%s: Validate = %v, want a *cabana.ValidationError", name, err)
continue
}
if !reflect.DeepEqual(invalid.Details, want) {
t.Errorf("BeforeValidate(%q) details = %v, want %v", url, invalid.Details, want)
if !reflect.DeepEqual(invalid.Details, c.want) {
t.Errorf("%s: details = %v, want %v", name, invalid.Details, c.want)
}
}
}