Files
probo/pkg/cookiebanner/reset_trackers_test.go
Émile Ré c763f83b13 Add proboctl catalog and banner reset commands
Add operator commands to proboctl for iterating on the cookie-banner
agents.

The global catalog groups (common-tracker-pattern, common-third-party)
list/filter/sort/show the catalogs using the shared coredata cursor
layer, and common-tracker-pattern reenrich re-describes selected rows by
running the enricher in-process (so it completes synchronously rather
than racing the async queue); a --cfg-file flag reuses probod's config
to wire the agent. --linked-banner/--linked-org target exactly the
catalog rows a banner or org depends on.

The cookie-banner reset-trackers command is tenant-scoped (it derives a
coredata.Scope from the banner/org GID) and rebuilds a banner's
uncategorised, non-excluded patterns from detected_trackers, decomposing
derived globs back into exacts, then re-arms the analysis and mapping
workers. --mapping-only skips the rebuild. A DB-backed test covers the
rebuild, link clearing, and preservation of categorised/excluded
patterns.

Signed-off-by: Émile Ré <emile@probo.com>
2026-06-09 17:00:04 +02:00

180 lines
6.9 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 (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
// TestResetBannerTrackers_FullRebuild seeds a banner with an
// uncategorised glob covering two detections, an uncategorised exact
// carrying catalog/vendor links, a categorised exact, and an excluded
// exact. A full reset must: decompose the glob into per-identifier
// exacts and relink its detections, clear links on the surviving
// uncategorised exact and re-arm its mapping, preserve the categorised
// and excluded patterns, and arm pattern analysis on the banner.
func TestResetBannerTrackers_FullRebuild(t *testing.T) {
t.Parallel()
client := newTestPgClient(t)
ctx := context.Background()
fx := seedWorkerFixture(t, ctx, client)
thirdPartyID := seedThirdParty(t, ctx, client, fx, "Reset Vendor")
commonPatternID := seedCommonTrackerPattern(t, ctx, client, "ga_linked")
glob := newGlobInCategory(fx, "_ga_*", fx.uncategorisedID, coredata.CookieSourceScript, nil)
linkedExact := newExactPattern(fx, "linked_cookie", fx.uncategorisedID, coredata.CookieSourcePreExisting, nil)
linkedExact.CommonTrackerPatternID = &commonPatternID
linkedExact.ThirdPartyID = &thirdPartyID
linkedExact.Description = "stale description"
categorised := newExactPattern(fx, "categorised_cookie", fx.normalCategoryID, coredata.CookieSourceScript, nil)
excluded := newExactPattern(fx, "excluded_cookie", fx.uncategorisedID, coredata.CookieSourceScript, nil)
excluded.Excluded = true
now := time.Now().UTC().Truncate(time.Microsecond)
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
for _, p := range []*coredata.TrackerPattern{glob, linkedExact, categorised, excluded} {
if err := p.Insert(ctx, tx, fx.scope); err != nil {
return err
}
}
for _, identifier := range []string{"_ga_ABC", "_ga_DEF"} {
detection := &coredata.DetectedTracker{
ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType),
CookieBannerID: fx.banner.ID,
TrackerPatternID: &glob.ID,
TrackerType: coredata.TrackerTypeCookie,
Identifier: identifier,
Source: ref(coredata.CookieSourceScript),
LastDetectedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := detection.Upsert(ctx, tx, fx.scope); err != nil {
return err
}
}
return nil
}))
result, err := ResetBannerTrackers(ctx, client, fx.scope, fx.banner.ID, false)
require.NoError(t, err)
require.Equal(t, 1, result.GlobsDecomposed)
require.Equal(t, 2, result.ExactsCreated)
require.Equal(t, 2, result.DetectionsRelinked)
require.True(t, result.AnalysisRequested)
// linked_cookie + _ga_ABC + _ga_DEF (excluded and categorised are untouched).
require.Equal(t, int64(3), result.PatternsReset)
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
// The glob is gone.
var goneGlob coredata.TrackerPattern
err := goneGlob.LoadByBannerIDTypeAndPattern(ctx, conn, fx.scope, fx.banner.ID, coredata.TrackerTypeCookie, "_ga_*", nil)
require.ErrorIs(t, err, coredata.ErrResourceNotFound)
// Each detection identifier is now its own exact, with mapping armed
// and no links, and the detection relinked to it.
for _, identifier := range []string{"_ga_ABC", "_ga_DEF"} {
var exact coredata.TrackerPattern
require.NoError(t, exact.LoadByBannerIDTypeAndPattern(ctx, conn, fx.scope, fx.banner.ID, coredata.TrackerTypeCookie, identifier, nil))
require.Equal(t, coredata.TrackerPatternMatchTypeExact, exact.MatchType)
require.Equal(t, fx.uncategorisedID, exact.CookieCategoryID)
require.Nil(t, exact.CommonTrackerPatternID)
require.Nil(t, exact.ThirdPartyID)
require.NotNil(t, exact.MappingRequestedAt)
var detections coredata.DetectedTrackers
require.NoError(t, detections.LoadAllByTrackerPatternID(ctx, conn, fx.scope, exact.ID))
require.Len(t, detections, 1)
require.Equal(t, identifier, detections[0].Identifier)
}
// The surviving uncategorised exact had its links and copied
// description cleared and mapping re-armed.
var survivor coredata.TrackerPattern
require.NoError(t, survivor.LoadByBannerIDTypeAndPattern(ctx, conn, fx.scope, fx.banner.ID, coredata.TrackerTypeCookie, "linked_cookie", nil))
require.Nil(t, survivor.CommonTrackerPatternID)
require.Nil(t, survivor.ThirdPartyID)
require.Empty(t, survivor.Description)
require.NotNil(t, survivor.MappingRequestedAt)
// The categorised pattern is untouched.
var categorisedRow coredata.TrackerPattern
require.NoError(t, categorisedRow.LoadByBannerIDTypeAndPattern(ctx, conn, fx.scope, fx.banner.ID, coredata.TrackerTypeCookie, "categorised_cookie", nil))
require.Equal(t, fx.normalCategoryID, categorisedRow.CookieCategoryID)
// The excluded pattern is preserved.
var excludedRow coredata.TrackerPattern
require.NoError(t, excludedRow.LoadByBannerIDTypeAndPattern(ctx, conn, fx.scope, fx.banner.ID, coredata.TrackerTypeCookie, "excluded_cookie", nil))
require.True(t, excludedRow.Excluded)
// Pattern analysis is armed on the banner.
var banner coredata.CookieBanner
require.NoError(t, banner.LoadByID(ctx, conn, fx.scope, fx.banner.ID))
require.NotNil(t, banner.PatternAnalysisRequestedAt)
return nil
}))
}
func ref[T any](v T) *T {
return &v
}
func seedCommonTrackerPattern(t *testing.T, ctx context.Context, client *pg.Client, pattern string) gid.GID {
t.Helper()
now := time.Now().UTC().Truncate(time.Microsecond)
cp := coredata.CommonTrackerPattern{
ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
TrackerType: coredata.TrackerTypeCookie,
Pattern: pattern,
MatchType: coredata.TrackerPatternMatchTypeExact,
Description: "seeded",
Confidence: 1,
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
return cp.Insert(ctx, tx)
}))
t.Cleanup(func() {
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
_, err := tx.Exec(ctx, `DELETE FROM common_tracker_patterns WHERE id = $1`, cp.ID)
return err
})
})
return cp.ID
}