consul/agent/grpc-external/services/resource/read_test.go

349 lines
11 KiB
Go
Raw Normal View History

// Copyright (c) HashiCorp, Inc.
[COMPLIANCE] License changes (#18443) * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Adding explicit MPL license for sub-package This directory and its subdirectories (packages) contain files licensed with the MPLv2 `LICENSE` file in this directory and are intentionally licensed separately from the BSL `LICENSE` file at the root of this repository. * Updating the license from MPL to Business Source License Going forward, this project will be licensed under the Business Source License v1.1. Please see our blog post for more details at <Blog URL>, FAQ at www.hashicorp.com/licensing-faq, and details of the license at www.hashicorp.com/bsl. * add missing license headers * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 * Update copyright file headers to BUSL-1.1 --------- Co-authored-by: hashicorp-copywrite[bot] <110428419+hashicorp-copywrite[bot]@users.noreply.github.com>
2023-08-11 13:12:13 +00:00
// SPDX-License-Identifier: BUSL-1.1
package resource
import (
"context"
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/acl/resolver"
"github.com/hashicorp/consul/agent/grpc-external/testutils"
"github.com/hashicorp/consul/internal/resource"
2023-04-06 09:40:04 +00:00
"github.com/hashicorp/consul/internal/resource/demo"
"github.com/hashicorp/consul/internal/storage"
"github.com/hashicorp/consul/proto-public/pbresource"
"github.com/hashicorp/consul/proto/private/prototest"
"github.com/hashicorp/consul/sdk/testutil"
)
func TestRead_InputValidation(t *testing.T) {
server := testServer(t)
client := testClient(t, server)
2023-04-25 11:52:35 +00:00
demo.RegisterTypes(server.Registry)
testCases := map[string]func(artistId, recordlabelId *pbresource.ID) *pbresource.ID{
"no id": func(artistId, recordLabelId *pbresource.ID) *pbresource.ID { return nil },
"no type": func(artistId, _ *pbresource.ID) *pbresource.ID {
artistId.Type = nil
return artistId
},
"no name": func(artistId, _ *pbresource.ID) *pbresource.ID {
artistId.Name = ""
return artistId
},
"partition scope with non-empty namespace": func(_, recordLabelId *pbresource.ID) *pbresource.ID {
recordLabelId.Tenancy.Namespace = "ishouldnothaveanamespace"
return recordLabelId
},
}
for desc, modFn := range testCases {
t.Run(desc, func(t *testing.T) {
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
recordLabel, err := demo.GenerateV1RecordLabel("LoonyTunes")
require.NoError(t, err)
// Each test case picks which resource to use based on the resource type's scope.
req := &pbresource.ReadRequest{Id: modFn(artist.Id, recordLabel.Id)}
_, err = client.Read(testContext(t), req)
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
})
}
}
func TestRead_TypeNotFound(t *testing.T) {
2023-04-06 09:40:04 +00:00
server := NewServer(Config{Registry: resource.NewRegistry()})
client := testClient(t, server)
2023-04-06 09:40:04 +00:00
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
_, err = client.Read(context.Background(), &pbresource.ReadRequest{Id: artist.Id})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.Contains(t, err.Error(), "resource type demo.v2.Artist not registered")
}
func TestRead_ResourceNotFound(t *testing.T) {
for desc, tc := range readTestCases() {
t.Run(desc, func(t *testing.T) {
tenancyCases := map[string]func(artistId, recordlabelId *pbresource.ID) *pbresource.ID{
"resource not found by name": func(artistId, _ *pbresource.ID) *pbresource.ID {
artistId.Name = "bogusname"
return artistId
},
"partition not found when namespace scoped": func(artistId, _ *pbresource.ID) *pbresource.ID {
id := clone(artistId)
id.Tenancy.Partition = "boguspartition"
return id
},
"namespace not found when namespace scoped": func(artistId, _ *pbresource.ID) *pbresource.ID {
id := clone(artistId)
id.Tenancy.Namespace = "bogusnamespace"
return id
},
"partition not found when partition scoped": func(_, recordLabelId *pbresource.ID) *pbresource.ID {
id := clone(recordLabelId)
id.Tenancy.Partition = "boguspartition"
return id
},
}
for tenancyDesc, modFn := range tenancyCases {
t.Run(tenancyDesc, func(t *testing.T) {
server := testServer(t)
demo.RegisterTypes(server.Registry)
client := testClient(t, server)
recordLabel, err := demo.GenerateV1RecordLabel("LoonyTunes")
require.NoError(t, err)
recordLabel, err = server.Backend.WriteCAS(tc.ctx, recordLabel)
require.NoError(t, err)
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
artist, err = server.Backend.WriteCAS(tc.ctx, artist)
require.NoError(t, err)
// Each tenancy test case picks which resource to use based on the resource type's scope.
_, err = client.Read(tc.ctx, &pbresource.ReadRequest{Id: modFn(artist.Id, recordLabel.Id)})
require.Error(t, err)
require.Equal(t, codes.NotFound.String(), status.Code(err).String())
require.Contains(t, err.Error(), "resource not found")
})
}
})
}
}
func TestRead_GroupVersionMismatch(t *testing.T) {
for desc, tc := range readTestCases() {
t.Run(desc, func(t *testing.T) {
server := testServer(t)
2023-04-25 11:52:35 +00:00
demo.RegisterTypes(server.Registry)
client := testClient(t, server)
2023-04-06 09:40:04 +00:00
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
2023-04-06 09:40:04 +00:00
_, err = server.Backend.WriteCAS(tc.ctx, artist)
require.NoError(t, err)
id := clone(artist.Id)
id.Type = demo.TypeV1Artist
_, err = client.Read(tc.ctx, &pbresource.ReadRequest{Id: id})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.Contains(t, err.Error(), "resource was requested with GroupVersion")
})
}
}
func TestRead_Success(t *testing.T) {
for desc, tc := range readTestCases() {
t.Run(desc, func(t *testing.T) {
for tenancyDesc, modFn := range tenancyCases() {
t.Run(tenancyDesc, func(t *testing.T) {
server := testServer(t)
demo.RegisterTypes(server.Registry)
client := testClient(t, server)
recordLabel, err := demo.GenerateV1RecordLabel("LoonyTunes")
require.NoError(t, err)
recordLabel, err = server.Backend.WriteCAS(tc.ctx, recordLabel)
require.NoError(t, err)
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
artist, err = server.Backend.WriteCAS(tc.ctx, artist)
require.NoError(t, err)
// Each tenancy test case picks which resource to use based on the resource type's scope.
req := &pbresource.ReadRequest{Id: modFn(artist.Id, recordLabel.Id)}
rsp, err := client.Read(tc.ctx, req)
require.NoError(t, err)
switch {
case proto.Equal(rsp.Resource.Id.Type, demo.TypeV2Artist):
prototest.AssertDeepEqual(t, artist, rsp.Resource)
case proto.Equal(rsp.Resource.Id.Type, demo.TypeV1RecordLabel):
prototest.AssertDeepEqual(t, recordLabel, rsp.Resource)
default:
require.Fail(t, "unexpected resource type")
}
})
}
})
}
}
func TestRead_VerifyReadConsistencyArg(t *testing.T) {
// Uses a mockBackend instead of the inmem Backend to verify the ReadConsistency argument is set correctly.
for desc, tc := range readTestCases() {
t.Run(desc, func(t *testing.T) {
server := testServer(t)
mockBackend := NewMockBackend(t)
server.Backend = mockBackend
2023-04-25 11:52:35 +00:00
demo.RegisterTypes(server.Registry)
2023-04-06 09:40:04 +00:00
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
mockBackend.On("Read", mock.Anything, mock.Anything, mock.Anything).Return(artist, nil)
client := testClient(t, server)
2023-04-06 09:40:04 +00:00
rsp, err := client.Read(tc.ctx, &pbresource.ReadRequest{Id: artist.Id})
require.NoError(t, err)
2023-04-06 09:40:04 +00:00
prototest.AssertDeepEqual(t, artist, rsp.Resource)
mockBackend.AssertCalled(t, "Read", mock.Anything, tc.consistency, mock.Anything)
})
}
}
2023-04-25 11:52:35 +00:00
// N.B. Uses key ACLs for now. See demo.RegisterTypes()
func TestRead_ACLs(t *testing.T) {
type testCase struct {
res *pbresource.Resource
authz resolver.Result
codeNotExist codes.Code
codeExists codes.Code
}
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
label, err := demo.GenerateV1RecordLabel("blink1982")
require.NoError(t, err)
testcases := map[string]testCase{
"artist-v1/read hook denied": {
res: artist,
authz: AuthorizerFrom(t, demo.ArtistV1ReadPolicy),
codeNotExist: codes.PermissionDenied,
codeExists: codes.PermissionDenied,
},
"artist-v2/read hook allowed": {
res: artist,
authz: AuthorizerFrom(t, demo.ArtistV2ReadPolicy),
codeNotExist: codes.NotFound,
codeExists: codes.OK,
},
// Labels have the read ACL that requires reading the data.
"label-v1/read hook denied": {
res: label,
authz: AuthorizerFrom(t, demo.LabelV1ReadPolicy),
codeNotExist: codes.NotFound,
codeExists: codes.PermissionDenied,
},
}
adminAuthz := AuthorizerFrom(t, `key_prefix "" { policy = "write" }`)
idx := 0
nextTokenContext := func(t *testing.T) context.Context {
// Each query should use a distinct token string to avoid caching so we can
// change the behavior each call.
token := fmt.Sprintf("token-%d", idx)
idx++
//nolint:staticcheck
return context.WithValue(testContext(t), "x-consul-token", token)
}
for desc, tc := range testcases {
t.Run(desc, func(t *testing.T) {
server := testServer(t)
client := testClient(t, server)
dr := &dummyACLResolver{
result: testutils.ACLsDisabled(t),
}
server.ACLResolver = dr
demo.RegisterTypes(server.Registry)
dr.SetResult(tc.authz)
testutil.RunStep(t, "does not exist", func(t *testing.T) {
_, err = client.Read(nextTokenContext(t), &pbresource.ReadRequest{Id: tc.res.Id})
if tc.codeNotExist == codes.OK {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.Equal(t, tc.codeNotExist.String(), status.Code(err).String(), "%v", err)
})
// Create it.
dr.SetResult(adminAuthz)
_, err = client.Write(nextTokenContext(t), &pbresource.WriteRequest{Resource: tc.res})
require.NoError(t, err, "could not write resource")
dr.SetResult(tc.authz)
testutil.RunStep(t, "does exist", func(t *testing.T) {
// exercise ACL when the data does exist
_, err = client.Read(nextTokenContext(t), &pbresource.ReadRequest{Id: tc.res.Id})
if tc.codeExists == codes.OK {
require.NoError(t, err)
} else {
require.Error(t, err)
}
require.Equal(t, tc.codeExists.String(), status.Code(err).String())
})
})
}
}
type dummyACLResolver struct {
lock sync.Mutex
result resolver.Result
}
var _ ACLResolver = (*dummyACLResolver)(nil)
func (r *dummyACLResolver) SetResult(result resolver.Result) {
r.lock.Lock()
defer r.lock.Unlock()
r.result = result
}
func (r *dummyACLResolver) ResolveTokenAndDefaultMeta(string, *acl.EnterpriseMeta, *acl.AuthorizerContext) (resolver.Result, error) {
r.lock.Lock()
defer r.lock.Unlock()
return r.result, nil
}
type readTestCase struct {
consistency storage.ReadConsistency
ctx context.Context
}
func readTestCases() map[string]readTestCase {
return map[string]readTestCase{
"eventually consistent read": {
consistency: storage.EventualConsistency,
ctx: context.Background(),
},
"strongly consistent read": {
consistency: storage.StrongConsistency,
ctx: metadata.NewOutgoingContext(
context.Background(),
metadata.New(map[string]string{"x-consul-consistency-mode": "consistent"}),
),
},
}
}