Add slug to cookie categories for stable consent identifiers
The category slug provides a stable, URL-safe key used as the data-cookie-consent attribute value and consent data key, replacing the fragile category name. This prevents breakage when categories are renamed. - Add slug column with unique-per-banner constraint and backfill migration - Add Slug validator (lowercase alphanumeric + hyphens) - Propagate slug through GraphQL schema, service layer, and snapshot - Update console UI with slug field in create/edit forms - Switch cookie-banner widget to use slug as consent data keys Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
|
||||
var (
|
||||
domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
|
||||
slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||||
)
|
||||
|
||||
// URL validates that a string is a valid URL with http or https scheme.
|
||||
@@ -177,6 +179,36 @@ func Origin() ValidatorFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Slug validates that a string is a lowercase alphanumeric slug (with hyphens, no
|
||||
// leading/trailing hyphens, no consecutive hyphens) and does not exceed maxLen.
|
||||
func Slug(maxLen int) 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 str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(str) > maxLen {
|
||||
return newValidationError(ErrorCodeTooLong, fmt.Sprintf("slug must be at most %d characters", maxLen))
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "slug must contain only lowercase letters, numbers, and hyphens")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Domain validates that a string is a valid domain name.
|
||||
func Domain() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
|
||||
@@ -164,6 +164,49 @@ func TestOrigin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
maxLen int
|
||||
wantError bool
|
||||
wantCode ErrorCode
|
||||
}{
|
||||
{"valid simple slug", "analytics", 100, false, ""},
|
||||
{"valid with hyphens", "my-category", 100, false, ""},
|
||||
{"valid multi-segment", "my-cool-category", 100, false, ""},
|
||||
{"valid single char", "a", 100, false, ""},
|
||||
{"valid digits only", "123", 100, false, ""},
|
||||
{"valid mixed", "cat2", 100, false, ""},
|
||||
{"valid digit-hyphen-alpha", "1-a", 100, false, ""},
|
||||
{"invalid - uppercase", "Analytics", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - leading hyphen", "-analytics", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - trailing hyphen", "analytics-", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - consecutive hyphens", "my--category", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - underscore", "my_category", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - spaces", "my category", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - special chars", "my@category", 100, true, ErrorCodeInvalidFormat},
|
||||
{"invalid - dot", "my.category", 100, true, ErrorCodeInvalidFormat},
|
||||
{"too long", "abcdefghijk", 10, true, ErrorCodeTooLong},
|
||||
{"exactly max length", "abcdefghij", 10, false, ""},
|
||||
{"empty string", "", 100, false, ""},
|
||||
{"nil pointer", (*string)(nil), 100, false, ""},
|
||||
{"non-string", 123, 100, true, ErrorCodeInvalidFormat},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Slug(tt.maxLen)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Slug(%d) error = %v, wantError %v", tt.maxLen, err, tt.wantError)
|
||||
}
|
||||
if err != nil && tt.wantCode != "" && err.Code != tt.wantCode {
|
||||
t.Errorf("Expected error code %s, got %s", tt.wantCode, err.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomain(t *testing.T) {
|
||||
t.Run("valid domain", func(t *testing.T) {
|
||||
str := "example.com"
|
||||
|
||||
Reference in New Issue
Block a user