From 478ccf6785a2eb295fb455ed3982f917cc719ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Sat, 2 May 2026 18:33:07 +0400 Subject: [PATCH] Exclude uncategorised category from consent contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uncategorised category is an admin-side inbox for detected cookies and should not be part of the visitor-facing consent contract. Filter it out of snapshots so changes to uncategorised patterns no longer trigger version bumps. Signed-off-by: Émile Ré --- e2e/console/cookie_banner_versioning_test.go | 64 ++++++++++++++++++++ pkg/cookiebanner/service.go | 4 -- pkg/cookiebanner/service_test.go | 8 ++- pkg/cookiebanner/snapshot.go | 36 +++++------ 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/e2e/console/cookie_banner_versioning_test.go b/e2e/console/cookie_banner_versioning_test.go index 36c445310..084e1b4a1 100644 --- a/e2e/console/cookie_banner_versioning_test.go +++ b/e2e/console/cookie_banner_versioning_test.go @@ -15,6 +15,10 @@ package console_test import ( + "bytes" + "encoding/json" + "fmt" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -526,6 +530,66 @@ func TestCookieBannerVersioning_TranslationChangesNeverBump(t *testing.T) { }) } +func activateBanner(t *testing.T, c *testutil.Client, bannerID string) { + t.Helper() + + const query = ` + mutation ActivateCookieBanner($input: ActivateCookieBannerInput!) { + activateCookieBanner(input: $input) { + cookieBanner { id state } + } + } + ` + + var result struct{} + require.NoError(t, c.Execute(query, map[string]any{ + "input": map[string]any{"cookieBannerId": bannerID}, + }, &result), "activateCookieBanner mutation failed") +} + +func reportDetectedCookies(t *testing.T, c *testutil.Client, bannerID string, names ...string) { + t.Helper() + + type entry struct { + Name string `json:"name"` + Source string `json:"source"` + } + cookies := make([]entry, len(names)) + for i, n := range names { + cookies[i] = entry{Name: n, Source: "script"} + } + + body, err := json.Marshal(map[string]any{"cookies": cookies}) + require.NoError(t, err) + + url := fmt.Sprintf("%s/cookie-banner/v1/%s/detected-cookies", c.BaseURL(), bannerID) + resp, err := c.HTTPClient().Post(url, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode, + "detected-cookies endpoint should return 204") +} + +func TestCookieBannerVersioning_DetectedCookiesNeverBump(t *testing.T) { + t.Parallel() + + t.Run("reporting detected cookies 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) + activateBanner(t, owner, bannerID) + baseline := published.Version + + reportDetectedCookies(t, owner, bannerID, "_unknown_cookie", "_another") + + got := latestVersion(t, owner, bannerID) + assert.Equal(t, baseline, got.Version, "detected cookies should not bump the version") + assert.Equal(t, "PUBLISHED", got.State) + }) +} + func TestCookieBannerVersioning_RealChangesStillBumpVersion(t *testing.T) { t.Parallel() diff --git a/pkg/cookiebanner/service.go b/pkg/cookiebanner/service.go index 7a3291842..1c4ea33ca 100644 --- a/pkg/cookiebanner/service.go +++ b/pkg/cookiebanner/service.go @@ -2281,10 +2281,6 @@ func (s *Service) ReportDetectedCookies( } if inserted > 0 { - if _, err := s.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil { - return fmt.Errorf("cannot ensure draft version: %w", err) - } - if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil { return fmt.Errorf("cannot request pattern analysis: %w", err) } diff --git a/pkg/cookiebanner/service_test.go b/pkg/cookiebanner/service_test.go index d8e150381..698d39f3f 100644 --- a/pkg/cookiebanner/service_test.go +++ b/pkg/cookiebanner/service_test.go @@ -211,13 +211,15 @@ func TestBuildSnapshot_RankInvariant(t *testing.T) { assert.True(t, snapshotsEqual(a, b)) }) - t.Run("Necessary comes first and Uncategorised comes last", func(t *testing.T) { + t.Run("Necessary comes first and uncategorised is excluded", func(t *testing.T) { t.Parallel() snap := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil) - require.Len(t, snap.Categories, 4) + require.Len(t, snap.Categories, 3) assert.Equal(t, coredata.CookieCategoryKindNecessary, snap.Categories[0].Kind) - assert.Equal(t, coredata.CookieCategoryKindUncategorised, snap.Categories[3].Kind) + for _, c := range snap.Categories { + assert.NotEqual(t, coredata.CookieCategoryKindUncategorised, c.Kind) + } }) } diff --git a/pkg/cookiebanner/snapshot.go b/pkg/cookiebanner/snapshot.go index 94048148b..58532aee1 100644 --- a/pkg/cookiebanner/snapshot.go +++ b/pkg/cookiebanner/snapshot.go @@ -25,13 +25,13 @@ import ( ) // resolveTranslations converts raw DB translations into the resolved map -// used by buildBannerConfig at serve time. Categories must be sorted in -// snapshot order so the positional category translations align. +// used by buildBannerConfig at serve time. Categories are filtered and sorted +// in snapshot order so the positional category translations align. func resolveTranslations( translations coredata.CookieBannerTranslations, categories coredata.CookieCategories, ) map[string]coredata.CookieBannerVersionSnapshotTranslation { - return buildSnapshotTranslations(translations, sortCategoriesForSnapshot(categories)) + return buildSnapshotTranslations(translations, consentCategories(categories)) } // snapshotsEqual reports whether two version snapshots are visitor-identical. @@ -43,34 +43,36 @@ func snapshotsEqual(a, b coredata.CookieBannerVersionSnapshot) bool { } // 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. +// keeps the visitor-facing layout invariants (NECESSARY first, then NORMAL +// sorted by ID) 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 + return 2 } } -// 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 { +// consentCategories returns the categories that are part of the consent +// contract (everything except UNCATEGORISED), sorted in snapshot order. +// UNCATEGORISED is an admin-side inbox and never shown to visitors. +func consentCategories(categories coredata.CookieCategories) coredata.CookieCategories { + filtered := make(coredata.CookieCategories, 0, len(categories)) + for _, c := range categories { + if c.Kind != coredata.CookieCategoryKindUncategorised { + filtered = append(filtered, c) + } + } + slices.SortStableFunc(filtered, 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 + return filtered } func buildSnapshot( @@ -78,7 +80,7 @@ func buildSnapshot( categories coredata.CookieCategories, allPatterns coredata.CookiePatterns, ) coredata.CookieBannerVersionSnapshot { - categories = sortCategoriesForSnapshot(categories) + categories = consentCategories(categories) cookiesByCategory := make(map[gid.GID]coredata.CookieItems) for _, p := range allPatterns {