2023-03-28 22:48:58 +00:00
|
|
|
// Copyright (c) HashiCorp, Inc.
|
2023-08-11 13:12:13 +00:00
|
|
|
// SPDX-License-Identifier: BUSL-1.1
|
2023-03-28 22:48:58 +00:00
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
package resource
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"io"
|
2023-10-16 17:55:30 +00:00
|
|
|
"strings"
|
2023-03-27 19:37:54 +00:00
|
|
|
"testing"
|
|
|
|
"time"
|
|
|
|
|
2023-04-11 11:10:14 +00:00
|
|
|
"github.com/hashicorp/consul/acl"
|
|
|
|
"github.com/hashicorp/consul/agent/grpc-external/testutils"
|
2023-08-21 20:02:23 +00:00
|
|
|
"github.com/hashicorp/consul/internal/resource"
|
2023-04-06 09:40:04 +00:00
|
|
|
"github.com/hashicorp/consul/internal/resource/demo"
|
2023-03-27 19:37:54 +00:00
|
|
|
"github.com/hashicorp/consul/proto-public/pbresource"
|
|
|
|
"github.com/hashicorp/consul/proto/private/prototest"
|
|
|
|
|
2023-04-11 11:10:14 +00:00
|
|
|
"github.com/stretchr/testify/mock"
|
2023-03-27 19:37:54 +00:00
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
|
|
"google.golang.org/grpc/status"
|
2023-08-21 20:02:23 +00:00
|
|
|
"google.golang.org/protobuf/proto"
|
2023-03-27 19:37:54 +00:00
|
|
|
)
|
|
|
|
|
2023-05-10 09:38:48 +00:00
|
|
|
func TestWatchList_InputValidation(t *testing.T) {
|
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
|
|
|
demo.RegisterTypes(server.Registry)
|
|
|
|
|
2023-10-16 17:55:30 +00:00
|
|
|
type testCase struct {
|
|
|
|
modFn func(*pbresource.WatchListRequest)
|
|
|
|
errContains string
|
|
|
|
}
|
|
|
|
|
|
|
|
testCases := map[string]testCase{
|
|
|
|
"no type": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) { req.Type = nil },
|
|
|
|
errContains: "type is required",
|
|
|
|
},
|
|
|
|
"partition mixed case": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) { req.Tenancy.Partition = "Default" },
|
|
|
|
errContains: "tenancy.partition invalid",
|
|
|
|
},
|
|
|
|
"partition too long": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) {
|
|
|
|
req.Tenancy.Partition = strings.Repeat("p", resource.MaxNameLength+1)
|
|
|
|
},
|
|
|
|
errContains: "tenancy.partition invalid",
|
|
|
|
},
|
|
|
|
"namespace mixed case": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) { req.Tenancy.Namespace = "Default" },
|
|
|
|
errContains: "tenancy.namespace invalid",
|
|
|
|
},
|
|
|
|
"namespace too long": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) {
|
|
|
|
req.Tenancy.Namespace = strings.Repeat("n", resource.MaxNameLength+1)
|
|
|
|
},
|
|
|
|
errContains: "tenancy.namespace invalid",
|
|
|
|
},
|
|
|
|
"name_prefix mixed case": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) { req.NamePrefix = "Smashing" },
|
|
|
|
errContains: "name_prefix invalid",
|
|
|
|
},
|
|
|
|
"partitioned type provides non-empty namespace": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) {
|
|
|
|
req.Type = demo.TypeV1RecordLabel
|
|
|
|
req.Tenancy.Namespace = "bad"
|
|
|
|
},
|
|
|
|
errContains: "cannot have a namespace",
|
2023-08-21 20:02:23 +00:00
|
|
|
},
|
2023-11-03 20:03:07 +00:00
|
|
|
"cluster scope with non-empty partition": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) {
|
|
|
|
req.Type = demo.TypeV1Executive
|
|
|
|
req.Tenancy = &pbresource.Tenancy{Partition: "bad"}
|
|
|
|
},
|
|
|
|
errContains: "cannot have a partition",
|
|
|
|
},
|
|
|
|
"cluster scope with non-empty namespace": {
|
|
|
|
modFn: func(req *pbresource.WatchListRequest) {
|
|
|
|
req.Type = demo.TypeV1Executive
|
|
|
|
req.Tenancy = &pbresource.Tenancy{Namespace: "bad"}
|
|
|
|
},
|
|
|
|
errContains: "cannot have a namespace",
|
|
|
|
},
|
2023-05-10 09:38:48 +00:00
|
|
|
}
|
2023-10-16 17:55:30 +00:00
|
|
|
for desc, tc := range testCases {
|
2023-05-10 09:38:48 +00:00
|
|
|
t.Run(desc, func(t *testing.T) {
|
|
|
|
req := &pbresource.WatchListRequest{
|
|
|
|
Type: demo.TypeV2Album,
|
2023-08-21 20:02:23 +00:00
|
|
|
Tenancy: resource.DefaultNamespacedTenancy(),
|
2023-05-10 09:38:48 +00:00
|
|
|
}
|
2023-10-16 17:55:30 +00:00
|
|
|
tc.modFn(req)
|
2023-05-10 09:38:48 +00:00
|
|
|
|
|
|
|
stream, err := client.WatchList(testContext(t), req)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
_, err = stream.Recv()
|
|
|
|
require.Error(t, err)
|
|
|
|
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
|
2023-10-16 17:55:30 +00:00
|
|
|
require.ErrorContains(t, err, tc.errContains)
|
2023-05-10 09:38:48 +00:00
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
func TestWatchList_TypeNotFound(t *testing.T) {
|
|
|
|
t.Parallel()
|
2023-04-11 11:10:14 +00:00
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
|
|
|
|
|
|
|
stream, err := client.WatchList(context.Background(), &pbresource.WatchListRequest{
|
2023-04-06 09:40:04 +00:00
|
|
|
Type: demo.TypeV2Artist,
|
2023-08-21 20:02:23 +00:00
|
|
|
Tenancy: resource.DefaultNamespacedTenancy(),
|
2023-03-27 19:37:54 +00:00
|
|
|
NamePrefix: "",
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
|
|
|
err = mustGetError(t, rspCh)
|
|
|
|
require.Equal(t, codes.InvalidArgument.String(), status.Code(err).String())
|
2023-06-26 12:25:14 +00:00
|
|
|
require.Contains(t, err.Error(), "resource type demo.v2.Artist not registered")
|
2023-03-27 19:37:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestWatchList_GroupVersionMatches(t *testing.T) {
|
|
|
|
t.Parallel()
|
2023-04-11 11:10:14 +00:00
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
2023-04-25 11:52:35 +00:00
|
|
|
demo.RegisterTypes(server.Registry)
|
2023-03-27 19:37:54 +00:00
|
|
|
ctx := context.Background()
|
|
|
|
|
|
|
|
// create a watch
|
|
|
|
stream, err := client.WatchList(ctx, &pbresource.WatchListRequest{
|
2023-04-06 09:40:04 +00:00
|
|
|
Type: demo.TypeV2Artist,
|
2023-08-21 20:02:23 +00:00
|
|
|
Tenancy: resource.DefaultNamespacedTenancy(),
|
2023-03-27 19:37:54 +00:00
|
|
|
NamePrefix: "",
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
2023-04-06 09:40:04 +00:00
|
|
|
artist, err := demo.GenerateV2Artist()
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
// insert and verify upsert event received
|
2023-04-06 09:40:04 +00:00
|
|
|
r1, err := server.Backend.WriteCAS(ctx, artist)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
rsp := mustGetResource(t, rspCh)
|
|
|
|
require.Equal(t, pbresource.WatchEvent_OPERATION_UPSERT, rsp.Operation)
|
|
|
|
prototest.AssertDeepEqual(t, r1, rsp.Resource)
|
|
|
|
|
|
|
|
// update and verify upsert event received
|
2023-04-06 09:40:04 +00:00
|
|
|
r2 := modifyArtist(t, r1)
|
2023-04-04 16:30:06 +00:00
|
|
|
r2, err = server.Backend.WriteCAS(ctx, r2)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
rsp = mustGetResource(t, rspCh)
|
|
|
|
require.Equal(t, pbresource.WatchEvent_OPERATION_UPSERT, rsp.Operation)
|
|
|
|
prototest.AssertDeepEqual(t, r2, rsp.Resource)
|
|
|
|
|
|
|
|
// delete and verify delete event received
|
2023-04-04 16:30:06 +00:00
|
|
|
err = server.Backend.DeleteCAS(ctx, r2.Id, r2.Version)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
rsp = mustGetResource(t, rspCh)
|
|
|
|
require.Equal(t, pbresource.WatchEvent_OPERATION_DELETE, rsp.Operation)
|
|
|
|
}
|
|
|
|
|
2023-08-21 20:02:23 +00:00
|
|
|
func TestWatchList_Tenancy_Defaults_And_Normalization(t *testing.T) {
|
|
|
|
// Test units of tenancy get lowercased and defaulted correctly when empty.
|
|
|
|
for desc, tc := range wildcardTenancyCases() {
|
|
|
|
t.Run(desc, func(t *testing.T) {
|
|
|
|
ctx := context.Background()
|
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
|
|
|
demo.RegisterTypes(server.Registry)
|
|
|
|
|
|
|
|
// Create a watch.
|
|
|
|
stream, err := client.WatchList(ctx, &pbresource.WatchListRequest{
|
|
|
|
Type: tc.typ,
|
|
|
|
Tenancy: tc.tenancy,
|
|
|
|
NamePrefix: "",
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
|
|
|
// Testcase will pick one of recordLabel or artist based on scope of type.
|
2023-10-16 17:55:30 +00:00
|
|
|
recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
|
2023-08-21 20:02:23 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
artist, err := demo.GenerateV2Artist()
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// Create and verify upsert event received.
|
|
|
|
recordLabel, err = server.Backend.WriteCAS(ctx, recordLabel)
|
|
|
|
require.NoError(t, err)
|
|
|
|
artist, err = server.Backend.WriteCAS(ctx, artist)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
var expected *pbresource.Resource
|
|
|
|
switch {
|
|
|
|
case proto.Equal(tc.typ, demo.TypeV1RecordLabel):
|
|
|
|
expected = recordLabel
|
|
|
|
case proto.Equal(tc.typ, demo.TypeV2Artist):
|
|
|
|
expected = artist
|
|
|
|
default:
|
|
|
|
require.Fail(t, "unsupported type", tc.typ)
|
|
|
|
}
|
|
|
|
|
|
|
|
rsp := mustGetResource(t, rspCh)
|
|
|
|
require.Equal(t, pbresource.WatchEvent_OPERATION_UPSERT, rsp.Operation)
|
|
|
|
prototest.AssertDeepEqual(t, expected, rsp.Resource)
|
|
|
|
})
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
func TestWatchList_GroupVersionMismatch(t *testing.T) {
|
2023-04-06 09:40:04 +00:00
|
|
|
// Given a watch on TypeArtistV1 that only differs from TypeArtistV2 by GroupVersion
|
|
|
|
// When a resource of TypeArtistV2 is created/updated/deleted
|
2023-03-27 19:37:54 +00:00
|
|
|
// Then no watch events should be emitted
|
|
|
|
t.Parallel()
|
2023-04-11 11:10:14 +00:00
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
server := testServer(t)
|
2023-04-25 11:52:35 +00:00
|
|
|
demo.RegisterTypes(server.Registry)
|
2023-03-27 19:37:54 +00:00
|
|
|
client := testClient(t, server)
|
|
|
|
ctx := context.Background()
|
|
|
|
|
2023-04-06 09:40:04 +00:00
|
|
|
// create a watch for TypeArtistV1
|
2023-03-27 19:37:54 +00:00
|
|
|
stream, err := client.WatchList(ctx, &pbresource.WatchListRequest{
|
2023-04-06 09:40:04 +00:00
|
|
|
Type: demo.TypeV1Artist,
|
2023-08-21 20:02:23 +00:00
|
|
|
Tenancy: resource.DefaultNamespacedTenancy(),
|
2023-03-27 19:37:54 +00:00
|
|
|
NamePrefix: "",
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
2023-04-06 09:40:04 +00:00
|
|
|
artist, err := demo.GenerateV2Artist()
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
// insert
|
2023-04-06 09:40:04 +00:00
|
|
|
r1, err := server.Backend.WriteCAS(ctx, artist)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// update
|
|
|
|
r2 := clone(r1)
|
2023-04-04 16:30:06 +00:00
|
|
|
r2, err = server.Backend.WriteCAS(ctx, r2)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// delete
|
2023-04-04 16:30:06 +00:00
|
|
|
err = server.Backend.DeleteCAS(ctx, r2.Id, r2.Version)
|
2023-03-27 19:37:54 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// verify no events received
|
|
|
|
mustGetNoResource(t, rspCh)
|
|
|
|
}
|
|
|
|
|
2023-04-25 11:52:35 +00:00
|
|
|
// N.B. Uses key ACLs for now. See demo.RegisterTypes()
|
2023-04-11 11:10:14 +00:00
|
|
|
func TestWatchList_ACL_ListDenied(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
// deny all
|
|
|
|
rspCh, _ := roundTripACL(t, testutils.ACLNoPermissions(t))
|
|
|
|
|
|
|
|
// verify key:list denied
|
|
|
|
err := mustGetError(t, rspCh)
|
|
|
|
require.Error(t, err)
|
|
|
|
require.Equal(t, codes.PermissionDenied.String(), status.Code(err).String())
|
|
|
|
require.Contains(t, err.Error(), "lacks permission 'key:list'")
|
|
|
|
}
|
|
|
|
|
2023-04-25 11:52:35 +00:00
|
|
|
// N.B. Uses key ACLs for now. See demo.RegisterTypes()
|
2023-04-11 11:10:14 +00:00
|
|
|
func TestWatchList_ACL_ListAllowed_ReadDenied(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
// allow list, deny read
|
|
|
|
authz := AuthorizerFrom(t, `
|
|
|
|
key_prefix "resource/" { policy = "list" }
|
2023-06-26 12:25:14 +00:00
|
|
|
key_prefix "resource/demo.v2.Artist/" { policy = "deny" }
|
2023-04-11 11:10:14 +00:00
|
|
|
`)
|
|
|
|
rspCh, _ := roundTripACL(t, authz)
|
|
|
|
|
|
|
|
// verify resource filtered out by key:read denied, hence no events
|
|
|
|
mustGetNoResource(t, rspCh)
|
|
|
|
}
|
|
|
|
|
2023-04-25 11:52:35 +00:00
|
|
|
// N.B. Uses key ACLs for now. See demo.RegisterTypes()
|
2023-04-11 11:10:14 +00:00
|
|
|
func TestWatchList_ACL_ListAllowed_ReadAllowed(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
|
|
|
|
// allow list, allow read
|
|
|
|
authz := AuthorizerFrom(t, `
|
|
|
|
key_prefix "resource/" { policy = "list" }
|
2023-06-26 12:25:14 +00:00
|
|
|
key_prefix "resource/demo.v2.Artist/" { policy = "read" }
|
2023-04-11 11:10:14 +00:00
|
|
|
`)
|
|
|
|
rspCh, artist := roundTripACL(t, authz)
|
|
|
|
|
|
|
|
// verify resource not filtered out by acl
|
|
|
|
event := mustGetResource(t, rspCh)
|
|
|
|
prototest.AssertDeepEqual(t, artist, event.Resource)
|
|
|
|
}
|
|
|
|
|
|
|
|
// roundtrip a WatchList which attempts to stream back a single write event
|
|
|
|
func roundTripACL(t *testing.T, authz acl.Authorizer) (<-chan resourceOrError, *pbresource.Resource) {
|
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
|
|
|
|
|
|
|
mockACLResolver := &MockACLResolver{}
|
|
|
|
mockACLResolver.On("ResolveTokenAndDefaultMeta", mock.Anything, mock.Anything, mock.Anything).
|
|
|
|
Return(authz, nil)
|
|
|
|
server.ACLResolver = mockACLResolver
|
2023-04-25 11:52:35 +00:00
|
|
|
demo.RegisterTypes(server.Registry)
|
2023-04-11 11:10:14 +00:00
|
|
|
|
|
|
|
artist, err := demo.GenerateV2Artist()
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
stream, err := client.WatchList(testContext(t), &pbresource.WatchListRequest{
|
|
|
|
Type: artist.Id.Type,
|
|
|
|
Tenancy: artist.Id.Tenancy,
|
|
|
|
NamePrefix: "",
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
|
|
|
// induce single watch event
|
|
|
|
artist, err = server.Backend.WriteCAS(context.Background(), artist)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// caller to make assertions on the rspCh and written artist
|
|
|
|
return rspCh, artist
|
|
|
|
}
|
|
|
|
|
2023-03-27 19:37:54 +00:00
|
|
|
func mustGetNoResource(t *testing.T, ch <-chan resourceOrError) {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
select {
|
|
|
|
case rsp := <-ch:
|
|
|
|
require.NoError(t, rsp.err)
|
|
|
|
require.Nil(t, rsp.rsp, "expected nil response with no error")
|
|
|
|
case <-time.After(250 * time.Millisecond):
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func mustGetResource(t *testing.T, ch <-chan resourceOrError) *pbresource.WatchEvent {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
select {
|
|
|
|
case rsp := <-ch:
|
|
|
|
require.NoError(t, rsp.err)
|
|
|
|
return rsp.rsp
|
|
|
|
case <-time.After(1 * time.Second):
|
|
|
|
t.Fatal("timeout waiting for WatchListResponse")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func mustGetError(t *testing.T, ch <-chan resourceOrError) error {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
select {
|
|
|
|
case rsp := <-ch:
|
|
|
|
require.Error(t, rsp.err)
|
|
|
|
return rsp.err
|
|
|
|
case <-time.After(2 * time.Second):
|
|
|
|
t.Fatal("timeout waiting for WatchListResponse")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func handleResourceStream(t *testing.T, stream pbresource.ResourceService_WatchListClient) <-chan resourceOrError {
|
|
|
|
t.Helper()
|
|
|
|
|
|
|
|
rspCh := make(chan resourceOrError)
|
|
|
|
go func() {
|
|
|
|
for {
|
|
|
|
rsp, err := stream.Recv()
|
|
|
|
if errors.Is(err, io.EOF) ||
|
|
|
|
errors.Is(err, context.Canceled) ||
|
|
|
|
errors.Is(err, context.DeadlineExceeded) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
rspCh <- resourceOrError{
|
|
|
|
rsp: rsp,
|
|
|
|
err: err,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
return rspCh
|
|
|
|
}
|
|
|
|
|
|
|
|
type resourceOrError struct {
|
|
|
|
rsp *pbresource.WatchEvent
|
|
|
|
err error
|
|
|
|
}
|
2023-11-03 20:03:07 +00:00
|
|
|
|
|
|
|
func TestWatchList_NoTenancy(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
|
|
server := testServer(t)
|
|
|
|
client := testClient(t, server)
|
|
|
|
demo.RegisterTypes(server.Registry)
|
|
|
|
|
|
|
|
// Create a watch.
|
|
|
|
stream, err := client.WatchList(ctx, &pbresource.WatchListRequest{
|
|
|
|
Type: demo.TypeV1RecordLabel,
|
|
|
|
})
|
|
|
|
require.NoError(t, err)
|
|
|
|
rspCh := handleResourceStream(t, stream)
|
|
|
|
|
|
|
|
recordLabel, err := demo.GenerateV1RecordLabel("looney-tunes")
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
// Create and verify upsert event received.
|
|
|
|
recordLabel, err = server.Backend.WriteCAS(ctx, recordLabel)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
rsp := mustGetResource(t, rspCh)
|
|
|
|
|
|
|
|
require.Equal(t, pbresource.WatchEvent_OPERATION_UPSERT, rsp.Operation)
|
|
|
|
prototest.AssertDeepEqual(t, recordLabel, rsp.Resource)
|
|
|
|
}
|