diff --git a/config/account.go b/config/account.go index 72b6c07a81e..cd2a38ffb8d 100644 --- a/config/account.go +++ b/config/account.go @@ -341,6 +341,7 @@ type AccountPrivacy struct { } type PrivacySandbox struct { + TopicsDomain string `mapstructure:"topicsdomain"` CookieDeprecation CookieDeprecation `mapstructure:"cookiedeprecation"` } diff --git a/config/config.go b/config/config.go index b2204053fac..3b306ac03d7 100644 --- a/config/config.go +++ b/config/config.go @@ -1146,6 +1146,7 @@ func SetupViper(v *viper.Viper, filename string, bidderInfos BidderInfos) { v.SetDefault("account_defaults.price_floors.fetch.max_age_sec", 86400) v.SetDefault("account_defaults.price_floors.fetch.period_sec", 3600) v.SetDefault("account_defaults.price_floors.fetch.max_schema_dims", 0) + v.SetDefault("account_defaults.privacy.privacysandbox.topicsdomain", "") v.SetDefault("account_defaults.privacy.privacysandbox.cookiedeprecation.enabled", false) v.SetDefault("account_defaults.privacy.privacysandbox.cookiedeprecation.ttl_sec", 604800) diff --git a/config/config_test.go b/config/config_test.go index 3cad8fa12f5..601c3194cb8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -203,6 +203,7 @@ func TestDefaults(t *testing.T) { cmpInts(t, "account_defaults.price_floors.fetch.period_sec", 3600, cfg.AccountDefaults.PriceFloors.Fetcher.Period) cmpInts(t, "account_defaults.price_floors.fetch.max_age_sec", 86400, cfg.AccountDefaults.PriceFloors.Fetcher.MaxAge) cmpInts(t, "account_defaults.price_floors.fetch.max_schema_dims", 0, cfg.AccountDefaults.PriceFloors.Fetcher.MaxSchemaDims) + cmpStrings(t, "account_defaults.privacy.topicsdomain", "", cfg.AccountDefaults.Privacy.PrivacySandbox.TopicsDomain) cmpBools(t, "account_defaults.privacy.privacysandbox.cookiedeprecation.enabled", false, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.Enabled) cmpInts(t, "account_defaults.privacy.privacysandbox.cookiedeprecation.ttl_sec", 604800, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.TTLSec) @@ -528,6 +529,7 @@ account_defaults: ipv4: anon_keep_bits: 20 privacysandbox: + topicsdomain: "test.com" cookiedeprecation: enabled: true ttl_sec: 86400 @@ -665,6 +667,7 @@ func TestFullConfig(t *testing.T) { cmpInts(t, "account_defaults.privacy.ipv6.anon_keep_bits", 50, cfg.AccountDefaults.Privacy.IPv6Config.AnonKeepBits) cmpInts(t, "account_defaults.privacy.ipv4.anon_keep_bits", 20, cfg.AccountDefaults.Privacy.IPv4Config.AnonKeepBits) + cmpStrings(t, "account_defaults.privacy.topicsdomain", "test.com", cfg.AccountDefaults.Privacy.PrivacySandbox.TopicsDomain) cmpBools(t, "account_defaults.privacy.cookiedeprecation.enabled", true, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.Enabled) cmpInts(t, "account_defaults.privacy.cookiedeprecation.ttl_sec", 86400, cfg.AccountDefaults.Privacy.PrivacySandbox.CookieDeprecation.TTLSec) diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go index 1b53b182ab8..4a6574e8421 100644 --- a/endpoints/openrtb2/amp_auction.go +++ b/endpoints/openrtb2/amp_auction.go @@ -156,6 +156,7 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h w.Header().Set("AMP-Access-Control-Allow-Source-Origin", origin) w.Header().Set("Access-Control-Expose-Headers", "AMP-Access-Control-Allow-Source-Origin") w.Header().Set("X-Prebid", version.BuildXPrebidHeader(version.Ver)) + setBrowsingTopicsHeader(w, r) // There is no body for AMP requests, so we pass a nil body and ignore the return value. _, rejectErr := hookExecutor.ExecuteEntrypointStage(r, nilBody) @@ -230,6 +231,11 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h return } + // Populate any "missing" OpenRTB fields with info from other sources, (e.g. HTTP request headers). + if errs := deps.setFieldsImplicitly(r, reqWrapper, account); len(errs) > 0 { + errL = append(errL, errs...) + } + hasStoredResponses := len(storedAuctionResponses) > 0 errs := deps.validateRequest(account, r, reqWrapper, true, hasStoredResponses, storedBidResponses, false) errL = append(errL, errs...) @@ -441,6 +447,9 @@ func getExtBidResponse( warnings = make(map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage) } for _, v := range errortypes.WarningOnly(errs) { + if errortypes.ReadScope(v) == errortypes.ScopeDebug && !(reqWrapper != nil && reqWrapper.Test == 1) { + continue + } bidderErr := openrtb_ext.ExtBidderMessage{ Code: errortypes.ReadCode(v), Message: v.Error(), @@ -501,9 +510,6 @@ func (deps *endpointDeps) parseAmpRequest(httpRequest *http.Request) (req *openr // move to using the request wrapper req = &openrtb_ext.RequestWrapper{BidRequest: reqNormal} - // Populate any "missing" OpenRTB fields with info from other sources, (e.g. HTTP request headers). - deps.setFieldsImplicitly(httpRequest, req) - // Need to ensure cache and targeting are turned on e = initAmpTargetingAndCache(req) if errs = append(errs, e...); errortypes.ContainsFatalError(errs) { diff --git a/endpoints/openrtb2/amp_auction_test.go b/endpoints/openrtb2/amp_auction_test.go index 08dec09e5dc..6e6e1ac855e 100644 --- a/endpoints/openrtb2/amp_auction_test.go +++ b/endpoints/openrtb2/amp_auction_test.go @@ -2052,7 +2052,7 @@ func TestAmpAuctionResponseHeaders(t *testing.T) { ) for _, test := range testCases { - httpReq := httptest.NewRequest("GET", fmt.Sprintf("/openrtb2/auction/amp"+test.requestURLArguments), nil) + httpReq := httptest.NewRequest("GET", "/openrtb2/auction/amp"+test.requestURLArguments, nil) recorder := httptest.NewRecorder() endpoint(recorder, httpReq, nil) @@ -2479,3 +2479,87 @@ func TestSetSeatNonBid(t *testing.T) { }) } } + +func TestAmpAuctionDebugWarningsOnly(t *testing.T) { + testCases := []struct { + description string + requestURLArguments string + addRequestHeaders func(r *http.Request) + expectedStatus int + expectedWarnings map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage + }{ + { + description: "debug_enabled_request_with_invalid_Sec-Browsing-Topics_header", + requestURLArguments: "?tag_id=1&debug=1", + addRequestHeaders: func(r *http.Request) { + r.Header.Add("Sec-Browsing-Topics", "foo") + }, + expectedStatus: 200, + expectedWarnings: map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage{ + "general": { + { + Code: 10012, + Message: "Invalid field in Sec-Browsing-Topics header: foo", + }, + }, + }, + }, + { + description: "debug_disabled_request_with_invalid_Sec-Browsing-Topics_header", + requestURLArguments: "?tag_id=1", + addRequestHeaders: func(r *http.Request) { + r.Header.Add("Sec-Browsing-Topics", "foo") + }, + expectedStatus: 200, + expectedWarnings: nil, + }, + } + + storedRequests := map[string]json.RawMessage{ + "1": json.RawMessage(validRequest(t, "site.json")), + } + exchange := &nobidExchange{} + endpoint, _ := NewAmpEndpoint( + fakeUUIDGenerator{}, + exchange, + newParamsValidator(t), + &mockAmpStoredReqFetcher{storedRequests}, + empty_fetcher.EmptyFetcher{}, + &config.Configuration{ + MaxRequestSize: maxSize, + AccountDefaults: config.Account{ + Privacy: config.AccountPrivacy{ + PrivacySandbox: config.PrivacySandbox{ + TopicsDomain: "abc", + }, + }, + }, + }, + &metricsConfig.NilMetricsEngine{}, + analyticsBuild.New(&config.Analytics{}), + map[string]string{}, + []byte{}, + openrtb_ext.BuildBidderMap(), + empty_fetcher.EmptyFetcher{}, + hooks.EmptyPlanBuilder{}, + nil, + ) + + for _, test := range testCases { + httpReq := httptest.NewRequest("GET", fmt.Sprintf("/openrtb2/auction/amp"+test.requestURLArguments), nil) + test.addRequestHeaders(httpReq) + recorder := httptest.NewRecorder() + + endpoint(recorder, httpReq, nil) + + assert.Equal(t, test.expectedStatus, recorder.Result().StatusCode) + + // Parse Response + var response AmpResponse + if err := jsonutil.UnmarshalValid(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("Error unmarshalling response: %s", err.Error()) + } + + assert.Equal(t, test.expectedWarnings, response.ORTB2.Ext.Warnings) + } +} diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index 9accae2e041..8c178ff3c0b 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -29,6 +29,7 @@ import ( "github.com/prebid/prebid-server/v2/hooks" "github.com/prebid/prebid-server/v2/ortb" "github.com/prebid/prebid-server/v2/privacy" + "github.com/prebid/prebid-server/v2/privacysandbox" "golang.org/x/net/publicsuffix" jsonpatch "gopkg.in/evanphx/json-patch.v4" @@ -61,6 +62,9 @@ const storedRequestTimeoutMillis = 50 const ampChannel = "amp" const appChannel = "app" const secCookieDeprecation = "Sec-Cookie-Deprecation" +const secBrowsingTopics = "Sec-Browsing-Topics" +const observeBrowsingTopics = "Observe-Browsing-Topics" +const observeBrowsingTopicsValue = "?1" var ( dntKey string = http.CanonicalHeaderKey("DNT") @@ -190,6 +194,7 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http }() w.Header().Set("X-Prebid", version.BuildXPrebidHeader(version.Ver)) + setBrowsingTopicsHeader(w, r) req, impExtInfoMap, storedAuctionResponses, storedBidResponses, bidderImpReplaceImp, account, errL := deps.parseRequest(r, &labels, hookExecutor) if errortypes.ContainsFatalError(errL) && writeError(errL, w, &labels) { @@ -393,6 +398,13 @@ func sendAuctionResponse( return labels, ao } +// setBrowsingTopicsHeader always set the Observe-Browsing-Topics header to a value of ?1 if the Sec-Browsing-Topics is present in request +func setBrowsingTopicsHeader(w http.ResponseWriter, r *http.Request) { + if value := r.Header.Get(secBrowsingTopics); value != "" { + w.Header().Set(observeBrowsingTopics, observeBrowsingTopicsValue) + } +} + // parseRequest turns the HTTP request into an OpenRTB request. This is guaranteed to return: // // - A context which times out appropriately, given the request. @@ -406,6 +418,7 @@ func sendAuctionResponse( func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metrics.Labels, hookExecutor hookexecution.HookStageExecutor) (req *openrtb_ext.RequestWrapper, impExtInfoMap map[string]exchange.ImpExtInfo, storedAuctionResponses stored_responses.ImpsWithBidResponses, storedBidResponses stored_responses.ImpBidderStoredResp, bidderImpReplaceImpId stored_responses.BidderImpReplaceImpID, account *config.Account, errs []error) { errs = nil var err error + var errL []error var r io.ReadCloser = httpRequest.Body reqContentEncoding := httputil.ContentEncoding(httpRequest.Header.Get("Content-Encoding")) if reqContentEncoding != "" { @@ -532,7 +545,9 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metric } // Populate any "missing" OpenRTB fields with info from other sources, (e.g. HTTP request headers). - deps.setFieldsImplicitly(httpRequest, req) + if errsL := deps.setFieldsImplicitly(httpRequest, req, account); len(errsL) > 0 { + errs = append(errs, errsL...) + } if err := ortb.SetDefaults(req); err != nil { errs = []error{err} @@ -547,13 +562,14 @@ func (deps *endpointDeps) parseRequest(httpRequest *http.Request, labels *metric lmt.ModifyForIOS(req.BidRequest) //Stored auction responses should be processed after stored requests due to possible impression modification - storedAuctionResponses, storedBidResponses, bidderImpReplaceImpId, errs = stored_responses.ProcessStoredResponses(ctx, req, deps.storedRespFetcher) - if len(errs) > 0 { + storedAuctionResponses, storedBidResponses, bidderImpReplaceImpId, errL = stored_responses.ProcessStoredResponses(ctx, req, deps.storedRespFetcher) + if len(errL) > 0 { + errs = append(errs, errL...) return nil, nil, nil, nil, nil, nil, errs } hasStoredResponses := len(storedAuctionResponses) > 0 - errL := deps.validateRequest(account, httpRequest, req, false, hasStoredResponses, storedBidResponses, hasStoredBidRequest) + errL = deps.validateRequest(account, httpRequest, req, false, hasStoredResponses, storedBidResponses, hasStoredBidRequest) if len(errL) > 0 { errs = append(errs, errL...) } @@ -876,7 +892,7 @@ func (deps *endpointDeps) validateRequest(account *config.Account, httpReq *http return append(errL, err) } - if err := validateOrFillCDep(httpReq, req, account); err != nil { + if err := validateOrFillCookieDeprecation(httpReq, req, account); err != nil { errL = append(errL, err) } @@ -1919,7 +1935,7 @@ func validateDevice(device *openrtb2.Device) error { return nil } -func validateOrFillCDep(httpReq *http.Request, req *openrtb_ext.RequestWrapper, account *config.Account) error { +func validateOrFillCookieDeprecation(httpReq *http.Request, req *openrtb_ext.RequestWrapper, account *config.Account) error { if account == nil || !account.Privacy.PrivacySandbox.CookieDeprecation.Enabled { return nil } @@ -2029,7 +2045,7 @@ func sanitizeRequest(r *openrtb_ext.RequestWrapper, ipValidator iputil.IPValidat // OpenRTB properties from the headers and other implicit info. // // This function _should not_ override any fields which were defined explicitly by the caller in the request. -func (deps *endpointDeps) setFieldsImplicitly(httpReq *http.Request, r *openrtb_ext.RequestWrapper) { +func (deps *endpointDeps) setFieldsImplicitly(httpReq *http.Request, r *openrtb_ext.RequestWrapper, account *config.Account) []error { sanitizeRequest(r, deps.privateNetworkIPValidator) setDeviceImplicitly(httpReq, r, deps.privateNetworkIPValidator) @@ -2041,6 +2057,9 @@ func (deps *endpointDeps) setFieldsImplicitly(httpReq *http.Request, r *openrtb_ } setAuctionTypeImplicitly(r) + + errs := setSecBrowsingTopicsImplicitly(httpReq, r, account) + return errs } // setDeviceImplicitly uses implicit info from httpReq to populate bidReq.Device @@ -2048,7 +2067,6 @@ func setDeviceImplicitly(httpReq *http.Request, r *openrtb_ext.RequestWrapper, i setIPImplicitly(httpReq, r, ipValidtor) setUAImplicitly(httpReq, r) setDoNotTrackImplicitly(httpReq, r) - } // setAuctionTypeImplicitly sets the auction type to 1 if it wasn't on the request, @@ -2059,6 +2077,31 @@ func setAuctionTypeImplicitly(r *openrtb_ext.RequestWrapper) { } } +// setSecBrowsingTopicsImplicitly updates user.data with data from request header 'Sec-Browsing-Topics' +func setSecBrowsingTopicsImplicitly(httpReq *http.Request, r *openrtb_ext.RequestWrapper, account *config.Account) []error { + secBrowsingTopics := httpReq.Header.Get(secBrowsingTopics) + if secBrowsingTopics == "" { + return nil + } + + // host must configure privacy sandbox + if account == nil || account.Privacy.PrivacySandbox.TopicsDomain == "" { + return nil + } + + topics, errs := privacysandbox.ParseTopicsFromHeader(secBrowsingTopics) + if len(topics) == 0 { + return errs + } + + if r.User == nil { + r.User = &openrtb2.User{} + } + + r.User.Data = privacysandbox.UpdateUserDataWithTopics(r.User.Data, topics, account.Privacy.PrivacySandbox.TopicsDomain) + return errs +} + func setSiteImplicitly(httpReq *http.Request, r *openrtb_ext.RequestWrapper) { if r.Site == nil { r.Site = &openrtb2.Site{} diff --git a/endpoints/openrtb2/auction_test.go b/endpoints/openrtb2/auction_test.go index 5356815d81b..af6f4a1b86a 100644 --- a/endpoints/openrtb2/auction_test.go +++ b/endpoints/openrtb2/auction_test.go @@ -13,6 +13,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -4660,13 +4661,13 @@ func TestValidateNativeAssetData(t *testing.T) { func TestAuctionResponseHeaders(t *testing.T) { testCases := []struct { description string - requestBody string + httpRequest *http.Request expectedStatus int expectedHeaders func(http.Header) }{ { description: "Success Response", - requestBody: validRequest(t, "site.json"), + httpRequest: httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, "site.json"))), expectedStatus: 200, expectedHeaders: func(h http.Header) { h.Set("X-Prebid", "pbs-go/unknown") @@ -4675,12 +4676,39 @@ func TestAuctionResponseHeaders(t *testing.T) { }, { description: "Failure Response", - requestBody: "{}", + httpRequest: httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader("{}")), expectedStatus: 400, expectedHeaders: func(h http.Header) { h.Set("X-Prebid", "pbs-go/unknown") }, }, + { + description: "Success Response with Chrome BrowsingTopicsHeader", + httpRequest: func() *http.Request { + httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(validRequest(t, "site.json"))) + httpReq.Header.Add(secBrowsingTopics, "sample-value") + return httpReq + }(), + expectedStatus: 200, + expectedHeaders: func(h http.Header) { + h.Set("X-Prebid", "pbs-go/unknown") + h.Set("Content-Type", "application/json") + h.Set("Observe-Browsing-Topics", "?1") + }, + }, + { + description: "Failure Response with Chrome BrowsingTopicsHeader", + httpRequest: func() *http.Request { + httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader("{}")) + httpReq.Header.Add(secBrowsingTopics, "sample-value") + return httpReq + }(), + expectedStatus: 400, + expectedHeaders: func(h http.Header) { + h.Set("X-Prebid", "pbs-go/unknown") + h.Set("Observe-Browsing-Topics", "?1") + }, + }, } exchange := &nobidExchange{} @@ -4702,10 +4730,9 @@ func TestAuctionResponseHeaders(t *testing.T) { ) for _, test := range testCases { - httpReq := httptest.NewRequest("POST", "/openrtb2/auction", strings.NewReader(test.requestBody)) recorder := httptest.NewRecorder() - endpoint(recorder, httpReq, nil) + endpoint(recorder, test.httpRequest, nil) expectedHeaders := http.Header{} test.expectedHeaders(expectedHeaders) @@ -6048,7 +6075,7 @@ func fakeNormalizeBidderName(name string) (openrtb_ext.BidderName, bool) { return openrtb_ext.BidderName(strings.ToLower(name)), true } -func TestValidateOrFillCDep(t *testing.T) { +func TestValidateOrFillCookieDeprecation(t *testing.T) { type args struct { httpReq *http.Request req *openrtb_ext.RequestWrapper @@ -6280,7 +6307,7 @@ func TestValidateOrFillCDep(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateOrFillCDep(tt.args.httpReq, tt.args.req, &tt.args.account) + err := validateOrFillCookieDeprecation(tt.args.httpReq, tt.args.req, &tt.args.account) assert.Equal(t, tt.wantErr, err) if tt.args.req != nil { err := tt.args.req.RebuildRequest() @@ -6436,3 +6463,168 @@ func TestValidateRequestCookieDeprecation(t *testing.T) { assert.Equal(t, test.wantCDep, deviceExt.GetCDep()) } } + +func TestSetSecBrowsingTopicsImplicitly(t *testing.T) { + type args struct { + httpReq *http.Request + r *openrtb_ext.RequestWrapper + account *config.Account + } + tests := []struct { + name string + args args + wantUser *openrtb2.User + }{ + { + name: "empty HTTP request, no change in user data", + args: args{ + httpReq: &http.Request{}, + r: &openrtb_ext.RequestWrapper{BidRequest: &openrtb2.BidRequest{}}, + account: &config.Account{Privacy: config.AccountPrivacy{PrivacySandbox: config.PrivacySandbox{TopicsDomain: "ads.pubmatic.com"}}}, + }, + wantUser: nil, + }, + { + name: "valid topic in request but topicsdomain not configured by host, no change in user data", + args: args{ + httpReq: &http.Request{ + Header: http.Header{ + secBrowsingTopics: []string{"(1);v=chrome.1:1:2, ();p=P00000000000"}, + }, + }, + r: &openrtb_ext.RequestWrapper{BidRequest: &openrtb2.BidRequest{}}, + account: &config.Account{Privacy: config.AccountPrivacy{PrivacySandbox: config.PrivacySandbox{TopicsDomain: ""}}}, + }, + wantUser: nil, + }, + { + name: "valid topic in request and topicsdomain configured by host, topics copied to user data", + args: args{ + httpReq: &http.Request{ + Header: http.Header{ + secBrowsingTopics: []string{"(1);v=chrome.1:1:2, ();p=P00000000000"}, + }, + }, + r: &openrtb_ext.RequestWrapper{BidRequest: &openrtb2.BidRequest{}}, + account: &config.Account{Privacy: config.AccountPrivacy{PrivacySandbox: config.PrivacySandbox{TopicsDomain: "ads.pubmatic.com"}}}, + }, + wantUser: &openrtb2.User{ + Data: []openrtb2.Data{ + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + { + ID: "1", + }, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + }, + { + name: "valid empty topic in request, no change in user data", + args: args{ + httpReq: &http.Request{ + Header: http.Header{ + secBrowsingTopics: []string{"();p=P0000000000000000000000000000000"}, + }, + }, + r: &openrtb_ext.RequestWrapper{BidRequest: &openrtb2.BidRequest{}}, + account: &config.Account{Privacy: config.AccountPrivacy{PrivacySandbox: config.PrivacySandbox{TopicsDomain: "ads.pubmatic.com"}}}, + }, + wantUser: nil, + }, + { + name: "request with a few valid topics (including duplicate topics, segIDs, matching segtax, segclass, etc) and a few invalid topics(different invalid format), only valid and unique topics copied/merged to/with user data", + args: args{ + httpReq: &http.Request{ + Header: http.Header{ + secBrowsingTopics: []string{"(1);v=chrome.1:1:2, (1 2);v=chrome.1:1:2,(4);v=chrome.1:1:2,();p=P0000000000,(4);v=chrome.1, 5);v=chrome.1, (6;v=chrome.1, ();v=chrome.1, ( );v=chrome.1, (1);v=chrome.1:1:2, (1 2 4 6 7 4567 ) ; v=chrome.1: 2 : 3,();p=P0000000000"}, + }, + }, + r: &openrtb_ext.RequestWrapper{BidRequest: &openrtb2.BidRequest{ + User: &openrtb2.User{ + Data: []openrtb2.Data{ + { + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + }, + Ext: json.RawMessage(`{"segtax":603,"segclass":"4"}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "3"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + }}, + account: &config.Account{Privacy: config.AccountPrivacy{PrivacySandbox: config.PrivacySandbox{TopicsDomain: "ads.pubmatic.com"}}}, + }, + wantUser: &openrtb2.User{ + Data: []openrtb2.Data{ + { + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + }, + Ext: json.RawMessage(`{"segtax":603,"segclass":"4"}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "4"}, + {ID: "6"}, + {ID: "7"}, + {ID: "4567"}, + }, + Ext: json.RawMessage(`{"segtax":601,"segclass":"3"}`), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setSecBrowsingTopicsImplicitly(tt.args.httpReq, tt.args.r, tt.args.account) + + // sequence is not guaranteed we're using a map to filter segids + sortUserData(tt.wantUser) + sortUserData(tt.args.r.User) + assert.Equal(t, tt.wantUser, tt.args.r.User, tt.name) + }) + } +} + +func sortUserData(user *openrtb2.User) { + if user != nil { + sort.Slice(user.Data, func(i, j int) bool { + if user.Data[i].Name == user.Data[j].Name { + return string(user.Data[i].Ext) < string(user.Data[j].Ext) + } + return user.Data[i].Name < user.Data[j].Name + }) + for g := range user.Data { + sort.Slice(user.Data[g].Segment, func(i, j int) bool { + return user.Data[g].Segment[i].ID < user.Data[g].Segment[j].ID + }) + } + } +} diff --git a/endpoints/openrtb2/sample-requests/video/video_invalid_sample_negative_tmax.json b/endpoints/openrtb2/sample-requests/video/video_invalid_sample_negative_tmax.json new file mode 100644 index 00000000000..e1dc02314f8 --- /dev/null +++ b/endpoints/openrtb2/sample-requests/video/video_invalid_sample_negative_tmax.json @@ -0,0 +1,87 @@ +{ + "description": "video request with negative tmax value. Expect error", + + "requestPayload": { + "tmax": -2, + "storedrequestid": "80ce30c53c16e6ede735f123ef6e32361bfc7b22", + "podconfig": { + "durationrangesec": [ + 30 + ], + "requireexactduration": true, + "pods": [{ + "podid": 1, + "adpoddurationsec": 180, + "configid": "fba10607-0c12-43d1-ad07-b8a513bc75d6" + }, + { + "podid": 2, + "adpoddurationsec": 150, + "configid": "8b452b41-2681-4a20-9086-6f16ffad7773" + } + ] + }, + "site": { + "page": "prebid.com" + }, + "regs": { + "ext": { + "gdpr": 0 + } + }, + "user": { + "yob": 1991, + "gender": "F", + "keywords": "Hotels, Travelling", + "ext": { + "prebid": { + "buyeruids": { + "appnexus": "unique_id_an", + "rubicon": "unique_id_rubi" + } + } + } + }, + "device": { + "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/537.13 (KHTML, like Gecko) Version/5.1.7 Safari/534.57.2", + "ip": "123.145.167.10", + "devicetype": 1, + "ifa": "AA000DFE74168477C70D291f574D344790E0BB11", + "lmt": 44, + "os": "mac os", + "w": 640, + "h": 480, + "didsha1": "didsha1", + "didmd5": "didmd5", + "dpidsha1": "dpidsha1", + "dpidmd5": "dpidmd5", + "macsha1": "macsha1", + "macmd5": "macmd5" + }, + "includebrandcategory": { + "primaryadserver": 1, + "publisher": "" + }, + "video": { + "w": 640, + "h": 480, + "mimes": [ + "video/mp4" + ], + "protocols": [ + 2, 3, 5, 6 + ] + }, + "content": { + "episode": 6, + "title": "episodeName", + "series": "TvName", + "season": "season3", + "len": 900, + "livestream": 0 + }, + "cacheconfig": { + "ttl": 42 + } + } + } \ No newline at end of file diff --git a/endpoints/openrtb2/video_auction.go b/endpoints/openrtb2/video_auction.go index 3ddfc31aa39..f74a4a224ae 100644 --- a/endpoints/openrtb2/video_auction.go +++ b/endpoints/openrtb2/video_auction.go @@ -165,6 +165,7 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re }() w.Header().Set("X-Prebid", version.BuildXPrebidHeader(version.Ver)) + setBrowsingTopicsHeader(w, r) lr := &io.LimitedReader{ R: r.Body, @@ -259,9 +260,6 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re // all code after this line should use the bidReqWrapper instead of bidReq directly bidReqWrapper := &openrtb_ext.RequestWrapper{BidRequest: bidReq} - // Populate any "missing" OpenRTB fields with info from other sources, (e.g. HTTP request headers). - deps.setFieldsImplicitly(r, bidReqWrapper) - if err := ortb.SetDefaults(bidReqWrapper); err != nil { handleError(&labels, w, errL, &vo, &debugLog) return @@ -300,7 +298,13 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re return } - errL = deps.validateRequest(account, r, bidReqWrapper, false, false, nil, false) + // Populate any "missing" OpenRTB fields with info from other sources, (e.g. HTTP request headers). + if errs := deps.setFieldsImplicitly(r, bidReqWrapper, account); len(errs) > 0 { + errL = append(errL, errs...) + } + + errs := deps.validateRequest(account, r, bidReqWrapper, false, false, nil, false) + errL = append(errL, errs...) if errortypes.ContainsFatalError(errL) { handleError(&labels, w, errL, &vo, &debugLog) return @@ -308,6 +312,8 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re activityControl = privacy.NewActivityControl(&account.Privacy) + warnings := errortypes.WarningOnly(errL) + secGPC := r.Header.Get("Sec-GPC") auctionRequest := &exchange.AuctionRequest{ BidRequestWrapper: bidReqWrapper, @@ -316,6 +322,7 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re RequestType: labels.RType, StartTime: start, LegacyLabels: labels, + Warnings: warnings, GlobalPrivacyControlHeader: secGPC, PubID: labels.PubID, HookExecutor: hookexecution.EmptyHookExecutor{}, diff --git a/endpoints/openrtb2/video_auction_test.go b/endpoints/openrtb2/video_auction_test.go index 98d6ca35c49..cc522c91d25 100644 --- a/endpoints/openrtb2/video_auction_test.go +++ b/endpoints/openrtb2/video_auction_test.go @@ -1162,6 +1162,7 @@ func TestVideoAuctionResponseHeaders(t *testing.T) { testCases := []struct { description string givenTestFile string + givenHeader map[string]string expectedStatus int expectedHeaders func(http.Header) }{ @@ -1173,7 +1174,8 @@ func TestVideoAuctionResponseHeaders(t *testing.T) { h.Set("X-Prebid", "pbs-go/unknown") h.Set("Content-Type", "application/json") }, - }, { + }, + { description: "Failure Response", givenTestFile: "sample-requests/video/video_invalid_sample.json", expectedStatus: 500, @@ -1181,6 +1183,27 @@ func TestVideoAuctionResponseHeaders(t *testing.T) { h.Set("X-Prebid", "pbs-go/unknown") }, }, + { + description: "Success Response with header Observe-Browsing-Topics", + givenTestFile: "sample-requests/video/video_valid_sample.json", + givenHeader: map[string]string{secBrowsingTopics: "anyValue"}, + expectedStatus: 200, + expectedHeaders: func(h http.Header) { + h.Set("X-Prebid", "pbs-go/unknown") + h.Set("Content-Type", "application/json") + h.Set("Observe-Browsing-Topics", "?1") + }, + }, + { + description: "Failure Response with header Observe-Browsing-Topics", + givenTestFile: "sample-requests/video/video_invalid_sample.json", + givenHeader: map[string]string{secBrowsingTopics: "anyValue"}, + expectedStatus: 500, + expectedHeaders: func(h http.Header) { + h.Set("X-Prebid", "pbs-go/unknown") + h.Set("Observe-Browsing-Topics", "?1") + }, + }, } exchange := &mockExchangeVideo{} @@ -1190,6 +1213,9 @@ func TestVideoAuctionResponseHeaders(t *testing.T) { requestBody := readVideoTestFile(t, test.givenTestFile) httpReq := httptest.NewRequest("POST", "/openrtb2/video", strings.NewReader(requestBody)) + for k, v := range test.givenHeader { + httpReq.Header.Add(k, v) + } recorder := httptest.NewRecorder() endpoint.VideoAuctionEndpoint(recorder, httpReq, nil) @@ -1472,3 +1498,18 @@ func readVideoTestFile(t *testing.T, filename string) string { return string(getRequestPayload(t, requestData)) } + +func TestVideoRequestValidationFailed(t *testing.T) { + ex := &mockExchangeVideo{} + reqBody := readVideoTestFile(t, "sample-requests/video/video_invalid_sample_negative_tmax.json") + req := httptest.NewRequest("POST", "/openrtb2/video", strings.NewReader(reqBody)) + recorder := httptest.NewRecorder() + + deps := mockDeps(t, ex) + deps.VideoAuctionEndpoint(recorder, req, nil) + + errorMessage := recorder.Body.String() + + assert.Equal(t, 500, recorder.Code, "Should catch error in request") + assert.Equal(t, "Critical error while running the video endpoint: request.tmax must be nonnegative. Got -2", errorMessage, "Incorrect request validation message") +} diff --git a/errortypes/code.go b/errortypes/code.go index a30bb8e4bc0..a56df67c9c9 100644 --- a/errortypes/code.go +++ b/errortypes/code.go @@ -33,6 +33,7 @@ const ( FloorBidRejectionWarningCode InvalidBidResponseDSAWarningCode SecCookieDeprecationLenWarningCode + SecBrowsingTopicsWarningCode ) // Coder provides an error or warning code with severity. diff --git a/errortypes/errortypes.go b/errortypes/errortypes.go index d31c4166b06..7ca5668c290 100644 --- a/errortypes/errortypes.go +++ b/errortypes/errortypes.go @@ -251,3 +251,26 @@ func (err *FailedToMarshal) Code() int { func (err *FailedToMarshal) Severity() Severity { return SeverityFatal } + +// DebugWarning is a generic non-fatal error used in debug mode. Throughout the codebase, an error can +// only be a warning if it's of the type defined below +type DebugWarning struct { + Message string + WarningCode int +} + +func (err *DebugWarning) Error() string { + return err.Message +} + +func (err *DebugWarning) Code() int { + return err.WarningCode +} + +func (err *DebugWarning) Severity() Severity { + return SeverityWarning +} + +func (err *DebugWarning) Scope() Scope { + return ScopeDebug +} diff --git a/errortypes/scope.go b/errortypes/scope.go new file mode 100644 index 00000000000..b97284358f5 --- /dev/null +++ b/errortypes/scope.go @@ -0,0 +1,19 @@ +package errortypes + +type Scope int + +const ( + ScopeAny Scope = iota + ScopeDebug +) + +type Scoped interface { + Scope() Scope +} + +func ReadScope(err error) Scope { + if e, ok := err.(Scoped); ok { + return e.Scope() + } + return ScopeAny +} diff --git a/errortypes/scope_test.go b/errortypes/scope_test.go new file mode 100644 index 00000000000..7d90d5585fd --- /dev/null +++ b/errortypes/scope_test.go @@ -0,0 +1,37 @@ +package errortypes + +import ( + "errors" + "testing" +) + +func TestReadScope(t *testing.T) { + tests := []struct { + name string + err error + want Scope + }{ + { + name: "scope-debug", + err: &DebugWarning{Message: "scope is debug"}, + want: ScopeDebug, + }, + { + name: "scope-any", + err: &Warning{Message: "scope is any"}, + want: ScopeAny, + }, + { + name: "default-error", + err: errors.New("default error"), + want: ScopeAny, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ReadScope(tt.err); got != tt.want { + t.Errorf("ReadScope() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/exchange/exchange.go b/exchange/exchange.go index c73eea9f10c..e688b3e0a9b 100644 --- a/exchange/exchange.go +++ b/exchange/exchange.go @@ -488,6 +488,9 @@ func (e *exchange) HoldAuction(ctx context.Context, r *AuctionRequest, debugLog } for _, warning := range r.Warnings { + if errortypes.ReadScope(warning) == errortypes.ScopeDebug && !responseDebugAllow { + continue + } generalWarning := openrtb_ext.ExtBidderMessage{ Code: errortypes.ReadCode(warning), Message: warning.Error(), diff --git a/privacysandbox/topics.go b/privacysandbox/topics.go new file mode 100644 index 00000000000..4c129d0a535 --- /dev/null +++ b/privacysandbox/topics.go @@ -0,0 +1,228 @@ +package privacysandbox + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/errortypes" + "github.com/prebid/prebid-server/v2/util/jsonutil" +) + +type Topic struct { + SegTax int `json:"segtax,omitempty"` + SegClass string `json:"segclass,omitempty"` + SegIDs []int `json:"segids,omitempty"` +} + +// ParseTopicsFromHeader parses the Sec-Browsing-Topics header data into Topics object +func ParseTopicsFromHeader(secBrowsingTopics string) ([]Topic, []error) { + topics := make([]Topic, 0, 10) + var warnings []error + + for _, field := range strings.Split(secBrowsingTopics, ",") { + field = strings.TrimSpace(field) + if field == "" || strings.HasPrefix(field, "();p=") { + continue + } + + if len(topics) < 10 { + if topic, ok := parseTopicSegment(field); ok { + topics = append(topics, topic) + } else { + warnings = append(warnings, formatWarning(field)) + } + } else { + warnings = append(warnings, formatWarning(field+" discarded due to limit reached.")) + } + } + + return topics, warnings +} + +// parseTopicSegment parses a single topic segment from the header into Topics object +func parseTopicSegment(field string) (Topic, bool) { + segment := strings.Split(field, ";") + if len(segment) != 2 { + return Topic{}, false + } + + segmentsIDs := strings.TrimSpace(segment[0]) + if len(segmentsIDs) < 3 || segmentsIDs[0] != '(' || segmentsIDs[len(segmentsIDs)-1] != ')' { + return Topic{}, false + } + + segtax, segclass := parseSegTaxSegClass(segment[1]) + if segtax == 0 || segclass == "" { + return Topic{}, false + } + + segIDs, err := parseSegmentIDs(segmentsIDs[1 : len(segmentsIDs)-1]) + if err != nil { + return Topic{}, false + } + + return Topic{ + SegTax: segtax, + SegClass: segclass, + SegIDs: segIDs, + }, true +} + +func parseSegTaxSegClass(seg string) (int, string) { + taxanomyModel := strings.Split(seg, ":") + if len(taxanomyModel) != 3 { + return 0, "" + } + + // taxanomyModel[0] is v=browser_version, we don't need it + taxanomyVer := strings.TrimSpace(taxanomyModel[1]) + taxanomy, err := strconv.Atoi(taxanomyVer) + if err != nil || taxanomy < 1 || taxanomy > 10 { + return 0, "" + } + + segtax := 600 + (taxanomy - 1) + segclass := strings.TrimSpace(taxanomyModel[2]) + return segtax, segclass +} + +// parseSegmentIDs parses the segment ids from the header string into int array +func parseSegmentIDs(segmentsIDs string) ([]int, error) { + var selectedSegmentIDs []int + for _, segmentID := range strings.Fields(segmentsIDs) { + segmentID = strings.TrimSpace(segmentID) + selectedSegmentID, err := strconv.Atoi(segmentID) + if err != nil || selectedSegmentID <= 0 { + return selectedSegmentIDs, errors.New("invalid segment id") + } + selectedSegmentIDs = append(selectedSegmentIDs, selectedSegmentID) + } + + return selectedSegmentIDs, nil +} + +func UpdateUserDataWithTopics(userData []openrtb2.Data, headerData []Topic, topicsDomain string) []openrtb2.Data { + if topicsDomain == "" { + return userData + } + + // headerDataMap groups segIDs by segtax and segclass for faster lookup and tracking of new segIDs yet to be added to user.data + // tracking is done by removing segIDs from segIDsMap once they are added to user.data, ensuring that headerDataMap will always have unique segtax-segclass-segIDs + // the only drawback of tracking via deleting segtax-segclass from headerDataMap is that this would not track duplicate entries within user.data which is fine because we are only merging header data with the provided user.data + headerDataMap := createHeaderDataMap(headerData) + + for i, data := range userData { + ext := &Topic{} + err := json.Unmarshal(data.Ext, ext) + if err != nil { + continue + } + + if ext.SegTax == 0 || ext.SegClass == "" { + continue + } + + if newSegIDs := findNewSegIDs(data.Name, topicsDomain, *ext, data.Segment, headerDataMap); newSegIDs != nil { + for _, segID := range newSegIDs { + userData[i].Segment = append(userData[i].Segment, openrtb2.Segment{ID: strconv.Itoa(segID)}) + } + + delete(headerDataMap[ext.SegTax], ext.SegClass) + } + } + + for segTax, segClassMap := range headerDataMap { + for segClass, segIDs := range segClassMap { + if len(segIDs) != 0 { + data := openrtb2.Data{ + Name: topicsDomain, + } + + var err error + data.Ext, err = jsonutil.Marshal(Topic{SegTax: segTax, SegClass: segClass}) + if err != nil { + continue + } + + for segID := range segIDs { + data.Segment = append(data.Segment, openrtb2.Segment{ + ID: strconv.Itoa(segID), + }) + } + + userData = append(userData, data) + } + } + } + + return userData +} + +// createHeaderDataMap creates a map of header data (segtax-segclass-segIDs) for faster lookup +// topicsdomain is not needed as we are only interested data from one domain configured in host config +func createHeaderDataMap(headerData []Topic) map[int]map[string]map[int]struct{} { + headerDataMap := make(map[int]map[string]map[int]struct{}) + + for _, topic := range headerData { + segClassMap, ok := headerDataMap[topic.SegTax] + if !ok { + segClassMap = make(map[string]map[int]struct{}) + headerDataMap[topic.SegTax] = segClassMap + } + + segIDsMap, ok := segClassMap[topic.SegClass] + if !ok { + segIDsMap = make(map[int]struct{}) + segClassMap[topic.SegClass] = segIDsMap + } + + for _, segID := range topic.SegIDs { + segIDsMap[segID] = struct{}{} + } + } + + return headerDataMap +} + +// findNewSegIDs merge unique segIDs in single user.data if request.user.data and header data match. i.e. segclass, segtax and topicsdomain match +func findNewSegIDs(dataName, topicsDomain string, userData Topic, userDataSegments []openrtb2.Segment, headerDataMap map[int]map[string]map[int]struct{}) []int { + if dataName != topicsDomain { + return nil + } + + segClassMap, exists := headerDataMap[userData.SegTax] + if !exists { + return nil + } + + segIDsMap, exists := segClassMap[userData.SegClass] + if !exists { + return nil + } + + // remove existing segIDs entries + for _, segID := range userDataSegments { + if id, err := strconv.Atoi(segID.ID); err == nil { + delete(segIDsMap, id) + } + } + + // collect remaining segIDs + segIDs := make([]int, 0, len(segIDsMap)) + for segID := range segIDsMap { + segIDs = append(segIDs, segID) + } + + return segIDs +} + +func formatWarning(msg string) error { + return &errortypes.DebugWarning{ + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + Message: fmt.Sprintf("Invalid field in Sec-Browsing-Topics header: %s", msg), + } +} diff --git a/privacysandbox/topics_test.go b/privacysandbox/topics_test.go new file mode 100644 index 00000000000..90a46f770c6 --- /dev/null +++ b/privacysandbox/topics_test.go @@ -0,0 +1,722 @@ +package privacysandbox + +import ( + "encoding/json" + "sort" + "testing" + + "github.com/prebid/openrtb/v20/openrtb2" + "github.com/prebid/prebid-server/v2/errortypes" + "github.com/stretchr/testify/assert" +) + +func TestParseTopicsFromHeader(t *testing.T) { + type args struct { + secBrowsingTopics string + } + tests := []struct { + name string + args args + wantTopic []Topic + wantError []error + }{ + { + name: "empty header", + args: args{secBrowsingTopics: " "}, + wantTopic: []Topic{}, + wantError: nil, + }, + { + name: "invalid header value", + args: args{secBrowsingTopics: "some-sec-cookie-value"}, + wantTopic: []Topic{}, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: some-sec-cookie-value", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with only finish padding", + args: args{secBrowsingTopics: "();p=P0000000000000000000000000000000"}, + wantTopic: []Topic{}, + wantError: nil, + }, + { + name: "header with one valid field", + args: args{secBrowsingTopics: "(1);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1}, + }, + }, + wantError: nil, + }, + { + name: "header without finish padding", + args: args{secBrowsingTopics: "(1);v=chrome.1:1:2"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1}, + }, + }, + wantError: nil, + }, + { + name: "header with more than 10 valid field, should return only 10", + args: args{secBrowsingTopics: "(1);v=chrome.1:1:2, (2);v=chrome.1:1:2, (3);v=chrome.1:1:2, (4);v=chrome.1:1:2, (5);v=chrome.1:1:2, (6);v=chrome.1:1:2, (7);v=chrome.1:1:2, (8);v=chrome.1:1:2, (9);v=chrome.1:1:2, (10);v=chrome.1:1:2, (11);v=chrome.1:1:2, (12);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{2}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{3}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{4}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{5}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{6}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{7}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{8}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{9}, + }, + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{10}, + }, + }, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (11);v=chrome.1:1:2 discarded due to limit reached.", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (12);v=chrome.1:1:2 discarded due to limit reached.", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with one valid field having multiple segIDs", + args: args{secBrowsingTopics: "(1 2);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1, 2}, + }, + }, + wantError: nil, + }, + { + name: "header with two valid fields having different taxonomies", + args: args{secBrowsingTopics: "(1);v=chrome.1:1:2, (1);v=chrome.1:2:2, ();p=P0000000000"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1}, + }, + { + SegTax: 601, + SegClass: "2", + SegIDs: []int{1}, + }, + }, + wantError: nil, + }, + { + name: "header with one valid field and another invalid field (w/o segIDs), should return only one valid field", + args: args{secBrowsingTopics: "(1);v=chrome.1:2:3, ();v=chrome.1:2:3, ();p=P0000000000"}, + wantTopic: []Topic{ + { + SegTax: 601, + SegClass: "3", + SegIDs: []int{1}, + }, + }, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: ();v=chrome.1:2:3", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with two valid fields having different model version", + args: args{secBrowsingTopics: "(1);v=chrome.1:2:3, (2);v=chrome.1:2:3, ();p=P0000000000"}, + wantTopic: []Topic{ + { + SegTax: 601, + SegClass: "3", + SegIDs: []int{1}, + }, + { + SegTax: 601, + SegClass: "3", + SegIDs: []int{2}, + }, + }, + wantError: nil, + }, + { + name: "header with one valid fields and two invalid fields (one with taxanomy < 0 and another with taxanomy > 10), should return only one valid field", + args: args{secBrowsingTopics: "(1);v=chrome.1:11:2, (1);v=chrome.1:5:6, (1);v=chrome.1:0:2, ();p=P0000000000"}, + wantTopic: []Topic{ + { + SegTax: 604, + SegClass: "6", + SegIDs: []int{1}, + }, + }, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1);v=chrome.1:11:2", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1);v=chrome.1:0:2", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with with valid fields having special characters (whitespaces, etc)", + args: args{secBrowsingTopics: "(1 2 4 6 7 4567 ) ; v=chrome.1: 1 : 2, (1);v=chrome.1, ();p=P0000000000"}, + wantTopic: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1, 2, 4, 6, 7, 4567}, + }, + }, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1);v=chrome.1", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with one valid field having a negative segId, drop field", + args: args{secBrowsingTopics: "(1 -3);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{}, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1 -3);v=chrome.1:1:2", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with one valid field having a segId=0, drop field", + args: args{secBrowsingTopics: "(1 0);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{}, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1 0);v=chrome.1:1:2", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + { + name: "header with one valid field having a segId value more than MaxInt, drop field", + args: args{secBrowsingTopics: "(1 9223372036854775808);v=chrome.1:1:2, ();p=P00000000000"}, + wantTopic: []Topic{}, + wantError: []error{ + &errortypes.DebugWarning{ + Message: "Invalid field in Sec-Browsing-Topics header: (1 9223372036854775808);v=chrome.1:1:2", + WarningCode: errortypes.SecBrowsingTopicsWarningCode, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotTopic, gotError := ParseTopicsFromHeader(tt.args.secBrowsingTopics) + assert.Equal(t, tt.wantTopic, gotTopic) + assert.Equal(t, tt.wantError, gotError) + }) + } +} + +func TestUpdateUserDataWithTopics(t *testing.T) { + type args struct { + userData []openrtb2.Data + headerData []Topic + topicsDomain string + } + tests := []struct { + name string + args args + want []openrtb2.Data + }{ + { + name: "empty topics, empty user data, no change in user data", + args: args{ + userData: nil, + headerData: nil, + }, + want: nil, + }, + { + name: "empty topics, non-empty user data, no change in user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + }, + headerData: nil, + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + }, + }, + { + name: "topicsDomain empty, no change in user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1, 2}, + }, + }, + topicsDomain: "", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + }, + }, + { + name: "non-empty topics, empty user data, topics from header copied to user data", + args: args{ + userData: nil, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{1, 2}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, non-empty user data, topics from header copied to user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, user data with invalid data.ext field, topics from header copied to user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{`), + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "data1", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, user data with invalid topic details (invalid segtax and segclass), topics from header copied to user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{"segtax":0,"segclass":""}`), + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{"segtax":0,"segclass":""}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, user data with non matching topic details (different topicdomains, segtax and segclass), topics from header copied to user data", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + ID: "2", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "5"}, + {ID: "6"}, + }, + Ext: json.RawMessage(`{"segtax":601,"segclass":"3"}`), + }, + { + ID: "3", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "7"}, + {ID: "8"}, + }, + Ext: json.RawMessage(`{"segtax":602,"segclass":"4"}`), + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{3, 4}, + }, + { + SegTax: 602, + SegClass: "2", + SegIDs: []int{3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "chrome.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + ID: "2", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "5"}, + {ID: "6"}, + }, + Ext: json.RawMessage(`{"segtax":601,"segclass":"3"}`), + }, + { + ID: "3", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "7"}, + {ID: "8"}, + }, + Ext: json.RawMessage(`{"segtax":602,"segclass":"4"}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":602,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, user data with same topic details (matching segtax and segclass), topics from header merged with user data (filter unique segIDs)", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{2, 3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + { + name: "non-empty topics, user data with duplicate topic details (matching segtax and segclass and segIDs), topics from header merged with user data (filter unique segIDs), user.data will not be deduped", + args: args{ + userData: []openrtb2.Data{ + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + headerData: []Topic{ + { + SegTax: 600, + SegClass: "2", + SegIDs: []int{2, 3, 4}, + }, + }, + topicsDomain: "ads.pubmatic.com", + }, + want: []openrtb2.Data{ + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + {ID: "4"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + { + ID: "1", + Name: "ads.pubmatic.com", + Segment: []openrtb2.Segment{ + {ID: "1"}, + {ID: "2"}, + {ID: "3"}, + }, + Ext: json.RawMessage(`{"segtax":600,"segclass":"2"}`), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UpdateUserDataWithTopics(tt.args.userData, tt.args.headerData, tt.args.topicsDomain) + sort.Slice(got, func(i, j int) bool { + if got[i].Name == got[j].Name { + return string(got[i].Ext) < string(got[j].Ext) + } + return got[i].Name < got[j].Name + }) + sort.Slice(tt.want, func(i, j int) bool { + if tt.want[i].Name == tt.want[j].Name { + return string(tt.want[i].Ext) < string(tt.want[j].Ext) + } + return tt.want[i].Name < tt.want[j].Name + }) + + for g := range got { + sort.Slice(got[g].Segment, func(i, j int) bool { + return got[g].Segment[i].ID < got[g].Segment[j].ID + }) + } + assert.Equal(t, tt.want, got, tt.name) + }) + } +}