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

447 lines
14 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"
"strings"
"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/internal/tenancy"
"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)
tenancy.RegisterTypes(server.Registry)
2023-04-25 11:52:35 +00:00
demo.RegisterTypes(server.Registry)
type testCase struct {
modFn func(artistId, recordlabelId, executiveId *pbresource.ID) *pbresource.ID
errContains string
}
testCases := map[string]testCase{
"no id": {
modFn: func(_, _, _ *pbresource.ID) *pbresource.ID {
return nil
},
errContains: "id is required",
},
"no type": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Type = nil
return artistId
},
errContains: "id.type is required",
},
"no name": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Name = ""
return artistId
},
errContains: "id.name invalid",
},
"name is mixed case": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Name = "MixedCaseNotAllowed"
return artistId
},
errContains: "id.name invalid",
},
"name too long": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Name = strings.Repeat("a", resource.MaxNameLength+1)
return artistId
},
errContains: "id.name invalid",
},
"partition is mixed case": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Tenancy.Partition = "Default"
return artistId
},
errContains: "id.tenancy.partition invalid",
},
"partition too long": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Tenancy.Partition = strings.Repeat("p", resource.MaxNameLength+1)
return artistId
},
errContains: "id.tenancy.partition invalid",
},
"namespace is mixed case": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Tenancy.Namespace = "Default"
return artistId
},
errContains: "id.tenancy.namespace invalid",
},
"namespace too long": {
modFn: func(artistId, _, _ *pbresource.ID) *pbresource.ID {
artistId.Tenancy.Namespace = strings.Repeat("n", resource.MaxNameLength+1)
return artistId
},
errContains: "id.tenancy.namespace invalid",
},
"partition scope with non-empty namespace": {
modFn: func(_, recordLabelId, _ *pbresource.ID) *pbresource.ID {
recordLabelId.Tenancy.Namespace = "ishouldnothaveanamespace"
return recordLabelId
},
errContains: "cannot have a namespace",
},
"cluster scope with non-empty partition": {
modFn: func(_, _, executiveId *pbresource.ID) *pbresource.ID {
executiveId.Tenancy = &pbresource.Tenancy{Partition: resource.DefaultPartitionName}
return executiveId
},
errContains: "cannot have a partition",
},
"cluster scope with non-empty namespace": {
modFn: func(_, _, executiveId *pbresource.ID) *pbresource.ID {
executiveId.Tenancy = &pbresource.Tenancy{Namespace: resource.DefaultNamespaceName}
return executiveId
},
errContains: "cannot have a namespace",
},
}
for desc, tc := range testCases {
t.Run(desc, func(t *testing.T) {
artist, err := demo.GenerateV2Artist()
require.NoError(t, err)
recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
require.NoError(t, err)
executive, err := demo.GenerateV1Executive("music-man", "CEO")
require.NoError(t, err)
// Each test case picks which resource to use based on the resource type's scope.
req := &pbresource.ReadRequest{Id: tc.modFn(artist.Id, recordLabel.Id, executive.Id)}
_, err = client.Read(testContext(t), req)
require.Error(t, err)
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
require.ErrorContains(t, err, tc.errContains)
})
}
}
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) {
type tenancyCase struct {
modFn func(artistId, recordlabelId *pbresource.ID) *pbresource.ID
errContains string
}
tenancyCases := map[string]tenancyCase{
"resource not found by name": {
modFn: func(artistId, _ *pbresource.ID) *pbresource.ID {
artistId.Name = "bogusname"
return artistId
},
errContains: "resource not found",
},
"partition not found when namespace scoped": {
modFn: func(artistId, _ *pbresource.ID) *pbresource.ID {
id := clone(artistId)
id.Tenancy.Partition = "boguspartition"
return id
},
errContains: "partition not found",
},
"namespace not found when namespace scoped": {
modFn: func(artistId, _ *pbresource.ID) *pbresource.ID {
id := clone(artistId)
id.Tenancy.Namespace = "bogusnamespace"
return id
},
errContains: "namespace not found",
},
"partition not found when partition scoped": {
modFn: func(_, recordLabelId *pbresource.ID) *pbresource.ID {
id := clone(recordLabelId)
id.Tenancy.Partition = "boguspartition"
return id
},
errContains: "partition not found",
},
}
for tenancyDesc, tenancyCase := range tenancyCases {
t.Run(tenancyDesc, func(t *testing.T) {
server := testServer(t)
demo.RegisterTypes(server.Registry)
client := testClient(t, server)
recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
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: tenancyCase.modFn(artist.Id, recordLabel.Id)})
require.Error(t, err)
require.Equal(t, codes.NotFound.String(), status.Code(err).String())
require.ErrorContains(t, err, tenancyCase.errContains)
})
}
})
}
}
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("looney-tunes")
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"}),
),
},
}
}