diff --git a/e2e/console/cookie_banner_versioning_test.go b/e2e/console/cookie_banner_versioning_test.go new file mode 100644 index 000000000..fd4c07693 --- /dev/null +++ b/e2e/console/cookie_banner_versioning_test.go @@ -0,0 +1,584 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package console_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/e2e/internal/factory" + "go.probo.inc/probo/e2e/internal/testutil" +) + +// versionInfo is the (version, state) tuple returned by the latestVersion field. +type versionInfo struct { + Version int + State string +} + +func latestVersion(t *testing.T, c *testutil.Client, bannerID string) versionInfo { + t.Helper() + + const query = ` + query($id: ID!) { + node(id: $id) { + ... on CookieBanner { + latestVersion { + version + state + } + } + } + } + ` + + var result struct { + Node struct { + LatestVersion *struct { + Version int `json:"version"` + State string `json:"state"` + } `json:"latestVersion"` + } `json:"node"` + } + + require.NoError(t, c.Execute(query, map[string]any{"id": bannerID}, &result), "latestVersion query failed") + require.NotNil(t, result.Node.LatestVersion, "expected latestVersion to be present") + + return versionInfo{ + Version: result.Node.LatestVersion.Version, + State: result.Node.LatestVersion.State, + } +} + +func publishBanner(t *testing.T, c *testutil.Client, bannerID string) versionInfo { + t.Helper() + + const query = ` + mutation PublishCookieBannerVersion($input: PublishCookieBannerVersionInput!) { + publishCookieBannerVersion(input: $input) { + cookieBannerVersion { + version + state + } + } + } + ` + + var result struct { + PublishCookieBannerVersion struct { + CookieBannerVersion struct { + Version int `json:"version"` + State string `json:"state"` + } `json:"cookieBannerVersion"` + } `json:"publishCookieBannerVersion"` + } + + require.NoError(t, c.Execute(query, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result), "publishCookieBannerVersion mutation failed") + + return versionInfo{ + Version: result.PublishCookieBannerVersion.CookieBannerVersion.Version, + State: result.PublishCookieBannerVersion.CookieBannerVersion.State, + } +} + +func setPatternExcluded(t *testing.T, c *testutil.Client, patternID string, excluded bool) { + t.Helper() + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { + cookiePattern { id excluded } + } + } + ` + + var result struct{} + require.NoError(t, c.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "excluded": excluded, + }, + }, &result), "updateCookiePattern excluded mutation failed") +} + +// upsertTranslation upserts a translation for a banner+language pair and +// returns nothing (we read the version separately). +func upsertTranslation(t *testing.T, c *testutil.Client, bannerID, language, translations string) { + t.Helper() + + const query = ` + mutation UpsertCookieBannerTranslation($input: UpsertCookieBannerTranslationInput!) { + upsertCookieBannerTranslation(input: $input) { + cookieBannerTranslation { id } + } + } + ` + + var result struct{} + require.NoError(t, c.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "language": language, + "translations": translations, + }, + }, &result), "upsertCookieBannerTranslation mutation failed") +} + +func TestCookieBannerVersioning_NoOpUpdates(t *testing.T) { + t.Parallel() + + t.Run("UpdateCookieBanner with all original values does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner, factory.Attrs{ + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }) + + published := publishBanner(t, owner, bannerID) + require.Equal(t, "PUBLISHED", published.State) + baseline := published.Version + + const query = ` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { cookieBanner { id } } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "cookiePolicyUrl": "https://example.com/cookies", + "consentExpiryDays": 365, + "consentMode": "OPT_IN", + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "version should not change for no-op banner update") + assert.Equal(t, "PUBLISHED", got.State, "no draft should be created") + }) + + t.Run("UpdateCookieBanner with only name change does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { cookieBanner { id name } } + } + ` + + var result struct { + UpdateCookieBanner struct { + CookieBanner struct { + Name string `json:"name"` + } `json:"cookieBanner"` + } `json:"updateCookieBanner"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "name": factory.SafeName("Renamed"), + }, + }, &result) + require.NoError(t, err) + assert.NotEmpty(t, result.UpdateCookieBanner.CookieBanner.Name) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "renaming should not affect the visitor-facing snapshot") + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("UpdateCookieCategory with all original values does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{ + "name": "Marketing", + "slug": "marketing-noop", + "description": "Marketing cookies", + "rank": 12, + }) + + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookieCategory($input: UpdateCookieCategoryInput!) { + updateCookieCategory(input: $input) { cookieCategory { id } } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "name": "Marketing", + "slug": "marketing-noop", + "description": "Marketing cookies", + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version) + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("ReorderCookieCategory with current rank does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{ + "slug": "reorder-noop", + "rank": 7, + }) + + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation ReorderCookieCategory($input: ReorderCookieCategoryInput!) { + reorderCookieCategory(input: $input) { cookieBanner { id } } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "rank": 7, + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version) + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("ReorderCookieCategory with new rank does not bump version", func(t *testing.T) { + // Rank is admin-only metadata; the snapshot is sorted by + // (Kind weight, ID), so a rank change is invisible to visitors. + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{ + "slug": "reorder-real", + "rank": 10, + }) + + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation ReorderCookieCategory($input: ReorderCookieCategoryInput!) { + reorderCookieCategory(input: $input) { cookieBanner { id } } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieCategoryId": categoryID, + "rank": 42, + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "real rank change must not bump the version") + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("UpdateCookiePattern on visible pattern with same value does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "visible-noop"}) + patternID := factory.CreateCookiePattern(owner, categoryID, factory.Attrs{ + "displayName": "GA Tracker", + "description": "Original description", + }) + + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { cookiePattern { id } } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "displayName": "GA Tracker", + "description": "Original description", + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version) + assert.Equal(t, "PUBLISHED", got.State) + }) +} + +func TestCookieBannerVersioning_ExcludedPattern(t *testing.T) { + t.Parallel() + + t.Run("Update on excluded pattern does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "excl-update"}) + patternID := factory.CreateCookiePattern(owner, categoryID, factory.Attrs{ + "displayName": "Original", + }) + + setPatternExcluded(t, owner, patternID, true) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { + cookiePattern { id displayName description } + } + } + ` + + var result struct { + UpdateCookiePattern struct { + CookiePattern struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` + } `json:"cookiePattern"` + } `json:"updateCookiePattern"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "displayName": "Renamed Excluded", + "description": "Now with notes", + }, + }, &result) + require.NoError(t, err) + assert.Equal(t, "Renamed Excluded", result.UpdateCookiePattern.CookiePattern.DisplayName) + assert.Equal(t, "Now with notes", result.UpdateCookiePattern.CookiePattern.Description) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "excluded pattern fields are invisible to visitors") + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("Delete of excluded pattern does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "excl-delete"}) + patternID := factory.CreateCookiePattern(owner, categoryID) + + setPatternExcluded(t, owner, patternID, true) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation DeleteCookiePattern($input: DeleteCookiePatternInput!) { + deleteCookiePattern(input: $input) { + deletedCookiePatternId + } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{"cookiePatternId": patternID}, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version) + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("Move of excluded pattern does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryA := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "excl-move-a"}) + categoryB := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "excl-move-b"}) + patternID := factory.CreateCookiePattern(owner, categoryA) + + setPatternExcluded(t, owner, patternID, true) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation MoveCookiePatternToCategory($input: MoveCookiePatternToCategoryInput!) { + moveCookiePatternToCategory(input: $input) { + cookiePattern { id } + } + } + ` + + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "targetCookieCategoryId": categoryB, + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version) + assert.Equal(t, "PUBLISHED", got.State) + }) +} + +func TestCookieBannerVersioning_NoOpTranslation(t *testing.T) { + t.Parallel() + + t.Run("re-upserting identical JSON does not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + // Insert a custom translation, then publish. + const customJSON = `{"banner_title":"Cookie Bar","button_accept_all":"Accept"}` + upsertTranslation(t, owner, bannerID, "it", customJSON) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + // Re-upsert the same JSON. + upsertTranslation(t, owner, bannerID, "it", customJSON) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "re-upserting identical JSON should not bump the version") + assert.Equal(t, "PUBLISHED", got.State) + }) + + t.Run("whitespace-only and key-order differences do not bump version", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + + const compact = `{"banner_title":"Cookie Bar","button_accept_all":"Accept"}` + const reformatted = `{ + "button_accept_all": "Accept", + "banner_title": "Cookie Bar" +}` + upsertTranslation(t, owner, bannerID, "it", compact) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + upsertTranslation(t, owner, bannerID, "it", reformatted) + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "JSON formatting differences should be canonicalised") + assert.Equal(t, "PUBLISHED", got.State) + }) +} + +func TestCookieBannerVersioning_RealChangesStillBumpVersion(t *testing.T) { + t.Parallel() + + t.Run("UpdateCookieBanner consent change creates a new draft", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner, factory.Attrs{ + "consentExpiryDays": 365, + }) + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookieBanner($input: UpdateCookieBannerInput!) { + updateCookieBanner(input: $input) { cookieBanner { id } } + } + ` + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookieBannerId": bannerID, + "consentExpiryDays": 90, + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Greater(t, got.Version, baseline, "real consent change should bump the version") + assert.Equal(t, "DRAFT", got.State) + }) + + t.Run("UpdateCookiePattern displayName change on visible pattern creates a new draft", func(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + bannerID := factory.CreateCookieBanner(owner) + categoryID := factory.CreateCookieCategory(owner, bannerID, factory.Attrs{"slug": "real-change"}) + patternID := factory.CreateCookiePattern(owner, categoryID, factory.Attrs{ + "displayName": "Original", + }) + + published := publishBanner(t, owner, bannerID) + baseline := published.Version + + const query = ` + mutation UpdateCookiePattern($input: UpdateCookiePatternInput!) { + updateCookiePattern(input: $input) { cookiePattern { id } } + } + ` + var result struct{} + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "cookiePatternId": patternID, + "displayName": "Renamed", + }, + }, &result) + require.NoError(t, err) + + got := latestVersion(t, owner, bannerID) + assert.Greater(t, got.Version, baseline) + assert.Equal(t, "DRAFT", got.State) + }) +} diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index e731084a2..76207a0f9 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -15,17 +15,21 @@ package cookiebanner import ( + "bytes" "context" "encoding/json" "errors" "fmt" "maps" "net/url" + "reflect" + "slices" "strings" "time" "go.gearno.de/kit/pg" "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/equal" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/validator" @@ -349,12 +353,53 @@ func CanonicalizeOrigin(raw string) string { return u.Scheme + "://" + host } +// snapshotsEqual reports whether two version snapshots are visitor-identical. +// buildSnapshot already normalises empty slices and nil maps, so reflect.DeepEqual +// is sufficient and is the single chokepoint we'd extend if we ever wanted to +// ignore particular fields. +func snapshotsEqual(a, b coredata.CookieBannerVersionSnapshot) bool { + return reflect.DeepEqual(a, b) +} + +// snapshotCategoryKindOrder returns a stable weight per Kind so the snapshot +// keeps the visitor-facing layout invariants (NECESSARY first, UNCATEGORISED +// last) without depending on the admin-controlled rank. +func snapshotCategoryKindOrder(k coredata.CookieCategoryKind) int { + switch k { + case coredata.CookieCategoryKindNecessary: + return 0 + case coredata.CookieCategoryKindNormal: + return 1 + case coredata.CookieCategoryKindUncategorised: + return 2 + default: + return 3 + } +} + +// sortCategoriesForSnapshot returns the categories ordered for snapshot +// rendering. The order is (Kind weight, ID byte order); rank is intentionally +// ignored so reordering is admin-only metadata and does not bump the version. +func sortCategoriesForSnapshot(categories coredata.CookieCategories) coredata.CookieCategories { + sorted := make(coredata.CookieCategories, len(categories)) + copy(sorted, categories) + slices.SortStableFunc(sorted, func(a, b *coredata.CookieCategory) int { + if d := snapshotCategoryKindOrder(a.Kind) - snapshotCategoryKindOrder(b.Kind); d != 0 { + return d + } + return bytes.Compare(a.ID[:], b.ID[:]) + }) + return sorted +} + func buildSnapshot( banner *coredata.CookieBanner, categories coredata.CookieCategories, allPatterns coredata.CookiePatterns, translations coredata.CookieBannerTranslations, ) coredata.CookieBannerVersionSnapshot { + categories = sortCategoriesForSnapshot(categories) + cookiesByCategory := make(map[gid.GID]coredata.CookieItems) for _, p := range allPatterns { cookiesByCategory[p.CookieCategoryID] = append( @@ -473,15 +518,21 @@ func (s *Service) ensureDraftVersion( var latest coredata.CookieBannerVersion err := latest.LoadLatestByCookieBannerID(ctx, tx, scope, banner.ID) - if err == nil && latest.State == coredata.CookieBannerVersionStateDraft { - if err := latest.SetSnapshot(snapshot); err != nil { - return nil, fmt.Errorf("cannot set snapshot: %w", err) + if err == nil { + if latestSnapshot, snapErr := latest.GetSnapshot(); snapErr == nil && snapshotsEqual(snapshot, latestSnapshot) { + return &latest, nil } - latest.UpdatedAt = time.Now() - if err := latest.Update(ctx, tx, scope); err != nil { - return nil, fmt.Errorf("cannot update draft version: %w", err) + + if latest.State == coredata.CookieBannerVersionStateDraft { + if err := latest.SetSnapshot(snapshot); err != nil { + return nil, fmt.Errorf("cannot set snapshot: %w", err) + } + latest.UpdatedAt = time.Now() + if err := latest.Update(ctx, tx, scope); err != nil { + return nil, fmt.Errorf("cannot update draft version: %w", err) + } + return &latest, nil } - return &latest, nil } if err != nil && !errors.Is(err, coredata.ErrResourceNotFound) { @@ -861,11 +912,18 @@ func (s *Service) UpdateCookieBanner( return fmt.Errorf("cannot load cookie banner: %w", err) } - consentChanged := req.PrivacyPolicyURL != nil || - req.CookiePolicyURL != nil || - req.ConsentExpiryDays != nil || - req.ConsentMode != nil || - req.DefaultLanguage != nil + nameChanged := req.Name != nil && *req.Name != banner.Name + privacyChanged := req.PrivacyPolicyURL != nil && !equal.Ptr(req.PrivacyPolicyURL, banner.PrivacyPolicyURL) + cookiePolicyChanged := req.CookiePolicyURL != nil && *req.CookiePolicyURL != banner.CookiePolicyURL + expiryChanged := req.ConsentExpiryDays != nil && *req.ConsentExpiryDays != banner.ConsentExpiryDays + consentModeChanged := req.ConsentMode != nil && *req.ConsentMode != banner.ConsentMode + defaultLangChanged := req.DefaultLanguage != nil && *req.DefaultLanguage != banner.DefaultLanguage + + snapshotChanged := privacyChanged || cookiePolicyChanged || expiryChanged || consentModeChanged || defaultLangChanged + + if !nameChanged && !snapshotChanged { + return nil + } if req.Name != nil { banner.Name = *req.Name @@ -895,7 +953,7 @@ func (s *Service) UpdateCookieBanner( return fmt.Errorf("cannot update cookie banner: %w", err) } - if consentChanged { + if snapshotChanged { if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil { return fmt.Errorf("cannot ensure draft version: %w", err) } @@ -1324,6 +1382,20 @@ func (s *Service) UpdateCookiePattern( return fmt.Errorf("cannot load cookie pattern: %w", err) } + displayNameChanged := req.DisplayName != nil && *req.DisplayName != pattern.DisplayName + maxAgeChanged := req.MaxAgeSeconds != nil && !equal.Ptr(*req.MaxAgeSeconds, pattern.MaxAgeSeconds) + descChanged := req.Description != nil && *req.Description != pattern.Description + excludedChanged := req.Excluded != nil && *req.Excluded != pattern.Excluded + + if !displayNameChanged && !maxAgeChanged && !descChanged && !excludedChanged { + return nil + } + + // A pattern that was excluded and stays excluded is invisible to visitors, + // so any field updates do not affect the published snapshot. We persist + // the row but skip the version bump. + staysExcluded := pattern.Excluded && (req.Excluded == nil || *req.Excluded) + if req.DisplayName != nil { pattern.DisplayName = *req.DisplayName } @@ -1343,8 +1415,10 @@ func (s *Service) UpdateCookiePattern( return fmt.Errorf("cannot update cookie pattern: %w", err) } - if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { - return fmt.Errorf("cannot ensure draft version: %w", err) + if !staysExcluded { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } } return nil @@ -1373,12 +1447,16 @@ func (s *Service) DeleteCookiePattern( return fmt.Errorf("cannot load cookie pattern: %w", err) } + wasExcluded := pattern.Excluded + if err := pattern.Delete(ctx, tx, scope); err != nil { return fmt.Errorf("cannot delete cookie pattern: %w", err) } - if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { - return fmt.Errorf("cannot ensure draft version: %w", err) + if !wasExcluded { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } } return nil @@ -1424,6 +1502,8 @@ func (s *Service) MoveCookiePatternToCategory( return ErrCategoriesBannerMismatch } + wasExcluded := pattern.Excluded + pattern.CookieCategoryID = target.ID pattern.UpdatedAt = time.Now() @@ -1436,8 +1516,10 @@ func (s *Service) MoveCookiePatternToCategory( return fmt.Errorf("cannot load cookie banner: %w", err) } - if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { - return fmt.Errorf("cannot ensure draft version: %w", err) + if !wasExcluded { + if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, pattern.CookieBannerID); err != nil { + return fmt.Errorf("cannot ensure draft version: %w", err) + } } result.CookiePattern = &pattern @@ -1555,6 +1637,16 @@ func (s *Service) UpdateCookieCategory( return fmt.Errorf("cannot load cookie category: %w", err) } + nameChanged := req.Name != nil && *req.Name != category.Name + slugChanged := req.Slug != nil && *req.Slug != category.Slug + descChanged := req.Description != nil && *req.Description != category.Description + gcmChanged := req.GCMConsentTypes != nil && !slices.Equal(*req.GCMConsentTypes, category.GCMConsentTypes) + posthogChanged := req.PostHogConsent != nil && *req.PostHogConsent != category.PostHogConsent + + if !nameChanged && !slugChanged && !descChanged && !gcmChanged && !posthogChanged { + return nil + } + if req.Name != nil { category.Name = *req.Name } @@ -1567,7 +1659,7 @@ func (s *Service) UpdateCookieCategory( if req.GCMConsentTypes != nil { category.GCMConsentTypes = *req.GCMConsentTypes } - if req.PostHogConsent != nil { + if posthogChanged { if *req.PostHogConsent && category.Kind != coredata.CookieCategoryKindNormal { return ErrPostHogConsentKindInvalid } @@ -1625,6 +1717,14 @@ func (s *Service) ReorderCookieCategory( return fmt.Errorf("cannot load cookie category: %w", err) } + if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner: %w", err) + } + + if category.Rank == req.Rank { + return nil + } + category.Rank = req.Rank category.UpdatedAt = time.Now() @@ -1632,13 +1732,9 @@ func (s *Service) ReorderCookieCategory( return fmt.Errorf("cannot reorder cookie category: %w", err) } - if err := banner.LoadByID(ctx, tx, scope, category.CookieBannerID); err != nil { - return fmt.Errorf("cannot load cookie banner: %w", err) - } - - if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, category.CookieBannerID); err != nil { - return fmt.Errorf("cannot ensure draft version: %w", err) - } + // Rank is admin-only metadata; the snapshot is sorted by + // (Kind weight, ID) in buildSnapshot, so reordering does not + // affect visitor view and must not bump the version. return nil }, @@ -2065,6 +2161,12 @@ func (s *Service) UpsertCookieBannerTranslation( err := existing.LoadByCookieBannerIDAndLanguage(ctx, tx, scope, req.CookieBannerID, req.Language) if err == nil { + same, eqErr := equal.JSON(existing.Translations, req.Translations) + if eqErr == nil && same { + result = &existing + return nil + } + existing.Translations = req.Translations existing.UpdatedAt = now if err := existing.Update(ctx, tx, scope); err != nil { diff --git a/pkg/cookiebanner/service_test.go b/pkg/cookiebanner/service_test.go new file mode 100644 index 000000000..8b44a0ced --- /dev/null +++ b/pkg/cookiebanner/service_test.go @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cookiebanner + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +func TestSnapshotsEqual(t *testing.T) { + t.Parallel() + + baseSnapshot := func() coredata.CookieBannerVersionSnapshot { + policy := "https://example.com/privacy" + maxAge := 3600 + return coredata.CookieBannerVersionSnapshot{ + PrivacyPolicyURL: &policy, + CookiePolicyURL: "https://example.com/cookies", + ConsentExpiryDays: 180, + ConsentMode: "OPT_IN", + DefaultLanguage: "en", + Categories: []coredata.CookieBannerVersionSnapshotCategory{ + { + Name: "Analytics", + Slug: "analytics", + Description: "Analytics cookies", + Kind: coredata.CookieCategoryKindNormal, + Cookies: coredata.CookieItems{ + {Name: "_ga", MaxAgeSeconds: &maxAge, Description: "Google Analytics"}, + }, + GCMConsentTypes: []string{"analytics_storage"}, + PostHogConsent: false, + }, + }, + Translations: map[string]coredata.CookieBannerVersionSnapshotTranslation{ + "fr": { + UI: map[string]string{"title": "Cookies"}, + Categories: []coredata.CookieBannerVersionSnapshotCategoryTranslation{ + {Name: "Analyse", Description: "Cookies d'analyse"}, + }, + }, + }, + } + } + + t.Run("two identical snapshots are equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + b := baseSnapshot() + + assert.True(t, snapshotsEqual(a, b)) + }) + + t.Run("snapshot equals itself after json roundtrip", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + + raw, err := json.Marshal(a) + require.NoError(t, err) + + var b coredata.CookieBannerVersionSnapshot + require.NoError(t, json.Unmarshal(raw, &b)) + + assert.True(t, snapshotsEqual(a, b)) + }) + + t.Run("differing CookiePolicyURL is not equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + b := baseSnapshot() + b.CookiePolicyURL = "https://other.example.com/cookies" + + assert.False(t, snapshotsEqual(a, b)) + }) + + t.Run("differing category name is not equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + b := baseSnapshot() + b.Categories[0].Name = "Tracking" + + assert.False(t, snapshotsEqual(a, b)) + }) + + t.Run("differing translation UI is not equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + b := baseSnapshot() + b.Translations["fr"] = coredata.CookieBannerVersionSnapshotTranslation{ + UI: map[string]string{"title": "Cookies updated"}, + Categories: a.Translations["fr"].Categories, + } + + assert.False(t, snapshotsEqual(a, b)) + }) + + t.Run("differing GCMConsentTypes order is not equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + a.Categories[0].GCMConsentTypes = []string{"analytics_storage", "ad_storage"} + b := baseSnapshot() + b.Categories[0].GCMConsentTypes = []string{"ad_storage", "analytics_storage"} + + assert.False(t, snapshotsEqual(a, b)) + }) + + t.Run("nil vs set PrivacyPolicyURL is not equal", func(t *testing.T) { + t.Parallel() + + a := baseSnapshot() + b := baseSnapshot() + b.PrivacyPolicyURL = nil + + assert.False(t, snapshotsEqual(a, b)) + }) + + t.Run("zero-value snapshots are equal", func(t *testing.T) { + t.Parallel() + + a := coredata.CookieBannerVersionSnapshot{} + b := coredata.CookieBannerVersionSnapshot{} + + assert.True(t, snapshotsEqual(a, b)) + }) +} + +func TestBuildSnapshot_RankInvariant(t *testing.T) { + t.Parallel() + + tenant := gid.NewTenantID() + bannerID := gid.New(tenant, coredata.CookieBannerEntityType) + + necessaryID := gid.New(tenant, coredata.CookieCategoryEntityType) + analyticsID := gid.New(tenant, coredata.CookieCategoryEntityType) + advertisingID := gid.New(tenant, coredata.CookieCategoryEntityType) + uncategorisedID := gid.New(tenant, coredata.CookieCategoryEntityType) + + mkCategories := func(necessaryRank, analyticsRank, advertisingRank, uncategorisedRank int) coredata.CookieCategories { + return coredata.CookieCategories{ + { + ID: necessaryID, + CookieBannerID: bannerID, + Name: "Necessary", + Slug: "necessary", + Description: "Required.", + Kind: coredata.CookieCategoryKindNecessary, + Rank: necessaryRank, + GCMConsentTypes: []string{"security_storage"}, + }, + { + ID: analyticsID, + CookieBannerID: bannerID, + Name: "Analytics", + Slug: "analytics", + Description: "Analytics.", + Kind: coredata.CookieCategoryKindNormal, + Rank: analyticsRank, + GCMConsentTypes: []string{"analytics_storage"}, + }, + { + ID: advertisingID, + CookieBannerID: bannerID, + Name: "Advertising", + Slug: "advertising", + Description: "Ads.", + Kind: coredata.CookieCategoryKindNormal, + Rank: advertisingRank, + GCMConsentTypes: []string{"ad_storage"}, + }, + { + ID: uncategorisedID, + CookieBannerID: bannerID, + Name: "Uncategorised", + Slug: "uncategorised", + Description: "Misc.", + Kind: coredata.CookieCategoryKindUncategorised, + Rank: uncategorisedRank, + GCMConsentTypes: nil, + }, + } + } + + banner := &coredata.CookieBanner{ + ID: bannerID, + CookiePolicyURL: "https://example.com/cookies", + ConsentExpiryDays: 365, + ConsentMode: coredata.CookieConsentModeOptIn, + DefaultLanguage: "en", + } + + t.Run("snapshot is identical regardless of rank values", func(t *testing.T) { + t.Parallel() + + original := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil, nil) + shuffled := buildSnapshot(banner, mkCategories(99, 50, 25, 10), nil, nil) + + assert.True(t, snapshotsEqual(original, shuffled), "rank changes must not affect the snapshot") + }) + + t.Run("snapshot is identical regardless of input slice order", func(t *testing.T) { + t.Parallel() + + ordered := mkCategories(0, 1, 2, 3) + reversed := coredata.CookieCategories{ordered[3], ordered[2], ordered[1], ordered[0]} + + a := buildSnapshot(banner, ordered, nil, nil) + b := buildSnapshot(banner, reversed, nil, nil) + + assert.True(t, snapshotsEqual(a, b)) + }) + + t.Run("Necessary comes first and Uncategorised comes last", func(t *testing.T) { + t.Parallel() + + snap := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil, nil) + + require.Len(t, snap.Categories, 4) + assert.Equal(t, coredata.CookieCategoryKindNecessary, snap.Categories[0].Kind) + assert.Equal(t, coredata.CookieCategoryKindUncategorised, snap.Categories[3].Kind) + }) +} diff --git a/pkg/equal/equal.go b/pkg/equal/equal.go new file mode 100644 index 000000000..f12f4fd86 --- /dev/null +++ b/pkg/equal/equal.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package equal contains small helpers for value equality across the +// codebase, with no business semantics. +package equal + +import ( + "encoding/json" + "fmt" + "reflect" +) + +// Ptr reports whether two nullable values are equal: both nil are equal, +// one nil is not equal to a non-nil, otherwise the pointed-to values are +// compared with ==. +func Ptr[T comparable](a, b *T) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// JSON reports whether two JSON blobs are semantically identical after +// normalising whitespace and (top-level and nested) object key ordering. +// Array element order is preserved as significant. Numbers are compared +// after JSON unmarshalling, so 1 and 1.0 compare equal. +func JSON(a, b json.RawMessage) (bool, error) { + var av, bv any + if err := json.Unmarshal(a, &av); err != nil { + return false, fmt.Errorf("cannot unmarshal first json blob: %w", err) + } + if err := json.Unmarshal(b, &bv); err != nil { + return false, fmt.Errorf("cannot unmarshal second json blob: %w", err) + } + return reflect.DeepEqual(av, bv), nil +} diff --git a/pkg/equal/equal_test.go b/pkg/equal/equal_test.go new file mode 100644 index 000000000..ad98ceab3 --- /dev/null +++ b/pkg/equal/equal_test.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package equal_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/equal" +) + +func TestPtr_String(t *testing.T) { + t.Parallel() + + a := "hello" + b := "hello" + c := "world" + + assert.True(t, equal.Ptr[string](nil, nil)) + assert.False(t, equal.Ptr(&a, nil)) + assert.False(t, equal.Ptr[string](nil, &a)) + assert.True(t, equal.Ptr(&a, &b)) + assert.False(t, equal.Ptr(&a, &c)) +} + +func TestPtr_Int(t *testing.T) { + t.Parallel() + + a := 42 + b := 42 + c := 99 + + assert.True(t, equal.Ptr[int](nil, nil)) + assert.False(t, equal.Ptr(&a, nil)) + assert.False(t, equal.Ptr[int](nil, &a)) + assert.True(t, equal.Ptr(&a, &b)) + assert.False(t, equal.Ptr(&a, &c)) +} + +func TestPtr_Bool(t *testing.T) { + t.Parallel() + + tt := true + tt2 := true + ff := false + + assert.True(t, equal.Ptr(&tt, &tt2)) + assert.False(t, equal.Ptr(&tt, &ff)) +} + +func TestJSON(t *testing.T) { + t.Parallel() + + t.Run("identical bytes", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"foo":"bar","n":1}`) + b := json.RawMessage(`{"foo":"bar","n":1}`) + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.True(t, eq) + }) + + t.Run("whitespace and key order differences", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"foo":"bar","n":1}`) + b := json.RawMessage("{\n \"n\": 1,\n \"foo\": \"bar\"\n}") + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.True(t, eq) + }) + + t.Run("nested objects with reordered keys", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"a":{"x":1,"y":2},"b":[1,2,3]}`) + b := json.RawMessage(`{"b":[1,2,3],"a":{"y":2,"x":1}}`) + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.True(t, eq) + }) + + t.Run("array order matters", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"items":[1,2,3]}`) + b := json.RawMessage(`{"items":[3,2,1]}`) + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.False(t, eq) + }) + + t.Run("real content change", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"title":"Cookies"}`) + b := json.RawMessage(`{"title":"Cookies updated"}`) + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.False(t, eq) + }) + + t.Run("integers and floats compare equal", func(t *testing.T) { + t.Parallel() + + a := json.RawMessage(`{"n":1}`) + b := json.RawMessage(`{"n":1.0}`) + + eq, err := equal.JSON(a, b) + require.NoError(t, err) + assert.True(t, eq, "json.Unmarshal yields float64 for both") + }) + + t.Run("invalid first blob returns error", func(t *testing.T) { + t.Parallel() + + _, err := equal.JSON(json.RawMessage(`not json`), json.RawMessage(`{}`)) + assert.Error(t, err) + }) + + t.Run("invalid second blob returns error", func(t *testing.T) { + t.Parallel() + + _, err := equal.JSON(json.RawMessage(`{}`), json.RawMessage(`not json`)) + assert.Error(t, err) + }) +}