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 package console_test
import ( import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"testing" "testing"
"github.com/stretchr/testify/assert" "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) { func TestCookieBannerVersioning_RealChangesStillBumpVersion(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -2281,10 +2281,6 @@ func (s *Service) ReportDetectedCookies(
} }
if inserted > 0 { 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 { if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request pattern analysis: %w", err) 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)) 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() t.Parallel()
snap := buildSnapshot(banner, mkCategories(0, 1, 2, 3), nil) 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.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 // resolveTranslations converts raw DB translations into the resolved map
// used by buildBannerConfig at serve time. Categories must be sorted in // used by buildBannerConfig at serve time. Categories are filtered and sorted
// snapshot order so the positional category translations align. // in snapshot order so the positional category translations align.
func resolveTranslations( func resolveTranslations(
translations coredata.CookieBannerTranslations, translations coredata.CookieBannerTranslations,
categories coredata.CookieCategories, categories coredata.CookieCategories,
) map[string]coredata.CookieBannerVersionSnapshotTranslation { ) map[string]coredata.CookieBannerVersionSnapshotTranslation {
return buildSnapshotTranslations(translations, sortCategoriesForSnapshot(categories)) return buildSnapshotTranslations(translations, consentCategories(categories))
} }
// snapshotsEqual reports whether two version snapshots are visitor-identical. // 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 // snapshotCategoryKindOrder returns a stable weight per Kind so the snapshot
// keeps the visitor-facing layout invariants (NECESSARY first, UNCATEGORISED // keeps the visitor-facing layout invariants (NECESSARY first, then NORMAL
// last) without depending on the admin-controlled rank. // sorted by ID) without depending on the admin-controlled rank.
func snapshotCategoryKindOrder(k coredata.CookieCategoryKind) int { func snapshotCategoryKindOrder(k coredata.CookieCategoryKind) int {
switch k { switch k {
case coredata.CookieCategoryKindNecessary: case coredata.CookieCategoryKindNecessary:
return 0 return 0
case coredata.CookieCategoryKindNormal: case coredata.CookieCategoryKindNormal:
return 1 return 1
case coredata.CookieCategoryKindUncategorised:
return 2
default: default:
return 3 return 2
} }
} }
// sortCategoriesForSnapshot returns the categories ordered for snapshot // consentCategories returns the categories that are part of the consent
// rendering. The order is (Kind weight, ID byte order); rank is intentionally // contract (everything except UNCATEGORISED), sorted in snapshot order.
// ignored so reordering is admin-only metadata and does not bump the version. // UNCATEGORISED is an admin-side inbox and never shown to visitors.
func sortCategoriesForSnapshot(categories coredata.CookieCategories) coredata.CookieCategories { func consentCategories(categories coredata.CookieCategories) coredata.CookieCategories {
sorted := make(coredata.CookieCategories, len(categories)) filtered := make(coredata.CookieCategories, 0, len(categories))
copy(sorted, categories) for _, c := range categories {
slices.SortStableFunc(sorted, func(a, b *coredata.CookieCategory) int { 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 { if d := snapshotCategoryKindOrder(a.Kind) - snapshotCategoryKindOrder(b.Kind); d != 0 {
return d return d
} }
return bytes.Compare(a.ID[:], b.ID[:]) return bytes.Compare(a.ID[:], b.ID[:])
}) })
return sorted return filtered
} }
func buildSnapshot( func buildSnapshot(
@@ -78,7 +80,7 @@ func buildSnapshot(
categories coredata.CookieCategories, categories coredata.CookieCategories,
allPatterns coredata.CookiePatterns, allPatterns coredata.CookiePatterns,
) coredata.CookieBannerVersionSnapshot { ) coredata.CookieBannerVersionSnapshot {
categories = sortCategoriesForSnapshot(categories) categories = consentCategories(categories)
cookiesByCategory := make(map[gid.GID]coredata.CookieItems) cookiesByCategory := make(map[gid.GID]coredata.CookieItems)
for _, p := range allPatterns { for _, p := range allPatterns {