diff --git a/pkg/cookiebanner/pattern_analysis_worker.go b/pkg/cookiebanner/pattern_analysis_worker.go index f46e4148a..b7141f990 100644 --- a/pkg/cookiebanner/pattern_analysis_worker.go +++ b/pkg/cookiebanner/pattern_analysis_worker.go @@ -137,18 +137,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co func(ctx context.Context, tx pg.Tx) error { scope := coredata.NewScopeFromObjectID(banner.ID) - var uncategorised coredata.CookieCategory - - hasUncategorised := true - - if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, banner.ID); err != nil { - if !errors.Is(err, coredata.ErrResourceNotFound) { - return fmt.Errorf("cannot load uncategorised category: %w", err) - } - - hasUncategorised = false - } - var exactPatterns coredata.TrackerPatterns if err := exactPatterns.LoadAllByCookieBannerID( ctx, @@ -163,8 +151,6 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co mergeGroups := findMergeGroups(exactPatterns, patternMergeThreshold) - consentChanged := false - for key, group := range mergeGroups { var maxAge *int @@ -203,9 +189,23 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co return fmt.Errorf("cannot load existing glob pattern %q: %w", key.template, err) } - if globPattern.CookieCategoryID != key.categoryID || globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob { + if globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob || globPattern.CookieCategoryID != key.categoryID { + // The slot is occupied by an exact pattern or + // a user-recategorised glob. Skip the relink + // here so we don't overwrite the user's + // categorisation; the exact patterns in + // `group` will be picked up below by + // adoptUncategorisedPatterns if they live in + // the uncategorised category and globMatch + // the existing glob. continue } + + if shouldPromoteSource(globPattern.Source, source) { + if err := globPattern.PromoteSource(ctx, tx, scope, *source, now); err != nil { + return fmt.Errorf("cannot promote source on glob pattern %q: %w", key.template, err) + } + } } for _, exactPattern := range group { @@ -219,20 +219,18 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co } } - if !hasUncategorised || key.categoryID != uncategorised.ID { - consentChanged = true - } - h.logger.InfoCtx( ctx, "merged exact patterns into glob pattern", log.String("template", key.template), log.Int("count", len(group)), + log.Bool("inserted", inserted), log.String("banner_id", banner.ID.String()), ) } - if _, err := h.adoptUncategorisedPatterns(ctx, tx, scope, banner); err != nil { + adopted, err := h.adoptUncategorisedPatterns(ctx, tx, scope, banner) + if err != nil { return fmt.Errorf("cannot adopt uncategorised patterns: %w", err) } @@ -241,7 +239,13 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co return fmt.Errorf("cannot refresh last_matched_at: %w", err) } - if consentChanged { + // Merging exact patterns into a glob in the same category + // does not change visitor consent for those identifiers + // (findMergeGroups keys on category, so every member of a + // group is already under key.categoryID). Adoption is the + // only operation in this worker that moves trackers + // between categories and therefore changes consent. + if adopted { if _, err := h.svc.ensureDraftVersionForBanner(ctx, tx, scope, banner.ID); err != nil { return fmt.Errorf("cannot ensure draft version: %w", err) } @@ -633,15 +637,45 @@ func globMatch(pattern, name string) bool { return true } +// sourceRank converts a CookieSource into a comparable rank that +// reflects signal strength: SCRIPT > EXTENSION > PRE_EXISTING. HTTP +// and nil collapse into the PRE_EXISTING rank because bestSource +// already normalises them; if a future caller hands us either, the +// ranking still produces a sane "no promotion" outcome against +// PRE_EXISTING/EXTENSION/SCRIPT existing values. +func sourceRank(s *coredata.CookieSource) int { + if s == nil { + return 0 + } + + switch *s { + case coredata.CookieSourceScript: + return 2 + case coredata.CookieSourceExtension: + return 1 + default: + return 0 + } +} + +// shouldPromoteSource reports whether candidate represents a stronger +// signal than existing under the SCRIPT > EXTENSION > PRE_EXISTING +// precedence used across the cookie-banner pipeline. Equal ranks do +// not promote so we avoid pointless writes. +func shouldPromoteSource(existing, candidate *coredata.CookieSource) bool { + return sourceRank(candidate) > sourceRank(existing) +} + // bestSource rolls up the source values of a group of exact patterns // being merged into a single glob. Precedence is SCRIPT > EXTENSION -// > PRE_EXISTING, mirroring both the upsert SQL's "page-script wins" -// rule and the asymmetric signal strength of each bucket: SCRIPT is -// high-confidence page evidence (a real page tracker), EXTENSION is -// high-confidence extension evidence, and PRE_EXISTING is the -// catch-all that may include extension state injected before SDK -// load. HTTP and nil collapse into PRE_EXISTING here, preserving -// the original two-value rollup behaviour for non-script values. +// > PRE_EXISTING, mirroring both the page-script-wins rule in +// detected_trackers and the asymmetric signal strength of each +// bucket: SCRIPT is high-confidence page evidence (a real page +// tracker), EXTENSION is high-confidence extension evidence, and +// PRE_EXISTING is the catch-all that may include extension state +// injected before SDK load. HTTP and nil collapse into PRE_EXISTING +// here, preserving the original two-value rollup behaviour for +// non-script values. func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource { var hasExtension bool diff --git a/pkg/cookiebanner/pattern_analysis_worker_process_test.go b/pkg/cookiebanner/pattern_analysis_worker_process_test.go new file mode 100644 index 000000000..29b447db4 --- /dev/null +++ b/pkg/cookiebanner/pattern_analysis_worker_process_test.go @@ -0,0 +1,492 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( + "context" + "errors" + "io" + "net" + "net/url" + "os" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +const testPgDSNEnvVar = "PROBO_TEST_PG_URL" + +func newTestPgClient(t *testing.T) *pg.Client { + t.Helper() + + dsn := os.Getenv(testPgDSNEnvVar) + if dsn == "" { + t.Skipf("skipping: %s not set (requires a migrated test database)", testPgDSNEnvVar) + } + + u, err := url.Parse(dsn) + require.NoError(t, err, "invalid %s value", testPgDSNEnvVar) + + opts := []pg.Option{pg.WithRegisterer(prometheus.NewRegistry())} + + if u.Host != "" { + host := u.Host + if u.Port() == "" { + host = net.JoinHostPort(u.Hostname(), "5432") + } + + opts = append(opts, pg.WithAddr(host)) + } + + if u.User != nil { + opts = append(opts, pg.WithUser(u.User.Username())) + if password, ok := u.User.Password(); ok { + opts = append(opts, pg.WithPassword(password)) + } + } + + if len(u.Path) > 1 { + opts = append(opts, pg.WithDatabase(u.Path[1:])) + } + + client, err := pg.NewClient(opts...) + require.NoError(t, err) + + t.Cleanup(func() { + client.Close() + }) + + return client +} + +// workerFixture bootstraps the parent rows the worker's transaction +// needs: an organization, a cookie banner, an uncategorised category, +// and a normal category. Patterns/detected trackers are seeded +// per-test. +type workerFixture struct { + scope *coredata.Scope + organizationID gid.GID + banner coredata.CookieBanner + uncategorisedID gid.GID + normalCategoryID gid.GID +} + +func seedWorkerFixture(t *testing.T, ctx context.Context, client *pg.Client) workerFixture { + t.Helper() + + tenantID := gid.NewTenantID() + scope := coredata.NewScope(tenantID) + organizationID := gid.New(tenantID, coredata.OrganizationEntityType) + bannerID := gid.New(tenantID, coredata.CookieBannerEntityType) + uncategorisedID := gid.New(tenantID, coredata.CookieCategoryEntityType) + normalCategoryID := gid.New(tenantID, coredata.CookieCategoryEntityType) + now := time.Now().UTC().Truncate(time.Microsecond) + + banner := coredata.CookieBanner{ + ID: bannerID, + OrganizationID: organizationID, + Name: "Worker Test Banner", + Origin: "https://worker-test-" + bannerID.String() + ".example.com", + State: coredata.CookieBannerStateActive, + CookiePolicyURL: "https://worker-test.example.com/cookies", + ConsentExpiryDays: 180, + ShowBranding: false, + DefaultLanguage: "en", + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + org := &coredata.Organization{ + ID: organizationID, + TenantID: tenantID, + Name: "Worker Test Org", + CreatedAt: now, + UpdatedAt: now, + } + if err := org.Insert(ctx, tx); err != nil { + return err + } + + if err := banner.Insert(ctx, tx, scope); err != nil { + return err + } + + uncategorised := &coredata.CookieCategory{ + ID: uncategorisedID, + OrganizationID: organizationID, + CookieBannerID: bannerID, + Name: "Uncategorised", + Slug: "uncategorised", + Description: "", + Kind: coredata.CookieCategoryKindUncategorised, + Rank: 0, + GCMConsentTypes: []string{}, + PostHogConsent: false, + CreatedAt: now, + UpdatedAt: now, + } + if err := uncategorised.Insert(ctx, tx, scope); err != nil { + return err + } + + normal := &coredata.CookieCategory{ + ID: normalCategoryID, + OrganizationID: organizationID, + CookieBannerID: bannerID, + Name: "Analytics", + Slug: "analytics", + Description: "", + Kind: coredata.CookieCategoryKindNormal, + Rank: 1, + GCMConsentTypes: []string{}, + PostHogConsent: false, + CreatedAt: now, + UpdatedAt: now, + } + if err := normal.Insert(ctx, tx, scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM detected_trackers WHERE cookie_banner_id = $1`, bannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM tracker_patterns WHERE cookie_banner_id = $1`, bannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM cookie_banner_versions WHERE cookie_banner_id = $1`, bannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM cookie_categories WHERE cookie_banner_id = $1`, bannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM cookie_banners WHERE id = $1`, bannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil { + return err + } + + return nil + }) + }) + + return workerFixture{ + scope: scope, + organizationID: organizationID, + banner: banner, + uncategorisedID: uncategorisedID, + normalCategoryID: normalCategoryID, + } +} + +func newExactPattern( + fx workerFixture, + pattern string, + categoryID gid.GID, + source coredata.CookieSource, + maxAge *int, +) *coredata.TrackerPattern { + now := time.Now().UTC().Truncate(time.Microsecond) + + return &coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: categoryID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: pattern, + MatchType: coredata.TrackerPatternMatchTypeExact, + DisplayName: pattern, + Description: "", + MaxAgeSeconds: maxAge, + Source: &source, + MappingRequestedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } +} + +func newGlobInCategory( + fx workerFixture, + pattern string, + categoryID gid.GID, + source coredata.CookieSource, + maxAge *int, +) *coredata.TrackerPattern { + now := time.Now().UTC().Truncate(time.Microsecond) + + return &coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: categoryID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: pattern, + MatchType: coredata.TrackerPatternMatchTypeGlob, + DisplayName: pattern, + Description: "", + MaxAgeSeconds: maxAge, + Source: &source, + MappingRequestedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } +} + +func newTestHandler(client *pg.Client) *patternAnalysisHandler { + return &patternAnalysisHandler{ + svc: NewService(client, false), + pg: client, + logger: log.NewLogger(log.WithOutput(io.Discard)), + } +} + +// TestPatternAnalysisWorker_PromotesSourceOnExistingGlob seeds a +// banner with a PRE_EXISTING `_ga_*` glob in the analytics category, +// adds three SCRIPT-source exacts that group under the same template, +// and asserts that running the worker promotes the glob's source to +// SCRIPT. This guards against the original regression where +// bestSource was only honoured on first insert and subsequent batches +// could never promote. +func TestPatternAnalysisWorker_PromotesSourceOnExistingGlob(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedWorkerFixture(t, ctx, client) + + maxAge := 7 * 24 * 3600 + + existingGlob := newGlobInCategory( + fx, + "_ga_*", + fx.normalCategoryID, + coredata.CookieSourcePreExisting, + &maxAge, + ) + exacts := []*coredata.TrackerPattern{ + newExactPattern(fx, "_ga_abc123", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_def456", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_xyz789", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + if err := existingGlob.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + for _, ep := range exacts { + if err := ep.Insert(ctx, tx, fx.scope); err != nil { + return err + } + } + + return nil + })) + + h := newTestHandler(client) + require.NoError(t, h.Process(ctx, fx.banner)) + + loaded := &coredata.TrackerPattern{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByBannerIDTypeAndPattern( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.TrackerTypeCookie, + "_ga_*", + &maxAge, + ) + })) + + require.NotNil(t, loaded.Source) + assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "glob source must be promoted to SCRIPT") + assert.Equal(t, existingGlob.ID, loaded.ID, "the existing glob row must be reused, not replaced") + assert.Equal(t, fx.normalCategoryID, loaded.CookieCategoryID, "category must not be touched") + + var remainingExacts coredata.TrackerPatterns + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return remainingExacts.LoadAllByCookieBannerID( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)), + nil, + ) + })) + assert.Empty(t, remainingExacts, "all three exacts must be relinked and deleted") +} + +// TestPatternAnalysisWorker_AdoptionTriggersDraftVersion seeds a +// banner with a categorised `_ga_*` glob and uncategorised exacts +// that match it. The merge loop must skip the relink (different +// category) but adoptUncategorisedPatterns must re-home the exacts; +// the worker must then create a draft banner version reflecting the +// consent state change. This guards against the original bug where +// the adopted bool was discarded. +func TestPatternAnalysisWorker_AdoptionTriggersDraftVersion(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedWorkerFixture(t, ctx, client) + + maxAge := 7 * 24 * 3600 + + existingGlob := newGlobInCategory( + fx, + "_ga_*", + fx.normalCategoryID, + coredata.CookieSourceScript, + &maxAge, + ) + exacts := []*coredata.TrackerPattern{ + newExactPattern(fx, "_ga_abc123", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge), + newExactPattern(fx, "_ga_def456", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge), + newExactPattern(fx, "_ga_xyz789", fx.uncategorisedID, coredata.CookieSourcePreExisting, &maxAge), + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + if err := existingGlob.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + for _, ep := range exacts { + if err := ep.Insert(ctx, tx, fx.scope); err != nil { + return err + } + } + + return nil + })) + + h := newTestHandler(client) + require.NoError(t, h.Process(ctx, fx.banner)) + + loaded := &coredata.TrackerPattern{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByBannerIDTypeAndPattern( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.TrackerTypeCookie, + "_ga_*", + &maxAge, + ) + })) + assert.Equal(t, fx.normalCategoryID, loaded.CookieCategoryID, "user-set category must not be overwritten by the worker") + assert.Equal(t, existingGlob.ID, loaded.ID, "existing glob row must be reused") + + var remainingExacts coredata.TrackerPatterns + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return remainingExacts.LoadAllByCookieBannerID( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)), + nil, + ) + })) + assert.Empty(t, remainingExacts, "adoptUncategorisedPatterns must absorb the uncategorised exacts into the existing glob") + + latest := &coredata.CookieBannerVersion{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return latest.LoadLatestByCookieBannerID(ctx, conn, fx.scope, fx.banner.ID) + })) + assert.Equal(t, coredata.CookieBannerVersionStateDraft, latest.State, "adoption must trigger a draft version") +} + +// TestPatternAnalysisWorker_MergeWithoutAdoptionSkipsDraftVersion +// asserts the inverse: when the worker only consolidates exacts into +// a glob in their own category (no consent transition), no draft +// version is created. This guards against the prior over-eager +// consentChanged flag, which produced redundant draft versions on +// every merge into a non-uncategorised category. +func TestPatternAnalysisWorker_MergeWithoutAdoptionSkipsDraftVersion(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedWorkerFixture(t, ctx, client) + + maxAge := 7 * 24 * 3600 + + exacts := []*coredata.TrackerPattern{ + newExactPattern(fx, "_ga_abc123", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_def456", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + newExactPattern(fx, "_ga_xyz789", fx.normalCategoryID, coredata.CookieSourceScript, &maxAge), + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + for _, ep := range exacts { + if err := ep.Insert(ctx, tx, fx.scope); err != nil { + return err + } + } + + return nil + })) + + h := newTestHandler(client) + require.NoError(t, h.Process(ctx, fx.banner)) + + var globs coredata.TrackerPatterns + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return globs.LoadAllByCookieBannerID( + ctx, + conn, + fx.scope, + fx.banner.ID, + coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeGlob), nil, new(false)), + nil, + ) + })) + require.Len(t, globs, 1, "the three exacts must consolidate into a single glob") + assert.Equal(t, "_ga_*", globs[0].Pattern) + assert.Equal(t, fx.normalCategoryID, globs[0].CookieCategoryID) + + latest := &coredata.CookieBannerVersion{} + err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return latest.LoadLatestByCookieBannerID(ctx, conn, fx.scope, fx.banner.ID) + }) + assert.ErrorIs(t, err, coredata.ErrResourceNotFound, "merge alone must not create a draft version") + // Sanity-check the negative assertion: if the lookup unexpectedly + // succeeds, fail with a clearer message than the bare error mismatch. + if !errors.Is(err, coredata.ErrResourceNotFound) { + t.Fatalf("merge-only run unexpectedly produced a banner version: state=%s", latest.State) + } +} diff --git a/pkg/cookiebanner/pattern_analysis_worker_test.go b/pkg/cookiebanner/pattern_analysis_worker_test.go index 41ef2fae3..aa9058f09 100644 --- a/pkg/cookiebanner/pattern_analysis_worker_test.go +++ b/pkg/cookiebanner/pattern_analysis_worker_test.go @@ -1051,3 +1051,103 @@ func TestDurationBucket(t *testing.T) { ) } } + +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 collapses to PRE_EXISTING rank: does not promote SCRIPT", + existing: &script, + candidate: &http, + want: false, + }, + { + name: "HTTP collapses to PRE_EXISTING rank: equal to nil existing", + existing: nil, + candidate: &http, + 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)) + }, + ) + } +} diff --git a/pkg/coredata/tracker_pattern.go b/pkg/coredata/tracker_pattern.go index 010dc9e7a..f29a80918 100644 --- a/pkg/coredata/tracker_pattern.go +++ b/pkg/coredata/tracker_pattern.go @@ -491,6 +491,52 @@ ON CONFLICT (cookie_banner_id, tracker_type, pattern, COALESCE(max_age_seconds, return result.RowsAffected() > 0, nil } +// PromoteSource overwrites the row's source column with newSource and +// refreshes updated_at. The caller is responsible for ranking +// newSource against the existing value before invoking this method — +// see shouldPromoteSource in pkg/cookiebanner. Returns ErrResourceNotFound +// if no row with the receiver's ID exists in scope. +func (tp *TrackerPattern) PromoteSource( + ctx context.Context, + tx pg.Tx, + scope Scoper, + newSource CookieSource, + updatedAt time.Time, +) error { + q := ` +UPDATE tracker_patterns +SET + source = @source, + updated_at = @updated_at +WHERE + %s + AND id = @id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "id": tp.ID, + "source": newSource, + "updated_at": updatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot promote tracker pattern source: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + tp.Source = &newSource + tp.UpdatedAt = updatedAt + + return nil +} + func (tp *TrackerPattern) Update( ctx context.Context, tx pg.Tx, diff --git a/pkg/coredata/tracker_pattern_promote_test.go b/pkg/coredata/tracker_pattern_promote_test.go new file mode 100644 index 000000000..711083809 --- /dev/null +++ b/pkg/coredata/tracker_pattern_promote_test.go @@ -0,0 +1,257 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 coredata_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +// trackerPatternFixture bootstraps the parent rows that a tracker +// pattern's FKs require: organization, cookie banner, and a normal +// cookie category. +type trackerPatternFixture struct { + scope *coredata.Scope + organizationID gid.GID + cookieBannerID gid.GID + cookieCategoryID gid.GID +} + +func seedTrackerPatternFixture(t *testing.T, ctx context.Context, client *pg.Client) trackerPatternFixture { + t.Helper() + + tenantID := gid.NewTenantID() + scope := coredata.NewScope(tenantID) + organizationID := gid.New(tenantID, coredata.OrganizationEntityType) + cookieBannerID := gid.New(tenantID, coredata.CookieBannerEntityType) + cookieCategoryID := gid.New(tenantID, coredata.CookieCategoryEntityType) + now := time.Now().UTC() + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + org := &coredata.Organization{ + ID: organizationID, + TenantID: tenantID, + Name: "TrackerPattern Promote Test Org", + CreatedAt: now, + UpdatedAt: now, + } + if err := org.Insert(ctx, tx); err != nil { + return err + } + + banner := &coredata.CookieBanner{ + ID: cookieBannerID, + OrganizationID: organizationID, + Name: "TrackerPattern Promote Test Banner", + Origin: "https://promote-test.example.com", + State: coredata.CookieBannerStateActive, + CookiePolicyURL: "https://promote-test.example.com/cookies", + ConsentExpiryDays: 180, + ShowBranding: false, + DefaultLanguage: "en", + CreatedAt: now, + UpdatedAt: now, + } + if err := banner.Insert(ctx, tx, scope); err != nil { + return err + } + + category := &coredata.CookieCategory{ + ID: cookieCategoryID, + OrganizationID: organizationID, + CookieBannerID: cookieBannerID, + Name: "Analytics", + Slug: "analytics", + Description: "", + Kind: coredata.CookieCategoryKindNormal, + Rank: 1, + GCMConsentTypes: []string{}, + PostHogConsent: false, + CreatedAt: now, + UpdatedAt: now, + } + if err := category.Insert(ctx, tx, scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + if _, err := tx.Exec(ctx, `DELETE FROM tracker_patterns WHERE cookie_banner_id = $1`, cookieBannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM cookie_categories WHERE cookie_banner_id = $1`, cookieBannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM cookie_banners WHERE id = $1`, cookieBannerID); err != nil { + return err + } + + if _, err := tx.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, organizationID); err != nil { + return err + } + + return nil + }) + }) + + return trackerPatternFixture{ + scope: scope, + organizationID: organizationID, + cookieBannerID: cookieBannerID, + cookieCategoryID: cookieCategoryID, + } +} + +func seedTrackerPattern( + t *testing.T, + ctx context.Context, + client *pg.Client, + fx trackerPatternFixture, + pattern string, + matchType coredata.TrackerPatternMatchType, + source coredata.CookieSource, +) *coredata.TrackerPattern { + t.Helper() + + now := time.Now().UTC().Truncate(time.Microsecond) + maxAge := 3600 + tp := &coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.cookieBannerID, + CookieCategoryID: fx.cookieCategoryID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: pattern, + MatchType: matchType, + DisplayName: pattern, + Description: "", + MaxAgeSeconds: &maxAge, + Source: &source, + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return tp.Insert(ctx, tx, fx.scope) + })) + + return tp +} + +func TestTrackerPattern_PromoteSource_OverwritesSource(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedTrackerPatternFixture(t, ctx, client) + + tp := seedTrackerPattern( + t, + ctx, + client, + fx, + "*_session", + coredata.TrackerPatternMatchTypeGlob, + coredata.CookieSourcePreExisting, + ) + + bumpedAt := time.Now().UTC().Add(time.Hour).Truncate(time.Microsecond) + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceScript, bumpedAt) + })) + + require.NotNil(t, tp.Source) + assert.Equal(t, coredata.CookieSourceScript, *tp.Source, "receiver must reflect the new source") + assert.True(t, tp.UpdatedAt.Equal(bumpedAt), "receiver must reflect the new updated_at") + + loaded := &coredata.TrackerPattern{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByID(ctx, conn, fx.scope, tp.ID) + })) + + require.NotNil(t, loaded.Source) + assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "DB row must reflect the promoted source") + assert.True(t, loaded.UpdatedAt.Equal(bumpedAt), "DB row must reflect the new updated_at") +} + +func TestTrackerPattern_PromoteSource_OnlyTouchesSourceAndUpdatedAt(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedTrackerPatternFixture(t, ctx, client) + + tp := seedTrackerPattern( + t, + ctx, + client, + fx, + "*_token", + coredata.TrackerPatternMatchTypeGlob, + coredata.CookieSourcePreExisting, + ) + + originalCategory := tp.CookieCategoryID + originalDisplay := tp.DisplayName + originalMaxAge := tp.MaxAgeSeconds + originalExcluded := tp.Excluded + originalDescription := tp.Description + + bumpedAt := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Microsecond) + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceExtension, bumpedAt) + })) + + loaded := &coredata.TrackerPattern{} + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return loaded.LoadByID(ctx, conn, fx.scope, tp.ID) + })) + + assert.Equal(t, originalCategory, loaded.CookieCategoryID, "category must be untouched") + assert.Equal(t, originalDisplay, loaded.DisplayName, "display_name must be untouched") + assert.Equal(t, originalMaxAge, loaded.MaxAgeSeconds, "max_age_seconds must be untouched") + assert.Equal(t, originalExcluded, loaded.Excluded, "excluded must be untouched") + assert.Equal(t, originalDescription, loaded.Description, "description must be untouched") +} + +func TestTrackerPattern_PromoteSource_NotFoundForMissingRow(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedTrackerPatternFixture(t, ctx, client) + + tp := &coredata.TrackerPattern{ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType)} + + err := client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + return tp.PromoteSource(ctx, tx, fx.scope, coredata.CookieSourceScript, time.Now().UTC()) + }) + + assert.ErrorIs(t, err, coredata.ErrResourceNotFound) +}