Trackers sharing a display name can differ in type, so the generated cookie and tracking technologies policy was ambiguous without it. Carry the tracker type through the banner version snapshot and surface it as a dedicated column in the policy table. Stop the snapshot from dropping non-cookie trackers so storage, IndexedDB and cache technologies appear in the policy and served banner config with their real type. Duration now reflects the type when no max-age applies: session storage clears with the tab, the remaining storage technologies persist. Legacy snapshots predate the field and only ever held cookies, so GetSnapshot backfills an empty type as COOKIE, keeping the non-null GraphQL enum and policy output valid without a migration. Signed-off-by: Émile Ré <emile@probo.com>
348 lines
9.5 KiB
Go
348 lines
9.5 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
|
//
|
|
// 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,
|
|
DefaultLanguage: "en",
|
|
Categories: []coredata.CookieBannerVersionSnapshotCategory{
|
|
{
|
|
Name: "Analytics",
|
|
Slug: "analytics",
|
|
Description: "Analytics cookies",
|
|
Kind: coredata.CookieCategoryKindNormal,
|
|
Cookies: coredata.CookieItems{
|
|
{Name: "_ga", TrackerType: coredata.TrackerTypeCookie, MaxAgeSeconds: &maxAge, Description: "Google Analytics"},
|
|
},
|
|
GCMConsentTypes: []string{"analytics_storage"},
|
|
PostHogConsent: false,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
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 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 TestUpsertCookieBannerTranslationRequest_Validate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
bannerID := gid.New(gid.NewTenantID(), coredata.CookieBannerEntityType)
|
|
|
|
t.Run("banner_description with cookie_policy_link passes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
translations, err := json.Marshal(map[string]string{
|
|
"banner_title": "Cookie Preferences",
|
|
"banner_description": "We use cookies. {{cookie_policy_link}}",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req := UpsertCookieBannerTranslationRequest{
|
|
CookieBannerID: bannerID,
|
|
Language: "en",
|
|
Translations: translations,
|
|
}
|
|
|
|
assert.NoError(t, req.Validate())
|
|
})
|
|
|
|
t.Run("banner_description without cookie_policy_link fails", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
translations, err := json.Marshal(map[string]string{
|
|
"banner_title": "Cookie Preferences",
|
|
"banner_description": "We use cookies to improve your experience.",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req := UpsertCookieBannerTranslationRequest{
|
|
CookieBannerID: bannerID,
|
|
Language: "en",
|
|
Translations: translations,
|
|
}
|
|
|
|
err = req.Validate()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "translations.banner_description")
|
|
})
|
|
|
|
t.Run("translations without banner_description passes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
translations, err := json.Marshal(map[string]string{
|
|
"banner_title": "Cookie Preferences",
|
|
"button_save": "Save",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req := UpsertCookieBannerTranslationRequest{
|
|
CookieBannerID: bannerID,
|
|
Language: "en",
|
|
Translations: translations,
|
|
}
|
|
|
|
assert.NoError(t, req.Validate())
|
|
})
|
|
}
|
|
|
|
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,
|
|
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)
|
|
shuffled := buildSnapshot(banner, mkCategories(99, 50, 25, 10), 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)
|
|
b := buildSnapshot(banner, reversed, nil)
|
|
|
|
assert.True(t, snapshotsEqual(a, b))
|
|
})
|
|
|
|
t.Run("Necessary comes first given consent-only categories", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
consentOnly := mkCategories(0, 1, 2, 3)[:3]
|
|
snap := buildSnapshot(banner, consentOnly, nil)
|
|
|
|
require.Len(t, snap.Categories, 3)
|
|
assert.Equal(t, coredata.CookieCategoryKindNecessary, snap.Categories[0].Kind)
|
|
})
|
|
}
|
|
|
|
func TestRemapTextsForConsentMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
baseTexts := func() map[string]string {
|
|
return map[string]string{
|
|
"banner_title": "We use cookies",
|
|
"banner_description": "This site uses cookies.",
|
|
"button_accept_all": "Accept All",
|
|
"button_reject_all": "Reject All",
|
|
"button_customize": "Customize",
|
|
"button_dismiss": "Dismiss",
|
|
}
|
|
}
|
|
|
|
t.Run("opt in mode keeps all buttons", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
texts := baseTexts()
|
|
remapTextsForConsentMode(texts, ConsentModeOptIn)
|
|
|
|
assert.Equal(t, "Accept All", texts["button_accept_all"])
|
|
assert.Equal(t, "Reject All", texts["button_reject_all"])
|
|
assert.Equal(t, "Customize", texts["button_customize"])
|
|
})
|
|
|
|
t.Run("opt out mode maps opt out to reject and clears customize", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
texts := baseTexts()
|
|
texts["button_opt_out"] = "Do Not Sell"
|
|
remapTextsForConsentMode(texts, ConsentModeOptOut)
|
|
|
|
assert.Equal(t, "Do Not Sell", texts["button_reject_all"])
|
|
assert.Empty(t, texts["button_customize"])
|
|
})
|
|
}
|
|
|
|
func TestIsLegacySDK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
version string
|
|
want bool
|
|
}{
|
|
{"0.1.0", true},
|
|
{"0.2.0", true},
|
|
{"0.2.5", true},
|
|
{"0.3.0", false},
|
|
{"1.0.0", false},
|
|
{"", false},
|
|
{"invalid", false},
|
|
{"v0.2.0", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.version, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, tt.want, isLegacySDK(tt.version))
|
|
})
|
|
}
|
|
}
|