Each package that exercises the database against a real Postgres carried its own copy of the connection bootstrap and schema setup. Those copies had already drifted: some keyed off PROBO_TEST_PG_ADDR with hardcoded defaults, others off PROBO_TEST_PG_URL, and the agentrun/coredata suites hand-applied individual agent_runs migrations to ensure the table existed. Introduce a single test.PGClient helper that parses PROBO_TEST_PG_URL (falling back to the local compose database), runs the full coredata migration set once per process, and skips when no database is reachable so make test stays a pure unit-test run. Migrate the agentrun, coredata, cookiebanner, iam, and thirdparty suites onto it and delete the duplicated helpers so the bootstrap can no longer diverge. Signed-off-by: Bryan Frimin <bryan@probo.com>
752 lines
24 KiB
Go
752 lines
24 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"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.gearno.de/kit/log"
|
|
"go.gearno.de/kit/pg"
|
|
"go.probo.inc/probo/internal/test"
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
"go.probo.inc/probo/pkg/gid"
|
|
)
|
|
|
|
// 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)),
|
|
}
|
|
}
|
|
|
|
// seedThirdParty inserts a minimal org ThirdParty so a tracker pattern's
|
|
// third_party_id (a FK to third_parties) can point at a real row.
|
|
func seedThirdParty(t *testing.T, ctx context.Context, client *pg.Client, fx workerFixture, name string) gid.GID {
|
|
t.Helper()
|
|
|
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
|
id := gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType)
|
|
|
|
party := coredata.ThirdParty{
|
|
ID: id,
|
|
OrganizationID: fx.organizationID,
|
|
Name: name,
|
|
Category: coredata.ThirdPartyCategoryAnalytics,
|
|
Certifications: []string{},
|
|
Countries: coredata.CountryCodes{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
return party.Insert(ctx, tx, fx.scope)
|
|
}))
|
|
|
|
t.Cleanup(func() {
|
|
_ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error {
|
|
_, err := tx.Exec(ctx, `DELETE FROM third_parties WHERE id = $1`, id)
|
|
return err
|
|
})
|
|
})
|
|
|
|
return id
|
|
}
|
|
|
|
// 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 := test.PGClient(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 := test.PGClient(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_AdoptionPromotesSourceCrossCategory
|
|
// seeds a banner with a PRE_EXISTING `ph_phc_*_posthog` glob already
|
|
// placed in a user-set category (analytics) and a single SCRIPT-source
|
|
// exact `ph_phc_<id>_posthog` in uncategorised. The merge loop hits
|
|
// the cross-category skip branch (the existing slot belongs to a
|
|
// recategorised glob), so promotion can only happen via the
|
|
// adoption path. This guards against the gap where last_matched_at
|
|
// was refreshed on the glob but its source stayed at PRE_EXISTING
|
|
// despite the new SDK-observed signal.
|
|
func TestPatternAnalysisWorker_AdoptionPromotesSourceCrossCategory(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
ctx := context.Background()
|
|
fx := seedWorkerFixture(t, ctx, client)
|
|
|
|
maxAge := 365 * 24 * 3600
|
|
|
|
existingGlob := newGlobInCategory(
|
|
fx,
|
|
"ph_phc_*_posthog",
|
|
fx.normalCategoryID,
|
|
coredata.CookieSourcePreExisting,
|
|
&maxAge,
|
|
)
|
|
uncategorisedExact := newExactPattern(
|
|
fx,
|
|
"ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_posthog",
|
|
fx.uncategorisedID,
|
|
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
|
|
}
|
|
|
|
return uncategorisedExact.Insert(ctx, tx, fx.scope)
|
|
}))
|
|
|
|
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,
|
|
"ph_phc_*_posthog",
|
|
&maxAge,
|
|
)
|
|
}))
|
|
|
|
require.NotNil(t, loaded.Source)
|
|
assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "adoption must promote PRE_EXISTING glob to SCRIPT when a stronger exact is absorbed")
|
|
assert.Equal(t, existingGlob.ID, loaded.ID, "the existing glob row must be reused, not replaced")
|
|
assert.Equal(t, fx.normalCategoryID, loaded.CookieCategoryID, "user-set category must not be overwritten by the worker")
|
|
|
|
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, "the uncategorised exact must be adopted into the existing glob")
|
|
}
|
|
|
|
// TestReportDetectedTrackers_PromotesSourceOnExistingGlob seeds a
|
|
// banner with a PRE_EXISTING `ph_phc_*_posthog` glob in
|
|
// uncategorised, then reports a SCRIPT-source cookie whose name
|
|
// globMatches the existing pattern (e.g. a posthog instance ID).
|
|
// FindMatchingPattern in reportDetectedTracker links the
|
|
// detected_tracker straight to the glob, so no new exact is
|
|
// created and the merge/adoption loops in
|
|
// patternAnalysisHandler.Process never see the new signal. Without
|
|
// the in-line source promotion in reportDetectedTracker (which
|
|
// mutates matchedPattern.Source and writes via Update), only
|
|
// last_matched_at would advance — source would stay stuck at
|
|
// PRE_EXISTING despite the new SDK-observed evidence. This test
|
|
// pins the same-category promotion path; the cross-category gap
|
|
// is covered by TestPatternAnalysisWorker_AdoptionPromotesSourceCrossCategory.
|
|
func TestReportDetectedTrackers_PromotesSourceOnExistingGlob(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
ctx := context.Background()
|
|
fx := seedWorkerFixture(t, ctx, client)
|
|
|
|
maxAge := 365 * 24 * 3600
|
|
|
|
existingGlob := newGlobInCategory(
|
|
fx,
|
|
"ph_phc_*_posthog",
|
|
fx.uncategorisedID,
|
|
coredata.CookieSourcePreExisting,
|
|
&maxAge,
|
|
)
|
|
|
|
require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error {
|
|
return existingGlob.Insert(ctx, tx, fx.scope)
|
|
}))
|
|
|
|
svc := NewService(client, false)
|
|
|
|
require.NoError(t, svc.ReportDetectedTrackers(ctx, fx.banner.ID, ReportDetectedTrackersRequest{
|
|
Cookies: []DetectedCookie{
|
|
{
|
|
Name: "ph_phc_XBwJ2pHAf0MoYgh3TNZK32Qk7zLlTldhk4p9llGtZMN_posthog",
|
|
MaxAgeSeconds: &maxAge,
|
|
Source: coredata.CookieSourceScript,
|
|
},
|
|
},
|
|
}))
|
|
|
|
loaded := &coredata.TrackerPattern{}
|
|
|
|
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
|
return loaded.LoadByID(ctx, conn, fx.scope, existingGlob.ID)
|
|
}))
|
|
|
|
require.NotNil(t, loaded.Source)
|
|
assert.Equal(t, coredata.CookieSourceScript, *loaded.Source, "reportDetectedTracker must promote a PRE_EXISTING glob to SCRIPT when a stronger detection matches it")
|
|
assert.NotNil(t, loaded.LastMatchedAt, "matched detections must bump last_matched_at")
|
|
assert.Equal(t, fx.uncategorisedID, loaded.CookieCategoryID, "category must not move")
|
|
|
|
var exacts coredata.TrackerPatterns
|
|
|
|
require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
|
return exacts.LoadAllByCookieBannerID(
|
|
ctx,
|
|
conn,
|
|
fx.scope,
|
|
fx.banner.ID,
|
|
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeExact), nil, new(false)),
|
|
nil,
|
|
)
|
|
}))
|
|
assert.Empty(t, exacts, "the detected cookie globMatches the existing glob; no exact pattern must be created")
|
|
}
|
|
|
|
// 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 := test.PGClient(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)
|
|
}
|
|
}
|
|
|
|
// TestPatternAnalysisWorker_GlobInheritsUnanimousMapping seeds three
|
|
// exacts that all resolve to the same org ThirdParty (one carrying a
|
|
// description) and asserts the merged glob inherits that third party and
|
|
// description, so the mapping worker can skip the expensive org
|
|
// resolution. Mapping is still re-armed so the glob derives its own
|
|
// catalog row.
|
|
func TestPatternAnalysisWorker_GlobInheritsUnanimousMapping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
ctx := context.Background()
|
|
fx := seedWorkerFixture(t, ctx, client)
|
|
|
|
thirdPartyID := seedThirdParty(t, ctx, client, fx, "Google Analytics")
|
|
|
|
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),
|
|
}
|
|
for _, ep := range exacts {
|
|
ep.ThirdPartyID = &thirdPartyID
|
|
}
|
|
|
|
exacts[1].Description = "Google Analytics measurement cookie"
|
|
|
|
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))
|
|
|
|
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.ThirdPartyID, "glob must inherit the unanimous org third party from the merged exacts")
|
|
assert.Equal(t, thirdPartyID, *loaded.ThirdPartyID)
|
|
assert.Equal(t, "Google Analytics measurement cookie", loaded.Description, "glob must inherit the description tied to the resolved third party")
|
|
assert.NotNil(t, loaded.MappingRequestedAt, "mapping must still be re-armed so the glob derives its own catalog row")
|
|
}
|
|
|
|
// TestPatternAnalysisWorker_GlobSkipsConflictingMapping seeds exacts
|
|
// that resolve to two different org ThirdParties and asserts the merged
|
|
// glob is left blank rather than guessing, preserving a fresh mapping
|
|
// pass.
|
|
func TestPatternAnalysisWorker_GlobSkipsConflictingMapping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := test.PGClient(t)
|
|
ctx := context.Background()
|
|
fx := seedWorkerFixture(t, ctx, client)
|
|
|
|
thirdPartyA := seedThirdParty(t, ctx, client, fx, "Vendor A")
|
|
thirdPartyB := seedThirdParty(t, ctx, client, fx, "Vendor B")
|
|
|
|
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),
|
|
}
|
|
exacts[0].ThirdPartyID = &thirdPartyA
|
|
exacts[1].ThirdPartyID = &thirdPartyB
|
|
|
|
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))
|
|
|
|
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.Nil(t, loaded.ThirdPartyID, "glob must not inherit a third party when the merged exacts disagree")
|
|
assert.Equal(t, "", loaded.Description, "glob must stay blank when no unanimous mapping exists")
|
|
assert.NotNil(t, loaded.MappingRequestedAt, "glob must be re-armed for a fresh mapping pass")
|
|
}
|