281 lines
9.0 KiB
Go
281 lines
9.0 KiB
Go
/*
|
|
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}
|
|
}
|