From 9d4af4d73e5568561cdb1de131f67356c05e07e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Sat, 2 May 2026 19:09:16 +0400 Subject: [PATCH] Validate cookie policy link in banner description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translations are no longer part of the version snapshot, so the banner_description text must be validated at write time to ensure the {{cookie_policy_link}} placeholder is present. Without it the cookie policy URL silently disappears from the rendered banner. Signed-off-by: Émile Ré --- pkg/cookiebanner/service.go | 7 ++- pkg/cookiebanner/service_test.go | 62 ++++++++++++++++++++++++++ pkg/validator/validator_string.go | 24 ++++++++++ pkg/validator/validator_string_test.go | 25 +++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 927695ad5..7895e2621 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -328,7 +328,12 @@ func (r *UpsertCookieBannerTranslationRequest) Validate() error { if json.Unmarshal(raw, &s) != nil { continue } - v.Check(s, "translations."+key, validator.NoHTML(), validator.MaxLen(2000)) + + validators := []validator.ValidatorFunc{validator.NoHTML(), validator.MaxLen(2000)} + if key == "banner_description" { + validators = append(validators, validator.ContainsSubstring("{{cookie_policy_link}}")) + } + v.Check(s, "translations."+key, validators...) } return v.Error() diff --git a/pkg/cookiebanner/service_test.go b/pkg/cookiebanner/service_test.go index 698d39f3f..5859a43b6 100644 --- a/pkg/cookiebanner/service_test.go +++ b/pkg/cookiebanner/service_test.go @@ -126,6 +126,68 @@ func TestSnapshotsEqual(t *testing.T) { }) } +func TestUpsertCookieBannerTranslationRequest_Validate(t *testing.T) { + t.Parallel() + + bannerID := gid.New(gid.NewTenantID(), coredata.CookieBannerEntityType) + + t.Run("banner_description with cookie_policy_link passes", func(t *testing.T) { + t.Parallel() + + translations, err := json.Marshal(map[string]string{ + "banner_title": "Cookie Preferences", + "banner_description": "We use cookies. {{cookie_policy_link}}", + }) + require.NoError(t, err) + + req := UpsertCookieBannerTranslationRequest{ + CookieBannerID: bannerID, + Language: "en", + Translations: translations, + } + + assert.NoError(t, req.Validate()) + }) + + t.Run("banner_description without cookie_policy_link fails", func(t *testing.T) { + t.Parallel() + + translations, err := json.Marshal(map[string]string{ + "banner_title": "Cookie Preferences", + "banner_description": "We use cookies to improve your experience.", + }) + require.NoError(t, err) + + req := UpsertCookieBannerTranslationRequest{ + CookieBannerID: bannerID, + Language: "en", + Translations: translations, + } + + err = req.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "translations.banner_description") + }) + + t.Run("translations without banner_description passes", func(t *testing.T) { + t.Parallel() + + translations, err := json.Marshal(map[string]string{ + "banner_title": "Cookie Preferences", + "button_save": "Save", + }) + require.NoError(t, err) + + req := UpsertCookieBannerTranslationRequest{ + CookieBannerID: bannerID, + Language: "en", + Translations: translations, + } + + assert.NoError(t, req.Validate()) + }) +} + func TestBuildSnapshot_RankInvariant(t *testing.T) { t.Parallel() diff --git a/pkg/validator/validator_string.go b/pkg/validator/validator_string.go index 1407ac614..e71c06d32 100644 --- a/pkg/validator/validator_string.go +++ b/pkg/validator/validator_string.go @@ -68,6 +68,30 @@ func MaxLen(maxLength int) ValidatorFunc { } } +// ContainsSubstring validates that a string contains the specified substring. +func ContainsSubstring(substr string) ValidatorFunc { + return func(value any) *ValidationError { + actualValue, isNil := dereferenceValue(value) + if isNil { + return nil + } + + str, ok := actualValue.(string) + if !ok { + return newValidationError(ErrorCodeInvalidFormat, "value must be a string") + } + + if !strings.Contains(str, substr) { + return newValidationError( + ErrorCodeInvalidFormat, + fmt.Sprintf("must contain %q", substr), + ) + } + + return nil + } +} + // OneOfSlice validates that a value is one of the allowed values in the slice. // Accepts a slice of any type. Compares by value first, then by string representation. func OneOfSlice[T any](allowed []T) ValidatorFunc { diff --git a/pkg/validator/validator_string_test.go b/pkg/validator/validator_string_test.go index 09d2f6e55..1bbc000f9 100644 --- a/pkg/validator/validator_string_test.go +++ b/pkg/validator/validator_string_test.go @@ -67,6 +67,31 @@ func TestMaxLen(t *testing.T) { } } +func TestContainsSubstring(t *testing.T) { + tests := []struct { + name string + value any + substr string + wantError bool + }{ + {"contains substring", "hello {{cookie_policy_link}} world", "{{cookie_policy_link}}", false}, + {"missing substring", "hello world", "{{cookie_policy_link}}", true}, + {"exact match", "{{cookie_policy_link}}", "{{cookie_policy_link}}", false}, + {"empty string", "", "{{cookie_policy_link}}", true}, + {"nil pointer", (*string)(nil), "{{cookie_policy_link}}", false}, + {"non-string", 123, "foo", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ContainsSubstring(tt.substr)(tt.value) + if (err != nil) != tt.wantError { + t.Errorf("ContainsSubstring() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + func TestOneOf(t *testing.T) { tests := []struct { name string