Repository: prebid/prebid-server

Issue:
Running server locally fails
I'm using pure master branch. Executing `go run .` now ends with

```
F1128 18:37:12.336450   71675 main.go:54] prebid-server failed: user sync (1 error):
  1: bidders appnexus, msft define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints
exit status 1
```

I can't run the server locally.

Discussion:
@SyntaxNode: Thank you for sharing this issue with us. We normally run releases through a pre-flight check list but delayed that step due to the US Thanksgiving holiday. I'll have a fix to address this issue in a moment.

tl;dr; When I originally developed the syncer architecture, I hand-waved this situation away and kept the code simple. Can't avoid that complexity now with the Microsoft adapter, which is a rewrite of the AppNexus adapter without the legacy parameter baggage.

Solution patch:
diff --git a/config/bidderinfo.go b/config/bidderinfo.go
index 8e873cbd..1f4041ec 100644
--- a/config/bidderinfo.go
+++ b/config/bidderinfo.go
@@ -11,6 +11,8 @@ import (
 
 	"github.com/prebid/prebid-server/v3/macros"
 	"github.com/prebid/prebid-server/v3/openrtb_ext"
+	"github.com/prebid/prebid-server/v3/util/ptrutil"
+	"github.com/prebid/prebid-server/v3/util/sliceutil"
 
 	validator "github.com/asaskevich/govalidator"
 	"gopkg.in/yaml.v3"
@@ -138,11 +140,44 @@ type Syncer struct {
 	SkipWhen *SkipWhen `yaml:"skipwhen" mapstructure:"skipwhen"`
 }
 
+func (s *Syncer) Equal(other *Syncer) bool {
+	if s == nil && other == nil {
+		return true
+	}
+
+	if s == nil || other == nil {
+		return false
+	}
+
+	return s.Key == other.Key &&
+		sliceutil.EqualIgnoreOrder(s.Supports, other.Supports) &&
+		s.IFrame.Equal(other.IFrame) &&
+		s.Redirect.Equal(other.Redirect) &&
+		s.ExternalURL == other.ExternalURL &&
+		ptrutil.Equal(s.SupportCORS, other.SupportCORS) &&
+		s.FormatOverride == other.FormatOverride &&
+		ptrutil.Equal(s.Enabled, other.Enabled) &&
+		s.SkipWhen.Equal(other.SkipWhen)
+}
+
 type SkipWhen struct {
 	GDPR   bool     `yaml:"gdpr" mapstructure:"gdpr"`
 	GPPSID []string `yaml:"gpp_sid" mapstructure:"gpp_sid"`
 }
 
+func (s *SkipWhen) Equal(other *SkipWhen) bool {
+	if s == nil && other == nil {
+		return true
+	}
+
+	if s == nil || other == nil {
+		return false
+	}
+
+	return s.GDPR == other.GDPR &&
+		sliceutil.EqualIgnoreOrder(s.GPPSID, other.GPPSID)
+}
+
 // SyncerEndpoint specifies the configuration of the URL returned by the /cookie_sync endpoint
 // for a specific bidder. Bidders must specify at least one endpoint configuration to be eligible
 // for selection during a user sync request.
@@ -200,6 +235,21 @@ type SyncerEndpoint struct {
 	UserMacro string `yaml:"userMacro" mapstructure:"user_macro"`
 }
 
+func (s *SyncerEndpoint) Equal(other *SyncerEndpoint) bool {
+	if s == nil && other == nil {
+		return true
+	}
+
+	if s == nil || other == nil {
+		return false
+	}
+
+	return s.URL == other.URL &&
+		s.RedirectURL == other.RedirectURL &&
+		s.ExternalURL == other.ExternalURL &&
+		s.UserMacro == other.UserMacro
+}
+
 func (bi BidderInfo) IsEnabled() bool {
 	return !bi.WhiteLabelOnly && !bi.Disabled
 }
diff --git a/usersync/syncersbuilder.go b/usersync/syncersbuilder.go
index 1127989e..2868a1da 100644
--- a/usersync/syncersbuilder.go
+++ b/usersync/syncersbuilder.go
@@ -76,6 +76,7 @@ func BuildSyncers(hostConfig *config.Configuration, bidderInfos config.BidderInf
 	if len(errs) > 0 {
 		return nil, errs
 	}
+
 	return syncers, nil
 }
 
@@ -97,10 +98,13 @@ func chooseSyncerConfig(biddersSyncerConfig []namedSyncerConfig) (namedSyncerCon
 	var bidderNames []string
 	var bidderNamesWithEndpoints []string
 	var syncerConfig namedSyncerConfig
+	var syncerConfigWithEndpoints []namedSyncerConfig
+
 	for _, bidder := range biddersSyncerConfig {
 		bidderNames = append(bidderNames, bidder.name)
 		if bidder.cfg.IFrame != nil || bidder.cfg.Redirect != nil {
 			bidderNamesWithEndpoints = append(bidderNamesWithEndpoints, bidder.name)
+			syncerConfigWithEndpoints = append(syncerConfigWithEndpoints, bidder)
 			syncerConfig = bidder
 		}
 	}
@@ -111,10 +115,16 @@ func chooseSyncerConfig(biddersSyncerConfig []namedSyncerConfig) (namedSyncerCon
 		return namedSyncerConfig{}, fmt.Errorf("bidders %s share the same syncer key, but none define endpoints (iframe and/or redirect)", bidders)
 	}
 
-	if len(bidderNamesWithEndpoints) > 1 {
-		sort.Strings(bidderNamesWithEndpoints)
-		bidders := strings.Join(bidderNamesWithEndpoints, ", ")
-		return namedSyncerConfig{}, fmt.Errorf("bidders %s define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints", bidders)
+	if len(syncerConfigWithEndpoints) > 1 {
+		// allowed if all configs are identical
+		firstConfig := syncerConfigWithEndpoints[0].cfg
+		for _, config := range syncerConfigWithEndpoints[1:] {
+			if !firstConfig.Equal(&config.cfg) {
+				sort.Strings(bidderNamesWithEndpoints)
+				bidders := strings.Join(bidderNamesWithEndpoints, ", ")
+				return namedSyncerConfig{}, fmt.Errorf("bidders %s define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints unless the configs are identical", bidders)
+			}
+		}
 	}
 
 	return syncerConfig, nil
diff --git a/util/ptrutil/ptrutil.go b/util/ptrutil/ptrutil.go
index 27616a0a..c700e6de 100644
--- a/util/ptrutil/ptrutil.go
+++ b/util/ptrutil/ptrutil.go
@@ -21,3 +21,15 @@ func ValueOrDefault[T any](v *T) T {
 	var def T
 	return def
 }
+
+func Equal[T comparable](v1, v2 *T) bool {
+	if v1 == nil && v2 == nil {
+		return true
+	}
+
+	if v1 == nil || v2 == nil {
+		return false
+	}
+
+	return *v1 == *v2
+}
diff --git a/util/sliceutil/equalignoreorder.go b/util/sliceutil/equalignoreorder.go
new file mode 100644
index 00000000..0322af65
--- /dev/null
+++ b/util/sliceutil/equalignoreorder.go
@@ -0,0 +1,26 @@
+package sliceutil
+
+import (
+	"maps"
+)
+
+// EqualIgnoreOrder checks if two slices contain the same elements, regardless of their order. This
+// is not optimized for memory usage and is intended for use at startup only. Empty and nil slices
+// are considered equal, matching slices.Equal behavior.
+func EqualIgnoreOrder[T comparable](s1, s2 []T) bool {
+	if len(s1) != len(s2) {
+		return false
+	}
+
+	counts1 := make(map[T]int, len(s1))
+	for _, item := range s1 {
+		counts1[item]++
+	}
+
+	counts2 := make(map[T]int, len(s2))
+	for _, item := range s2 {
+		counts2[item]++
+	}
+
+	return maps.Equal(counts1, counts2)
+}



Test patch:
diff --git a/config/bidderinfo_test.go b/config/bidderinfo_test.go
index 41f96878..8c251842 100644
--- a/config/bidderinfo_test.go
+++ b/config/bidderinfo_test.go
@@ -1598,6 +1598,445 @@ func TestBidderInfoIsEnabled(t *testing.T) {
 	}
 }
 
+func TestSyncerEqual(t *testing.T) {
+	testCases := []struct {
+		name     string
+		syncer1  *Syncer
+		syncer2  *Syncer
+		expected bool
+	}{
+		{
+			name:     "nil",
+			syncer1:  nil,
+			syncer2:  nil,
+			expected: true,
+		},
+		{
+			name:     "nil-syncer1",
+			syncer1:  nil,
+			syncer2:  &Syncer{Key: "anyKey"},
+			expected: false,
+		},
+		{
+			name:     "nil-syncer2",
+			syncer1:  &Syncer{Key: "anyKey"},
+			syncer2:  nil,
+			expected: false,
+		},
+		{
+			name: "different-key",
+			syncer1: &Syncer{
+				Key:         "key1",
+				ExternalURL: "https://example.com",
+			},
+			syncer2: &Syncer{
+				Key:         "key2",
+				ExternalURL: "https://example.com",
+			},
+			expected: false,
+		},
+		{
+			name: "different-external-url",
+			syncer1: &Syncer{
+				Key:         "key",
+				ExternalURL: "https://example1.com",
+			},
+			syncer2: &Syncer{
+				Key:         "key",
+				ExternalURL: "https://example2.com",
+			},
+			expected: false,
+		},
+		{
+			name: "same-supports-nil-vs-empty",
+			syncer1: &Syncer{
+				Key:      "key",
+				Supports: nil,
+			},
+			syncer2: &Syncer{
+				Key:      "key",
+				Supports: []string{},
+			},
+			expected: true,
+		},
+		{
+			name: "different-supports-ordered",
+			syncer1: &Syncer{
+				Key:      "key",
+				Supports: []string{"iframe"},
+			},
+			syncer2: &Syncer{
+				Key:      "key",
+				Supports: []string{"redirect"},
+			},
+			expected: false,
+		},
+		{
+			name: "different-supports-unordered",
+			syncer1: &Syncer{
+				Key:      "key",
+				Supports: []string{"iframe", "redirect"},
+			},
+			syncer2: &Syncer{
+				Key:      "key",
+				Supports: []string{"redirect", "iframe"},
+			},
+			expected: true,
+		},
+		{
+			name: "different-iframe-endpoints",
+			syncer1: &Syncer{
+				Key: "key",
+				IFrame: &SyncerEndpoint{
+					URL: "https://iframe1.com",
+				},
+			},
+			syncer2: &Syncer{
+				Key: "key",
+				IFrame: &SyncerEndpoint{
+					URL: "https://iframe2.com",
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "different-redirect-endpoints",
+			syncer1: &Syncer{
+				Key: "key",
+				Redirect: &SyncerEndpoint{
+					URL: "https://redirect1.com",
+				},
+			},
+			syncer2: &Syncer{
+				Key: "key",
+				Redirect: &SyncerEndpoint{
+					URL: "https://redirect2.com",
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "different-support-cors",
+			syncer1: &Syncer{
+				Key:         "key",
+				SupportCORS: ptrutil.ToPtr(true),
+			},
+			syncer2: &Syncer{
+				Key:         "key",
+				SupportCORS: ptrutil.ToPtr(false),
+			},
+			expected: false,
+		},
+		{
+			name: "different-format-override",
+			syncer1: &Syncer{
+				Key:            "key",
+				FormatOverride: "i",
+			},
+			syncer2: &Syncer{
+				Key:            "key",
+				FormatOverride: "b",
+			},
+			expected: false,
+		},
+		{
+			name: "different-enabled",
+			syncer1: &Syncer{
+				Key:     "key",
+				Enabled: ptrutil.ToPtr(true),
+			},
+			syncer2: &Syncer{
+				Key:     "key",
+				Enabled: ptrutil.ToPtr(false),
+			},
+			expected: false,
+		},
+		{
+			name: "different-skip-when",
+			syncer1: &Syncer{
+				Key: "key",
+				SkipWhen: &SkipWhen{
+					GDPR: true,
+				},
+			},
+			syncer2: &Syncer{
+				Key: "key",
+				SkipWhen: &SkipWhen{
+					GDPR: false,
+				},
+			},
+			expected: false,
+		},
+		{
+			name: "same-complete",
+			syncer1: &Syncer{
+				Key:      "key",
+				Supports: []string{"iframe", "redirect"},
+				IFrame: &SyncerEndpoint{
+					URL:         "https://iframe.com",
+					RedirectURL: "https://redirect.com",
+					UserMacro:   "$UID",
+				},
+				Redirect: &SyncerEndpoint{
+					URL:       "https://redirect.com",
+					UserMacro: "$UID",
+				},
+				ExternalURL:    "https://external.com",
+				SupportCORS:    ptrutil.ToPtr(true),
+				FormatOverride: "i",
+				Enabled:        ptrutil.ToPtr(true),
+				SkipWhen: &SkipWhen{
+					GDPR:   true,
+					GPPSID: []string{"1", "2"},
+				},
+			},
+			syncer2: &Syncer{
+				Key:      "key",
+				Supports: []string{"redirect", "iframe"},
+				IFrame: &SyncerEndpoint{
+					URL:         "https://iframe.com",
+					RedirectURL: "https://redirect.com",
+					UserMacro:   "$UID",
+				},
+				Redirect: &SyncerEndpoint{
+					URL:       "https://redirect.com",
+					UserMacro: "$UID",
+				},
+				ExternalURL:    "https://external.com",
+				SupportCORS:    ptrutil.ToPtr(true),
+				FormatOverride: "i",
+				Enabled:        ptrutil.ToPtr(true),
+				SkipWhen: &SkipWhen{
+					GDPR:   true,
+					GPPSID: []string{"2", "1"},
+				},
+			},
+			expected: true,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			result := test.syncer1.Equal(test.syncer2)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestSkipWhenEqual(t *testing.T) {
+	testCases := []struct {
+		name      string
+		skipWhen1 *SkipWhen
+		skipWhen2 *SkipWhen
+		expected  bool
+	}{
+		{
+			name:      "nil",
+			skipWhen1: nil,
+			skipWhen2: nil,
+			expected:  true,
+		},
+		{
+			name:      "nil-skipWhen1",
+			skipWhen1: nil,
+			skipWhen2: &SkipWhen{GDPR: true, GPPSID: []string{"1"}},
+			expected:  false,
+		},
+		{
+			name:      "nil-skipWhen2",
+			skipWhen1: &SkipWhen{GDPR: true, GPPSID: []string{"1"}},
+			skipWhen2: nil,
+			expected:  false,
+		},
+		{
+			name:      "empty",
+			skipWhen1: &SkipWhen{GDPR: false, GPPSID: []string{}},
+			skipWhen2: &SkipWhen{GDPR: false, GPPSID: []string{}},
+			expected:  true,
+		},
+		{
+			name:      "same",
+			skipWhen1: &SkipWhen{GDPR: true, GPPSID: []string{"1", "2"}},
+			skipWhen2: &SkipWhen{GDPR: true, GPPSID: []string{"1", "2"}},
+			expected:  true,
+		},
+		{
+			name:      "different-gdpr",
+			skipWhen1: &SkipWhen{GDPR: true, GPPSID: []string{"1", "2"}},
+			skipWhen2: &SkipWhen{GDPR: false, GPPSID: []string{"1", "2"}},
+			expected:  false,
+		},
+		{
+			name:      "different-gppsid",
+			skipWhen1: &SkipWhen{GDPR: true, GPPSID: []string{"1", "2"}},
+			skipWhen2: &SkipWhen{GDPR: true, GPPSID: []string{"1", "3"}},
+			expected:  false,
+		},
+		{
+			name:      "same-gppsid-unordered",
+			skipWhen1: &SkipWhen{GDPR: true, GPPSID: []string{"1", "2"}},
+			skipWhen2: &SkipWhen{GDPR: true, GPPSID: []string{"2", "1"}},
+			expected:  true,
+		},
+		{
+			name:      "nil-vs-empty-slice",
+			skipWhen1: &SkipWhen{GDPR: false, GPPSID: nil},
+			skipWhen2: &SkipWhen{GDPR: false, GPPSID: []string{}},
+			expected:  true,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			result := test.skipWhen1.Equal(test.skipWhen2)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestSyncerEndpointEqual(t *testing.T) {
+	testCases := []struct {
+		name      string
+		endpoint1 *SyncerEndpoint
+		endpoint2 *SyncerEndpoint
+		expected  bool
+	}{
+		{
+			name:      "nil",
+			endpoint1: nil,
+			endpoint2: nil,
+			expected:  true,
+		},
+		{
+			name:      "nil-endpoint1",
+			endpoint1: nil,
+			endpoint2: &SyncerEndpoint{URL: "https://example.com"},
+			expected:  false,
+		},
+		{
+			name:      "nil-endpoint2",
+			endpoint1: &SyncerEndpoint{URL: "https://example.com"},
+			endpoint2: nil,
+			expected:  false,
+		},
+		{
+			name: "empty",
+			endpoint1: &SyncerEndpoint{
+				URL:         "",
+				RedirectURL: "",
+				ExternalURL: "",
+				UserMacro:   "",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "",
+				RedirectURL: "",
+				ExternalURL: "",
+				UserMacro:   "",
+			},
+			expected: true,
+		},
+		{
+			name: "same",
+			endpoint1: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			expected: true,
+		},
+		{
+			name: "different-url",
+			endpoint1: &SyncerEndpoint{
+				URL:         "https://sync1.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "https://sync2.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			expected: false,
+		},
+		{
+			name: "different-redirect-url",
+			endpoint1: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host1.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host2.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			expected: false,
+		},
+		{
+			name: "different-external-url",
+			endpoint1: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host1.example.com",
+				UserMacro:   "$UID",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host2.example.com",
+				UserMacro:   "$UID",
+			},
+			expected: false,
+		},
+		{
+			name: "different-user-macro",
+			endpoint1: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$UID",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:         "https://sync.example.com/iframe",
+				RedirectURL: "https://host.example.com/setuid",
+				ExternalURL: "https://host.example.com",
+				UserMacro:   "$USER_ID",
+			},
+			expected: false,
+		},
+		{
+			name: "different-user-macro-case-sensitive",
+			endpoint1: &SyncerEndpoint{
+				URL:       "https://sync.example.com",
+				UserMacro: "$uid",
+			},
+			endpoint2: &SyncerEndpoint{
+				URL:       "https://sync.example.com",
+				UserMacro: "$UID",
+			},
+			expected: false,
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.name, func(t *testing.T) {
+			result := test.endpoint1.Equal(test.endpoint2)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
 func TestSyncerDefined(t *testing.T) {
 	testCases := []struct {
 		name        string
diff --git a/usersync/syncersbuilder_test.go b/usersync/syncersbuilder_test.go
index d7026543..a08df081 100644
--- a/usersync/syncersbuilder_test.go
+++ b/usersync/syncersbuilder_test.go
@@ -22,8 +22,10 @@ func TestBuildSyncers(t *testing.T) {
 	var (
 		hostConfig              = config.Configuration{ExternalURL: "http://host.com", UserSync: config.UserSync{RedirectURL: "{{.ExternalURL}}/{{.SyncerKey}}/host"}}
 		iframeConfig            = &config.SyncerEndpoint{URL: "https://bidder.com/iframe?redirect={{.RedirectURL}}"}
+		iframeConfigAlt         = &config.SyncerEndpoint{URL: "https://bidder.com/iframe-alt?redirect={{.RedirectURL}}"}
 		iframeConfigError       = &config.SyncerEndpoint{URL: "https://bidder.com/iframe?redirect={{xRedirectURL}}"} // Error caused by invalid macro
 		infoKeyAPopulated       = config.BidderInfo{Disabled: false, Syncer: &config.Syncer{Key: "a", IFrame: iframeConfig}}
+		infoKeyAPopulatedAlt    = config.BidderInfo{Disabled: false, Syncer: &config.Syncer{Key: "a", IFrame: iframeConfigAlt}}
 		infoKeyADisabled        = config.BidderInfo{Disabled: true, Syncer: &config.Syncer{Key: "a", IFrame: iframeConfig}}
 		infoKeyAEmpty           = config.BidderInfo{Disabled: false, Syncer: &config.Syncer{Key: "a"}}
 		infoKeyAError           = config.BidderInfo{Disabled: false, Syncer: &config.Syncer{Key: "a", IFrame: iframeConfigError}}
@@ -86,11 +88,20 @@ func TestBuildSyncers(t *testing.T) {
 			},
 		},
 		{
-			description:      "Many - Same Syncers - Many Primaries",
+			description:      "Many - Same Syncers - Many Primaries - Identical",
 			givenConfig:      hostConfig,
 			givenBidderInfos: map[string]config.BidderInfo{"bidder1": infoKeyAPopulated, "bidder2": infoKeyAPopulated},
+			expectedIFramesURLs: map[string]string{
+				"bidder1": "https://bidder.com/iframe?redirect=http%3A%2F%2Fhost.com%2Fbidder1%2Fhost",
+				"bidder2": "https://bidder.com/iframe?redirect=http%3A%2F%2Fhost.com%2Fbidder2%2Fhost",
+			},
+		},
+		{
+			description:      "Many - Same Syncers - Many Primaries - Different",
+			givenConfig:      hostConfig,
+			givenBidderInfos: map[string]config.BidderInfo{"bidder1": infoKeyAPopulated, "bidder2": infoKeyAPopulatedAlt},
 			expectedErrors: []string{
-				"bidders bidder1, bidder2 define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints",
+				"bidders bidder1, bidder2 define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints unless the configs are identical",
 			},
 		},
 		{
@@ -294,10 +305,11 @@ func TestShouldCreateSyncer(t *testing.T) {
 
 func TestChooseSyncerConfig(t *testing.T) {
 	var (
-		bidderAPopulated = namedSyncerConfig{name: "bidderA", cfg: config.Syncer{Key: "a", IFrame: &config.SyncerEndpoint{URL: "anyURL"}}}
-		bidderAEmpty     = namedSyncerConfig{name: "bidderA", cfg: config.Syncer{}}
-		bidderBPopulated = namedSyncerConfig{name: "bidderB", cfg: config.Syncer{Key: "a", IFrame: &config.SyncerEndpoint{URL: "anyURL"}}}
-		bidderBEmpty     = namedSyncerConfig{name: "bidderB", cfg: config.Syncer{}}
+		bidderAPopulated    = namedSyncerConfig{name: "bidderA", cfg: config.Syncer{Key: "a", IFrame: &config.SyncerEndpoint{URL: "anyURL"}}}
+		bidderAEmpty        = namedSyncerConfig{name: "bidderA", cfg: config.Syncer{}}
+		bidderBPopulated    = namedSyncerConfig{name: "bidderB", cfg: config.Syncer{Key: "a", IFrame: &config.SyncerEndpoint{URL: "anyURL"}}}
+		bidderBPopulatedAlt = namedSyncerConfig{name: "bidderB", cfg: config.Syncer{Key: "a", IFrame: &config.SyncerEndpoint{URL: "differentURL"}}}
+		bidderBEmpty        = namedSyncerConfig{name: "bidderB", cfg: config.Syncer{}}
 	)
 
 	testCases := []struct {
@@ -317,9 +329,14 @@ func TestChooseSyncerConfig(t *testing.T) {
 			expectedConfig: bidderBPopulated,
 		},
 		{
-			description:   "Many - Same Key - Multiple Configs",
-			given:         []namedSyncerConfig{bidderAPopulated, bidderBPopulated},
-			expectedError: "bidders bidderA, bidderB define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints",
+			description:    "Many - Same Key - Multiple - Same Configs",
+			given:          []namedSyncerConfig{bidderAPopulated, bidderBPopulated},
+			expectedConfig: bidderBPopulated,
+		},
+		{
+			description:   "Many - Same Key - Multiple - Different Configs",
+			given:         []namedSyncerConfig{bidderAPopulated, bidderBPopulatedAlt},
+			expectedError: "bidders bidderA, bidderB define endpoints (iframe and/or redirect) for the same syncer key, but only one bidder is permitted to define endpoints unless the configs are identical",
 		},
 		{
 			description:   "Many - Same Key - No Configs",
diff --git a/util/ptrutil/ptrutil_test.go b/util/ptrutil/ptrutil_test.go
index 85671e31..028939eb 100644
--- a/util/ptrutil/ptrutil_test.go
+++ b/util/ptrutil/ptrutil_test.go
@@ -43,3 +43,103 @@ func TestValueOrDefault(t *testing.T) {
 		assert.Equal(t, "something", r)
 	})
 }
+
+func TestEqualInt(t *testing.T) {
+	tests := []struct {
+		name     string
+		value1   *int
+		value2   *int
+		expected bool
+	}{
+		{
+			name:     "nil",
+			value1:   nil,
+			value2:   nil,
+			expected: true,
+		},
+		{
+			name:     "nil-value1",
+			value1:   nil,
+			value2:   ToPtr(42),
+			expected: false,
+		},
+		{
+			name:     "nil-value2",
+			value1:   ToPtr(42),
+			value2:   nil,
+			expected: false,
+		},
+		{
+			name:     "same",
+			value1:   ToPtr(42),
+			value2:   ToPtr(42),
+			expected: true,
+		},
+		{
+			name:     "different",
+			value1:   ToPtr(42),
+			value2:   ToPtr(24),
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := Equal(tt.value1, tt.value2)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestEqualString(t *testing.T) {
+	tests := []struct {
+		name     string
+		value1   *string
+		value2   *string
+		expected bool
+	}{
+		{
+			name:     "nil",
+			value1:   nil,
+			value2:   nil,
+			expected: true,
+		},
+		{
+			name:     "nil-value1",
+			value1:   nil,
+			value2:   ToPtr("hello"),
+			expected: false,
+		},
+		{
+			name:     "nil-value2",
+			value1:   ToPtr("hello"),
+			value2:   nil,
+			expected: false,
+		},
+		{
+			name:     "same",
+			value1:   ToPtr("hello"),
+			value2:   ToPtr("hello"),
+			expected: true,
+		},
+		{
+			name:     "different",
+			value1:   ToPtr("hello"),
+			value2:   ToPtr("world"),
+			expected: false,
+		},
+		{
+			name:     "empty",
+			value1:   ToPtr(""),
+			value2:   ToPtr(""),
+			expected: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := Equal(tt.value1, tt.value2)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
diff --git a/util/sliceutil/equalignoreorder_test.go b/util/sliceutil/equalignoreorder_test.go
new file mode 100644
index 00000000..f6db241a
--- /dev/null
+++ b/util/sliceutil/equalignoreorder_test.go
@@ -0,0 +1,233 @@
+package sliceutil
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestEqualIgnoreOrderInt(t *testing.T) {
+	tests := []struct {
+		name     string
+		slice1   []int
+		slice2   []int
+		expected bool
+	}{
+		{
+			name:     "nil-both",
+			slice1:   nil,
+			slice2:   nil,
+			expected: true,
+		},
+		{
+			name:     "nil-s1",
+			slice1:   nil,
+			slice2:   []int{1},
+			expected: false,
+		},
+		{
+			name:     "nil-s2",
+			slice1:   []int{1},
+			slice2:   nil,
+			expected: false,
+		},
+		{
+			name:     "empty-both",
+			slice1:   []int{},
+			slice2:   []int{},
+			expected: true,
+		},
+		{
+			name:     "empty-s1",
+			slice1:   []int{},
+			slice2:   []int{1},
+			expected: false,
+		},
+		{
+			name:     "empty-s2",
+			slice1:   []int{1},
+			slice2:   []int{},
+			expected: false,
+		},
+		{
+			name:     "nil-empty-mix-1",
+			slice1:   nil,
+			slice2:   []int{},
+			expected: true,
+		},
+		{
+			name:     "nil-empty-mix-2",
+			slice1:   []int{},
+			slice2:   nil,
+			expected: true,
+		},
+		{
+			name:     "one-same",
+			slice1:   []int{1},
+			slice2:   []int{1},
+			expected: true,
+		},
+		{
+			name:     "one-different",
+			slice1:   []int{1},
+			slice2:   []int{2},
+			expected: false,
+		},
+		{
+			name:     "many-same-ordered",
+			slice1:   []int{1, 2},
+			slice2:   []int{1, 2},
+			expected: true,
+		},
+		{
+			name:     "many-same-unordered",
+			slice1:   []int{1, 2},
+			slice2:   []int{2, 1},
+			expected: true,
+		},
+		{
+			name:     "many-different-lengths",
+			slice1:   []int{1},
+			slice2:   []int{2, 3},
+			expected: false,
+		},
+		{
+			name:     "many-different",
+			slice1:   []int{1, 2},
+			slice2:   []int{3, 4},
+			expected: false,
+		},
+		{
+			name:     "many-different-duplicates-unordered",
+			slice1:   []int{1, 2, 2, 3},
+			slice2:   []int{2, 1, 2, 4},
+			expected: false,
+		},
+		{
+			name:     "many-same-duplicates-unordered",
+			slice1:   []int{1, 2, 2},
+			slice2:   []int{2, 1, 2},
+			expected: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			result := EqualIgnoreOrder(test.slice1, test.slice2)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestEqualIgnoreOrderString(t *testing.T) {
+	tests := []struct {
+		name     string
+		slice1   []string
+		slice2   []string
+		expected bool
+	}{
+		{
+			name:     "nil-both",
+			slice1:   nil,
+			slice2:   nil,
+			expected: true,
+		},
+		{
+			name:     "nil-s1",
+			slice1:   nil,
+			slice2:   []string{"a"},
+			expected: false,
+		},
+		{
+			name:     "nil-s2",
+			slice1:   []string{"a"},
+			slice2:   nil,
+			expected: false,
+		},
+		{
+			name:     "empty-both",
+			slice1:   []string{},
+			slice2:   []string{},
+			expected: true,
+		},
+		{
+			name:     "empty-s1",
+			slice1:   []string{},
+			slice2:   []string{"a"},
+			expected: false,
+		},
+		{
+			name:     "empty-s2",
+			slice1:   []string{"a"},
+			slice2:   []string{},
+			expected: false,
+		},
+		{
+			name:     "nil-empty-mix-1",
+			slice1:   nil,
+			slice2:   []string{},
+			expected: true,
+		},
+		{
+			name:     "nil-empty-mix-2",
+			slice1:   []string{},
+			slice2:   nil,
+			expected: true,
+		},
+		{
+			name:     "one-same",
+			slice1:   []string{"a"},
+			slice2:   []string{"a"},
+			expected: true,
+		},
+		{
+			name:     "one-different",
+			slice1:   []string{"a"},
+			slice2:   []string{"b"},
+			expected: false,
+		},
+		{
+			name:     "many-same-ordered",
+			slice1:   []string{"a", "b"},
+			slice2:   []string{"a", "b"},
+			expected: true,
+		},
+		{
+			name:     "many-same-unordered",
+			slice1:   []string{"a", "b"},
+			slice2:   []string{"b", "a"},
+			expected: true,
+		},
+		{
+			name:     "many-different-lengths",
+			slice1:   []string{"a"},
+			slice2:   []string{"b", "c"},
+			expected: false,
+		},
+		{
+			name:     "many-different",
+			slice1:   []string{"a", "b"},
+			slice2:   []string{"c", "d"},
+			expected: false,
+		},
+		{
+			name:     "many-different-duplicates-unordered",
+			slice1:   []string{"a", "b", "b", "c"},
+			slice2:   []string{"b", "a", "b", "d"},
+			expected: false,
+		},
+		{
+			name:     "many-same-duplicates-unordered",
+			slice1:   []string{"a", "b", "b"},
+			slice2:   []string{"b", "a", "b"},
+			expected: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			result := EqualIgnoreOrder(test.slice1, test.slice2)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}