github.com/mailgun/holster/v4@v4.20.0/mxresolv/mxresolv_test.go (about)

     1  package mxresolv_test
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"math"
     7  	"math/rand"
     8  	"net"
     9  	"os"
    10  	"reflect"
    11  	"regexp"
    12  	"sort"
    13  	"testing"
    14  	"time"
    15  
    16  	"github.com/foxcpp/go-mockdns"
    17  	"github.com/mailgun/holster/v4/clock"
    18  	"github.com/mailgun/holster/v4/errors"
    19  	"github.com/mailgun/holster/v4/mxresolv"
    20  	"github.com/stretchr/testify/assert"
    21  	"github.com/stretchr/testify/require"
    22  )
    23  
    24  func TestMain(m *testing.M) {
    25  	zones := map[string]mockdns.Zone{
    26  		"test-a.definbox.com.": {
    27  			A: []string{"192.168.19.2"},
    28  		},
    29  		"test-cname.definbox.com.": {
    30  			CNAME: "definbox.com.",
    31  		},
    32  		"definbox.com.": {
    33  			MX: []net.MX{
    34  				{Host: "mxa.ninomail.com.", Pref: 10},
    35  				{Host: "mxb.ninomail.com.", Pref: 10},
    36  			},
    37  		},
    38  		"prefer.example.com.": {
    39  			MX: []net.MX{
    40  				{Host: "mxa.example.com.", Pref: 20},
    41  				{Host: "mxb.example.com.", Pref: 1},
    42  			},
    43  		},
    44  		"prefer3.example.com.": {
    45  			MX: []net.MX{
    46  				{Host: "mxa.example.com.", Pref: 1},
    47  				{Host: "mxb.example.com.", Pref: 1},
    48  				{Host: "mxc.example.com.", Pref: 2},
    49  			},
    50  		},
    51  		"test-unicode.definbox.com.": {
    52  			MX: []net.MX{
    53  				{Host: "mxa.definbox.com.", Pref: 1},
    54  				{Host: "ex\\228mple.com.", Pref: 2},
    55  				{Host: "mxb.definbox.com.", Pref: 3},
    56  			},
    57  		},
    58  		"test-underscore.definbox.com.": {
    59  			MX: []net.MX{
    60  				{Host: "foo_bar.definbox.com.", Pref: 1},
    61  			},
    62  		},
    63  		"xn--test--xweh4bya7b6j.definbox.com.": {
    64  			MX: []net.MX{
    65  				{Host: "xn--test---mofb0ab4b8camvcmn8gxd.definbox.com.", Pref: 10},
    66  			},
    67  		},
    68  		"test-mx-ipv4.definbox.com.": {
    69  			MX: []net.MX{
    70  				{Host: "34.150.176.225.", Pref: 10},
    71  			},
    72  		},
    73  		"test-mx-ipv6.definbox.com.": {
    74  			MX: []net.MX{
    75  				{Host: "::ffff:2296:b0e1.", Pref: 10},
    76  			},
    77  		},
    78  		"example.com.": {
    79  			MX: []net.MX{
    80  				{Host: ".", Pref: 0},
    81  			},
    82  		},
    83  		"test-mx-zero.definbox.com.": {
    84  			MX: []net.MX{
    85  				{Host: "0.0.0.0.", Pref: 0},
    86  			},
    87  		},
    88  		"test-mx.definbox.com.": {
    89  			MX: []net.MX{
    90  				{Host: "mxg.definbox.com.", Pref: 3},
    91  				{Host: "mxa.definbox.com.", Pref: 1},
    92  				{Host: "mxe.definbox.com.", Pref: 1},
    93  				{Host: "mxi.definbox.com.", Pref: 1},
    94  				{Host: "mxd.definbox.com.", Pref: 3},
    95  				{Host: "mxc.definbox.com.", Pref: 2},
    96  				{Host: "mxb.definbox.com.", Pref: 3},
    97  				{Host: "mxf.definbox.com.", Pref: 3},
    98  				{Host: "mxh.definbox.com.", Pref: 3},
    99  			},
   100  		},
   101  	}
   102  	server, err := SpawnMockDNS(zones)
   103  	if err != nil {
   104  		panic(err)
   105  	}
   106  
   107  	server.Patch(mxresolv.Resolver)
   108  	exitVal := m.Run()
   109  	server.UnPatch(mxresolv.Resolver)
   110  	server.Stop()
   111  	os.Exit(exitVal)
   112  }
   113  
   114  func TestLookupWithPref(t *testing.T) {
   115  	for _, tc := range []struct {
   116  		desc          string
   117  		inDomainName  string
   118  		outMXHosts    []*net.MX
   119  		outImplicitMX bool
   120  	}{{
   121  		desc:         "MX record preference is respected",
   122  		inDomainName: "test-mx.definbox.com",
   123  		outMXHosts: []*net.MX{
   124  			{Host: "mxa.definbox.com", Pref: 1}, {Host: "mxe.definbox.com", Pref: 1}, {Host: "mxi.definbox.com", Pref: 1},
   125  			{Host: "mxc.definbox.com", Pref: 2},
   126  			{Host: "mxb.definbox.com", Pref: 3}, {Host: "mxd.definbox.com", Pref: 3}, {Host: "mxf.definbox.com", Pref: 3}, {Host: "mxg.definbox.com", Pref: 3}, {Host: "mxh.definbox.com", Pref: 3},
   127  		},
   128  		outImplicitMX: false,
   129  	}, {
   130  		inDomainName:  "test-a.definbox.com",
   131  		outMXHosts:    []*net.MX{{Host: "test-a.definbox.com", Pref: 1}},
   132  		outImplicitMX: true,
   133  	}, {
   134  		inDomainName:  "test-cname.definbox.com",
   135  		outMXHosts:    []*net.MX{{Host: "mxa.ninomail.com", Pref: 10}, {Host: "mxb.ninomail.com", Pref: 10}},
   136  		outImplicitMX: false,
   137  	}, {
   138  		inDomainName:  "definbox.com",
   139  		outMXHosts:    []*net.MX{{Host: "mxa.ninomail.com", Pref: 10}, {Host: "mxb.ninomail.com", Pref: 10}},
   140  		outImplicitMX: false,
   141  	}, {
   142  		desc: "If an MX host returned by the resolver contains non ASCII " +
   143  			"characters then it is silently dropped from the returned list",
   144  		inDomainName:  "test-unicode.definbox.com",
   145  		outMXHosts:    []*net.MX{{Host: "mxa.definbox.com", Pref: 1}, {Host: "mxb.definbox.com", Pref: 3}},
   146  		outImplicitMX: false,
   147  	}, {
   148  		desc:          "Underscore is allowed in domain names",
   149  		inDomainName:  "test-underscore.definbox.com",
   150  		outMXHosts:    []*net.MX{{Host: "foo_bar.definbox.com", Pref: 1}},
   151  		outImplicitMX: false,
   152  	}, {
   153  		inDomainName:  "test-яндекс.definbox.com",
   154  		outMXHosts:    []*net.MX{{Host: "xn--test---mofb0ab4b8camvcmn8gxd.definbox.com", Pref: 10}},
   155  		outImplicitMX: false,
   156  	}, {
   157  		inDomainName:  "xn--test--xweh4bya7b6j.definbox.com",
   158  		outMXHosts:    []*net.MX{{Host: "xn--test---mofb0ab4b8camvcmn8gxd.definbox.com", Pref: 10}},
   159  		outImplicitMX: false,
   160  	}, {
   161  		inDomainName:  "test-mx-ipv4.definbox.com",
   162  		outMXHosts:    []*net.MX{{Host: "34.150.176.225", Pref: 10}},
   163  		outImplicitMX: false,
   164  	}, {
   165  		inDomainName:  "test-mx-ipv6.definbox.com",
   166  		outMXHosts:    []*net.MX{{Host: "::ffff:2296:b0e1", Pref: 10}},
   167  		outImplicitMX: false,
   168  	}} {
   169  		t.Run(tc.inDomainName, func(t *testing.T) {
   170  			defer mxresolv.SetDeterministicInTests()()
   171  
   172  			// When
   173  			ctx, cancel := context.WithTimeout(context.Background(), 3*clock.Second)
   174  			defer cancel()
   175  			mxRecords, implicitMX, err := mxresolv.LookupWithPref(ctx, tc.inDomainName)
   176  			// Then
   177  			assert.NoError(t, err)
   178  			assert.Equal(t, tc.outMXHosts, mxRecords)
   179  			assert.Equal(t, tc.outImplicitMX, implicitMX)
   180  		})
   181  	}
   182  }
   183  
   184  func TestLookup(t *testing.T) {
   185  	for _, tc := range []struct {
   186  		desc          string
   187  		inDomainName  string
   188  		outMXHosts    []string
   189  		outImplicitMX bool
   190  	}{{
   191  		desc:         "MX record preference is respected",
   192  		inDomainName: "test-mx.definbox.com",
   193  		outMXHosts: []string{
   194  			/* 1 */ "mxa.definbox.com", "mxi.definbox.com", "mxe.definbox.com",
   195  			/* 2 */ "mxc.definbox.com",
   196  			/* 3 */ "mxb.definbox.com", "mxf.definbox.com", "mxh.definbox.com", "mxd.definbox.com", "mxg.definbox.com",
   197  		},
   198  		outImplicitMX: false,
   199  	}, {
   200  		inDomainName:  "test-a.definbox.com",
   201  		outMXHosts:    []string{"test-a.definbox.com"},
   202  		outImplicitMX: true,
   203  	}, {
   204  		inDomainName:  "test-cname.definbox.com",
   205  		outMXHosts:    []string{"mxa.ninomail.com", "mxb.ninomail.com"},
   206  		outImplicitMX: false,
   207  	}, {
   208  		inDomainName:  "definbox.com",
   209  		outMXHosts:    []string{"mxa.ninomail.com", "mxb.ninomail.com"},
   210  		outImplicitMX: false,
   211  	}, {
   212  		desc: "If an MX host returned by the resolver contains non ASCII " +
   213  			"characters then it is silently dropped from the returned list",
   214  		inDomainName:  "test-unicode.definbox.com",
   215  		outMXHosts:    []string{"mxa.definbox.com", "mxb.definbox.com"},
   216  		outImplicitMX: false,
   217  	}, {
   218  		desc:          "Underscore is allowed in domain names",
   219  		inDomainName:  "test-underscore.definbox.com",
   220  		outMXHosts:    []string{"foo_bar.definbox.com"},
   221  		outImplicitMX: false,
   222  	}, {
   223  		inDomainName:  "test-яндекс.definbox.com",
   224  		outMXHosts:    []string{"xn--test---mofb0ab4b8camvcmn8gxd.definbox.com"},
   225  		outImplicitMX: false,
   226  	}, {
   227  		inDomainName:  "xn--test--xweh4bya7b6j.definbox.com",
   228  		outMXHosts:    []string{"xn--test---mofb0ab4b8camvcmn8gxd.definbox.com"},
   229  		outImplicitMX: false,
   230  	}, {
   231  		inDomainName:  "test-mx-ipv4.definbox.com",
   232  		outMXHosts:    []string{"34.150.176.225"},
   233  		outImplicitMX: false,
   234  	}, {
   235  		inDomainName:  "test-mx-ipv6.definbox.com",
   236  		outMXHosts:    []string{"::ffff:2296:b0e1"},
   237  		outImplicitMX: false,
   238  	}} {
   239  		t.Run(tc.inDomainName, func(t *testing.T) {
   240  			defer mxresolv.SetDeterministicInTests()()
   241  
   242  			// When
   243  			ctx, cancel := context.WithTimeout(context.Background(), 3*clock.Second)
   244  			defer cancel()
   245  			mxHosts, implicitMX, err := mxresolv.Lookup(ctx, tc.inDomainName)
   246  			// Then
   247  			assert.NoError(t, err)
   248  			assert.Equal(t, tc.outMXHosts, mxHosts)
   249  			assert.Equal(t, tc.outImplicitMX, implicitMX)
   250  		})
   251  	}
   252  }
   253  
   254  func TestLookupRegression(t *testing.T) {
   255  	defer mxresolv.SetDeterministicInTests()()
   256  	mxresolv.ResetCache()
   257  
   258  	// When
   259  	ctx, cancel := context.WithTimeout(context.Background(), 3*clock.Second)
   260  	defer cancel()
   261  
   262  	mxHosts, explictMX, err := mxresolv.Lookup(ctx, "test-mx.definbox.com")
   263  	// Then
   264  	require.NoError(t, err)
   265  	assert.Equal(t, []string{
   266  		/* 1 */ "mxa.definbox.com", "mxi.definbox.com", "mxe.definbox.com",
   267  		/* 2 */ "mxc.definbox.com",
   268  		/* 3 */ "mxb.definbox.com", "mxf.definbox.com", "mxh.definbox.com", "mxd.definbox.com", "mxg.definbox.com",
   269  	}, mxHosts)
   270  	assert.Equal(t, false, explictMX)
   271  
   272  	// The second lookup returns the cached result, the cached result is shuffled.
   273  	mxHosts, explictMX, err = mxresolv.Lookup(ctx, "test-mx.definbox.com")
   274  	require.NoError(t, err)
   275  	assert.Equal(t, []string{
   276  		/* 1 */ "mxe.definbox.com", "mxi.definbox.com", "mxa.definbox.com",
   277  		/* 2 */ "mxc.definbox.com",
   278  		/* 3 */ "mxh.definbox.com", "mxf.definbox.com", "mxg.definbox.com", "mxd.definbox.com", "mxb.definbox.com",
   279  	}, mxHosts)
   280  	assert.Equal(t, false, explictMX)
   281  
   282  	mxHosts, _, err = mxresolv.Lookup(ctx, "definbox.com")
   283  	require.NoError(t, err)
   284  	assert.Equal(t, []string{"mxb.ninomail.com", "mxa.ninomail.com"}, mxHosts)
   285  
   286  	// Should always prefer mxb over mxa since mxb has a lower pref than mxa
   287  	for i := 0; i < 100; i++ {
   288  		mxHosts, _, err = mxresolv.Lookup(ctx, "prefer.example.com")
   289  		require.NoError(t, err)
   290  		assert.Equal(t, []string{"mxb.example.com", "mxa.example.com"}, mxHosts)
   291  	}
   292  
   293  	// Should randomly order mxa and mxb. We make lookup 10 times and make sure
   294  	// that the returned result is not always the same.
   295  	mxHosts, _, err = mxresolv.Lookup(ctx, "prefer3.example.com")
   296  	require.NoError(t, err)
   297  	assert.Equal(t, []string{"mxb.example.com", "mxa.example.com", "mxc.example.com"}, mxHosts)
   298  	sameCount := 0
   299  	for i := 0; i < 10; i++ {
   300  		mxHosts2, _, err := mxresolv.Lookup(ctx, "prefer3.example.com")
   301  		assert.NoError(t, err)
   302  		if reflect.DeepEqual(mxHosts, mxHosts2) {
   303  			sameCount++
   304  		}
   305  	}
   306  	assert.Less(t, sameCount, 10)
   307  
   308  	// mxc.example.com should always be last as it has a different priority,
   309  	// than the other two.
   310  	for i := 0; i < 100; i++ {
   311  		mxHosts, _, err = mxresolv.Lookup(ctx, "prefer3.example.com")
   312  		require.NoError(t, err)
   313  		assert.Equal(t, "mxc.example.com", mxHosts[2])
   314  	}
   315  }
   316  
   317  func TestLookupError(t *testing.T) {
   318  	for _, tc := range []struct {
   319  		desc         string
   320  		inDomainName string
   321  		outError     string
   322  	}{
   323  		{
   324  			inDomainName: "test-bogus.definbox.com",
   325  			outError:     "lookup test-bogus.definbox.com.*: no such host",
   326  		},
   327  		{
   328  			inDomainName: "",
   329  			outError:     "lookup : no such host",
   330  		},
   331  		{
   332  			inDomainName: "kaboom",
   333  			outError:     "lookup kaboom.*: no such host",
   334  		},
   335  		{
   336  			// MX  0  .
   337  			inDomainName: "example.com",
   338  			outError:     "domain accepts no mail",
   339  		},
   340  		{
   341  			// MX  10  0.0.0.0.
   342  			inDomainName: "test-mx-zero.definbox.com",
   343  			outError:     "domain accepts no mail",
   344  		},
   345  	} {
   346  		t.Run(tc.inDomainName, func(t *testing.T) {
   347  			// When
   348  			ctx, cancel := context.WithTimeout(context.Background(), 3*clock.Second)
   349  			defer cancel()
   350  			_, _, err := mxresolv.Lookup(ctx, tc.inDomainName)
   351  
   352  			// Then
   353  			require.Error(t, err)
   354  			assert.Regexp(t, regexp.MustCompile(tc.outError), err.Error())
   355  
   356  			gotTemporary := false
   357  			var temporary interface{ Temporary() bool }
   358  			if errors.As(err, &temporary) {
   359  				gotTemporary = temporary.Temporary()
   360  			}
   361  			assert.False(t, gotTemporary)
   362  
   363  			// The second lookup returns the cached result, that only shows on the
   364  			// coverage report.
   365  			_, _, err = mxresolv.Lookup(ctx, tc.inDomainName)
   366  			assert.Regexp(t, regexp.MustCompile(tc.outError), err.Error())
   367  		})
   368  	}
   369  }
   370  
   371  // Shuffling does not cross preference group boundaries.
   372  //
   373  // Preference groups are:
   374  //
   375  //	1: mxa.definbox.com, mxe.definbox.com, mxi.definbox.com
   376  //	2: mxc.definbox.com
   377  //	3: mxb.definbox.com, mxd.definbox.com, mxf.definbox.com, mxg.definbox.com, mxh.definbox.com
   378  func TestLookupShuffle(t *testing.T) {
   379  	defer mxresolv.SetDeterministicInTests()()
   380  
   381  	// When
   382  	ctx, cancel := context.WithTimeout(context.Background(), 3*clock.Second)
   383  	defer cancel()
   384  	shuffle1, _, err := mxresolv.Lookup(ctx, "test-mx.definbox.com")
   385  	assert.NoError(t, err)
   386  	shuffle2, _, err := mxresolv.Lookup(ctx, "test-mx.definbox.com")
   387  	assert.NoError(t, err)
   388  
   389  	// Then
   390  	assert.NotEqual(t, shuffle1[:3], shuffle2[:3])
   391  	assert.NotEqual(t, shuffle1[4:], shuffle2[4:])
   392  
   393  	sort.Strings(shuffle1[:3])
   394  	sort.Strings(shuffle2[:3])
   395  	assert.Equal(t, []string{"mxa.definbox.com", "mxe.definbox.com", "mxi.definbox.com"}, shuffle1[:3])
   396  	assert.Equal(t, shuffle1[:3], shuffle2[:3])
   397  
   398  	assert.Equal(t, "mxc.definbox.com", shuffle1[3])
   399  	assert.Equal(t, shuffle1[3], shuffle2[3])
   400  
   401  	sort.Strings(shuffle1[4:])
   402  	sort.Strings(shuffle2[4:])
   403  	assert.Equal(t, []string{"mxb.definbox.com", "mxd.definbox.com", "mxf.definbox.com",
   404  		"mxg.definbox.com", "mxh.definbox.com"}, shuffle1[4:])
   405  	assert.Equal(t, shuffle1[4:], shuffle2[4:])
   406  }
   407  
   408  func TestDistribution(t *testing.T) {
   409  	mxresolv.ResetCache()
   410  
   411  	// 2 host distribution should be uniform
   412  	dist := make(map[string]int, 2)
   413  	for i := 0; i < 1000; i++ {
   414  		s, _, _ := mxresolv.Lookup(context.Background(), "definbox.com")
   415  		_, ok := dist[s[0]]
   416  		if ok {
   417  			dist[s[0]] += 1
   418  		} else {
   419  			dist[s[0]] = 0
   420  		}
   421  	}
   422  
   423  	assertDistribution(t, dist, 35.0)
   424  
   425  	dist = make(map[string]int, 3)
   426  	for i := 0; i < 1000; i++ {
   427  		s, _, _ := mxresolv.Lookup(context.Background(), "test-mx.definbox.com")
   428  		_, ok := dist[s[0]]
   429  		if ok {
   430  			dist[s[0]] += 1
   431  		} else {
   432  			dist[s[0]] = 0
   433  		}
   434  	}
   435  	assertDistribution(t, dist, 35.0)
   436  
   437  	// This is what a standard distribution looks like when 3 hosts have the same MX priority
   438  	// spew.Dump(dist)
   439  	// (map[string]int) (len=3) {
   440  	// 	(string) (len=16) "mxa.definbox.com": (int) 324,
   441  	//	(string) (len=16) "mxe.definbox.com": (int) 359,
   442  	//	(string) (len=16) "mxi.definbox.com": (int) 314
   443  	// }
   444  }
   445  
   446  // Golang optimizes the allocation so there is no hit to performance or memory usage when calling
   447  // `rand.New()` for each call to `shuffleNew()` over `rand.Shuffle()` which has a mutex.
   448  //
   449  // pkg: github.com/mailgun/holster/v4/mxresolv
   450  // BenchmarkShuffleWithNew
   451  // BenchmarkShuffleWithNew-10    	   61962	     18434 ns/op	    5376 B/op	       1 allocs/op
   452  // BenchmarkShuffleGlobal
   453  // BenchmarkShuffleGlobal-10     	   65205	     18480 ns/op	       0 B/op	       0 allocs/op
   454  func BenchmarkShuffleWithNew(b *testing.B) {
   455  	for n := b.N; n > 0; n-- {
   456  		shuffleNew()
   457  	}
   458  	b.ReportAllocs()
   459  }
   460  
   461  func BenchmarkShuffleGlobal(b *testing.B) {
   462  	for n := b.N; n > 0; n-- {
   463  		shuffleGlobal()
   464  	}
   465  	b.ReportAllocs()
   466  }
   467  
   468  func shuffleNew() {
   469  	r := rand.New(rand.NewSource(time.Now().UnixNano()))
   470  	r.Shuffle(52, func(i, j int) {})
   471  }
   472  
   473  func shuffleGlobal() {
   474  	rand.Shuffle(52, func(i, j int) {})
   475  }
   476  
   477  func assertDistribution(t *testing.T, dist map[string]int, expected float64) {
   478  	t.Helper()
   479  
   480  	// Calculate the mean of the distribution
   481  	var sum int
   482  	for _, value := range dist {
   483  		sum += value
   484  	}
   485  	mean := float64(sum) / float64(len(dist))
   486  
   487  	// Calculate the sum of squared differences
   488  	var squaredDifferences float64
   489  	for _, value := range dist {
   490  		diff := float64(value) - mean
   491  		squaredDifferences += diff * diff
   492  	}
   493  
   494  	// Calculate the variance and standard deviation
   495  	variance := squaredDifferences / float64(len(dist))
   496  	stdDev := math.Sqrt(variance)
   497  
   498  	// The distribution of random hosts chosen should not exceed 35
   499  	assert.False(t, stdDev > expected,
   500  		fmt.Sprintf("Standard deviation is greater than %f:", expected)+"%.2f", stdDev)
   501  
   502  }