github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/tenants/manager_test.go (about)

     1  package tenants
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"testing"
     8  	"time"
     9  
    10  	"github.com/grafana/synthetic-monitoring-agent/internal/cache"
    11  	sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring"
    12  	"github.com/rs/zerolog"
    13  	"github.com/stretchr/testify/require"
    14  	"google.golang.org/grpc"
    15  )
    16  
    17  type testTenantsClient struct {
    18  	tenants      map[int64]sm.Tenant
    19  	requestCount map[int64]int
    20  	err          error
    21  }
    22  
    23  var errTenantNotFound = errors.New("tenant not found")
    24  
    25  func (c *testTenantsClient) GetTenant(ctx context.Context, in *sm.TenantInfo, opts ...grpc.CallOption) (*sm.Tenant, error) {
    26  	c.requestCount[in.Id]++
    27  
    28  	if c.err != nil {
    29  		return nil, c.err
    30  	}
    31  
    32  	tenant, found := c.tenants[in.Id]
    33  	if !found {
    34  		return nil, errTenantNotFound
    35  	}
    36  
    37  	return &tenant, nil
    38  }
    39  
    40  func makeTenant(idx int) sm.Tenant {
    41  	return sm.Tenant{
    42  		Id:    int64(idx),
    43  		OrgId: int64(idx * 1000),
    44  		MetricsRemote: &sm.RemoteInfo{
    45  			Name:     fmt.Sprintf("test-%d", idx),
    46  			Url:      fmt.Sprintf("http://127.0.0.1/%d", idx),
    47  			Username: fmt.Sprintf("user-%d", idx),
    48  			Password: fmt.Sprintf("pw-%d", idx),
    49  		},
    50  	}
    51  }
    52  
    53  func TestTenantManagerGetTenant(t *testing.T) {
    54  	tc := testTenantsClient{
    55  		tenants: map[int64]sm.Tenant{
    56  			1: makeTenant(1),
    57  		},
    58  		requestCount: make(map[int64]int),
    59  	}
    60  
    61  	deadline, hasTimeout := t.Deadline()
    62  	if !hasTimeout {
    63  		deadline = time.Now().Add(5 * time.Second)
    64  	}
    65  
    66  	ctx, cancel := context.WithDeadline(context.Background(), deadline)
    67  	defer cancel()
    68  
    69  	tenantCh := make(chan sm.Tenant)
    70  	cacheExpirationTime := 200 * time.Millisecond
    71  	maxCacheExpirationTime := cacheExpirationTime + time.Duration(defaultCacheJitter*float64(cacheExpirationTime))
    72  	logger := zerolog.New(zerolog.NewTestWriter(t))
    73  
    74  	// Create a local cache for testing
    75  	// Use DefaultTTL = 0 to disable automatic expiration, allowing us to test
    76  	// the stale data fallback behavior (ValidUntil is checked manually)
    77  	localCache, err := cache.NewLocal(cache.LocalConfig{
    78  		MaxCapacity:     100,
    79  		InitialCapacity: 10,
    80  		DefaultTTL:      0, // Disable automatic expiration
    81  		Logger:          logger,
    82  	})
    83  	require.NoError(t, err)
    84  
    85  	defer localCache.Close()
    86  
    87  	tm := NewManager(ctx, &tc, tenantCh, cacheExpirationTime, localCache, logger)
    88  
    89  	t1 := tc.tenants[1]
    90  
    91  	// requesting an existent tenant should return it
    92  	tenant, err := tm.GetTenant(ctx, &sm.TenantInfo{Id: t1.Id})
    93  	require.NoError(t, err)
    94  	require.NotNil(t, tenant)
    95  	require.Equal(t, 1, tc.requestCount[t1.Id])
    96  	require.Equal(t, t1, *tenant)
    97  
    98  	// requesting the same tenant within a short period of time
    99  	// should not cause a new request to the server, so the count
   100  	// should remain at 1.
   101  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t1.Id})
   102  	require.NoError(t, err)
   103  	require.NotNil(t, tenant)
   104  	require.Equal(t, 1, tc.requestCount[t1.Id])
   105  	require.Equal(t, t1, *tenant)
   106  
   107  	// requesting the same tenant after a longer time should evict
   108  	// the existing tenant and make a new request; make sure we are
   109  	// not getting a cached copy.
   110  	time.Sleep(maxCacheExpirationTime)
   111  
   112  	t1.MetricsRemote.Password += "-new"
   113  
   114  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t1.Id})
   115  	require.NoError(t, err)
   116  	require.NotNil(t, tenant)
   117  	require.Equal(t, 2, tc.requestCount[t1.Id])
   118  	require.Equal(t, t1, *tenant)
   119  
   120  	// create a new tenant but don't insert it in the cache
   121  	t2 := makeTenant(2)
   122  
   123  	// requesting a non-existent tenant should return an error
   124  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t2.Id})
   125  	require.Error(t, err)
   126  	require.Equal(t, errTenantNotFound, err)
   127  	require.Nil(t, tenant)
   128  	require.Equal(t, 1, tc.requestCount[t2.Id])
   129  
   130  	// negative responses should not be cached
   131  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t2.Id})
   132  	require.Error(t, err)
   133  	require.Equal(t, errTenantNotFound, err)
   134  	require.Nil(t, tenant)
   135  	require.Equal(t, 2, tc.requestCount[t2.Id])
   136  
   137  	// after adding the tenant, a new request for that tenant should
   138  	// return the correct information
   139  	tc.tenants[2] = t2
   140  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t2.Id})
   141  	require.NoError(t, err)
   142  	require.NotNil(t, tenant)
   143  	require.Equal(t, 3, tc.requestCount[t2.Id])
   144  	require.Equal(t, t2, *tenant)
   145  
   146  	// create a new tenant and send it over the channel to the
   147  	// tenant manager
   148  	t3 := makeTenant(3)
   149  
   150  	tenantCh <- t3
   151  	// Give the manager goroutine some time to process the channel message
   152  	// before we start checking. This reduces unnecessary API calls.
   153  	time.Sleep(10 * time.Millisecond)
   154  
   155  	// Now check if the tenant was added to the cache via the channel
   156  	// by verifying that GetTenant succeeds without making an API request
   157  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t3.Id})
   158  	require.NoError(t, err)
   159  	require.NotNil(t, tenant)
   160  	require.Equal(t, 0, tc.requestCount[t3.Id], "should not have made API request for tenant received via channel")
   161  	require.Equal(t, t3, *tenant)
   162  
   163  	// wait for tenants to expire
   164  	time.Sleep(maxCacheExpirationTime)
   165  	// force tenants client to return an error
   166  	tc.err = errors.New("network error")
   167  
   168  	// if a tenant is present in the cache, this one should be returned, even
   169  	// if it is expired, in case new data can not be retrieved from the API
   170  	tenant, err = tm.GetTenant(ctx, &sm.TenantInfo{Id: t3.Id})
   171  	require.NoError(t, err)
   172  	require.NotNil(t, tenant)
   173  	require.Equal(t, 1, tc.requestCount[t3.Id])
   174  	require.Equal(t, t3, *tenant)
   175  }
   176  
   177  func TestCalculateValidUntil(t *testing.T) {
   178  	var (
   179  		now = time.Now()
   180  		// compute the relative error if there's a 1 second difference
   181  		// between the time we expect (which is around now+timeout) and
   182  		// a time that is 1 second later. In other words, tolare a
   183  		// relative difference of about 1 second in the results we get.
   184  		defEpsilon = float64(now.Add(1*time.Second).UnixNano()-now.UnixNano()) / float64(now.UnixNano())
   185  		// For cases on which the secret store expiraiton is not taken into
   186  		// account, a jitter is applied to the timeout.
   187  		jitterEpsilon = defaultCacheJitter
   188  	)
   189  
   190  	// Some tests below assume this is true.
   191  	require.Greater(t, sm.MaxScriptedTimeout, 2*time.Minute)
   192  	require.Less(t, sm.MaxScriptedTimeout, 5*time.Minute)
   193  
   194  	testCases := map[string]struct {
   195  		timeout time.Duration
   196  		tenant  *sm.Tenant
   197  		want    time.Duration
   198  		epsilon float64
   199  	}{
   200  		"10 minute timeout, no secret store": {
   201  			timeout: 10 * time.Minute,
   202  			tenant:  &sm.Tenant{},
   203  			want:    10 * time.Minute,
   204  			epsilon: jitterEpsilon,
   205  		},
   206  		"1 hour timeout, no secret store": {
   207  			timeout: 1 * time.Hour,
   208  			tenant:  &sm.Tenant{},
   209  			want:    1 * time.Hour,
   210  			epsilon: jitterEpsilon,
   211  		},
   212  		"7.5 minute timeout, secret store expires in 2 minutes (less than MaxScriptedTimeout)": {
   213  			timeout: 7*time.Minute + 30*time.Second,
   214  			tenant: &sm.Tenant{
   215  				SecretStore: &sm.SecretStore{
   216  					Url:    "https://secrets.example.com",
   217  					Token:  "token",
   218  					Expiry: float64(now.Add(2*time.Minute).UnixNano()) / 1e9,
   219  				},
   220  			},
   221  			want:    2 * time.Minute,
   222  			epsilon: defEpsilon,
   223  		},
   224  		"7.5 minute timeout, secret store expires in 5 minutes (more than MaxScriptedTimeout)": {
   225  			timeout: 7*time.Minute + 30*time.Second,
   226  			tenant: &sm.Tenant{
   227  				SecretStore: &sm.SecretStore{
   228  					Url:    "https://secrets.example.com",
   229  					Token:  "token",
   230  					Expiry: float64(now.Add(5*time.Minute).UnixNano()) / 1e9,
   231  				},
   232  			},
   233  			want:    5*time.Minute - sm.MaxScriptedTimeout,
   234  			epsilon: defEpsilon,
   235  		},
   236  		"7.5 minute timeout, secret store expires in 1 hour": {
   237  			timeout: 7*time.Minute + 30*time.Second,
   238  			tenant: &sm.Tenant{
   239  				SecretStore: &sm.SecretStore{
   240  					Url:    "https://secrets.example.com",
   241  					Token:  "token",
   242  					Expiry: float64(now.Add(1*time.Hour).UnixNano()) / 1e9,
   243  				},
   244  			},
   245  			want:    7*time.Minute + 30*time.Second,
   246  			epsilon: defEpsilon,
   247  		},
   248  		// This should not make a difference wrt to the previous tests. Make sure that's the case.
   249  		"10 minute timeout, secret store expires in 2 minutes": {
   250  			timeout: 10 * time.Minute,
   251  			tenant: &sm.Tenant{
   252  				SecretStore: &sm.SecretStore{
   253  					Url:    "https://secrets.example.com",
   254  					Token:  "token",
   255  					Expiry: float64(now.Add(2*time.Minute).UnixNano()) / 1e9,
   256  				},
   257  			},
   258  			want:    2 * time.Minute,
   259  			epsilon: defEpsilon,
   260  		},
   261  		"10 minute timeout, secret store expires in 5 minutes": {
   262  			timeout: 10 * time.Minute,
   263  			tenant: &sm.Tenant{
   264  				SecretStore: &sm.SecretStore{
   265  					Url:    "https://secrets.example.com",
   266  					Token:  "token",
   267  					Expiry: float64(now.Add(5*time.Minute).UnixNano()) / 1e9,
   268  				},
   269  			},
   270  			want:    5*time.Minute - sm.MaxScriptedTimeout,
   271  			epsilon: defEpsilon,
   272  		},
   273  		"10 minute timeout, secret store expires in 10 minutes": {
   274  			timeout: 10 * time.Minute,
   275  			tenant: &sm.Tenant{
   276  				SecretStore: &sm.SecretStore{
   277  					Url:    "https://secrets.example.com",
   278  					Token:  "token",
   279  					Expiry: float64(now.Add(10*time.Minute).UnixNano()) / 1e9,
   280  				},
   281  			},
   282  			want:    10*time.Minute - sm.MaxScriptedTimeout,
   283  			epsilon: defEpsilon,
   284  		},
   285  		"10 minute timeout, secret store expires in 1 hour": {
   286  			timeout: 10 * time.Minute,
   287  			tenant: &sm.Tenant{
   288  				SecretStore: &sm.SecretStore{
   289  					Url:    "https://secrets.example.com",
   290  					Token:  "token",
   291  					Expiry: float64(now.Add(1*time.Hour).UnixNano()) / 1e9,
   292  				},
   293  			},
   294  			want:    10 * time.Minute,
   295  			epsilon: defEpsilon,
   296  		},
   297  		"10 minute timeout, secret store with empty URL (not configured)": {
   298  			timeout: 10 * time.Minute,
   299  			tenant: &sm.Tenant{
   300  				SecretStore: &sm.SecretStore{
   301  					Url:    "",
   302  					Token:  "token",
   303  					Expiry: float64(now.Add(1*time.Hour).UnixNano()) / 1e9,
   304  				},
   305  			},
   306  			want:    0, // Cache should be invalid immediately
   307  			epsilon: defEpsilon,
   308  		},
   309  		"10 minute timeout, secret store with empty token (not configured)": {
   310  			timeout: 10 * time.Minute,
   311  			tenant: &sm.Tenant{
   312  				SecretStore: &sm.SecretStore{
   313  					Url:    "https://secrets.example.com",
   314  					Token:  "",
   315  					Expiry: float64(now.Add(1*time.Hour).UnixNano()) / 1e9,
   316  				},
   317  			},
   318  			want:    0, // Cache should be invalid immediately
   319  			epsilon: defEpsilon,
   320  		},
   321  		"10 minute timeout, secret store with both URL and token empty (not configured)": {
   322  			timeout: 10 * time.Minute,
   323  			tenant: &sm.Tenant{
   324  				SecretStore: &sm.SecretStore{
   325  					Url:    "",
   326  					Token:  "",
   327  					Expiry: float64(now.Add(1*time.Hour).UnixNano()) / 1e9,
   328  				},
   329  			},
   330  			want:    0, // Cache should be invalid immediately
   331  			epsilon: defEpsilon,
   332  		},
   333  	}
   334  
   335  	for name, tc := range testCases {
   336  		t.Run(name, func(t *testing.T) {
   337  			tm := &Manager{
   338  				timeout: tc.timeout,
   339  			}
   340  
   341  			expected := time.Now().Add(tc.want)
   342  			actual := tm.calculateValidUntil(tc.tenant)
   343  
   344  			require.InEpsilonf(t, expected.UnixNano(), actual.UnixNano(), tc.epsilon,
   345  				"calculateValidUntil() should be within range. expected: %d, actual: %d, delta: %d",
   346  				expected.Sub(now).Milliseconds(),
   347  				actual.Sub(now).Milliseconds(),
   348  				expected.UnixMilli()-actual.UnixMilli(),
   349  			)
   350  		})
   351  	}
   352  }