diff --git a/internal/crud/crud.go b/internal/crud/crud.go new file mode 100644 index 00000000..82d32082 --- /dev/null +++ b/internal/crud/crud.go @@ -0,0 +1,165 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package crud + +import ( + "bytes" + "context" + "encoding/base32" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + + "go.pinniped.dev/internal/constable" +) + +//nolint:gosec // ignore lint warnings that these are credentials +const ( + secretNameFormat = "pinniped-storage-%s-%s" + secretLabelKey = "storage.pinniped.dev" + secretTypeFormat = "storage.pinniped.dev/%s" + secretVersion = "1" + secretDataKey = "pinniped-storage-data" + secretVersionKey = "pinniped-storage-version" + + ErrSecretTypeMismatch = constable.Error("secret storage data has incorrect type") + ErrSecretLabelMismatch = constable.Error("secret storage data has incorrect label") + ErrSecretVersionMismatch = constable.Error("secret storage data has incorrect version") // TODO do we need this? +) + +type Storage interface { + Create(ctx context.Context, signature string, data JSON) (resourceVersion string, err error) + Get(ctx context.Context, signature string, data JSON) (resourceVersion string, err error) + Update(ctx context.Context, signature, resourceVersion string, data JSON) (newResourceVersion string, err error) + Delete(ctx context.Context, signature string) error +} + +type JSON interface{} // document that we need valid JSON types + +func New(resource string, secrets corev1client.SecretInterface) Storage { + return &secretsStorage{ + resource: resource, + secretType: corev1.SecretType(fmt.Sprintf(secretTypeFormat, resource)), + secretVersion: []byte(secretVersion), + secrets: secrets, + } +} + +type secretsStorage struct { + resource string + secretType corev1.SecretType + secretVersion []byte + secrets corev1client.SecretInterface +} + +func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON) (string, error) { + secret, err := s.toSecret(signature, "", data) + if err != nil { + return "", err + } + secret, err = s.secrets.Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create %s for signature %s: %w", s.resource, signature, err) + } + return secret.ResourceVersion, nil +} + +func (s *secretsStorage) Get(ctx context.Context, signature string, data JSON) (string, error) { + secret, err := s.secrets.Get(ctx, s.getName(signature), metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get %s for signature %s: %w", s.resource, signature, err) + } + if err := s.validateSecret(secret); err != nil { + return "", err + } + if err := json.Unmarshal(secret.Data[secretDataKey], data); err != nil { + return "", fmt.Errorf("failed to decode %s for signature %s: %w", s.resource, signature, err) + } + return secret.ResourceVersion, nil +} + +func (s *secretsStorage) validateSecret(secret *corev1.Secret) error { + if secret.Type != s.secretType { + return fmt.Errorf("%w: %s must equal %s", ErrSecretTypeMismatch, secret.Type, s.secretType) + } + if labelResource := secret.Labels[secretLabelKey]; labelResource != s.resource { + return fmt.Errorf("%w: %s must equal %s", ErrSecretLabelMismatch, labelResource, s.resource) + } + if !bytes.Equal(secret.Data[secretVersionKey], s.secretVersion) { + return ErrSecretVersionMismatch // TODO should this be fatal or not? + } + return nil +} + +func (s *secretsStorage) Update(ctx context.Context, signature, resourceVersion string, data JSON) (string, error) { + secret, err := s.toSecret(signature, resourceVersion, data) + if err != nil { + return "", err + } + secret, err = s.secrets.Update(ctx, secret, metav1.UpdateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to update %s for signature %s at resource version %s: %w", s.resource, signature, resourceVersion, err) + } + return secret.ResourceVersion, nil +} + +func (s *secretsStorage) Delete(ctx context.Context, signature string) error { + if err := s.secrets.Delete(ctx, s.getName(signature), metav1.DeleteOptions{}); err != nil { + return fmt.Errorf("failed to delete %s for signature %s: %w", s.resource, signature, err) + } + return nil +} + +//nolint: gochecknoglobals +var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + +func (s *secretsStorage) getName(signature string) string { + // try to decode base64 signatures to prevent double encoding of binary data + signatureBytes := maybeBase64Decode(signature) + // lower case base32 encoding insures that our secret name is valid per ValidateSecretName in k/k + signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes)) + return fmt.Sprintf(secretNameFormat, s.resource, signatureAsValidName) +} + +func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON) (*corev1.Secret, error) { + buf, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to encode secret data for %s: %w", s.getName(signature), err) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: s.getName(signature), + ResourceVersion: resourceVersion, + Labels: map[string]string{ + secretLabelKey: s.resource, // make it easier to find this stuff via kubectl + }, + OwnerReferences: nil, // TODO we should set this to make sure stuff gets clean up + }, + Data: map[string][]byte{ + secretDataKey: buf, + secretVersionKey: s.secretVersion, + }, + Type: s.secretType, + }, nil +} + +func maybeBase64Decode(signature string) []byte { + for _, encoding := range []*base64.Encoding{ + // ordered in most likely used by HMAC, JWT, etc signatures + base64.RawURLEncoding, + base64.URLEncoding, + base64.RawStdEncoding, + base64.StdEncoding, + } { + if signatureBytes, err := encoding.DecodeString(signature); err == nil { + return signatureBytes + } + } + return []byte(signature) +} diff --git a/internal/crud/crud_test.go b/internal/crud/crud_test.go new file mode 100644 index 00000000..93ee9818 --- /dev/null +++ b/internal/crud/crud_test.go @@ -0,0 +1,635 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package crud + +import ( + "context" + "errors" + "testing" + + "github.com/ory/fosite/compose" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/validation" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + coretesting "k8s.io/client-go/testing" +) + +func TestStorage(t *testing.T) { + ctx := context.Background() + secretsGVR := schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "secrets", + } + + type testJSON struct { + Data string + } + + type mocker interface { + AddReactor(verb, resource string, reaction coretesting.ReactionFunc) + PrependReactor(verb, resource string, reaction coretesting.ReactionFunc) + Tracker() coretesting.ObjectTracker + } + + hmac := compose.NewOAuth2HMACStrategy(&compose.Config{}, []byte("super-secret-32-byte-for-testing"), nil) + // test data generation via: + // code, signature, err := hmac.GenerateAuthorizeCode(ctx, nil) + + validateSecretName := validation.NameIsDNSSubdomain // matches k/k + + const ( + namespace = "test-ns" + authorizationCode1 = "81qE408EKL-e99gcXo3UnXBz9W05yGm92_hBmvXeadM.R5h38Bmw7yOaWNy0ypB3feh9toM-3T2zlwMXQyeE9B0" + authorizationCode2 = "p7aIiOLy-btBBlCro5RWm1QABANKCiC0JmDPhUtfOY4.XXJsYsMWhnSMJi9TXJcPO6SDVO2R_QXImwroxxnQPA8" + authorizationCode3 = "skKp1RjGgIwZhT3vaB_k1F3cIj2yp7U8a7UD0xAaemU.5aUhdNmfWLW3yKX8Zfz5ztS5IiiWBgu36Gja-o2xl0I" + ) + + tests := []struct { + name string + resource string + mocks func(*testing.T, mocker) + run func(*testing.T, Storage) error + wantActions []coretesting.Action + wantSecrets []corev1.Secret + wantErr string + }{ + { + name: "get non-existent", + resource: "authorization-codes", + mocks: nil, + run: func(t *testing.T, storage Storage) error { + _, err := storage.Get(ctx, "not-exists", nil) + return err + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-authorization-codes-t2fx46yyvs3a"), + }, + wantSecrets: nil, + wantErr: `failed to get authorization-codes for signature not-exists: secrets "pinniped-storage-authorization-codes-t2fx46yyvs3a" not found`, + }, + { + name: "delete non-existent", + resource: "tokens", + mocks: nil, + run: func(t *testing.T, storage Storage) error { + return storage.Delete(ctx, "not-a-token") + }, + wantActions: []coretesting.Action{ + coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-tokens-t2fx427lnci6s"), + }, + wantSecrets: nil, + wantErr: `failed to delete tokens for signature not-a-token: secrets "pinniped-storage-tokens-t2fx427lnci6s" not found`, + }, + { + name: "create and get", + resource: "access-tokens", + mocks: nil, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode1) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + data := &testJSON{Data: "create-and-get"} + rv1, err := storage.Create(ctx, signature, data) + require.Empty(t, rv1) // fake client does not set this + require.NoError(t, err) + + out := &testJSON{} + rv2, err := storage.Get(ctx, signature, out) + require.Empty(t, rv2) // fake client does not set this + require.NoError(t, err) + require.Equal(t, data, out) + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq", + ResourceVersion: "", + Labels: map[string]string{ + "storage.pinniped.dev": "access-tokens", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"create-and-get"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/access-tokens", + }), + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq", + Namespace: namespace, + ResourceVersion: "", + Labels: map[string]string{ + "storage.pinniped.dev": "access-tokens", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"create-and-get"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/access-tokens", + }, + }, + wantErr: "", + }, + { + name: "get existing", + resource: "pandas-are-best", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq", + Namespace: namespace, + ResourceVersion: "", + Labels: map[string]string{ + "storage.pinniped.dev": "pandas-are-best", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"snorlax"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/pandas-are-best", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode2) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + data := &testJSON{Data: "snorlax"} + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Empty(t, rv1) // fake client does not set this + require.NoError(t, err) + require.Equal(t, data, out) + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq", + Namespace: namespace, + ResourceVersion: "", + Labels: map[string]string{ + "storage.pinniped.dev": "pandas-are-best", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"snorlax"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/pandas-are-best", + }, + }, + wantErr: "", + }, + { + name: "update existing", + resource: "stores", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "35", + Labels: map[string]string{ + "storage.pinniped.dev": "stores", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"pants"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/stores", + }) + require.NoError(t, err) + + mock.PrependReactor("update", "secrets", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) { + secret := action.(coretesting.UpdateAction).GetObject().(*corev1.Secret) + secret.ResourceVersion = "45" + return false, nil, nil // we mutated the secret in place but we do not "handle" it + }) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode3) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + data := &testJSON{Data: "pants"} + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Equal(t, "35", rv1) // set in mock above + require.NoError(t, err) + require.Equal(t, data, out) + + newData := &testJSON{Data: "shirts"} + rv2, err := storage.Update(ctx, signature, rv1, newData) + require.Equal(t, "45", rv2) // mock sets to a higher value on update + require.NoError(t, err) + + newOut := &testJSON{} + rv3, err := storage.Get(ctx, signature, newOut) + require.Equal(t, "45", rv3) // we should see new rv now + require.NoError(t, err) + require.Equal(t, newData, newOut) + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + coretesting.NewUpdateAction(secretsGVR, namespace, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + ResourceVersion: "35", // update at initial RV + Labels: map[string]string{ + "storage.pinniped.dev": "stores", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"shirts"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/stores", + }), + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "45", // final list at new RV + Labels: map[string]string{ + "storage.pinniped.dev": "stores", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"shirts"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/stores", + }, + }, + wantErr: "", + }, + { + name: "delete existing", + resource: "seals", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq", + Namespace: namespace, + ResourceVersion: "", + Labels: map[string]string{ + "storage.pinniped.dev": "seals", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"sad-seal"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/seals", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode2) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + return storage.Delete(ctx, signature) + }, + wantActions: []coretesting.Action{ + coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"), + }, + wantSecrets: nil, + wantErr: "", + }, + { + name: "invalid exiting secret type", + resource: "candies", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies-not", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode3) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Empty(t, rv1) + require.Empty(t, out.Data) + require.True(t, errors.Is(err, ErrSecretTypeMismatch)) + require.EqualError(t, err, "secret storage data has incorrect type: storage.pinniped.dev/candies-not must equal storage.pinniped.dev/candies") + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies-not", + }, + }, + wantErr: "", + }, + { + name: "invalid exiting secret wrong label", + resource: "candies", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies-are-bad", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode3) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Empty(t, rv1) + require.Empty(t, out.Data) + require.True(t, errors.Is(err, ErrSecretLabelMismatch)) + require.EqualError(t, err, "secret storage data has incorrect label: candies-are-bad must equal candies") + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies-are-bad", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies", + }, + }, + wantErr: "", + }, + { + name: "invalid exiting secret wrong version", + resource: "candies", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("77"), + }, + Type: "storage.pinniped.dev/candies", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode3) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Empty(t, rv1) + require.Empty(t, out.Data) + require.True(t, errors.Is(err, ErrSecretVersionMismatch)) + require.EqualError(t, err, "secret storage data has incorrect version") + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"Data":"twizzlers"}`), + "pinniped-storage-version": []byte("77"), + }, + Type: "storage.pinniped.dev/candies", + }, + }, + wantErr: "", + }, + { + name: "invalid exiting secret not json", + resource: "candies", + mocks: func(t *testing.T, mock mocker) { + err := mock.Tracker().Add(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`}}bad data{{`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies", + }) + require.NoError(t, err) + }, + run: func(t *testing.T, storage Storage) error { + signature := hmac.AuthorizeCodeSignature(authorizationCode3) + require.NotEmpty(t, signature) + require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is + + out := &testJSON{} + rv1, err := storage.Get(ctx, signature, out) + require.Empty(t, rv1) + require.Empty(t, out.Data) + require.EqualError(t, err, "failed to decode candies for signature 5aUhdNmfWLW3yKX8Zfz5ztS5IiiWBgu36Gja-o2xl0I: invalid character '}' looking for beginning of value") + + return nil + }, + wantActions: []coretesting.Action{ + coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"), + }, + wantSecrets: []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba", + Namespace: namespace, + ResourceVersion: "55", + Labels: map[string]string{ + "storage.pinniped.dev": "candies", + }, + }, + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`}}bad data{{`), + "pinniped-storage-version": []byte("1"), + }, + Type: "storage.pinniped.dev/candies", + }, + }, + wantErr: "", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client := fake.NewSimpleClientset() + if tt.mocks != nil { + tt.mocks(t, client) + } + secrets := client.CoreV1().Secrets(namespace) + storage := New(tt.resource, secrets) + + err := tt.run(t, storage) + + require.Equal(t, tt.wantErr, errString(err)) + require.Equal(t, tt.wantActions, client.Actions()) + checkSecretActionNames(t, client.Actions()) + actualSecrets, err := secrets.List(ctx, metav1.ListOptions{}) + require.NoError(t, err) + require.Equal(t, tt.wantSecrets, actualSecrets.Items) + checkSecretListNames(t, actualSecrets.Items) + }) + } +} + +func checkSecretActionNames(t *testing.T, actions []coretesting.Action) { + t.Helper() + + for _, action := range actions { + name := getName(t, action) + assertValidName(t, name) + } +} + +func checkSecretListNames(t *testing.T, secrets []corev1.Secret) { + t.Helper() + + for _, secret := range secrets { + assertValidName(t, secret.Name) + } +} + +func assertValidName(t *testing.T, name string) { + t.Helper() + + validateSecretName := validation.NameIsDNSSubdomain // matches k/k + + require.NotEmpty(t, name) + require.Empty(t, validateSecretName(name, false)) + require.Empty(t, validateSecretName(name, true)) // I do not think we actually care about this case +} + +func getName(t *testing.T, action coretesting.Action) string { + t.Helper() + + if getter, ok := action.(interface { + GetName() string + }); ok { + return getter.GetName() + } + + if getter, ok := action.(interface { + GetObject() runtime.Object + }); ok { + accessor, err := meta.Accessor(getter.GetObject()) + require.NoError(t, err) + return accessor.GetName() + } + + t.Fatalf("failed to get name for action: %#v", action) + panic("unreachable") +} + +func errString(err error) string { + if err == nil { + return "" + } + + return err.Error() +}