diff --git a/endpoints/cookie_sync.go b/endpoints/cookie_sync.go index fa154bbcbff..ef3c40e8815 100644 --- a/endpoints/cookie_sync.go +++ b/endpoints/cookie_sync.go @@ -90,8 +90,10 @@ func (c *cookieSyncEndpoint) Handle(w http.ResponseWriter, r *http.Request, _ ht c.handleError(w, err, http.StatusBadRequest) return } + decoder := usersync.Base64Decoder{} - cookie := usersync.ParseCookieFromRequest(r, &c.config.HostCookie) + cookie := usersync.ReadCookie(r, decoder, &c.config.HostCookie) + usersync.SyncHostCookie(r, cookie, &c.config.HostCookie) result := c.chooser.Choose(request, cookie) switch result.Status { diff --git a/endpoints/cookie_sync_test.go b/endpoints/cookie_sync_test.go index 37acf0c2add..a69a2cdb819 100644 --- a/endpoints/cookie_sync_test.go +++ b/endpoints/cookie_sync_test.go @@ -11,7 +11,6 @@ import ( "strings" "testing" "testing/iotest" - "time" "github.com/prebid/prebid-server/analytics" "github.com/prebid/prebid-server/config" @@ -106,7 +105,7 @@ func TestCookieSyncHandle(t *testing.T) { syncer.On("GetSync", syncTypeExpected, privacy.Policies{}).Return(sync, nil).Maybe() cookieWithSyncs := usersync.NewCookie() - cookieWithSyncs.TrySync("foo", "anyID") + cookieWithSyncs.Sync("foo", "anyID") testCases := []struct { description string @@ -271,7 +270,9 @@ func TestCookieSyncHandle(t *testing.T) { request := httptest.NewRequest("POST", "/cookiesync", test.givenBody) if test.givenCookie != nil { - request.AddCookie(test.givenCookie.ToHTTPCookie(24 * time.Hour)) + httpCookie, err := ToHTTPCookie(test.givenCookie) + assert.NoError(t, err) + request.AddCookie(httpCookie) } writer := httptest.NewRecorder() @@ -1663,7 +1664,7 @@ func TestCookieSyncHandleResponse(t *testing.T) { cookie := usersync.NewCookie() if test.givenCookieHasSyncs { - if err := cookie.TrySync("foo", "anyID"); err != nil { + if err := cookie.Sync("foo", "anyID"); err != nil { assert.FailNow(t, test.description+":set_cookie") } } diff --git a/endpoints/getuids.go b/endpoints/getuids.go index ad984a8df00..f420c64fa6b 100644 --- a/endpoints/getuids.go +++ b/endpoints/getuids.go @@ -18,9 +18,11 @@ type userSyncs struct { // returns all the existing syncs for the user func NewGetUIDsEndpoint(cfg config.HostCookie) httprouter.Handle { return httprouter.Handle(func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - pc := usersync.ParseCookieFromRequest(r, &cfg) + cookie := usersync.ReadCookie(r, usersync.Base64Decoder{}, &cfg) + usersync.SyncHostCookie(r, cookie, &cfg) + userSyncs := new(userSyncs) - userSyncs.BuyerUIDs = pc.GetUIDs() + userSyncs.BuyerUIDs = cookie.GetUIDs() json.NewEncoder(w).Encode(userSyncs) }) } diff --git a/endpoints/openrtb2/amp_auction.go b/endpoints/openrtb2/amp_auction.go index 36248a8820b..e260f644978 100644 --- a/endpoints/openrtb2/amp_auction.go +++ b/endpoints/openrtb2/amp_auction.go @@ -181,12 +181,15 @@ func (deps *endpointDeps) AmpAuction(w http.ResponseWriter, r *http.Request, _ h } defer cancel() - usersyncs := usersync.ParseCookieFromRequest(r, &(deps.cfg.HostCookie)) + // Read UserSyncs/Cookie from Request + usersyncs := usersync.ReadCookie(r, usersync.Base64Decoder{}, &deps.cfg.HostCookie) + usersync.SyncHostCookie(r, usersyncs, &deps.cfg.HostCookie) if usersyncs.HasAnyLiveSyncs() { labels.CookieFlag = metrics.CookieFlagYes } else { labels.CookieFlag = metrics.CookieFlagNo } + labels.PubID = getAccountID(reqWrapper.Site.Publisher) // Look up account now that we have resolved the pubID value account, acctIDErrs := accountService.GetAccount(ctx, deps.cfg, deps.accounts, labels.PubID, deps.metricsEngine) diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go index 962c72d4cbf..505e5ea9cfc 100644 --- a/endpoints/openrtb2/auction.go +++ b/endpoints/openrtb2/auction.go @@ -198,7 +198,11 @@ func (deps *endpointDeps) Auction(w http.ResponseWriter, r *http.Request, _ http defer cancel() } - usersyncs := usersync.ParseCookieFromRequest(r, &(deps.cfg.HostCookie)) + // Read Usersyncs/Cookie + decoder := usersync.Base64Decoder{} + usersyncs := usersync.ReadCookie(r, decoder, &deps.cfg.HostCookie) + usersync.SyncHostCookie(r, usersyncs, &deps.cfg.HostCookie) + if req.Site != nil { if usersyncs.HasAnyLiveSyncs() { labels.CookieFlag = metrics.CookieFlagYes diff --git a/endpoints/openrtb2/video_auction.go b/endpoints/openrtb2/video_auction.go index 79ac261492c..80a30cf7746 100644 --- a/endpoints/openrtb2/video_auction.go +++ b/endpoints/openrtb2/video_auction.go @@ -275,7 +275,11 @@ func (deps *endpointDeps) VideoAuctionEndpoint(w http.ResponseWriter, r *http.Re defer cancel() } - usersyncs := usersync.ParseCookieFromRequest(r, &(deps.cfg.HostCookie)) + // Read Usersyncs/Cookie + decoder := usersync.Base64Decoder{} + usersyncs := usersync.ReadCookie(r, decoder, &deps.cfg.HostCookie) + usersync.SyncHostCookie(r, usersyncs, &deps.cfg.HostCookie) + if bidReqWrapper.App != nil { labels.Source = metrics.DemandApp labels.PubID = getAccountID(bidReqWrapper.App.Publisher) diff --git a/endpoints/setuid.go b/endpoints/setuid.go index a4d04749eae..955da0628e5 100644 --- a/endpoints/setuid.go +++ b/endpoints/setuid.go @@ -7,7 +7,6 @@ import ( "net/url" "strconv" "strings" - "time" "github.com/julienschmidt/httprouter" accountService "github.com/prebid/prebid-server/account" @@ -28,8 +27,11 @@ const ( chromeiOSStrLen = len(chromeiOSStr) ) +const uidCookieName = "uids" + func NewSetUIDEndpoint(cfg *config.Configuration, syncersByBidder map[string]usersync.Syncer, gdprPermsBuilder gdpr.PermissionsBuilder, tcf2CfgBuilder gdpr.TCF2ConfigBuilder, pbsanalytics analytics.PBSAnalyticsModule, accountsFetcher stored_requests.AccountFetcher, metricsEngine metrics.MetricsEngine) httprouter.Handle { - cookieTTL := time.Duration(cfg.HostCookie.TTL) * 24 * time.Hour + encoder := usersync.Base64Encoder{} + decoder := usersync.Base64Decoder{} // convert map of syncers by bidder to map of syncers by key // - its safe to assume that if multiple bidders map to the same key, the syncers are interchangeable. @@ -46,13 +48,14 @@ func NewSetUIDEndpoint(cfg *config.Configuration, syncersByBidder map[string]use defer pbsanalytics.LogSetUIDObject(&so) - pc := usersync.ParseCookieFromRequest(r, &cfg.HostCookie) - if !pc.AllowSyncs() { + cookie := usersync.ReadCookie(r, decoder, &cfg.HostCookie) + if !cookie.AllowSyncs() { w.WriteHeader(http.StatusUnauthorized) metricsEngine.RecordSetUid(metrics.SetUidOptOut) so.Status = http.StatusUnauthorized return } + usersync.SyncHostCookie(r, cookie, &cfg.HostCookie) query := r.URL.Query() @@ -121,18 +124,28 @@ func NewSetUIDEndpoint(cfg *config.Configuration, syncersByBidder map[string]use so.UID = uid if uid == "" { - pc.Unsync(syncer.Key()) + cookie.Unsync(syncer.Key()) metricsEngine.RecordSetUid(metrics.SetUidOK) metricsEngine.RecordSyncerSet(syncer.Key(), metrics.SyncerSetUidCleared) so.Success = true - } else if err = pc.TrySync(syncer.Key(), uid); err == nil { + } else if err = cookie.Sync(syncer.Key(), uid); err == nil { metricsEngine.RecordSetUid(metrics.SetUidOK) metricsEngine.RecordSyncerSet(syncer.Key(), metrics.SyncerSetUidOK) so.Success = true } setSiteCookie := siteCookieCheck(r.UserAgent()) - pc.SetCookieOnResponse(w, setSiteCookie, &cfg.HostCookie, cookieTTL) + + // Write Cookie + encodedCookie, err := cookie.PrepareCookieForWrite(&cfg.HostCookie, encoder) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + metricsEngine.RecordSetUid(metrics.SetUidBadRequest) + so.Errors = []error{err} + so.Status = http.StatusBadRequest + return + } + usersync.WriteCookie(w, encodedCookie, &cfg.HostCookie, setSiteCookie) switch responseFormat { case "i": diff --git a/endpoints/setuid_test.go b/endpoints/setuid_test.go index 609d85395fd..395cb6cb5b4 100644 --- a/endpoints/setuid_test.go +++ b/endpoints/setuid_test.go @@ -697,7 +697,7 @@ func makeRequest(uri string, existingSyncs map[string]string) *http.Request { if len(existingSyncs) > 0 { pbsCookie := usersync.NewCookie() for key, value := range existingSyncs { - pbsCookie.TrySync(key, value) + pbsCookie.Sync(key, value) } addCookie(request, pbsCookie) } @@ -749,10 +749,12 @@ func doRequest(req *http.Request, analytics analytics.PBSAnalyticsModule, metric } func addCookie(req *http.Request, cookie *usersync.Cookie) { - req.AddCookie(cookie.ToHTTPCookie(time.Duration(1) * time.Hour)) + httpCookie, _ := ToHTTPCookie(cookie) + req.AddCookie(httpCookie) } func parseCookieString(t *testing.T, response *httptest.ResponseRecorder) *usersync.Cookie { + decoder := usersync.Base64Decoder{} cookieString := response.Header().Get("Set-Cookie") parser := regexp.MustCompile("uids=(.*?);") res := parser.FindStringSubmatch(cookieString) @@ -761,7 +763,7 @@ func parseCookieString(t *testing.T, response *httptest.ResponseRecorder) *users Name: "uids", Value: res[1], } - return usersync.ParseCookie(&httpCookie) + return decoder.Decode(httpCookie.Value) } type fakePermissionsBuilder struct { @@ -830,3 +832,18 @@ func (s fakeSyncer) SupportsType(syncTypes []usersync.SyncType) bool { func (s fakeSyncer) GetSync(syncTypes []usersync.SyncType, privacyPolicies privacy.Policies) (usersync.Sync, error) { return usersync.Sync{}, nil } + +func ToHTTPCookie(cookie *usersync.Cookie) (*http.Cookie, error) { + encoder := usersync.Base64Encoder{} + encodedCookie, err := encoder.Encode(cookie) + if err != nil { + return nil, nil + } + + return &http.Cookie{ + Name: uidCookieName, + Value: encodedCookie, + Expires: time.Now().Add((90 * 24 * time.Hour)), + Path: "/", + }, nil +} diff --git a/pbs/usersync.go b/pbs/usersync.go index 748581af759..7b468cb039d 100644 --- a/pbs/usersync.go +++ b/pbs/usersync.go @@ -58,6 +58,8 @@ func (deps *UserSyncDeps) VerifyRecaptcha(response string) error { func (deps *UserSyncDeps) OptOut(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { optout := r.FormValue("optout") rr := r.FormValue("g-recaptcha-response") + encoder := usersync.Base64Encoder{} + decoder := usersync.Base64Decoder{} if rr == "" { http.Redirect(w, r, fmt.Sprintf("%s/static/optout.html", deps.ExternalUrl), http.StatusMovedPermanently) @@ -73,10 +75,18 @@ func (deps *UserSyncDeps) OptOut(w http.ResponseWriter, r *http.Request, _ httpr return } - pc := usersync.ParseCookieFromRequest(r, deps.HostCookieConfig) + // Read Cookie + pc := usersync.ReadCookie(r, decoder, deps.HostCookieConfig) + usersync.SyncHostCookie(r, pc, deps.HostCookieConfig) pc.SetOptOut(optout != "") - pc.SetCookieOnResponse(w, false, deps.HostCookieConfig, deps.HostCookieConfig.TTLDuration()) + // Write Cookie + encodedCookie, err := pc.PrepareCookieForWrite(deps.HostCookieConfig, encoder) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + usersync.WriteCookie(w, encodedCookie, deps.HostCookieConfig, false) if optout == "" { http.Redirect(w, r, deps.HostCookieConfig.OptInURL, http.StatusMovedPermanently) diff --git a/usersync/chooser_test.go b/usersync/chooser_test.go index 3b820b99f24..0071a2e36b0 100644 --- a/usersync/chooser_test.go +++ b/usersync/chooser_test.go @@ -238,8 +238,8 @@ func TestChooserEvaluate(t *testing.T) { Redirect: NewUniformBidderFilter(BidderFilterModeExclude)} cookieNeedsSync := Cookie{} - cookieAlreadyHasSyncForA := Cookie{uids: map[string]uidWithExpiry{"keyA": {Expires: time.Now().Add(time.Duration(24) * time.Hour)}}} - cookieAlreadyHasSyncForB := Cookie{uids: map[string]uidWithExpiry{"keyB": {Expires: time.Now().Add(time.Duration(24) * time.Hour)}}} + cookieAlreadyHasSyncForA := Cookie{uids: map[string]UIDEntry{"keyA": {Expires: time.Now().Add(time.Duration(24) * time.Hour)}}} + cookieAlreadyHasSyncForB := Cookie{uids: map[string]UIDEntry{"keyB": {Expires: time.Now().Add(time.Duration(24) * time.Hour)}}} testCases := []struct { description string diff --git a/usersync/cookie.go b/usersync/cookie.go index 4f80fa5d5f6..c0eb898c5ea 100644 --- a/usersync/cookie.go +++ b/usersync/cookie.go @@ -1,11 +1,10 @@ package usersync import ( - "encoding/base64" "encoding/json" "errors" - "math" "net/http" + "sort" "time" "github.com/prebid/prebid-server/config" @@ -20,70 +19,155 @@ const uidTTL = 14 * 24 * time.Hour // Cookie is the cookie used in Prebid Server. // -// To get an instance of this from a request, use ParseCookieFromRequest. -// To write an instance onto a response, use SetCookieOnResponse. +// To get an instance of this from a request, use ReadCookie. +// To write an instance onto a response, use WriteCookie. type Cookie struct { - uids map[string]uidWithExpiry + uids map[string]UIDEntry optOut bool } -// uidWithExpiry bundles the UID with an Expiration date. -// After the expiration, the UID is no longer valid. -type uidWithExpiry struct { +// UIDEntry bundles the UID with an Expiration date. +type UIDEntry struct { // UID is the ID given to a user by a particular bidder UID string `json:"uid"` // Expires is the time at which this UID should no longer apply. Expires time.Time `json:"expires"` } -// ParseCookieFromRequest parses the UserSyncMap from an HTTP Request. -func ParseCookieFromRequest(r *http.Request, cookie *config.HostCookie) *Cookie { - if cookie.OptOutCookie.Name != "" { - optOutCookie, err1 := r.Cookie(cookie.OptOutCookie.Name) - if err1 == nil && optOutCookie.Value == cookie.OptOutCookie.Value { - pc := NewCookie() - pc.SetOptOut(true) - return pc - } +// NewCookie returns a new empty cookie. +func NewCookie() *Cookie { + return &Cookie{ + uids: make(map[string]UIDEntry), } - var parsed *Cookie - uidCookie, err2 := r.Cookie(uidCookieName) - if err2 == nil { - parsed = ParseCookie(uidCookie) - } else { - parsed = NewCookie() +} + +// ReadCookie reads the cookie from the request +func ReadCookie(r *http.Request, decoder Decoder, host *config.HostCookie) *Cookie { + if hostOptOutCookie := checkHostCookieOptOut(r, host); hostOptOutCookie != nil { + return hostOptOutCookie + } + + // Read cookie from request + cookieFromRequest, err := r.Cookie(uidCookieName) + if err != nil { + return NewCookie() } - // Fixes #582 - if uid, _, _ := parsed.GetUID(cookie.Family); uid == "" && cookie.CookieName != "" { - if hostCookie, err := r.Cookie(cookie.CookieName); err == nil { - parsed.TrySync(cookie.Family, hostCookie.Value) + decodedCookie := decoder.Decode(cookieFromRequest.Value) + + return decodedCookie +} + +// PrepareCookieForWrite ejects UIDs as long as the cookie is too full +func (cookie *Cookie) PrepareCookieForWrite(cfg *config.HostCookie, encoder Encoder) (string, error) { + uuidKeys := sortUIDs(cookie.uids) + + i := 0 + for len(cookie.uids) > 0 { + encodedCookie, err := encoder.Encode(cookie) + if err != nil { + return encodedCookie, nil + } + + // Convert to HTTP Cookie to Get Size + httpCookie := &http.Cookie{ + Name: uidCookieName, + Value: encodedCookie, + Expires: time.Now().Add(cfg.TTLDuration()), + Path: "/", + } + cookieSize := len([]byte(httpCookie.String())) + + isCookieTooBig := cookieSize > cfg.MaxCookieSizeBytes && cfg.MaxCookieSizeBytes > 0 + if !isCookieTooBig { + return encodedCookie, nil } + + uidToDelete := uuidKeys[i] + delete(cookie.uids, uidToDelete) + + i++ } - return parsed + return "", nil } -// ParseCookie parses the UserSync cookie from a raw HTTP cookie. -func ParseCookie(httpCookie *http.Cookie) *Cookie { - jsonValue, err := base64.URLEncoding.DecodeString(httpCookie.Value) - if err != nil { - // corrupted cookie; we should reset - return NewCookie() +// WriteCookie sets the prepared cookie onto the header +func WriteCookie(w http.ResponseWriter, encodedCookie string, cfg *config.HostCookie, setSiteCookie bool) { + ttl := cfg.TTLDuration() + + httpCookie := &http.Cookie{ + Name: uidCookieName, + Value: encodedCookie, + Expires: time.Now().Add(ttl), + Path: "/", } - var cookie Cookie - if err = json.Unmarshal(jsonValue, &cookie); err != nil { - // corrupted cookie; we should reset - return NewCookie() + if cfg.Domain != "" { + httpCookie.Domain = cfg.Domain + } + + if setSiteCookie { + httpCookie.Secure = true + httpCookie.SameSite = http.SameSiteNoneMode } - return &cookie + w.Header().Add("Set-Cookie", httpCookie.String()) } -// NewCookie returns a new empty cookie. -func NewCookie() *Cookie { - return &Cookie{ - uids: make(map[string]uidWithExpiry), +// Sync tries to set the UID for some syncer key. It returns an error if the set didn't happen. +func (cookie *Cookie) Sync(key string, uid string) error { + if !cookie.AllowSyncs() { + return errors.New("the user has opted out of prebid server cookie syncs") + } + + if checkAudienceNetwork(key, uid) { + return errors.New("audienceNetwork uses a UID of 0 as \"not yet recognized\"") } + + // Sync + cookie.uids[key] = UIDEntry{ + UID: uid, + Expires: time.Now().Add(uidTTL), + } + + return nil +} + +// sortUIDs is used to get a list of uids sorted from oldest to newest +// This list is used to eject oldest uids from the cookie +// This will be incorporated with a more complex ejection framework in a future PR +func sortUIDs(uids map[string]UIDEntry) []string { + if len(uids) > 0 { + uuidKeys := make([]string, 0, len(uids)) + for key := range uids { + uuidKeys = append(uuidKeys, key) + } + sort.SliceStable(uuidKeys, func(i, j int) bool { + return uids[uuidKeys[i]].Expires.Before(uids[uuidKeys[j]].Expires) + }) + return uuidKeys + } + return nil +} + +// SyncHostCookie syncs the request cookie with the host cookie +func SyncHostCookie(r *http.Request, requestCookie *Cookie, host *config.HostCookie) { + if uid, _, _ := requestCookie.GetUID(host.Family); uid == "" && host.CookieName != "" { + if hostCookie, err := r.Cookie(host.CookieName); err == nil { + requestCookie.Sync(host.Family, hostCookie.Value) + } + } +} + +func checkHostCookieOptOut(r *http.Request, host *config.HostCookie) *Cookie { + if host.OptOutCookie.Name != "" { + optOutCookie, err := r.Cookie(host.OptOutCookie.Name) + if err == nil && optOutCookie.Value == host.OptOutCookie.Value { + hostOptOut := NewCookie() + hostOptOut.SetOptOut(true) + return hostOptOut + } + } + return nil } // AllowSyncs is true if the user lets bidders sync cookies, and false otherwise. @@ -96,30 +180,12 @@ func (cookie *Cookie) SetOptOut(optOut bool) { cookie.optOut = optOut if optOut { - cookie.uids = make(map[string]uidWithExpiry) - } -} - -// Gets an HTTP cookie containing all the data from this UserSyncMap. This is a snapshot--not a live view. -func (cookie *Cookie) ToHTTPCookie(ttl time.Duration) *http.Cookie { - j, _ := json.Marshal(cookie) - b64 := base64.URLEncoding.EncodeToString(j) - - return &http.Cookie{ - Name: uidCookieName, - Value: b64, - Expires: time.Now().Add(ttl), - Path: "/", + cookie.uids = make(map[string]UIDEntry) } } // GetUID Gets this user's ID for the given syncer key. -// The first returned value is the user's ID. -// The second returned value is true if we had a value stored, and false if we didn't. -// The third returned value is true if that value is "active", and false if it's expired. -// -// If no value was stored, then the "isActive" return value will be false. -func (cookie *Cookie) GetUID(key string) (string, bool, bool) { +func (cookie *Cookie) GetUID(key string) (uid string, isUIDFound bool, isUIDActive bool) { if cookie != nil { if uid, ok := cookie.uids[key]; ok { return uid.UID, true, time.Now().Before(uid.Expires) @@ -140,41 +206,6 @@ func (cookie *Cookie) GetUIDs() map[string]string { return uids } -// SetCookieOnResponse is a shortcut for "ToHTTPCookie(); cookie.setDomain(domain); setCookie(w, cookie)" -func (cookie *Cookie) SetCookieOnResponse(w http.ResponseWriter, setSiteCookie bool, cfg *config.HostCookie, ttl time.Duration) { - httpCookie := cookie.ToHTTPCookie(ttl) - var domain string = cfg.Domain - - if domain != "" { - httpCookie.Domain = domain - } - - var currSize int = len([]byte(httpCookie.String())) - for cfg.MaxCookieSizeBytes > 0 && currSize > cfg.MaxCookieSizeBytes && len(cookie.uids) > 0 { - var oldestElem string = "" - var oldestDate int64 = math.MaxInt64 - for key, value := range cookie.uids { - timeUntilExpiration := time.Until(value.Expires) - if timeUntilExpiration < time.Duration(oldestDate) { - oldestElem = key - oldestDate = int64(timeUntilExpiration) - } - } - delete(cookie.uids, oldestElem) - httpCookie = cookie.ToHTTPCookie(ttl) - if domain != "" { - httpCookie.Domain = domain - } - currSize = len([]byte(httpCookie.String())) - } - - if setSiteCookie { - httpCookie.Secure = true - httpCookie.SameSite = http.SameSiteNoneMode - } - w.Header().Add("Set-Cookie", httpCookie.String()) -} - // Unsync removes the user's ID for the given syncer key from this cookie. func (cookie *Cookie) Unsync(key string) { delete(cookie.uids, key) @@ -199,24 +230,8 @@ func (cookie *Cookie) HasAnyLiveSyncs() bool { return false } -// TrySync tries to set the UID for some syncer key. It returns an error if the set didn't happen. -func (cookie *Cookie) TrySync(key string, uid string) error { - if !cookie.AllowSyncs() { - return errors.New("The user has opted out of prebid server cookie syncs.") - } - - // At the moment, Facebook calls /setuid with a UID of 0 if the user isn't logged into Facebook. - // They shouldn't be sending us a sentinel value... but since they are, we're refusing to save that ID. - if key == string(openrtb_ext.BidderAudienceNetwork) && uid == "0" { - return errors.New("audienceNetwork uses a UID of 0 as \"not yet recognized\".") - } - - cookie.uids[key] = uidWithExpiry{ - UID: uid, - Expires: time.Now().Add(uidTTL), - } - - return nil +func checkAudienceNetwork(key string, uid string) bool { + return key == string(openrtb_ext.BidderAudienceNetwork) && uid == "0" } // cookieJson defines the JSON contract for the cookie data's storage format. @@ -224,8 +239,8 @@ func (cookie *Cookie) TrySync(key string, uid string) error { // This exists so that Cookie (which is public) can have private fields, and the rest of // the code doesn't have to worry about the cookie data storage format. type cookieJson struct { - UIDs map[string]uidWithExpiry `json:"tempUIDs,omitempty"` - OptOut bool `json:"optout,omitempty"` + UIDs map[string]UIDEntry `json:"tempUIDs,omitempty"` + OptOut bool `json:"optout,omitempty"` } func (cookie *Cookie) MarshalJSON() ([]byte, error) { @@ -250,10 +265,10 @@ func (cookie *Cookie) UnmarshalJSON(b []byte) error { } if cookie.uids == nil { - cookie.uids = make(map[string]uidWithExpiry) + cookie.uids = make(map[string]UIDEntry) } - // Audience Network / Facebook Handling + // Audience Network Handling if id, ok := cookie.uids[string(openrtb_ext.BidderAudienceNetwork)]; ok && id.UID == "0" { delete(cookie.uids, string(openrtb_ext.BidderAudienceNetwork)) } diff --git a/usersync/cookie_test.go b/usersync/cookie_test.go index ee5062ea152..5b86df54441 100644 --- a/usersync/cookie_test.go +++ b/usersync/cookie_test.go @@ -1,177 +1,597 @@ package usersync import ( - "encoding/base64" + "errors" "net/http" "net/http/httptest" - "strings" "testing" "time" "github.com/prebid/prebid-server/config" + "github.com/prebid/prebid-server/openrtb_ext" "github.com/stretchr/testify/assert" ) -func TestOptOutCookie(t *testing.T) { - cookie := &Cookie{ - uids: map[string]uidWithExpiry{"appnexus": {UID: "test"}}, - optOut: true, +func TestReadCookie(t *testing.T) { + testCases := []struct { + name string + givenRequest *http.Request + givenHttpCookie *http.Cookie + givenCookie *Cookie + givenDecoder Decoder + expectedCookie *Cookie + }{ + { + name: "simple-cookie", + givenRequest: httptest.NewRequest("POST", "http://www.prebid.com", nil), + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + }, + }, + optOut: false, + }, + }, + { + name: "empty-cookie", + givenRequest: httptest.NewRequest("POST", "http://www.prebid.com", nil), + givenCookie: &Cookie{}, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: false, + }, + }, + { + name: "nil-cookie", + givenRequest: httptest.NewRequest("POST", "http://www.prebid.com", nil), + givenCookie: nil, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: false, + }, + }, + { + name: "corruptted-http-cookie", + givenRequest: httptest.NewRequest("POST", "http://www.prebid.com", nil), + givenHttpCookie: &http.Cookie{ + Name: "uids", + Value: "bad base64 encoding", + }, + givenCookie: nil, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: false, + }, + }, } - ensureConsistency(t, cookie) -} -func TestEmptyOptOutCookie(t *testing.T) { - cookie := &Cookie{ - uids: make(map[string]uidWithExpiry), - optOut: true, + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + if test.givenCookie != nil { + httpCookie, err := ToHTTPCookie(test.givenCookie) + assert.NoError(t, err) + test.givenRequest.AddCookie(httpCookie) + } else if test.givenCookie == nil && test.givenHttpCookie != nil { + test.givenRequest.AddCookie(test.givenHttpCookie) + } + actualCookie := ReadCookie(test.givenRequest, Base64Decoder{}, &config.HostCookie{}) + assert.Equal(t, test.expectedCookie.uids, actualCookie.uids) + assert.Equal(t, test.expectedCookie.optOut, actualCookie.optOut) + }) } - ensureConsistency(t, cookie) } -func TestEmptyCookie(t *testing.T) { - cookie := &Cookie{ - uids: make(map[string]uidWithExpiry), - optOut: false, +func TestWriteCookie(t *testing.T) { + encoder := Base64Encoder{} + decoder := Base64Decoder{} + + testCases := []struct { + name string + givenCookie *Cookie + givenSetSiteCookie bool + expectedCookie *Cookie + }{ + { + name: "simple-cookie", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + givenSetSiteCookie: false, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + }, + { + name: "simple-cookie-opt-out", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: true, + }, + givenSetSiteCookie: true, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: true, + }, + }, + { + name: "cookie-multiple-uids", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + "rubicon": { + UID: "UID2", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + givenSetSiteCookie: true, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + "rubicon": { + UID: "UID2", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + }, } - ensureConsistency(t, cookie) -} -func TestCookieWithData(t *testing.T) { - cookie := newSampleCookie() - ensureConsistency(t, cookie) + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + // Write Cookie + w := httptest.NewRecorder() + encodedCookie, err := encoder.Encode(test.givenCookie) + assert.NoError(t, err) + WriteCookie(w, encodedCookie, &config.HostCookie{}, test.givenSetSiteCookie) + writtenCookie := w.Header().Get("Set-Cookie") + + // Read Cookie + header := http.Header{} + header.Add("Cookie", writtenCookie) + r := &http.Request{Header: header} + actualCookie := ReadCookie(r, decoder, &config.HostCookie{}) + + assert.Equal(t, test.expectedCookie, actualCookie) + }) + } } -func TestBidderNameGets(t *testing.T) { - cookie := newSampleCookie() - id, exists, _ := cookie.GetUID("adnxs") - if !exists { - t.Errorf("Cookie missing expected Appnexus ID") - } - if id != "123" { - t.Errorf("Bad appnexus id. Expected %s, got %s", "123", id) +func TestSync(t *testing.T) { + testCases := []struct { + name string + givenCookie *Cookie + givenSyncerKey string + givenUID string + expectedCookie *Cookie + expectedError error + }{ + { + name: "simple-sync", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{}, + }, + givenSyncerKey: "adnxs", + givenUID: "123", + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "123", + }, + }, + }, + }, + { + name: "dont-allow-syncs", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: true, + }, + givenSyncerKey: "adnxs", + givenUID: "123", + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + }, + expectedError: errors.New("the user has opted out of prebid server cookie syncs"), + }, + { + name: "audienceNetwork", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{}, + }, + givenSyncerKey: string(openrtb_ext.BidderAudienceNetwork), + givenUID: "0", + expectedError: errors.New("audienceNetwork uses a UID of 0 as \"not yet recognized\""), + }, } - id, exists, _ = cookie.GetUID("rubicon") - if !exists { - t.Errorf("Cookie missing expected Rubicon ID") - } - if id != "456" { - t.Errorf("Bad rubicon id. Expected %s, got %s", "456", id) + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + err := test.givenCookie.Sync(test.givenSyncerKey, test.givenUID) + if test.expectedError != nil { + assert.Equal(t, test.expectedError, err) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expectedCookie.uids[test.givenSyncerKey].UID, test.givenCookie.uids[test.givenSyncerKey].UID) + } + }) } } -func TestRejectAudienceNetworkCookie(t *testing.T) { - raw := &Cookie{ - uids: map[string]uidWithExpiry{ - "audienceNetwork": newTempId("0", 10), +func TestGetUIDs(t *testing.T) { + testCases := []struct { + name string + givenCookie *Cookie + expectedCookie *Cookie + expectedLen int + }{ + { + name: "two-uids", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "123", + }, + "rubicon": { + UID: "456", + }, + }, + }, + expectedLen: 2, + }, + { + name: "one-uid", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "123", + }, + }, + }, + expectedLen: 1, + }, + { + name: "empty", + givenCookie: &Cookie{}, + expectedLen: 0, + }, + { + name: "nil", + givenCookie: nil, + expectedLen: 0, }, - optOut: false, - } - parsed := ParseCookie(raw.ToHTTPCookie(90 * 24 * time.Hour)) - if parsed.HasLiveSync("audienceNetwork") { - t.Errorf("Cookie serializing and deserializing should delete audienceNetwork values of 0") } - err := parsed.TrySync("audienceNetwork", "0") - if err == nil { - t.Errorf("Cookie should reject audienceNetwork values of 0.") - } - if parsed.HasLiveSync("audienceNetwork") { - t.Errorf("Cookie The cookie should have rejected the audienceNetwork sync.") + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + uids := test.givenCookie.GetUIDs() + assert.Len(t, uids, test.expectedLen) + for key, value := range uids { + assert.Equal(t, test.givenCookie.uids[key].UID, value) + } + + }) } } -func TestOptOutReset(t *testing.T) { - cookie := newSampleCookie() +func TestWriteCookieUserAgent(t *testing.T) { + encoder := Base64Encoder{} - cookie.SetOptOut(true) - if cookie.AllowSyncs() { - t.Error("After SetOptOut(true), a cookie should not allow more user syncs.") + testCases := []struct { + name string + givenUserAgent string + givenCookie *Cookie + givenHostCookie config.HostCookie + givenSetSiteCookie bool + expectedContains string + expectedNotContains string + }{ + { + name: "same-site-none", + givenUserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + givenHostCookie: config.HostCookie{}, + givenSetSiteCookie: true, + expectedContains: "; Secure;", + }, + { + name: "older-chrome-version", + givenUserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3770.142 Safari/537.36", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + givenHostCookie: config.HostCookie{}, + givenSetSiteCookie: true, + expectedNotContains: "SameSite=none", + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + // Set Up + req := httptest.NewRequest("GET", "http://www.prebid.com", nil) + req.Header.Set("User-Agent", test.givenUserAgent) + + // Write Cookie + w := httptest.NewRecorder() + encodedCookie, err := encoder.Encode(test.givenCookie) + assert.NoError(t, err) + WriteCookie(w, encodedCookie, &test.givenHostCookie, test.givenSetSiteCookie) + writtenCookie := w.Header().Get("Set-Cookie") + + if test.expectedContains == "" { + assert.NotContains(t, writtenCookie, test.expectedNotContains) + } else { + assert.Contains(t, writtenCookie, test.expectedContains) + } + }) } - ensureConsistency(t, cookie) } -func TestOptIn(t *testing.T) { - cookie := &Cookie{ - uids: make(map[string]uidWithExpiry), - optOut: true, +func TestPrepareCookieForWrite(t *testing.T) { + encoder := Base64Encoder{} + decoder := Base64Decoder{} + cookieToSend := &Cookie{ + uids: map[string]UIDEntry{ + "1": newTempId("1234567890123456789012345678901234567890123456", 7), + "7": newTempId("abcdefghijklmnopqrstuvwxy", 1), + "2": newTempId("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 6), + "3": newTempId("123456789012345678901234567896123456789012345678", 5), + "4": newTempId("aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ", 4), + "5": newTempId("12345678901234567890123456789012345678901234567890", 3), + "6": newTempId("abcdefghij", 2), + }, + optOut: false, } - cookie.SetOptOut(false) - if !cookie.AllowSyncs() { - t.Error("After SetOptOut(false), a cookie should allow more user syncs.") + testCases := []struct { + name string + givenMaxCookieSize int + expectedRemainingUidKeys []string + }{ + { + name: "no-uids-ejected", + givenMaxCookieSize: 2000, + expectedRemainingUidKeys: []string{ + "1", "2", "3", "4", "5", "6", "7", + }, + }, + { + name: "no-uids-ejected-2", + givenMaxCookieSize: 0, + expectedRemainingUidKeys: []string{ + "1", "2", "3", "4", "5", "6", "7", + }, + }, + { + name: "one-uid-ejected", + givenMaxCookieSize: 900, + expectedRemainingUidKeys: []string{ + "1", "2", "3", "4", "5", "6", + }, + }, + { + name: "four-uids-ejected", + givenMaxCookieSize: 500, + expectedRemainingUidKeys: []string{ + "1", "2", "3", + }, + }, + { + name: "all-but-one-uids-ejected", + givenMaxCookieSize: 300, + expectedRemainingUidKeys: []string{ + "1", + }, + }, + { + name: "all-uids-ejected", + givenMaxCookieSize: 100, + expectedRemainingUidKeys: []string{}, + }, + { + name: "invalid-max-size", + givenMaxCookieSize: -100, + expectedRemainingUidKeys: []string{}, + }, } - ensureConsistency(t, cookie) -} -func TestParseCorruptedCookie(t *testing.T) { - raw := http.Cookie{ - Name: "uids", - Value: "bad base64 encoding", + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + encodedCookie, err := cookieToSend.PrepareCookieForWrite(&config.HostCookie{MaxCookieSizeBytes: test.givenMaxCookieSize}, encoder) + assert.NoError(t, err) + decodedCookie := decoder.Decode(encodedCookie) + + for _, key := range test.expectedRemainingUidKeys { + _, ok := decodedCookie.uids[key] + assert.Equal(t, true, ok) + } + assert.Equal(t, len(decodedCookie.uids), len(test.expectedRemainingUidKeys)) + }) } - parsed := ParseCookie(&raw) - ensureEmptyMap(t, parsed) } -func TestParseCorruptedCookieJSON(t *testing.T) { - cookieData := base64.URLEncoding.EncodeToString([]byte("bad json")) - raw := http.Cookie{ - Name: "uids", - Value: cookieData, +func TestSyncHostCookie(t *testing.T) { + testCases := []struct { + name string + givenCookie *Cookie + givenUID string + givenHostCookie *config.HostCookie + expectedCookie *Cookie + expectedError error + }{ + { + name: "simple-sync", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{}, + }, + givenHostCookie: &config.HostCookie{ + Family: "syncer", + CookieName: "adnxs", + }, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "syncer": { + UID: "some-user-id", + }, + }, + }, + }, + { + name: "uids-already-present", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "some-syncer": { + UID: "some-other-user-id", + }, + }, + }, + givenHostCookie: &config.HostCookie{ + Family: "syncer", + CookieName: "adnxs", + }, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "syncer": { + UID: "some-user-id", + }, + "some-syncer": { + UID: "some-other-user-id", + }, + }, + }, + }, + { + name: "host-already-synced", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "syncer": { + UID: "some-user-id", + }, + }, + }, + givenHostCookie: &config.HostCookie{ + Family: "syncer", + CookieName: "adnxs", + }, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "syncer": { + UID: "some-user-id", + }, + }, + }, + }, } - parsed := ParseCookie(&raw) - ensureEmptyMap(t, parsed) -} -func TestParseNilSyncMap(t *testing.T) { - raw := http.Cookie{ - Name: uidCookieName, - Value: "", + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "http://www.prebid.com", nil) + r.AddCookie(&http.Cookie{ + Name: test.givenHostCookie.CookieName, + Value: "some-user-id", + }) + + SyncHostCookie(r, test.givenCookie, test.givenHostCookie) + for key, value := range test.expectedCookie.uids { + assert.Equal(t, value.UID, test.givenCookie.uids[key].UID) + } + }) } - parsed := ParseCookie(&raw) - ensureEmptyMap(t, parsed) - ensureConsistency(t, parsed) } -func TestParseOtherCookie(t *testing.T) { - req := httptest.NewRequest("POST", "http://www.prebid.com", nil) - otherCookieName := "other" - id := "some-user-id" - req.AddCookie(&http.Cookie{ - Name: otherCookieName, - Value: id, - }) - parsed := ParseCookieFromRequest(req, &config.HostCookie{ - Family: "adnxs", - CookieName: otherCookieName, - }) - val, _, _ := parsed.GetUID("adnxs") - if val != id { - t.Errorf("Bad cookie value. Expected %s, got %s", id, val) +func TestBidderNameGets(t *testing.T) { + cookie := newSampleCookie() + id, exists, _ := cookie.GetUID("adnxs") + if !exists { + t.Errorf("Cookie missing expected Appnexus ID") + } + if id != "123" { + t.Errorf("Bad appnexus id. Expected %s, got %s", "123", id) + } + + id, exists, _ = cookie.GetUID("rubicon") + if !exists { + t.Errorf("Cookie missing expected Rubicon ID") + } + if id != "456" { + t.Errorf("Bad rubicon id. Expected %s, got %s", "456", id) } } -func TestParseCookieFromRequestOptOut(t *testing.T) { +func TestReadCookieOptOut(t *testing.T) { optOutCookieName := "optOutCookieName" optOutCookieValue := "optOutCookieValue" + decoder := Base64Decoder{} - existingCookie := *(&Cookie{ - uids: map[string]uidWithExpiry{ + cookie := *(&Cookie{ + uids: map[string]UIDEntry{ "foo": newTempId("fooID", 1), "bar": newTempId("barID", 2), }, optOut: false, - }).ToHTTPCookie(24 * time.Hour) + }) + + existingCookie, _ := ToHTTPCookie(&cookie) testCases := []struct { description string - givenExistingCookies []http.Cookie + givenExistingCookies []*http.Cookie expectedEmpty bool expectedSetOptOut bool }{ { description: "Opt Out Cookie", - givenExistingCookies: []http.Cookie{ + givenExistingCookies: []*http.Cookie{ existingCookie, {Name: optOutCookieName, Value: optOutCookieValue}}, expectedEmpty: true, @@ -179,14 +599,14 @@ func TestParseCookieFromRequestOptOut(t *testing.T) { }, { description: "No Opt Out Cookie", - givenExistingCookies: []http.Cookie{ + givenExistingCookies: []*http.Cookie{ existingCookie}, expectedEmpty: false, expectedSetOptOut: false, }, { description: "Opt Out Cookie - Wrong Value", - givenExistingCookies: []http.Cookie{ + givenExistingCookies: []*http.Cookie{ existingCookie, {Name: optOutCookieName, Value: "wrong"}}, expectedEmpty: false, @@ -194,7 +614,7 @@ func TestParseCookieFromRequestOptOut(t *testing.T) { }, { description: "Opt Out Cookie - Wrong Name", - givenExistingCookies: []http.Cookie{ + givenExistingCookies: []*http.Cookie{ existingCookie, {Name: "wrong", Value: optOutCookieValue}}, expectedEmpty: false, @@ -202,7 +622,7 @@ func TestParseCookieFromRequestOptOut(t *testing.T) { }, { description: "Opt Out Cookie - No Host Cookies", - givenExistingCookies: []http.Cookie{ + givenExistingCookies: []*http.Cookie{ {Name: optOutCookieName, Value: optOutCookieValue}}, expectedEmpty: true, expectedSetOptOut: true, @@ -213,10 +633,10 @@ func TestParseCookieFromRequestOptOut(t *testing.T) { req := httptest.NewRequest("POST", "http://www.prebid.com", nil) for _, c := range test.givenExistingCookies { - req.AddCookie(&c) + req.AddCookie(c) } - parsed := ParseCookieFromRequest(req, &config.HostCookie{ + parsed := ReadCookie(req, decoder, &config.HostCookie{ Family: "foo", OptOutCookie: config.Cookie{ Name: optOutCookieName, @@ -233,125 +653,59 @@ func TestParseCookieFromRequestOptOut(t *testing.T) { } } -func TestCookieReadWrite(t *testing.T) { - cookie := newSampleCookie() - - received := writeThenRead(cookie, 0) - uid, exists, isLive := received.GetUID("adnxs") - if !exists || !isLive || uid != "123" { - t.Errorf("Received cookie should have the adnxs ID=123. Got %s", uid) - } - - uid, exists, isLive = received.GetUID("rubicon") - if !exists || !isLive || uid != "456" { - t.Errorf("Received cookie should have the rubicon ID=456. Got %s", uid) - } - - assert.True(t, received.HasAnyLiveSyncs(), "Has Live Syncs") - assert.Len(t, received.uids, 2, "Sync Count") -} - -func TestNilCookie(t *testing.T) { - var nilCookie *Cookie - - if nilCookie.HasLiveSync("anything") { - t.Error("nil cookies should respond with false when asked if they have a sync") - } - - if nilCookie.HasAnyLiveSyncs() { - t.Error("nil cookies shouldn't have any syncs.") - } - - if nilCookie.AllowSyncs() { - t.Error("nil cookies shouldn't allow syncs to take place.") +func TestOptIn(t *testing.T) { + cookie := &Cookie{ + uids: make(map[string]UIDEntry), + optOut: true, } - uid, hadUID, isLive := nilCookie.GetUID("anything") - - if uid != "" { - t.Error("nil cookies should return empty strings for the UID.") - } - if hadUID { - t.Error("nil cookies shouldn't claim to have a UID mapping.") - } - if isLive { - t.Error("nil cookies shouldn't report live UID mappings.") + cookie.SetOptOut(false) + if !cookie.AllowSyncs() { + t.Error("After SetOptOut(false), a cookie should allow more user syncs.") } + ensureConsistency(t, cookie) } -func TestGetUIDs(t *testing.T) { +func TestOptOutReset(t *testing.T) { cookie := newSampleCookie() - uids := cookie.GetUIDs() - assert.Len(t, uids, 2, "GetUIDs should return user IDs for all bidders") - assert.Equal(t, "123", uids["adnxs"], "GetUIDs should return the correct user ID for each bidder") - assert.Equal(t, "456", uids["rubicon"], "GetUIDs should return the correct user ID for each bidder") + cookie.SetOptOut(true) + if cookie.AllowSyncs() { + t.Error("After SetOptOut(true), a cookie should not allow more user syncs.") + } + ensureConsistency(t, cookie) } -func TestGetUIDsWithEmptyCookie(t *testing.T) { - cookie := &Cookie{} - uids := cookie.GetUIDs() - - assert.Len(t, uids, 0, "GetUIDs shouldn't return any user syncs for an empty cookie") +func TestOptOutCookie(t *testing.T) { + cookie := &Cookie{ + uids: make(map[string]UIDEntry), + optOut: true, + } + ensureConsistency(t, cookie) } -func TestGetUIDsWithNilCookie(t *testing.T) { - var cookie *Cookie - uids := cookie.GetUIDs() - - assert.Len(t, uids, 0, "GetUIDs shouldn't return any user syncs for a nil cookie") +func newTempId(uid string, offset int) UIDEntry { + return UIDEntry{ + UID: uid, + Expires: time.Now().Add(time.Duration(offset) * time.Minute).UTC(), + } } -func TestTrimCookiesClosestExpirationDates(t *testing.T) { - cookieToSend := &Cookie{ - uids: map[string]uidWithExpiry{ - "k1": newTempId("12345678901234567890123456789012345678901234567890", 7), - "k2": newTempId("abcdefghijklmnopqrstuvwxyz", 1), - "k3": newTempId("ABCDEFGHIJKLMNOPQRSTUVWXYZ", 6), - "k4": newTempId("12345678901234567890123456789612345678901234567890", 5), - "k5": newTempId("aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ", 4), - "k6": newTempId("12345678901234567890123456789012345678901234567890", 3), - "k7": newTempId("abcdefghijklmnopqrstuvwxyz", 2), +func newSampleCookie() *Cookie { + return &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": newTempId("123", 10), + "rubicon": newTempId("456", 10), }, optOut: false, } - - type aTest struct { - maxCookieSize int - expKeys []string - } - testCases := []aTest{ - {maxCookieSize: 2000, expKeys: []string{"k1", "k2", "k3", "k4", "k5", "k6", "k7"}}, //1 don't trim, set - {maxCookieSize: 0, expKeys: []string{"k1", "k2", "k3", "k4", "k5", "k6", "k7"}}, //2 unlimited size: don't trim, set - {maxCookieSize: 800, expKeys: []string{"k1", "k5", "k4", "k3", "k6"}}, //3 trim to size and set - {maxCookieSize: 500, expKeys: []string{"k1", "k4", "k3"}}, //4 trim to size and set - {maxCookieSize: 200, expKeys: []string{}}, //5 insufficient size, trim to zero length and set - {maxCookieSize: -100, expKeys: []string{}}, //6 invalid size, trim to zero length and set - } - for i := range testCases { - processedCookie := writeThenRead(cookieToSend, testCases[i].maxCookieSize) - - actualKeys := make([]string, 0, 7) - for key := range processedCookie.uids { - actualKeys = append(actualKeys, key) - } - - assert.ElementsMatch(t, testCases[i].expKeys, actualKeys, "[Test %d]", i+1) - } -} - -func ensureEmptyMap(t *testing.T, cookie *Cookie) { - if !cookie.AllowSyncs() { - t.Error("Empty cookies should allow user syncs.") - } - if cookie.HasAnyLiveSyncs() { - t.Error("Empty cookies shouldn't have any user syncs. Found at least 1.") - } } func ensureConsistency(t *testing.T, cookie *Cookie) { + decoder := Base64Decoder{} + if cookie.AllowSyncs() { - err := cookie.TrySync("pulsepoint", "1") + err := cookie.Sync("pulsepoint", "1") if err != nil { t.Errorf("Cookie sync should succeed if the user has opted in.") } @@ -380,13 +734,14 @@ func ensureConsistency(t *testing.T, cookie *Cookie) { t.Error("If the user opted out, the PBSCookie should have no user syncs.") } - err := cookie.TrySync("adnxs", "123") + err := cookie.Sync("adnxs", "123") if err == nil { t.Error("TrySync should fail if the user has opted out of PBSCookie syncs, but it succeeded.") } } - - copiedCookie := ParseCookie(cookie.ToHTTPCookie(90 * 24 * time.Hour)) + httpCookie, err := ToHTTPCookie(cookie) + assert.NoError(t, err) + copiedCookie := decoder.Decode(httpCookie.Value) if copiedCookie.AllowSyncs() != cookie.AllowSyncs() { t.Error("The PBSCookie interface shouldn't let modifications happen if the user has opted out") } @@ -414,61 +769,17 @@ func ensureConsistency(t *testing.T, cookie *Cookie) { } } -func newTempId(uid string, offset int) uidWithExpiry { - return uidWithExpiry{ - UID: uid, - Expires: time.Now().Add(time.Duration(offset) * time.Minute).UTC(), +func ToHTTPCookie(cookie *Cookie) (*http.Cookie, error) { + encoder := Base64Encoder{} + encodedCookie, err := encoder.Encode(cookie) + if err != nil { + return nil, nil } -} -func newSampleCookie() *Cookie { - return &Cookie{ - uids: map[string]uidWithExpiry{ - "adnxs": newTempId("123", 10), - "rubicon": newTempId("456", 10), - }, - optOut: false, - } -} - -func writeThenRead(cookie *Cookie, maxCookieSize int) *Cookie { - w := httptest.NewRecorder() - hostCookie := &config.HostCookie{Domain: "mock-domain", MaxCookieSizeBytes: maxCookieSize} - cookie.SetCookieOnResponse(w, false, hostCookie, 90*24*time.Hour) - writtenCookie := w.HeaderMap.Get("Set-Cookie") - - header := http.Header{} - header.Add("Cookie", writtenCookie) - request := http.Request{Header: header} - return ParseCookieFromRequest(&request, hostCookie) -} - -func TestSetCookieOnResponseForSameSiteNone(t *testing.T) { - cookie := newSampleCookie() - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "http://www.prebid.com", nil) - ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36" - req.Header.Set("User-Agent", ua) - hostCookie := &config.HostCookie{Domain: "mock-domain", MaxCookieSizeBytes: 0} - cookie.SetCookieOnResponse(w, true, hostCookie, 90*24*time.Hour) - writtenCookie := w.HeaderMap.Get("Set-Cookie") - t.Log("Set-Cookie is: ", writtenCookie) - if !strings.Contains(writtenCookie, "; Secure;") { - t.Error("Set-Cookie should contain Secure") - } -} - -func TestSetCookieOnResponseForOlderChromeVersion(t *testing.T) { - cookie := newSampleCookie() - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "http://www.prebid.com", nil) - ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3770.142 Safari/537.36" - req.Header.Set("User-Agent", ua) - hostCookie := &config.HostCookie{Domain: "mock-domain", MaxCookieSizeBytes: 0} - cookie.SetCookieOnResponse(w, false, hostCookie, 90*24*time.Hour) - writtenCookie := w.HeaderMap.Get("Set-Cookie") - t.Log("Set-Cookie is: ", writtenCookie) - if strings.Contains(writtenCookie, "SameSite=none") { - t.Error("Set-Cookie should not contain SameSite=none") - } + return &http.Cookie{ + Name: uidCookieName, + Value: encodedCookie, + Expires: time.Now().Add((90 * 24 * time.Hour)), + Path: "/", + }, nil } diff --git a/usersync/decoder.go b/usersync/decoder.go new file mode 100644 index 00000000000..3ff13aa3242 --- /dev/null +++ b/usersync/decoder.go @@ -0,0 +1,27 @@ +package usersync + +import ( + "encoding/base64" + "encoding/json" +) + +type Decoder interface { + // Decode takes an encoded string and decodes it into a cookie + Decode(v string) *Cookie +} + +type Base64Decoder struct{} + +func (d Base64Decoder) Decode(encodedValue string) *Cookie { + jsonValue, err := base64.URLEncoding.DecodeString(encodedValue) + if err != nil { + return NewCookie() + } + + var cookie Cookie + if err = json.Unmarshal(jsonValue, &cookie); err != nil { + return NewCookie() + } + + return &cookie +} diff --git a/usersync/encoder.go b/usersync/encoder.go new file mode 100644 index 00000000000..eef7e2ef34f --- /dev/null +++ b/usersync/encoder.go @@ -0,0 +1,23 @@ +package usersync + +import ( + "encoding/base64" + "encoding/json" +) + +type Encoder interface { + // Encode a cookie into a base 64 string + Encode(c *Cookie) (string, error) +} + +type Base64Encoder struct{} + +func (e Base64Encoder) Encode(c *Cookie) (string, error) { + j, err := json.Marshal(c) + if err != nil { + return "", err + } + b64 := base64.URLEncoding.EncodeToString(j) + + return b64, nil +} diff --git a/usersync/encoder_decoder_test.go b/usersync/encoder_decoder_test.go new file mode 100644 index 00000000000..5a87d4e7c82 --- /dev/null +++ b/usersync/encoder_decoder_test.go @@ -0,0 +1,149 @@ +package usersync + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestEncoderDecoder(t *testing.T) { + encoder := Base64Encoder{} + decoder := Base64Decoder{} + + testCases := []struct { + name string + givenRequest *http.Request + givenHttpCookie *http.Cookie + givenCookie *Cookie + givenDecoder Decoder + expectedCookie *Cookie + }{ + { + name: "simple-cookie", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + }, + }, + optOut: false, + }, + }, + { + name: "empty-cookie", + givenCookie: &Cookie{}, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: false, + }, + }, + { + name: "nil-cookie", + givenCookie: nil, + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{}, + optOut: false, + }, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + encodedCookie, err := encoder.Encode(test.givenCookie) + assert.NoError(t, err) + decodedCookie := decoder.Decode(encodedCookie) + + assert.Equal(t, test.expectedCookie.uids, decodedCookie.uids) + assert.Equal(t, test.expectedCookie.optOut, decodedCookie.optOut) + }) + } +} + +func TestEncoder(t *testing.T) { + encoder := Base64Encoder{} + + testCases := []struct { + name string + givenCookie *Cookie + expectedEncodedCookie string + }{ + { + name: "simple-cookie", + givenCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + Expires: time.Time{}, + }, + }, + optOut: false, + }, + expectedEncodedCookie: "eyJ0ZW1wVUlEcyI6eyJhZG54cyI6eyJ1aWQiOiJVSUQiLCJleHBpcmVzIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifX19", + }, + { + name: "empty-cookie", + givenCookie: &Cookie{}, + expectedEncodedCookie: "e30=", + }, + { + name: "nil-cookie", + givenCookie: nil, + expectedEncodedCookie: "bnVsbA==", + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + encodedCookie, err := encoder.Encode(test.givenCookie) + assert.NoError(t, err) + + assert.Equal(t, test.expectedEncodedCookie, encodedCookie) + }) + } +} + +func TestDecoder(t *testing.T) { + decoder := Base64Decoder{} + + testCases := []struct { + name string + givenEncodedCookie string + expectedCookie *Cookie + }{ + { + name: "simple-encoded-cookie", + givenEncodedCookie: "eyJ0ZW1wVUlEcyI6eyJhZG54cyI6eyJ1aWQiOiJVSUQiLCJleHBpcmVzIjoiMDAwMS0wMS0wMVQwMDowMDowMFoifX19", + expectedCookie: &Cookie{ + uids: map[string]UIDEntry{ + "adnxs": { + UID: "UID", + }, + }, + optOut: false, + }, + }, + { + name: "nil-encoded-cookie", + givenEncodedCookie: "", + expectedCookie: NewCookie(), + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + decodedCookie := decoder.Decode(test.givenEncodedCookie) + assert.Equal(t, test.expectedCookie, decodedCookie) + }) + } +}