Exclude uncategorised category from consent contract

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é <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-02 18:33:07 +04:00
parent 887453fee2
commit 478ccf6785
4 changed files with 88 additions and 24 deletions

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}
})
}

View File

@@ -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 {