The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
1222 lines
29 KiB
Go
1222 lines
29 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package cookiebanner
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
)
|
|
|
|
func TestLooksVariable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
token string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "long mixed alphanumeric",
|
|
token: "XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "short mixed alphanumeric below threshold",
|
|
token: "abc123",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "exactly 8 chars mixed",
|
|
token: "a1b2c3d4",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "long hex string 16 chars",
|
|
token: "a1b2c3d4e5f60718",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "short hex string below threshold",
|
|
token: "abcdef12",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "pure letters not variable",
|
|
token: "posthog",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "long pure letters not variable",
|
|
token: "authentication",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "brand name with digit",
|
|
token: "auth0",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "UUID shape",
|
|
token: "550e8400-e29b-41d4-a716-446655440000",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "8 digit number",
|
|
token: "12345678",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "short digit number",
|
|
token: "12345",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
token: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "single char",
|
|
token: "x",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "all uppercase letters",
|
|
token: "MEASUREMENT",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "short brand c15t",
|
|
token: "c15t",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "GA measurement ID style",
|
|
token: "G-1234ABCDEF",
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
assert.Equal(t, tt.expected, looksVariable(tt.token))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestIsUUIDShape(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "valid UUID lowercase",
|
|
input: "550e8400-e29b-41d4-a716-446655440000",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "valid UUID uppercase",
|
|
input: "550E8400-E29B-41D4-A716-446655440000",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "wrong length",
|
|
input: "550e8400-e29b-41d4-a716",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "no dashes",
|
|
input: "550e8400e29b41d4a716446655440000xxxx",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "dashes in wrong positions",
|
|
input: "550e840-0e29b-41d4-a716-446655440000",
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
assert.Equal(t, tt.expected, isUUIDShape(tt.input))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHeuristicTemplate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
template string
|
|
changed bool
|
|
}{
|
|
{
|
|
name: "posthog hash in middle",
|
|
input: "ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_window_id",
|
|
template: "ph_phc_*_window_id",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "no variable tokens",
|
|
input: "probo_consent_given",
|
|
template: "",
|
|
changed: false,
|
|
},
|
|
{
|
|
name: "trailing hash",
|
|
input: "auth0_session_a1b2c3d4e5f6",
|
|
template: "auth0_session_*",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "no separator with variable token",
|
|
input: "a1b2c3d4e5f6g7h8",
|
|
template: "",
|
|
changed: false,
|
|
},
|
|
{
|
|
name: "no separator without variable token",
|
|
input: "PHPSESSID",
|
|
template: "",
|
|
changed: false,
|
|
},
|
|
{
|
|
name: "consecutive variable tokens collapse to single star",
|
|
input: "ph_a1b2c3d4_e5f6g7h8_window",
|
|
template: "ph_*_window",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "UUID token replaced",
|
|
input: "session_550e8400-e29b-41d4-a716-446655440000_data",
|
|
template: "session_*_data",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "leading underscore with hash",
|
|
input: "_ga_G1234ABCDEF",
|
|
template: "_ga_*",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "dash separator with hash",
|
|
input: "c15t-consent-a1b2c3d4e5f6",
|
|
template: "c15t-consent-*",
|
|
changed: true,
|
|
},
|
|
{
|
|
name: "leading underscores with dash not variable",
|
|
input: "__Secure-1PSID",
|
|
changed: false,
|
|
},
|
|
{
|
|
name: "all variable tokens with leading underscores rejected",
|
|
input: "__a1b2c3d4_e5f6g7h8",
|
|
changed: false,
|
|
},
|
|
{
|
|
name: "colon-delimited trailing UUID collapses to wildcard",
|
|
input: "letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789",
|
|
template: "letaido.onboarding.invite_done:*",
|
|
changed: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tmpl, changed := heuristicTemplate(tt.input)
|
|
assert.Equal(t, tt.changed, changed)
|
|
|
|
if changed {
|
|
assert.Equal(t, tt.template, tmpl)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestTemplateCandidates(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "single underscore produces prefix candidate",
|
|
input: "ph_abc123",
|
|
expected: []string{
|
|
"ph_*",
|
|
},
|
|
},
|
|
{
|
|
name: "multiple underscores produces prefix and sandwich candidates",
|
|
input: "ph_phc_abc123",
|
|
expected: []string{
|
|
"ph_*",
|
|
"ph_phc_*",
|
|
"ph_*_abc123",
|
|
},
|
|
},
|
|
{
|
|
name: "four tokens produces multiple sandwich candidates",
|
|
input: "ph_phc_abc123_posthog",
|
|
expected: []string{
|
|
"ph_*",
|
|
"ph_phc_*",
|
|
"ph_phc_abc123_*",
|
|
"ph_*_abc123_posthog",
|
|
"ph_phc_*_posthog",
|
|
},
|
|
},
|
|
{
|
|
name: "leading underscore drops anchor-free prefix",
|
|
input: "_ga_GB2J3DLBHE",
|
|
expected: []string{
|
|
"_ga_*",
|
|
"_*_GB2J3DLBHE",
|
|
},
|
|
},
|
|
{
|
|
name: "dash separator",
|
|
input: "c15t-consent-abc123",
|
|
expected: []string{
|
|
"c15t-*",
|
|
"c15t-consent-*",
|
|
"c15t-*-abc123",
|
|
},
|
|
},
|
|
{
|
|
name: "no separators",
|
|
input: "PHPSESSID",
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "brand with digits",
|
|
input: "auth0_session_abc123",
|
|
expected: []string{
|
|
"auth0_*",
|
|
"auth0_session_*",
|
|
"auth0_*_abc123",
|
|
},
|
|
},
|
|
{
|
|
name: "double leading underscore drops anchor-free prefixes",
|
|
input: "__support__",
|
|
expected: []string{
|
|
"__support_*",
|
|
"__support__*",
|
|
"_*_support__",
|
|
"__support_*_",
|
|
},
|
|
},
|
|
{
|
|
name: "double underscore extension key drops anchor-free prefixes",
|
|
input: "__darkreader__wasEnabledForHost",
|
|
expected: []string{
|
|
"__darkreader_*",
|
|
"__darkreader__*",
|
|
"_*_darkreader__wasEnabledForHost",
|
|
"__*__wasEnabledForHost",
|
|
"__darkreader_*_wasEnabledForHost",
|
|
},
|
|
},
|
|
{
|
|
name: "double leading dash drops anchor-free prefixes",
|
|
input: "--leading-dash-foo",
|
|
expected: []string{
|
|
"--leading-*",
|
|
"--leading-dash-*",
|
|
"-*-leading-dash-foo",
|
|
"--*-dash-foo",
|
|
"--leading-*-foo",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := templateCandidates(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestGlobMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
pattern string
|
|
input string
|
|
match bool
|
|
}{
|
|
{
|
|
name: "prefix glob matches",
|
|
pattern: "ph_phc_*",
|
|
input: "ph_phc_abc123",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "prefix glob does not match different prefix",
|
|
pattern: "ph_phc_*",
|
|
input: "ph_session_abc123",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "suffix glob matches",
|
|
pattern: "*_posthog",
|
|
input: "ph_phc_abc123_posthog",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "suffix glob does not match different suffix",
|
|
pattern: "*_posthog",
|
|
input: "ph_phc_abc123_analytics",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "sandwich glob matches",
|
|
pattern: "ph_phc_*_posthog",
|
|
input: "ph_phc_abc123_posthog",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "sandwich glob does not match wrong prefix",
|
|
pattern: "ph_phc_*_posthog",
|
|
input: "xx_phc_abc123_posthog",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "sandwich glob does not match wrong suffix",
|
|
pattern: "ph_phc_*_posthog",
|
|
input: "ph_phc_abc123_other",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "sandwich glob matches minimal middle",
|
|
pattern: "ph_phc_*_posthog",
|
|
input: "ph_phc_x_posthog",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "sandwich glob matches empty middle",
|
|
pattern: "ph_*_posthog",
|
|
input: "ph__posthog",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "exact match without wildcard",
|
|
pattern: "probo_consent",
|
|
input: "probo_consent",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "no match without wildcard",
|
|
pattern: "probo_consent",
|
|
input: "probo_consent2",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "input shorter than pattern fixed chars does not match",
|
|
pattern: "ph_phc_*_posthog",
|
|
input: "ph_phc_posthog",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "multi-star matches",
|
|
pattern: "ph_*_something_*_end",
|
|
input: "ph_hash1_something_hash2_end",
|
|
match: true,
|
|
},
|
|
{
|
|
name: "multi-star wrong middle segment",
|
|
pattern: "ph_*_something_*_end",
|
|
input: "ph_hash1_other_hash2_end",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "multi-star wrong suffix",
|
|
pattern: "ph_*_something_*_end",
|
|
input: "ph_hash1_something_hash2_nope",
|
|
match: false,
|
|
},
|
|
{
|
|
name: "multi-star with underscore-heavy middle",
|
|
pattern: "a_*_b_*_c",
|
|
input: "a_x_y_z_b_q_r_c",
|
|
match: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
assert.Equal(t, tt.match, globMatch(tt.pattern, tt.input))
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestSplitTokens(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
tokens []string
|
|
seps []byte
|
|
}{
|
|
{
|
|
name: "underscore separator",
|
|
input: "ph_phc_abc",
|
|
tokens: []string{"ph", "phc", "abc"},
|
|
seps: []byte{'_', '_'},
|
|
},
|
|
{
|
|
name: "dash separator",
|
|
input: "c15t-consent-abc",
|
|
tokens: []string{"c15t", "consent", "abc"},
|
|
seps: []byte{'-', '-'},
|
|
},
|
|
{
|
|
name: "no separator",
|
|
input: "PHPSESSID",
|
|
tokens: []string{"PHPSESSID"},
|
|
seps: nil,
|
|
},
|
|
{
|
|
name: "mixed separators split both",
|
|
input: "foo_bar-baz",
|
|
tokens: []string{"foo", "bar", "baz"},
|
|
seps: []byte{'_', '-'},
|
|
},
|
|
{
|
|
name: "leading underscores with dash",
|
|
input: "__Secure-1PSID",
|
|
tokens: []string{"", "", "Secure", "1PSID"},
|
|
seps: []byte{'_', '_', '-'},
|
|
},
|
|
{
|
|
name: "UUID preserved as single token",
|
|
input: "session_550e8400-e29b-41d4-a716-446655440000_data",
|
|
tokens: []string{"session", "550e8400-e29b-41d4-a716-446655440000", "data"},
|
|
seps: []byte{'_', '_'},
|
|
},
|
|
{
|
|
name: "colon and dot separators isolate trailing UUID",
|
|
input: "letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789",
|
|
tokens: []string{"letaido", "onboarding", "invite", "done", "0a1b2c3d-4e5f-6789-abcd-ef0123456789"},
|
|
seps: []byte{'.', '.', '_', ':'},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tokens, seps := splitTokens(tt.input)
|
|
assert.Equal(t, tt.tokens, tokens)
|
|
assert.Equal(t, tt.seps, seps)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestFindMergeGroups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oneYear := 365 * 24 * 3600
|
|
|
|
makePattern := func(name string, maxAge *int) *coredata.TrackerPattern {
|
|
return &coredata.TrackerPattern{
|
|
Pattern: name,
|
|
TrackerType: coredata.TrackerTypeCookie,
|
|
MatchType: coredata.TrackerPatternMatchTypeExact,
|
|
MaxAgeSeconds: maxAge,
|
|
}
|
|
}
|
|
|
|
t.Run(
|
|
"multi-separator names pick longest prefix template",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_abc123", &oneYear),
|
|
makePattern("ph_phc_def456", &oneYear),
|
|
makePattern("ph_phc_ghi789", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"leading separator cookies",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("_ga_ABC123", &oneYear),
|
|
makePattern("_ga_DEF456", &oneYear),
|
|
makePattern("_ga_GHI789", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "_ga_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"brand name with digits in prefix",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("auth0_session_abc123", &oneYear),
|
|
makePattern("auth0_session_def456", &oneYear),
|
|
makePattern("auth0_session_ghi789", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "auth0_session_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"no merge below threshold",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("deadbeef_setting", &oneYear),
|
|
makePattern("something_else", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
assert.Empty(t, groups)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"nested prefix resolution prefers longest",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("foo_bar_aaa", &oneYear),
|
|
makePattern("foo_bar_bbb", &oneYear),
|
|
makePattern("foo_bar_ccc", &oneYear),
|
|
makePattern("foo_baz_xxx", &oneYear),
|
|
makePattern("foo_baz_yyy", &oneYear),
|
|
makePattern("foo_baz_zzz", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 2)
|
|
|
|
barGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "foo_bar_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, barGroup, 3)
|
|
|
|
bazGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "foo_baz_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, bazGroup, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"specific prefix wins over broad prefix",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_abc123", &oneYear),
|
|
makePattern("ph_phc_def456", &oneYear),
|
|
makePattern("ph_phc_ghi789", &oneYear),
|
|
makePattern("ph_session_xyz", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"leftover patterns form group under shorter prefix",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_abc123", &oneYear),
|
|
makePattern("ph_phc_def456", &oneYear),
|
|
makePattern("ph_phc_ghi789", &oneYear),
|
|
makePattern("ph_session_aaa", &oneYear),
|
|
makePattern("ph_session_bbb", &oneYear),
|
|
makePattern("ph_session_ccc", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 2)
|
|
|
|
phcGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, phcGroup, 3)
|
|
|
|
sessionGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_session_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, sessionGroup, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"no separators means no merge",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("PHPSESSID", nil),
|
|
makePattern("JSESSIONID", nil),
|
|
makePattern("ASPSESSIONID", nil),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
assert.Empty(t, groups)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"session and persistent cookies do not merge",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("_ga_ABC123", nil),
|
|
makePattern("_ga_DEF456", nil),
|
|
makePattern("_ga_GHI789", nil),
|
|
makePattern("_ga_JKL012", &oneYear),
|
|
makePattern("_ga_MNO345", &oneYear),
|
|
makePattern("_ga_PQR678", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 2)
|
|
|
|
sessionGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "_ga_*", durationBucket: -1}]
|
|
require.True(t, ok)
|
|
assert.Len(t, sessionGroup, 3)
|
|
|
|
persistentGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "_ga_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, persistentGroup, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"close durations snap to same bucket and merge",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
exactYear := 365 * 24 * 3600
|
|
almostYear := 364 * 24 * 3600
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("_ga_ABC123", &exactYear),
|
|
makePattern("_ga_DEF456", &almostYear),
|
|
makePattern("_ga_GHI789", &exactYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"different durations do not merge",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
oneDay := 24 * 3600
|
|
thirtyDays := 30 * 24 * 3600
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("_ga_ABC123", &oneDay),
|
|
makePattern("_ga_DEF456", &oneDay),
|
|
makePattern("_ga_GHI789", &oneDay),
|
|
makePattern("_ga_JKL012", &thirtyDays),
|
|
makePattern("_ga_MNO345", &thirtyDays),
|
|
makePattern("_ga_PQR678", &thirtyDays),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 2)
|
|
|
|
dayGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "_ga_*", durationBucket: durationBucket(&oneDay)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, dayGroup, 3)
|
|
|
|
monthGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "_ga_*", durationBucket: durationBucket(&thirtyDays)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, monthGroup, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"sandwich pattern discovered from shared prefix and suffix",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_abc123_posthog", &oneYear),
|
|
makePattern("ph_phc_def456_posthog", &oneYear),
|
|
makePattern("ph_phc_ghi789_posthog", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_posthog", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"sandwich pattern wins over shorter prefix",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_abc123_posthog", &oneYear),
|
|
makePattern("ph_phc_def456_posthog", &oneYear),
|
|
makePattern("ph_phc_ghi789_posthog", &oneYear),
|
|
makePattern("ph_phc_other", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_posthog", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"single pattern with hash normalized via heuristic",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_window_id", nil),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_window_id", durationBucket: -1}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 1)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"same hash different suffixes produce separate heuristic globs",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_window_id", nil),
|
|
makePattern("ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_posthog", nil),
|
|
makePattern("ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_primary_window_exists", nil),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 3)
|
|
|
|
windowGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_window_id", durationBucket: -1}]
|
|
require.True(t, ok)
|
|
assert.Len(t, windowGroup, 1)
|
|
|
|
posthogGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_posthog", durationBucket: -1}]
|
|
require.True(t, ok)
|
|
assert.Len(t, posthogGroup, 1)
|
|
|
|
primaryGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_primary_window_exists", durationBucket: -1}]
|
|
require.True(t, ok)
|
|
assert.Len(t, primaryGroup, 1)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"patterns without variable tokens still require statistical threshold",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("foo_bar_aaa", &oneYear),
|
|
makePattern("foo_bar_bbb", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
assert.Empty(t, groups)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"heuristic and statistical patterns coexist",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_window_id", &oneYear),
|
|
makePattern("foo_bar_aaa", &oneYear),
|
|
makePattern("foo_bar_bbb", &oneYear),
|
|
makePattern("foo_bar_ccc", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 2)
|
|
|
|
heuristicGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "ph_phc_*_window_id", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, heuristicGroup, 1)
|
|
|
|
statGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "foo_bar_*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, statGroup, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"secure prefix cookies do not produce heuristic glob",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("__Secure-1PSID", &oneYear),
|
|
makePattern("__Secure-1PSIDTS", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
assert.Empty(t, groups)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"colon-delimited UUID keys merge under heuristic glob",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("letaido.onboarding.invite_done:0a1b2c3d-4e5f-6789-abcd-ef0123456789", &oneYear),
|
|
makePattern("letaido.onboarding.invite_done:11111111-2222-3333-4444-555555555555", &oneYear),
|
|
makePattern("letaido.onboarding.invite_done:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", &oneYear),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
require.Len(t, groups, 1)
|
|
|
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, template: "letaido.onboarding.invite_done:*", durationBucket: durationBucket(&oneYear)}]
|
|
require.True(t, ok)
|
|
assert.Len(t, group, 3)
|
|
},
|
|
)
|
|
|
|
t.Run(
|
|
"unrelated double-underscore keys do not merge under anchor-free glob",
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
patterns := coredata.TrackerPatterns{
|
|
makePattern("__support__", nil),
|
|
makePattern("__darkreader__wasEnabledForHost", nil),
|
|
makePattern("__EXT_APP_REFRESH_BLACK_SUB_DOMAINS__", nil),
|
|
}
|
|
|
|
groups := findMergeGroups(patterns, 3)
|
|
assert.Empty(t, groups)
|
|
},
|
|
)
|
|
}
|
|
|
|
func TestDurationBucket(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
maxAge *int
|
|
expected int
|
|
}{
|
|
{
|
|
name: "nil is session",
|
|
maxAge: nil,
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "zero is session",
|
|
maxAge: new(0),
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "negative is session",
|
|
maxAge: new(-1),
|
|
expected: -1,
|
|
},
|
|
{
|
|
name: "exact 1 year",
|
|
maxAge: new(365 * 24 * 3600),
|
|
expected: 365 * 24 * 3600,
|
|
},
|
|
{
|
|
name: "364 days snaps to 1 year",
|
|
maxAge: new(364 * 24 * 3600),
|
|
expected: 365 * 24 * 3600,
|
|
},
|
|
{
|
|
name: "exact 30 days",
|
|
maxAge: new(30 * 24 * 3600),
|
|
expected: 30 * 24 * 3600,
|
|
},
|
|
{
|
|
name: "exact 1 day",
|
|
maxAge: new(24 * 3600),
|
|
expected: 24 * 3600,
|
|
},
|
|
{
|
|
name: "23h snaps to 1 day",
|
|
maxAge: new(23 * 3600),
|
|
expected: 24 * 3600,
|
|
},
|
|
{
|
|
name: "exact 1 hour",
|
|
maxAge: new(3600),
|
|
expected: 3600,
|
|
},
|
|
{
|
|
name: "58 minutes snaps to 1 hour",
|
|
maxAge: new(58 * 60),
|
|
expected: 3600,
|
|
},
|
|
{
|
|
name: "exact 5 minutes",
|
|
maxAge: new(5 * 60),
|
|
expected: 5 * 60,
|
|
},
|
|
{
|
|
name: "1 day and 30 days are different buckets",
|
|
maxAge: new(24 * 3600),
|
|
expected: 24 * 3600,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
result := durationBucket(tt.maxAge)
|
|
assert.Equal(t, tt.expected, result)
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestShouldPromoteSource(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
script := coredata.CookieSourceScript
|
|
extension := coredata.CookieSourceExtension
|
|
preExisting := coredata.CookieSourcePreExisting
|
|
http := coredata.CookieSourceHTTP
|
|
|
|
tests := []struct {
|
|
name string
|
|
existing *coredata.CookieSource
|
|
candidate *coredata.CookieSource
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil existing promotes to SCRIPT",
|
|
existing: nil,
|
|
candidate: &script,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "nil existing promotes to EXTENSION",
|
|
existing: nil,
|
|
candidate: &extension,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "nil existing does not promote to PRE_EXISTING (equal rank)",
|
|
existing: nil,
|
|
candidate: &preExisting,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "PRE_EXISTING promotes to SCRIPT",
|
|
existing: &preExisting,
|
|
candidate: &script,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "PRE_EXISTING promotes to EXTENSION",
|
|
existing: &preExisting,
|
|
candidate: &extension,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "EXTENSION promotes to SCRIPT",
|
|
existing: &extension,
|
|
candidate: &script,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "SCRIPT does not promote to PRE_EXISTING",
|
|
existing: &script,
|
|
candidate: &preExisting,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "SCRIPT does not promote to EXTENSION",
|
|
existing: &script,
|
|
candidate: &extension,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "EXTENSION does not promote to PRE_EXISTING",
|
|
existing: &extension,
|
|
candidate: &preExisting,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "SCRIPT does not promote to SCRIPT (equal rank, no write)",
|
|
existing: &script,
|
|
candidate: &script,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "HTTP does not promote to SCRIPT (HTTP ranks below SCRIPT)",
|
|
existing: &script,
|
|
candidate: &http,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "nil existing promotes to HTTP",
|
|
existing: nil,
|
|
candidate: &http,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "PRE_EXISTING promotes to HTTP",
|
|
existing: &preExisting,
|
|
candidate: &http,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "EXTENSION promotes to HTTP (real server cookie outranks extension state)",
|
|
existing: &extension,
|
|
candidate: &http,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "HTTP promotes to SCRIPT",
|
|
existing: &http,
|
|
candidate: &script,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "HTTP does not promote to EXTENSION",
|
|
existing: &http,
|
|
candidate: &extension,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "HTTP does not promote to PRE_EXISTING",
|
|
existing: &http,
|
|
candidate: &preExisting,
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(
|
|
tt.name,
|
|
func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, tt.want, shouldPromoteSource(tt.existing, tt.candidate))
|
|
},
|
|
)
|
|
}
|
|
}
|