fix: set_url_seo and record imports refuse a locale the record does not have (WR-18)

A locale given with a URL write overrode the matched one unchecked, so
"xx" created an entry nothing ever reads while the answer reported
success. SetURLSEO (API, MCP, batch set_seo, records import) now requires
one of the record's locales and refuses others with api.locale_unknown.
This commit is contained in:
2026-10-27 15:37:31 +01:00
parent 24e6f384ca
commit cff2d29fdd
5 changed files with 23 additions and 2 deletions

View File

@@ -156,7 +156,15 @@ func (s *Service) SetURLSEO(ctx context.Context, tx *gorm.DB, in URLSEOWrite) (U
}
if owner.WrittenTo == WrittenToRecord {
locale := owner.Locale
if l := strings.TrimSpace(in.Locale); l != "" {
if l := strings.TrimSpace(in.Locale); l != "" && l != locale {
// Another locale must be one the record describes (WR-18): an
// entry in any other would never be read by the resolver or the
// SEO tab.
if _, known := s.recordLocales(ctx, typ.Key, owner.ID)[l]; !known {
return URLSEOResult{}, &ProblemsError{Problems: map[string][]models.Problem{
"locale": {{Key: "golem15.seo::lang.api.locale_unknown", Params: map[string]string{"locale": l}}},
}}
}
locale = l
}
owner.Locale = locale

View File

@@ -40,7 +40,7 @@ func (v SEOValues) Map() map[string]string {
// SetSEOInput is the body of PUT /_seo/api/v1/seo.
type SetSEOInput struct {
URL string `json:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO; the locale the URL matches when omitted"`
Locale string `json:"locale,omitempty" jsonschema:"the locale of a record's SEO, one of the record's locales (422 locale_unknown otherwise); the locale the URL matches when omitted"`
Values SEOValues `json:"values" jsonschema:"the values to change"`
ForceRule bool `json:"force_rule,omitempty" jsonschema:"write a URL rule even when a record is behind the URL"`
}

View File

@@ -315,6 +315,7 @@ api:
canonical_invalid: The canonical URL must be a path starting with / or an absolute http(s) URL.
bot_unknown: This is not an AI bot of the robots.txt screen.
value_invalid: This value is not valid here.
locale_unknown: "The record behind this address has no locale “:locale”."
type_unknown: No content type with this key is registered.
batch_too_large: "A batch takes at most :max changes."
change_not_found: The record this change names does not exist.

View File

@@ -325,6 +325,7 @@ api:
canonical_invalid: Adres kanoniczny musi być ścieżką zaczynającą się od / lub pełnym adresem http(s).
bot_unknown: To nie jest bot AI z ekranu robots.txt.
value_invalid: Ta wartość jest tu nieprawidłowa.
locale_unknown: "Rekord pod tym adresem nie ma wersji językowej „:locale”."
type_unknown: Nie zarejestrowano typu treści o tym kluczu.
batch_too_large: "Partia może mieć najwyżej :max zmian."
change_not_found: Rekord wskazany w tej zmianie nie istnieje.

View File

@@ -257,6 +257,17 @@ func TestManagementAPI(t *testing.T) {
}
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": "https://cdn.example/a.png"}})
// A locale the record does not describe is refused (WR-18); one it
// does is written.
for _, bad := range []string{"xx", "EN "} {
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": bad, "values": map[string]any{"title": "Lost"}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"locale"`) {
t.Fatalf("set_url_seo locale %q = %d %s, want 422 on locale", bad, rec.Code, rec.Body.String())
}
}
if got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "locale": "pl", "values": map[string]any{"title": "Po polsku"}}); got["locale"] != "pl" {
t.Fatalf("set_url_seo locale pl = %v", got)
}
// URL rules.
rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"})
data, _ := rule["data"].(map[string]any)