/* Copyright IBM Corp. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ package discovery import ( "encoding/asn1" "errors" "sync" "testing" "github.com/hyperledger/fabric/protoutil" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) func TestSignedDataToKey(t *testing.T) { key1, err1 := signedDataToKey(protoutil.SignedData{ Data: []byte{1, 2, 3, 4}, Identity: []byte{5, 6, 7}, Signature: []byte{8, 9}, }) key2, err2 := signedDataToKey(protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte{4, 5, 6}, Signature: []byte{7, 8, 9}, }) require.NoError(t, err1) require.NoError(t, err2) require.NotEqual(t, key1, key2) } type mockAcSupport struct { mock.Mock } func (as *mockAcSupport) EligibleForService(channel string, data protoutil.SignedData) error { return as.Called(channel, data).Error(0) } func (as *mockAcSupport) ConfigSequence(channel string) uint64 { return as.Called(channel).Get(0).(uint64) } func TestCacheDisabled(t *testing.T) { sd := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte("authorizedIdentity"), Signature: []byte{1, 2, 3}, } as := &mockAcSupport{} as.On("ConfigSequence", "foo").Return(uint64(0)) as.On("EligibleForService", "foo", sd).Return(nil) cache := newAuthCache(as, authCacheConfig{maxCacheSize: 100, purgeRetentionRatio: 0.5}) // Call the cache twice with the same argument and ensure the call isn't cached cache.EligibleForService("foo", sd) cache.EligibleForService("foo", sd) as.AssertNumberOfCalls(t, "EligibleForService", 2) } func TestCacheUsage(t *testing.T) { as := &mockAcSupport{} as.On("ConfigSequence", "foo").Return(uint64(0)) as.On("ConfigSequence", "bar").Return(uint64(0)) cache := newAuthCache(as, defaultConfig()) sd1 := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte("authorizedIdentity"), Signature: []byte{1, 2, 3}, } sd2 := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte("authorizedIdentity"), Signature: []byte{1, 2, 3}, } sd3 := protoutil.SignedData{ Data: []byte{1, 2, 3, 3}, Identity: []byte("unAuthorizedIdentity"), Signature: []byte{1, 2, 3}, } testCases := []struct { channel string expectedErr error sd protoutil.SignedData }{ { sd: sd1, channel: "foo", }, { sd: sd2, channel: "bar", }, { channel: "bar", sd: sd3, expectedErr: errors.New("user revoked"), }, } for _, tst := range testCases { // Scenario I: Invocation is not cached invoked := false as.On("EligibleForService", tst.channel, tst.sd).Return(tst.expectedErr).Once().Run(func(_ mock.Arguments) { invoked = true }) t.Run("Not cached test", func(t *testing.T) { err := cache.EligibleForService(tst.channel, tst.sd) if tst.expectedErr == nil { require.NoError(t, err) } else { require.Equal(t, tst.expectedErr.Error(), err.Error()) } require.True(t, invoked) // Reset invoked to false for next test invoked = false }) // Scenario II: Invocation is cached. // We don't define the mock invocation because it should be the same as last time. // If the cache isn't used, the test would fail because the mock wasn't defined t.Run("Cached test", func(t *testing.T) { err := cache.EligibleForService(tst.channel, tst.sd) if tst.expectedErr == nil { require.NoError(t, err) } else { require.Equal(t, tst.expectedErr.Error(), err.Error()) } require.False(t, invoked) }) } } func TestCacheMarshalFailure(t *testing.T) { as := &mockAcSupport{} cache := newAuthCache(as, defaultConfig()) asBytes = func(_ interface{}) ([]byte, error) { return nil, errors.New("failed marshaling ASN1") } defer func() { asBytes = asn1.Marshal }() err := cache.EligibleForService("mychannel", protoutil.SignedData{}) require.Contains(t, err.Error(), "failed marshaling ASN1") } func TestCacheConfigChange(t *testing.T) { as := &mockAcSupport{} sd := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte("identity"), Signature: []byte{1, 2, 3}, } cache := newAuthCache(as, defaultConfig()) // Scenario I: At first, the identity is authorized as.On("EligibleForService", "mychannel", sd).Return(nil).Once() as.On("ConfigSequence", "mychannel").Return(uint64(0)).Times(2) err := cache.EligibleForService("mychannel", sd) require.NoError(t, err) // Scenario II: The identity is still authorized, and config hasn't changed yet. // Result should be cached as.On("ConfigSequence", "mychannel").Return(uint64(0)).Once() err = cache.EligibleForService("mychannel", sd) require.NoError(t, err) // Scenario III: A config change occurred, cache should be disregarded as.On("ConfigSequence", "mychannel").Return(uint64(1)).Times(2) as.On("EligibleForService", "mychannel", sd).Return(errors.New("unauthorized")).Once() err = cache.EligibleForService("mychannel", sd) require.Contains(t, err.Error(), "unauthorized") } func TestCachePurgeCache(t *testing.T) { as := &mockAcSupport{} cache := newAuthCache(as, authCacheConfig{maxCacheSize: 4, purgeRetentionRatio: 0.75, enabled: true}) as.On("ConfigSequence", "mychannel").Return(uint64(0)) // Warm up the cache - attempt to place 4 identities to fill it up for _, id := range []string{"identity1", "identity2", "identity3", "identity4"} { sd := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte(id), Signature: []byte{1, 2, 3}, } // At first, all identities are eligible of the service as.On("EligibleForService", "mychannel", sd).Return(nil).Once() err := cache.EligibleForService("mychannel", sd) require.NoError(t, err) } // Now, ensure that at least 1 of the identities was evicted from the cache, but not all var evicted int for _, id := range []string{"identity5", "identity1", "identity2"} { sd := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte(id), Signature: []byte{1, 2, 3}, } as.On("EligibleForService", "mychannel", sd).Return(errors.New("unauthorized")).Once() err := cache.EligibleForService("mychannel", sd) if err != nil { evicted++ } } require.True(t, evicted > 0 && evicted < 4, "evicted: %d, but expected between 1 and 3 evictions", evicted) } func TestCacheConcurrentConfigUpdate(t *testing.T) { // Scenario: 2 requests for the same identity are made concurrently. // Both are not cached, and thus their computation results might both enter the cache. // The first request enters when the config sequence is 0, and a config update // that revokes the identity takes place in the same time the access control check of the first request is evaluated. // The first request's computation is stalled because of scheduling, and completes after the second, // which happens after the config update takes place. // The second request's computation result should not be overridden by the computation result // of the first request although the first request's computation completes after the second request. as := &mockAcSupport{} sd := protoutil.SignedData{ Data: []byte{1, 2, 3}, Identity: []byte{1, 2, 3}, Signature: []byte{1, 2, 3}, } var firstRequestInvoked sync.WaitGroup firstRequestInvoked.Add(1) var firstRequestFinished sync.WaitGroup firstRequestFinished.Add(1) var secondRequestFinished sync.WaitGroup secondRequestFinished.Add(1) cache := newAuthCache(as, defaultConfig()) // At first, the identity is eligible. as.On("EligibleForService", "mychannel", mock.Anything).Return(nil).Once().Run(func(_ mock.Arguments) { firstRequestInvoked.Done() secondRequestFinished.Wait() }) // But after the config change, it is not as.On("EligibleForService", "mychannel", mock.Anything).Return(errors.New("unauthorized")).Once() // The config sequence the first request sees as.On("ConfigSequence", "mychannel").Return(uint64(0)).Once() as.On("ConfigSequence", "mychannel").Return(uint64(1)).Once() // The config sequence the second request sees as.On("ConfigSequence", "mychannel").Return(uint64(1)).Times(2) // First request returns OK go func() { defer firstRequestFinished.Done() firstResult := cache.EligibleForService("mychannel", sd) require.NoError(t, firstResult) }() firstRequestInvoked.Wait() // Second request returns that the identity isn't authorized secondResult := cache.EligibleForService("mychannel", sd) // Mark second request as finished to signal first request to finish its computation secondRequestFinished.Done() // Wait for first request to return firstRequestFinished.Wait() require.Contains(t, secondResult.Error(), "unauthorized") // Now make another request and ensure that the second request's result (an-authorized) was cached, // even though it finished before the first request. as.On("ConfigSequence", "mychannel").Return(uint64(1)).Once() cachedResult := cache.EligibleForService("mychannel", sd) require.Contains(t, cachedResult.Error(), "unauthorized") } func defaultConfig() authCacheConfig { return authCacheConfig{maxCacheSize: defaultMaxCacheSize, purgeRetentionRatio: defaultRetentionRatio, enabled: true} }