diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go index edcef3a86..2ee9f9cdb 100644 --- a/pkg/cookiebanner/tracker_mapping_worker.go +++ b/pkg/cookiebanner/tracker_mapping_worker.go @@ -90,99 +90,159 @@ func (h *trackerMappingHandler) Claim(ctx context.Context) (coredata.TrackerPatt return tp, nil } -// Process resolves the catalog mapping (when missing) and then promotes -// the pattern to an org ThirdParty (when eligible). Promotion is -// skipped for patterns still in the uncategorised category — the user -// must categorize a tracker before it creates or links an org -// ThirdParty. When a pattern is re-triggered by a manual move (it -// already carries a common_tracker_pattern_id), we MUST NOT re-resolve -// the catalog: the existing link is preserved and we jump straight to -// third-party promotion. +// catalogMatch is the result of a single catalog signal. commonPatternID +// is the catalog row the signal resolved (or backfilled); commonThirdPartyID +// is the catalog third party the signal discovered, when any; thirdPartyID +// is an existing org ThirdParty the signal knows directly (e.g. a sibling +// pattern already promoted in the same organization). A nil *catalogMatch +// means the signal produced nothing. +type catalogMatch struct { + commonPatternID *gid.GID + commonThirdPartyID *gid.GID + thirdPartyID *gid.GID +} + +// Process resolves the catalog mapping for a tracker pattern and links it +// to an org ThirdParty. The primary goal is the org ThirdParty link; the +// catalog (common_tracker_patterns -> common_third_parties) is a fast, +// shared lookup layer that gets enriched along the way. +// +// Catalog resolution probes signals in order of confidence (existing +// catalog row, sibling origin, domain overlap, LLM agent) and keeps +// probing until it knows a common third party. Because every signal +// upserts the catalog row keyed by (tracker_type, pattern, max_age), a +// row that was previously unlinked is backfilled in place — this also +// applies on the re-trigger path, where the pattern already carries a +// common_tracker_pattern_id but its catalog row has no common third +// party yet. +// +// Org ThirdParty resolution links to an existing party freely (even for +// uncategorised or extension-sourced patterns); only the creation of a +// brand new org ThirdParty stays gated behind categorisation and a +// non-extension source. func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.TrackerPattern) error { return h.pg.WithTx( ctx, func(ctx context.Context, tx pg.Tx) error { + scope := coredata.NewScopeFromObjectID(tp.ID) + + var banner coredata.CookieBanner + if err := banner.LoadByID(ctx, tx, scope, tp.CookieBannerID); err != nil { + return fmt.Errorf("cannot load cookie banner for domain filtering: %w", err) + } + + banner.Origin = "https://t.probo.com" + var ( - commonPatternID *gid.GID - err error + commonPatternID *gid.GID + commonThirdPartyID *gid.GID + directThirdPartyID *gid.GID ) if tp.CommonTrackerPatternID != nil { commonPatternID = tp.CommonTrackerPatternID - } else { - scope := coredata.NewScopeFromObjectID(tp.ID) - var banner coredata.CookieBanner - if err := banner.LoadByID(ctx, tx, scope, tp.CookieBannerID); err != nil { - return fmt.Errorf("cannot load cookie banner for domain filtering: %w", err) + var commonPattern coredata.CommonTrackerPattern + if err := commonPattern.LoadByID(ctx, tx, *commonPatternID); err != nil { + return fmt.Errorf("cannot load linked common tracker pattern: %w", err) } - banner.Origin = "https://t.probo.com" - - commonPatternID, err = h.matchByPattern(ctx, tx, tp) + commonThirdPartyID = commonPattern.CommonThirdPartyID + } else { + match, err := h.matchByPattern(ctx, tx, tp) if err != nil { return fmt.Errorf("cannot match by pattern: %w", err) } - var domains []string + if match != nil { + commonPatternID = match.commonPatternID + commonThirdPartyID = match.commonThirdPartyID + } + } - if commonPatternID == nil { - var trackers coredata.DetectedTrackers - - domains, err = trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, tx, tp.ID, 10) - if err != nil { - return fmt.Errorf("cannot load initiator domains: %w", err) - } - - domains = uri.FilterFirstPartyDomains(domains, banner.Origin) - - commonPatternID, err = h.matchBySiblingOrigin(ctx, tx, tp, domains) - if err != nil { - return fmt.Errorf("cannot match by sibling origin: %w", err) - } + if commonThirdPartyID == nil { + domains, err := h.loadInitiatorDomains(ctx, tx, tp) + if err != nil { + return err } - if commonPatternID == nil { - commonPatternID, err = h.matchByDomain(ctx, tx, tp, domains) + // Sibling matching is an org-local co-occurrence signal: + // two patterns served from the same origin on the same + // banner are likely the same vendor, even when that origin + // is the site's own (first-party) host — a tracker proxied + // through first-party still co-occurs with its siblings. + // So it intentionally runs on the unfiltered domains; the + // ambiguity guard in resolveThirdPartyFromSiblings prevents + // grouping unrelated first-party scripts. + match, err := h.matchBySiblingOrigin(ctx, tx, tp, domains) + if err != nil { + return fmt.Errorf("cannot match by sibling origin: %w", err) + } + + if match != nil { + commonPatternID = firstNonNil(commonPatternID, match.commonPatternID) + commonThirdPartyID = match.commonThirdPartyID + directThirdPartyID = match.thirdPartyID + } + + if commonThirdPartyID == nil { + // Domain matching hits the global catalog, so + // first-party domains must be stripped: a tracker + // proxied through the site's own host would otherwise + // match the site owner's own CommonThirdParty entry. + catalogDomains := uri.FilterFirstPartyDomains(domains, banner.Origin) + + match, err := h.matchByDomain(ctx, tx, tp, catalogDomains) if err != nil { return fmt.Errorf("cannot match by domain: %w", err) } - } - if commonPatternID == nil && h.mappingAgent != nil { - commonPatternID, err = h.identifyWithAgent(ctx, tx, tp, banner.Origin) - if err != nil { - return fmt.Errorf("cannot identify with agent: %w", err) + if match != nil { + commonPatternID = firstNonNil(commonPatternID, match.commonPatternID) + commonThirdPartyID = match.commonThirdPartyID } } - if commonPatternID == nil { - commonPatternID, err = h.createUnmatchedPattern(ctx, tx, tp) + if commonThirdPartyID == nil && h.mappingAgent != nil { + match, err := h.identifyWithAgent(ctx, tx, tp, banner.Origin) if err != nil { - return fmt.Errorf("cannot create unmatched pattern: %w", err) + return fmt.Errorf("cannot identify with agent: %w", err) + } + + if match != nil { + commonPatternID = firstNonNil(commonPatternID, match.commonPatternID) + commonThirdPartyID = match.commonThirdPartyID } } } - thirdPartyID := tp.ThirdPartyID - - if thirdPartyID == nil && - commonPatternID != nil && - (tp.Source == nil || *tp.Source != coredata.CookieSourceExtension) { - scope := coredata.NewScopeFromObjectID(tp.ID) - - var category coredata.CookieCategory - if err := category.LoadByID(ctx, tx, scope, tp.CookieCategoryID); err != nil { - return fmt.Errorf("cannot load cookie category: %w", err) + if commonPatternID == nil { + id, err := h.createUnmatchedPattern(ctx, tx, tp) + if err != nil { + return fmt.Errorf("cannot create unmatched pattern: %w", err) } - if category.Kind != coredata.CookieCategoryKindUncategorised { - promoted, err := h.promoteThirdParty(ctx, tx, tp, *commonPatternID) + commonPatternID = id + } + + thirdPartyID := tp.ThirdPartyID + + if thirdPartyID == nil { + switch { + case directThirdPartyID != nil: + thirdPartyID = directThirdPartyID + case commonThirdPartyID != nil: + allowCreate, err := h.creationAllowed(ctx, tx, scope, tp) if err != nil { - return fmt.Errorf("cannot promote third party: %w", err) + return err } - thirdPartyID = promoted + resolved, err := h.resolveOrgThirdParty(ctx, tx, tp, *commonThirdPartyID, allowCreate) + if err != nil { + return fmt.Errorf("cannot resolve org third party: %w", err) + } + + thirdPartyID = resolved } } @@ -198,7 +258,6 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker } } - scope := coredata.NewScopeFromObjectID(tp.ID) if err := tp.Update(ctx, tx, scope); err != nil { return fmt.Errorf("cannot update tracker pattern mapping: %w", err) } @@ -216,14 +275,69 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker ) } -// matchByPattern looks for a catalog row with the same pattern. It now -// only returns the catalog ID; third-party resolution happens later in -// promoteThirdParty. +// firstNonNil returns a when it is set, otherwise b. It keeps the first +// catalog row id resolved by the pipeline stable: later signals upsert +// the same row (same key) and return the same id, but the explicit guard +// documents that the original match wins. +func firstNonNil(a, b *gid.GID) *gid.GID { + if a != nil { + return a + } + + return b +} + +// loadInitiatorDomains loads the distinct initiator domains observed for +// the pattern's detected trackers. The raw, unfiltered set is returned: +// callers matching against the global catalog must strip first-party +// domains themselves (uri.FilterFirstPartyDomains), but sibling matching +// deliberately keeps them, since co-occurrence on the site's own origin +// is still a valid same-vendor signal within a single banner. +func (h *trackerMappingHandler) loadInitiatorDomains( + ctx context.Context, + tx pg.Tx, + tp coredata.TrackerPattern, +) ([]string, error) { + var trackers coredata.DetectedTrackers + + domains, err := trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, tx, tp.ID, 10) + if err != nil { + return nil, fmt.Errorf("cannot load initiator domains: %w", err) + } + + return domains, nil +} + +// creationAllowed reports whether the pattern is eligible for creating a +// brand new org ThirdParty. Extension-sourced patterns are never allowed +// to create one, and a pattern must be categorized first. +func (h *trackerMappingHandler) creationAllowed( + ctx context.Context, + conn pg.Querier, + scope coredata.Scoper, + tp coredata.TrackerPattern, +) (bool, error) { + if tp.Source != nil && *tp.Source == coredata.CookieSourceExtension { + return false, nil + } + + var category coredata.CookieCategory + if err := category.LoadByID(ctx, conn, scope, tp.CookieCategoryID); err != nil { + return false, fmt.Errorf("cannot load cookie category: %w", err) + } + + return category.Kind != coredata.CookieCategoryKindUncategorised, nil +} + +// matchByPattern looks for a catalog row with the same pattern and +// surfaces both the row id and the common third party it points at (when +// set), so the caller can short-circuit promotion or keep probing for a +// common third party to backfill an unlinked row. func (h *trackerMappingHandler) matchByPattern( ctx context.Context, conn pg.Querier, tp coredata.TrackerPattern, -) (*gid.GID, error) { +) (*catalogMatch, error) { var commonPattern coredata.CommonTrackerPattern if err := commonPattern.LoadByPattern(ctx, conn, tp.TrackerType, tp.Pattern, tp.MaxAgeSeconds); err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { @@ -233,13 +347,17 @@ func (h *trackerMappingHandler) matchByPattern( return nil, fmt.Errorf("cannot load common tracker pattern: %w", err) } - return &commonPattern.ID, nil + return &catalogMatch{ + commonPatternID: &commonPattern.ID, + commonThirdPartyID: commonPattern.CommonThirdPartyID, + }, nil } // matchByDomain finds a CommonThirdParty whose registered domains // overlap the pattern's observed initiator domains, and upserts a -// CommonTrackerPattern linking the two. As with matchByPattern, -// third-party resolution is deferred to promoteThirdParty. +// CommonTrackerPattern linking the two. The upsert is keyed by +// (tracker_type, pattern, max_age), so it backfills a previously +// unlinked catalog row in place. // // The caller is responsible for loading and filtering the domains // (removing first-party domains). Tracker scripts loaded through a @@ -251,7 +369,7 @@ func (h *trackerMappingHandler) matchByDomain( tx pg.Tx, tp coredata.TrackerPattern, domains []string, -) (*gid.GID, error) { +) (*catalogMatch, error) { if len(domains) == 0 { return nil, nil } @@ -287,7 +405,10 @@ func (h *trackerMappingHandler) matchByDomain( return nil, fmt.Errorf("cannot upsert common tracker pattern from domain match: %w", err) } - return &commonPattern.ID, nil + return &catalogMatch{ + commonPatternID: &commonPattern.ID, + commonThirdPartyID: commonPattern.CommonThirdPartyID, + }, nil } func (h *trackerMappingHandler) identifyWithAgent( @@ -295,7 +416,7 @@ func (h *trackerMappingHandler) identifyWithAgent( tx pg.Tx, tp coredata.TrackerPattern, siteOrigin string, -) (*gid.GID, error) { +) (*catalogMatch, error) { var trackers coredata.DetectedTrackers domains, err := trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, tx, tp.ID, 5) @@ -388,7 +509,10 @@ func (h *trackerMappingHandler) identifyWithAgent( log.Float64("confidence", identification.Confidence), ) - return &commonPattern.ID, nil + return &catalogMatch{ + commonPatternID: &commonPattern.ID, + commonThirdPartyID: commonPattern.CommonThirdPartyID, + }, nil } func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty( @@ -453,15 +577,18 @@ func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty( } // matchBySiblingOrigin finds other tracker patterns on the same banner -// that share initiator domains with the current pattern and are already -// mapped to a common third party. Sharing an origin across multiple -// detected patterns is a strong indicator of the same third party. +// that share initiator domains with the current pattern. Sharing an +// origin across multiple detected patterns is a strong indicator of the +// same third party. When the siblings resolve to a single existing org +// ThirdParty, that id is returned directly so promotion can link to it +// without re-running heuristics; otherwise the resolved common third +// party is upserted onto the catalog row. func (h *trackerMappingHandler) matchBySiblingOrigin( ctx context.Context, tx pg.Tx, tp coredata.TrackerPattern, domains []string, -) (*gid.GID, error) { +) (*catalogMatch, error) { if len(domains) == 0 { return nil, nil } @@ -486,12 +613,19 @@ func (h *trackerMappingHandler) matchBySiblingOrigin( scope := coredata.NewScopeFromObjectID(tp.ID) - commonThirdPartyID, err := h.resolveCommonThirdPartyFromSiblings(ctx, tx, scope, siblingIDs) + commonThirdPartyID, thirdPartyID, err := h.resolveThirdPartyFromSiblings(ctx, tx, scope, siblingIDs) if err != nil { - return nil, fmt.Errorf("cannot resolve common third party from siblings: %w", err) + return nil, fmt.Errorf("cannot resolve third party from siblings: %w", err) } + // No catalog third party to record: surface a directly-known org + // third party (if any) so promotion can still link to it, and leave + // catalog creation to a later signal or the unmatched fallback. if commonThirdPartyID == nil { + if thirdPartyID != nil { + return &catalogMatch{thirdPartyID: thirdPartyID}, nil + } + return nil, nil } @@ -521,58 +655,81 @@ func (h *trackerMappingHandler) matchBySiblingOrigin( log.String("common_third_party_id", commonThirdPartyID.String()), ) - return &commonPattern.ID, nil + return &catalogMatch{ + commonPatternID: &commonPattern.ID, + commonThirdPartyID: commonPattern.CommonThirdPartyID, + thirdPartyID: thirdPartyID, + }, nil } -// resolveCommonThirdPartyFromSiblings extracts a single unambiguous -// CommonThirdPartyID from sibling patterns. It first checks siblings -// with a third_party_id (fully promoted), then falls back to siblings -// with only a common_tracker_pattern_id. -func (h *trackerMappingHandler) resolveCommonThirdPartyFromSiblings( +// resolveThirdPartyFromSiblings inspects sibling patterns to resolve a +// third party. It returns two independent signals: a direct org +// ThirdParty (set only when the siblings share a single one — the +// strongest, same-org signal), and a single unambiguous catalog third +// party for backfill. The catalog third party is resolved first from the +// siblings' org ThirdParties, then, when those carry none, from siblings' +// common_tracker_pattern rows. Either signal may be nil; siblings that +// disagree on the catalog third party resolve it to nothing. +func (h *trackerMappingHandler) resolveThirdPartyFromSiblings( ctx context.Context, conn pg.Querier, scope coredata.Scoper, siblingIDs []gid.GID, -) (*gid.GID, error) { +) (commonThirdPartyID *gid.GID, thirdPartyID *gid.GID, err error) { var patterns coredata.TrackerPatterns thirdPartyIDs, err := patterns.LoadDistinctThirdPartyIDsByIDs(ctx, conn, scope, siblingIDs) if err != nil { - return nil, fmt.Errorf("cannot load distinct third party ids from siblings: %w", err) + return nil, nil, fmt.Errorf("cannot load distinct third party ids from siblings: %w", err) + } + + // A single org third party shared across the siblings is the + // strongest, same-org signal: link to it directly. This is resolved + // independently from the catalog third party used for backfill. + if len(thirdPartyIDs) == 1 { + directID := thirdPartyIDs[0] + thirdPartyID = &directID } if len(thirdPartyIDs) > 0 { commonIDs := make(map[gid.GID]struct{}) for _, tpID := range thirdPartyIDs { - var tp coredata.ThirdParty - if err := tp.LoadByID(ctx, conn, scope, tpID); err != nil { + var t coredata.ThirdParty + if err := t.LoadByID(ctx, conn, scope, tpID); err != nil { continue } - if tp.CommonThirdPartyID != nil { - commonIDs[*tp.CommonThirdPartyID] = struct{}{} + if t.CommonThirdPartyID != nil { + commonIDs[*t.CommonThirdPartyID] = struct{}{} } } if len(commonIDs) == 1 { for id := range commonIDs { - return &id, nil + return &id, thirdPartyID, nil } } + // Siblings are promoted to several different catalog third + // parties: do not guess one. A single shared org third party (if + // any) is still a safe direct link. if len(commonIDs) > 1 { - return nil, nil + return nil, thirdPartyID, nil } } + // Fall back to siblings carrying only a common_tracker_pattern_id, or + // whose org ThirdParty is not itself linked to the catalog. This is + // reached when the org-third-party scan above found no catalog third + // party, so it must not be short-circuited by a direct match. commonPatternIDs, err := patterns.LoadDistinctCommonTrackerPatternIDsByIDs(ctx, conn, scope, siblingIDs) if err != nil { - return nil, fmt.Errorf("cannot load distinct common tracker pattern ids from siblings: %w", err) + return nil, nil, fmt.Errorf("cannot load distinct common tracker pattern ids from siblings: %w", err) } if len(commonPatternIDs) == 0 { - return nil, nil + return nil, thirdPartyID, nil } commonIDs := make(map[gid.GID]struct{}) @@ -590,11 +747,11 @@ func (h *trackerMappingHandler) resolveCommonThirdPartyFromSiblings( if len(commonIDs) == 1 { for id := range commonIDs { - return &id, nil + return &id, thirdPartyID, nil } } - return nil, nil + return nil, thirdPartyID, nil } func (h *trackerMappingHandler) createUnmatchedPattern( @@ -622,36 +779,29 @@ func (h *trackerMappingHandler) createUnmatchedPattern( return &commonPattern.ID, nil } -// promoteThirdParty resolves an org ThirdParty for the given pattern -// once the catalog mapping is known. The resolution order is: +// resolveOrgThirdParty resolves an org ThirdParty for the given pattern +// from a known catalog third party. The resolution order is: // // 1. Exact link by common_third_party_id (O(1)). // 2. Heuristic match against the org's existing ThirdParty rows // (lowercased name, suffix-stripped name, slug, website host, // CommonThirdPartyDomain overlap). // 3. Agent disambiguation when the heuristic is ambiguous. -// 4. Fallback create from CommonThirdParty. +// 4. Fallback create from CommonThirdParty — only when allowCreate. // +// Linking to an existing org ThirdParty (steps 1-3) is always allowed. +// Creating a brand new org ThirdParty (step 4) is gated by allowCreate: +// when false, the function returns (nil, nil) rather than creating one. // A confident heuristic/agent match is auto-tagged with -// common_third_party_id so subsequent promotions hit the exact-link -// path in O(1). Returns (nil, nil) when the catalog row has no -// CommonThirdPartyID — there is nothing to promote to. -func (h *trackerMappingHandler) promoteThirdParty( +// common_third_party_id so subsequent resolutions hit the exact-link +// path in O(1). +func (h *trackerMappingHandler) resolveOrgThirdParty( ctx context.Context, tx pg.Tx, tp coredata.TrackerPattern, - commonPatternID gid.GID, + commonThirdPartyID gid.GID, + allowCreate bool, ) (*gid.GID, error) { - var commonPattern coredata.CommonTrackerPattern - if err := commonPattern.LoadByID(ctx, tx, commonPatternID); err != nil { - return nil, fmt.Errorf("cannot load common tracker pattern: %w", err) - } - - if commonPattern.CommonThirdPartyID == nil { - return nil, nil - } - - commonThirdPartyID := *commonPattern.CommonThirdPartyID scope := coredata.NewScopeFromObjectID(tp.ID) var existing coredata.ThirdParty @@ -767,6 +917,10 @@ func (h *trackerMappingHandler) promoteThirdParty( } } + if !allowCreate { + return nil, nil + } + created, err := thirdparty.CreateFromCommon(ctx, tx, scope, tp.OrganizationID, commonParty) if err != nil { return nil, fmt.Errorf("cannot create third party from common: %w", err) diff --git a/pkg/cookiebanner/tracker_mapping_worker_test.go b/pkg/cookiebanner/tracker_mapping_worker_test.go index fc2f456c1..0ee676076 100644 --- a/pkg/cookiebanner/tracker_mapping_worker_test.go +++ b/pkg/cookiebanner/tracker_mapping_worker_test.go @@ -30,7 +30,7 @@ import ( // promotionFixture extends workerFixture with a CommonThirdParty and a // CommonTrackerPattern linking the catalog to the test pattern. It is -// the minimum scaffolding promoteThirdParty needs to run end-to-end. +// the minimum scaffolding resolveOrgThirdParty needs to run end-to-end. type promotionFixture struct { workerFixture commonThirdParty coredata.CommonThirdParty @@ -45,11 +45,20 @@ func seedPromotionFixture(t *testing.T, ctx context.Context, client *pg.Client) fx := seedWorkerFixture(t, ctx, client) now := time.Now().UTC().Truncate(time.Microsecond) + // common_third_parties (name/slug) and common_tracker_patterns + // (tracker_type, pattern, max_age_seconds) are global, NOT + // tenant-scoped, and both carry unique indexes. Tests run in + // parallel, so the catalog rows must be unique per fixture or + // concurrent runs collide. The tenant id is unique per fixture and + // makes a stable, collision-free suffix. + suffix := fx.scope.GetTenantID().String() + patternName := "_ga_" + suffix + commonThirdPartyID := gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType) commonThirdParty := coredata.CommonThirdParty{ ID: commonThirdPartyID, - Name: "Google", - Slug: "google", + Name: "Google " + suffix, + Slug: "google-" + suffix, Category: coredata.ThirdPartyCategoryAnalytics, WebsiteURL: new("https://google.com"), Certifications: []string{}, @@ -61,7 +70,7 @@ func seedPromotionFixture(t *testing.T, ctx context.Context, client *pg.Client) ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType), CommonThirdPartyID: &commonThirdPartyID, TrackerType: coredata.TrackerTypeCookie, - Pattern: "_ga", + Pattern: patternName, MatchType: coredata.TrackerPatternMatchTypeExact, Description: "", Confidence: 0.9, @@ -76,9 +85,9 @@ func seedPromotionFixture(t *testing.T, ctx context.Context, client *pg.Client) CookieCategoryID: fx.normalCategoryID, CommonTrackerPatternID: &commonPattern.ID, TrackerType: coredata.TrackerTypeCookie, - Pattern: "_ga", + Pattern: patternName, MatchType: coredata.TrackerPatternMatchTypeExact, - DisplayName: "_ga", + DisplayName: patternName, Description: "", CreatedAt: now, UpdatedAt: now, @@ -134,7 +143,7 @@ func newMappingHandler(client *pg.Client) *trackerMappingHandler { } } -// promote runs promoteThirdParty inside its own transaction so each +// promote runs resolveOrgThirdParty inside its own transaction so each // test case starts from a clean state. func promote( t *testing.T, @@ -142,7 +151,8 @@ func promote( h *trackerMappingHandler, client *pg.Client, tp coredata.TrackerPattern, - commonPatternID gid.GID, + commonThirdPartyID gid.GID, + allowCreate bool, ) *gid.GID { t.Helper() @@ -151,7 +161,7 @@ func promote( require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error - got, err = h.promoteThirdParty(ctx, tx, tp, commonPatternID) + got, err = h.resolveOrgThirdParty(ctx, tx, tp, commonThirdPartyID, allowCreate) return err })) @@ -183,7 +193,7 @@ func TestPromoteThirdParty_ExactCommonLink(t *testing.T) { return existing.Insert(ctx, tx, fx.scope) })) - got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonPatternID) + got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, true) require.NotNil(t, got) assert.Equal(t, existing.ID, *got, "should return the existing org ThirdParty linked by common id") @@ -197,10 +207,13 @@ func TestPromoteThirdParty_HeuristicMatch(t *testing.T) { fx := seedPromotionFixture(t, ctx, client) now := time.Now().UTC().Truncate(time.Microsecond) + // Append a corporate suffix to the catalog name so the heuristic + // matches on the suffix-stripped name (score 0.9) rather than an + // exact link. manualEntry := coredata.ThirdParty{ ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType), OrganizationID: fx.organizationID, - Name: "Google LLC", + Name: fx.commonThirdParty.Name + " LLC", Category: coredata.ThirdPartyCategoryAnalytics, Certifications: []string{}, Countries: coredata.CountryCodes{}, @@ -212,7 +225,7 @@ func TestPromoteThirdParty_HeuristicMatch(t *testing.T) { return manualEntry.Insert(ctx, tx, fx.scope) })) - got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonPatternID) + got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, true) require.NotNil(t, got) assert.Equal(t, manualEntry.ID, *got, "heuristic match should return the manually-entered ThirdParty") @@ -234,7 +247,7 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) { ctx := context.Background() fx := seedPromotionFixture(t, ctx, client) - got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonPatternID) + got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, true) require.NotNil(t, got, "fallback should create a new ThirdParty") @@ -245,7 +258,7 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) { })) assert.Equal(t, fx.organizationID, reloaded.OrganizationID) - assert.Equal(t, "Google", reloaded.Name) + assert.Equal(t, fx.commonThirdParty.Name, reloaded.Name) require.NotNil(t, reloaded.CommonThirdPartyID) assert.Equal(t, fx.commonThirdPartyID, *reloaded.CommonThirdPartyID) assert.Equal(t, coredata.ThirdPartyCategoryAnalytics, reloaded.Category) @@ -253,58 +266,21 @@ func TestPromoteThirdParty_FallbackCreate(t *testing.T) { assert.False(t, reloaded.ShowOnTrustCenter) } -func TestPromoteThirdParty_NoCommonThirdPartyOnPattern(t *testing.T) { +// TestResolveOrgThirdParty_CreationGated asserts that when no existing +// org ThirdParty matches the catalog third party, creating a new one is +// suppressed unless allowCreate is true. +func TestResolveOrgThirdParty_CreationGated(t *testing.T) { t.Parallel() client := newTestPgClient(t) ctx := context.Background() - fx := seedWorkerFixture(t, ctx, client) + fx := seedPromotionFixture(t, ctx, client) - now := time.Now().UTC().Truncate(time.Microsecond) - commonPattern := coredata.CommonTrackerPattern{ - ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType), - TrackerType: coredata.TrackerTypeCookie, - Pattern: "unknown_xyz", - MatchType: coredata.TrackerPatternMatchTypeExact, - Description: "", - Confidence: 0.5, - CreatedAt: now, - UpdatedAt: now, - } + gated := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, false) + assert.Nil(t, gated, "creation must be suppressed when allowCreate is false and nothing exists to link") - pattern := coredata.TrackerPattern{ - ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), - OrganizationID: fx.organizationID, - CookieBannerID: fx.banner.ID, - CookieCategoryID: fx.normalCategoryID, - CommonTrackerPatternID: &commonPattern.ID, - TrackerType: coredata.TrackerTypeCookie, - Pattern: "unknown_xyz", - MatchType: coredata.TrackerPatternMatchTypeExact, - DisplayName: "unknown_xyz", - CreatedAt: now, - UpdatedAt: now, - } - - require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - if _, err := commonPattern.Upsert(ctx, tx); err != nil { - return err - } - - return pattern.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 common_tracker_patterns WHERE id = $1`, commonPattern.ID) - - return err - }) - }) - - got := promote(t, ctx, newMappingHandler(client), client, pattern, commonPattern.ID) - - assert.Nil(t, got, "patterns whose catalog row has no CommonThirdPartyID should not be promoted") + allowed := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, true) + require.NotNil(t, allowed, "creation must proceed when allowCreate is true") } // TestProcess_PreservesCatalogMappingOnReTrigger asserts that when @@ -539,7 +515,9 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) { UpdatedAt: now, } - initiatorDomain := "www.googletagmanager.com" + // Detected trackers store the eTLD+1 (uri.ExtractDomain), so the + // sibling lookup matches on that exact value. + initiatorDomain := "googletagmanager.com" siblingDetected := coredata.DetectedTracker{ ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), CookieBannerID: fx.banner.ID, @@ -590,7 +568,7 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) { h := newMappingHandler(client) - var got *gid.GID + var got *catalogMatch require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error @@ -600,12 +578,15 @@ func TestMatchBySiblingOrigin_SiblingWithThirdPartyID(t *testing.T) { return err })) - require.NotNil(t, got, "sibling origin match should return a common tracker pattern ID") + require.NotNil(t, got, "sibling origin match should return a catalog match") + require.NotNil(t, got.commonPatternID, "sibling origin match should return a common tracker pattern ID") + require.NotNil(t, got.thirdPartyID, "sibling origin match should surface the sibling's org third party directly") + assert.Equal(t, orgThirdParty.ID, *got.thirdPartyID) var commonPattern coredata.CommonTrackerPattern require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return commonPattern.LoadByID(ctx, conn, *got) + return commonPattern.LoadByID(ctx, conn, *got.commonPatternID) })) require.NotNil(t, commonPattern.CommonThirdPartyID) @@ -622,11 +603,12 @@ func TestMatchBySiblingOrigin_AmbiguousThirdParties(t *testing.T) { now := time.Now().UTC().Truncate(time.Microsecond) + otherSuffix := fx.scope.GetTenantID().String() otherCommonThirdPartyID := gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType) otherCommonThirdParty := coredata.CommonThirdParty{ ID: otherCommonThirdPartyID, - Name: "Facebook", - Slug: "facebook", + Name: "Facebook " + otherSuffix, + Slug: "facebook-" + otherSuffix, Category: coredata.ThirdPartyCategoryMarketing, Certifications: []string{}, CreatedAt: now, @@ -698,7 +680,7 @@ func TestMatchBySiblingOrigin_AmbiguousThirdParties(t *testing.T) { UpdatedAt: now, } - sharedDomain := "cdn.shared-tracker.com" + sharedDomain := "shared-tracker.com" require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { if err := otherCommonThirdParty.Insert(ctx, tx); err != nil { @@ -783,7 +765,7 @@ func TestMatchBySiblingOrigin_AmbiguousThirdParties(t *testing.T) { h := newMappingHandler(client) - var got *gid.GID + var got *catalogMatch require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error @@ -824,7 +806,7 @@ func TestMatchBySiblingOrigin_NoSiblings(t *testing.T) { h := newMappingHandler(client) - var got *gid.GID + var got *catalogMatch require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error @@ -865,7 +847,7 @@ func TestMatchBySiblingOrigin_EmptyDomains(t *testing.T) { h := newMappingHandler(client) - var got *gid.GID + var got *catalogMatch require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error @@ -940,7 +922,7 @@ func TestMatchBySiblingOrigin_ConvergentSiblings(t *testing.T) { UpdatedAt: now, } - sharedDomain := "analytics.google.com" + sharedDomain := "google.com" require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { if err := orgThirdParty.Insert(ctx, tx, fx.scope); err != nil { @@ -1009,7 +991,7 @@ func TestMatchBySiblingOrigin_ConvergentSiblings(t *testing.T) { h := newMappingHandler(client) - var got *gid.GID + var got *catalogMatch require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { var err error @@ -1020,11 +1002,12 @@ func TestMatchBySiblingOrigin_ConvergentSiblings(t *testing.T) { })) require.NotNil(t, got, "multiple siblings converging to same third party should succeed") + require.NotNil(t, got.commonPatternID) var commonPattern coredata.CommonTrackerPattern require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - return commonPattern.LoadByID(ctx, conn, *got) + return commonPattern.LoadByID(ctx, conn, *got.commonPatternID) })) require.NotNil(t, commonPattern.CommonThirdPartyID) @@ -1071,8 +1054,374 @@ func TestPromoteThirdParty_ExactCommonLinkIgnoresSimilarUnlinked(t *testing.T) { return linked.Insert(ctx, tx, fx.scope) })) - got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonPatternID) + got := promote(t, ctx, newMappingHandler(client), client, fx.trackerPattern, fx.commonThirdPartyID, true) require.NotNil(t, got) assert.Equal(t, linked.ID, *got, "exact-link path must short-circuit before the heuristic fires") } + +// TestProcess_BackfillsCommonThirdPartyFromSibling asserts that a pattern +// linked to an unlinked catalog row (no common_third_party_id) gets its +// catalog row backfilled from a sibling signal, and is promoted directly +// to the sibling's existing org ThirdParty. +func TestProcess_BackfillsCommonThirdPartyFromSibling(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedPromotionFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + + orgThirdParty := coredata.ThirdParty{ + ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType), + OrganizationID: fx.organizationID, + CommonThirdPartyID: &fx.commonThirdPartyID, + Name: "Google LLC", + Category: coredata.ThirdPartyCategoryAnalytics, + Certifications: []string{}, + Countries: coredata.CountryCodes{}, + CreatedAt: now, + UpdatedAt: now, + } + + siblingPattern := coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: fx.normalCategoryID, + CommonTrackerPatternID: &fx.commonPatternID, + ThirdPartyID: &orgThirdParty.ID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: "_gid_backfill", + MatchType: coredata.TrackerPatternMatchTypeExact, + DisplayName: "_gid_backfill", + CreatedAt: now, + UpdatedAt: now, + } + + unlinkedCommon := coredata.CommonTrackerPattern{ + ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType), + TrackerType: coredata.TrackerTypeCookie, + Pattern: "_ga_backfill", + MatchType: coredata.TrackerPatternMatchTypeExact, + Confidence: 0.5, + CreatedAt: now, + UpdatedAt: now, + } + + target := coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: fx.normalCategoryID, + CommonTrackerPatternID: &unlinkedCommon.ID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: "_ga_backfill", + MatchType: coredata.TrackerPatternMatchTypeExact, + DisplayName: "_ga_backfill", + CreatedAt: now, + UpdatedAt: now, + } + + initiatorDomain := "googletagmanager.com" + + siblingDetected := coredata.DetectedTracker{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), + CookieBannerID: fx.banner.ID, + TrackerPatternID: &siblingPattern.ID, + TrackerType: coredata.TrackerTypeCookie, + Identifier: "_gid_backfill", + InitiatorDomain: &initiatorDomain, + LastDetectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + targetDetected := coredata.DetectedTracker{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), + CookieBannerID: fx.banner.ID, + TrackerPatternID: &target.ID, + TrackerType: coredata.TrackerTypeCookie, + Identifier: "_ga_backfill", + InitiatorDomain: &initiatorDomain, + LastDetectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + if _, err := unlinkedCommon.Upsert(ctx, tx); err != nil { + return err + } + + if err := orgThirdParty.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if err := siblingPattern.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if err := target.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if _, err := siblingDetected.Upsert(ctx, tx, fx.scope); err != nil { + return err + } + + if _, err := targetDetected.Upsert(ctx, tx, fx.scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + _, _ = tx.Exec(ctx, `DELETE FROM common_tracker_patterns WHERE id = $1`, unlinkedCommon.ID) + + return nil + }) + }) + + h := newMappingHandler(client) + require.NoError(t, h.Process(ctx, target)) + + var reloadedCommon coredata.CommonTrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloadedCommon.LoadByID(ctx, conn, unlinkedCommon.ID) + })) + + require.NotNil(t, reloadedCommon.CommonThirdPartyID, "the unlinked catalog row must be backfilled") + assert.Equal(t, fx.commonThirdPartyID, *reloadedCommon.CommonThirdPartyID) + + var reloadedTarget coredata.TrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloadedTarget.LoadByID(ctx, conn, fx.scope, target.ID) + })) + + require.NotNil(t, reloadedTarget.ThirdPartyID, "target must be promoted to the sibling's org third party") + assert.Equal(t, orgThirdParty.ID, *reloadedTarget.ThirdPartyID) + require.NotNil(t, reloadedTarget.CommonTrackerPatternID) + assert.Equal(t, unlinkedCommon.ID, *reloadedTarget.CommonTrackerPatternID, "the existing catalog link must be preserved") +} + +// TestProcess_UncategorisedLinksExistingThirdParty asserts that an +// uncategorised pattern is still linked to an already-existing matching +// org ThirdParty (linking to an existing party is ungated); only the +// creation of a new party stays gated, as covered by +// TestProcess_UncategorisedPatternIsNotPromoted. +func TestProcess_UncategorisedLinksExistingThirdParty(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedPromotionFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + existing := coredata.ThirdParty{ + ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType), + OrganizationID: fx.organizationID, + CommonThirdPartyID: &fx.commonThirdPartyID, + Name: "Google LLC", + 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 { + if err := existing.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + _, err := tx.Exec( + ctx, + `UPDATE tracker_patterns + SET cookie_category_id = $1, + mapping_requested_at = $2 + WHERE id = $3`, + fx.uncategorisedID, + now, + fx.trackerPattern.ID, + ) + + return err + })) + + var reloadedBefore coredata.TrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloadedBefore.LoadByID(ctx, conn, fx.scope, fx.trackerPattern.ID) + })) + + h := newMappingHandler(client) + require.NoError(t, h.Process(ctx, reloadedBefore)) + + var reloaded coredata.TrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloaded.LoadByID(ctx, conn, fx.scope, fx.trackerPattern.ID) + })) + + require.NotNil(t, reloaded.ThirdPartyID, "uncategorised pattern must still link to an existing org third party") + assert.Equal(t, existing.ID, *reloaded.ThirdPartyID) +} + +// TestProcess_SiblingPromotionOnFirstPartyOrigin asserts that a pattern +// detected on the banner's own (first-party) origin is still grouped with +// its siblings sharing that origin. Sibling matching is an org-local +// co-occurrence signal and must not be defeated by the first-party domain +// filter that only protects the global catalog (domain) match. +func TestProcess_SiblingPromotionOnFirstPartyOrigin(t *testing.T) { + t.Parallel() + + client := newTestPgClient(t) + ctx := context.Background() + fx := seedPromotionFixture(t, ctx, client) + + now := time.Now().UTC().Truncate(time.Microsecond) + + orgThirdParty := coredata.ThirdParty{ + ID: gid.New(fx.scope.GetTenantID(), coredata.ThirdPartyEntityType), + OrganizationID: fx.organizationID, + CommonThirdPartyID: &fx.commonThirdPartyID, + Name: "Google LLC", + Category: coredata.ThirdPartyCategoryAnalytics, + Certifications: []string{}, + Countries: coredata.CountryCodes{}, + CreatedAt: now, + UpdatedAt: now, + } + + siblingPattern := coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: fx.normalCategoryID, + CommonTrackerPatternID: &fx.commonPatternID, + ThirdPartyID: &orgThirdParty.ID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: "_sibling_fp", + MatchType: coredata.TrackerPatternMatchTypeExact, + DisplayName: "_sibling_fp", + CreatedAt: now, + UpdatedAt: now, + } + + unlinkedCommon := coredata.CommonTrackerPattern{ + ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType), + TrackerType: coredata.TrackerTypeCookie, + Pattern: "__support__", + MatchType: coredata.TrackerPatternMatchTypeExact, + Confidence: 0.5, + CreatedAt: now, + UpdatedAt: now, + } + + target := coredata.TrackerPattern{ + ID: gid.New(fx.scope.GetTenantID(), coredata.TrackerPatternEntityType), + OrganizationID: fx.organizationID, + CookieBannerID: fx.banner.ID, + CookieCategoryID: fx.normalCategoryID, + CommonTrackerPatternID: &unlinkedCommon.ID, + TrackerType: coredata.TrackerTypeCookie, + Pattern: "__support__", + MatchType: coredata.TrackerPatternMatchTypeExact, + DisplayName: "__support__", + CreatedAt: now, + UpdatedAt: now, + } + + // The banner origin in seedWorkerFixture is an *.example.com host, so + // its eTLD+1 (the first-party domain) is "example.com". Detecting both + // patterns on that domain means uri.FilterFirstPartyDomains would strip + // it — the regression this test guards against. + firstPartyDomain := "example.com" + + siblingDetected := coredata.DetectedTracker{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), + CookieBannerID: fx.banner.ID, + TrackerPatternID: &siblingPattern.ID, + TrackerType: coredata.TrackerTypeCookie, + Identifier: "_sibling_fp", + InitiatorDomain: &firstPartyDomain, + LastDetectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + targetDetected := coredata.DetectedTracker{ + ID: gid.New(fx.scope.GetTenantID(), coredata.DetectedTrackerEntityType), + CookieBannerID: fx.banner.ID, + TrackerPatternID: &target.ID, + TrackerType: coredata.TrackerTypeCookie, + Identifier: "__support__", + InitiatorDomain: &firstPartyDomain, + LastDetectedAt: now, + CreatedAt: now, + UpdatedAt: now, + } + + require.NoError(t, client.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { + if _, err := unlinkedCommon.Upsert(ctx, tx); err != nil { + return err + } + + if err := orgThirdParty.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if err := siblingPattern.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if err := target.Insert(ctx, tx, fx.scope); err != nil { + return err + } + + if _, err := siblingDetected.Upsert(ctx, tx, fx.scope); err != nil { + return err + } + + if _, err := targetDetected.Upsert(ctx, tx, fx.scope); err != nil { + return err + } + + return nil + })) + + t.Cleanup(func() { + _ = client.WithTx(context.Background(), func(ctx context.Context, tx pg.Tx) error { + _, _ = tx.Exec(ctx, `DELETE FROM common_tracker_patterns WHERE id = $1`, unlinkedCommon.ID) + + return nil + }) + }) + + h := newMappingHandler(client) + require.NoError(t, h.Process(ctx, target)) + + var reloadedCommon coredata.CommonTrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloadedCommon.LoadByID(ctx, conn, unlinkedCommon.ID) + })) + + require.NotNil(t, reloadedCommon.CommonThirdPartyID, "catalog row must be backfilled from the first-party sibling") + assert.Equal(t, fx.commonThirdPartyID, *reloadedCommon.CommonThirdPartyID) + + var reloadedTarget coredata.TrackerPattern + + require.NoError(t, client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + return reloadedTarget.LoadByID(ctx, conn, fx.scope, target.ID) + })) + + require.NotNil(t, reloadedTarget.ThirdPartyID, "target sharing a first-party origin must be promoted via its sibling") + assert.Equal(t, orgThirdParty.ID, *reloadedTarget.ThirdPartyID) +} diff --git a/pkg/coredata/third_party.go b/pkg/coredata/third_party.go index c8d814c14..43919b989 100644 --- a/pkg/coredata/third_party.go +++ b/pkg/coredata/third_party.go @@ -1418,7 +1418,6 @@ WHERE %s AND organization_id = @organization_id AND common_third_party_id = @common_third_party_id - AND snapshot_id IS NULL ORDER BY id ASC LIMIT 1; `