// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package execcredcache import ( "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "go.pinniped.dev/internal/testutil" ) func TestNew(t *testing.T) { t.Parallel() tmp := testutil.TempDir(t) + "/credentials.yaml" c := New(tmp) require.NotNil(t, c) require.Equal(t, tmp, c.path) require.NotNil(t, c.errReporter) c.errReporter(fmt.Errorf("some error")) } func TestGet(t *testing.T) { t.Parallel() now := time.Now().Round(1 * time.Second) oneHourFromNow := metav1.NewTime(now.Add(1 * time.Hour)) type testKey struct{ K1, K2 string } tests := []struct { name string makeTestFile func(t *testing.T, tmp string) trylockFunc func(*testing.T) error unlockFunc func(*testing.T) error key testKey want *clientauthenticationv1beta1.ExecCredential wantErrors []string wantTestFile func(t *testing.T, tmp string) }{ { name: "not found", key: testKey{}, }, { name: "file lock error", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.WriteFile(tmp, []byte(""), 0600)) }, trylockFunc: func(t *testing.T) error { return fmt.Errorf("some lock error") }, unlockFunc: func(t *testing.T) error { require.Fail(t, "should not be called"); return nil }, key: testKey{}, wantErrors: []string{"could not lock cache file: some lock error"}, }, { name: "invalid file", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.WriteFile(tmp, []byte("invalid yaml"), 0600)) }, key: testKey{}, wantErrors: []string{ "failed to read cache, resetting: invalid cache file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type execcredcache.credCache", }, }, { name: "invalid file, fail to unlock", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.WriteFile(tmp, []byte("invalid"), 0600)) }, trylockFunc: func(t *testing.T) error { return nil }, unlockFunc: func(t *testing.T) error { return fmt.Errorf("some unlock error") }, key: testKey{}, wantErrors: []string{ "failed to read cache, resetting: invalid cache file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type execcredcache.credCache", "could not unlock cache file: some unlock error", }, }, { name: "unreadable file", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.Mkdir(tmp, 0700)) }, key: testKey{}, wantErrors: []string{ "failed to read cache, resetting: could not read cache file: read TEMPFILE: is a directory", "could not write cache: open TEMPFILE: is a directory", }, }, { name: "valid file but cache miss", makeTestFile: func(t *testing.T, tmp string) { validCache := emptyCache() validCache.Entries = []entry{{ Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ Token: "test-token", ExpirationTimestamp: &oneHourFromNow, }, }} require.NoError(t, validCache.writeTo(tmp)) }, key: testKey{K1: "v1", K2: "v2"}, wantErrors: []string{}, }, { name: "valid file but expired cache hit", makeTestFile: func(t *testing.T, tmp string) { validCache := emptyCache() oneMinuteAgo := metav1.NewTime(now.Add(-1 * time.Minute)) validCache.Entries = []entry{{ Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ Token: "test-token", ExpirationTimestamp: &oneMinuteAgo, }, }} require.NoError(t, validCache.writeTo(tmp)) }, key: testKey{K1: "v1", K2: "v2"}, wantErrors: []string{}, }, { name: "valid file with cache hit", makeTestFile: func(t *testing.T, tmp string) { validCache := emptyCache() validCache.Entries = []entry{{ Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ Token: "test-token", ExpirationTimestamp: &oneHourFromNow, }, }} require.NoError(t, validCache.writeTo(tmp)) }, key: testKey{K1: "v1", K2: "v2"}, wantErrors: []string{}, want: &clientauthenticationv1beta1.ExecCredential{ TypeMeta: metav1.TypeMeta{ Kind: "ExecCredential", APIVersion: "client.authentication.k8s.io/v1beta1", }, Spec: clientauthenticationv1beta1.ExecCredentialSpec{}, Status: &clientauthenticationv1beta1.ExecCredentialStatus{ Token: "test-token", ExpirationTimestamp: &oneHourFromNow, }, }, wantTestFile: func(t *testing.T, tmp string) { cache, err := readCache(tmp) require.NoError(t, err) require.Len(t, cache.Entries, 1) require.Less(t, time.Since(cache.Entries[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds()) }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp := testutil.TempDir(t) + "/sessions.yaml" if tt.makeTestFile != nil { tt.makeTestFile(t, tmp) } // Initialize a cache with a reporter that collects errors errors := errorCollector{t: t} c := New(tmp) c.errReporter = errors.report if tt.trylockFunc != nil { c.trylockFunc = func() error { return tt.trylockFunc(t) } } if tt.unlockFunc != nil { c.unlockFunc = func() error { return tt.unlockFunc(t) } } got := c.Get(tt.key) require.Equal(t, tt.want, got) errors.require(tt.wantErrors, "TEMPFILE", tmp) if tt.wantTestFile != nil { tt.wantTestFile(t, tmp) } }) } } func TestPutToken(t *testing.T) { t.Parallel() now := time.Now().Round(1 * time.Second) type testKey struct{ K1, K2 string } tests := []struct { name string makeTestFile func(t *testing.T, tmp string) key testKey cred *clientauthenticationv1beta1.ExecCredential wantErrors []string wantTestFile func(t *testing.T, tmp string) }{ { name: "fail to create directory", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.WriteFile(filepath.Dir(tmp), []byte{}, 0600)) }, wantErrors: []string{ "could not create credential cache directory: mkdir TEMPDIR: not a directory", }, }, { name: "update to existing entry", makeTestFile: func(t *testing.T, tmp string) { validCache := emptyCache() validCache.Entries = []entry{ { Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "token-one", }, }, // A second entry that was created over a day ago. { Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "token-two", }, }, } require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700)) require.NoError(t, validCache.writeTo(tmp)) }, key: testKey{K1: "v1", K2: "v2"}, cred: &clientauthenticationv1beta1.ExecCredential{ TypeMeta: metav1.TypeMeta{ Kind: "ExecCredential", APIVersion: "client.authentication.k8s.io/v1beta1", }, Status: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "token-one", }, }, wantTestFile: func(t *testing.T, tmp string) { cache, err := readCache(tmp) require.NoError(t, err) require.Len(t, cache.Entries, 1) require.Less(t, time.Since(cache.Entries[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds()) require.Equal(t, &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour).Local()), Token: "token-one", }, cache.Entries[0].Credential) }, }, { name: "new entry", makeTestFile: func(t *testing.T, tmp string) { validCache := emptyCache() validCache.Entries = []entry{ { Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}), CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)), LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)), Credential: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "other-token", }, }, } require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700)) require.NoError(t, validCache.writeTo(tmp)) }, key: testKey{K1: "v1", K2: "v2"}, cred: &clientauthenticationv1beta1.ExecCredential{ TypeMeta: metav1.TypeMeta{ Kind: "ExecCredential", APIVersion: "client.authentication.k8s.io/v1beta1", }, Status: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "token-one", }, }, wantTestFile: func(t *testing.T, tmp string) { cache, err := readCache(tmp) require.NoError(t, err) require.Len(t, cache.Entries, 2) require.Less(t, time.Since(cache.Entries[1].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds()) require.Equal(t, &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour).Local()), Token: "token-one", }, cache.Entries[1].Credential) }, }, { name: "error writing cache", makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, os.MkdirAll(tmp, 0700)) }, key: testKey{K1: "v1", K2: "v2"}, cred: &clientauthenticationv1beta1.ExecCredential{ TypeMeta: metav1.TypeMeta{ Kind: "ExecCredential", APIVersion: "client.authentication.k8s.io/v1beta1", }, Status: &clientauthenticationv1beta1.ExecCredentialStatus{ ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)), Token: "token-one", }, }, wantErrors: []string{ "failed to read cache, resetting: could not read cache file: read TEMPFILE: is a directory", "could not write cache: open TEMPFILE: is a directory", }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() tmp := testutil.TempDir(t) + "/cachedir/credentials.yaml" if tt.makeTestFile != nil { tt.makeTestFile(t, tmp) } // Initialize a cache with a reporter that collects errors errors := errorCollector{t: t} c := New(tmp) c.errReporter = errors.report c.Put(tt.key, tt.cred) errors.require(tt.wantErrors, "TEMPFILE", tmp, "TEMPDIR", filepath.Dir(tmp)) if tt.wantTestFile != nil { tt.wantTestFile(t, tmp) } }) } } func TestHashing(t *testing.T) { type testKey struct{ K1, K2 string } require.Equal(t, "38e0b9de817f645c4bec37c0d4a3e58baecccb040f5718dc069a72c7385a0bed", jsonSHA256Hex(nil)) require.Equal(t, "625bb1f93dc90a1bda400fdaceb8c96328e567a0c6aaf81e7fccc68958b4565d", jsonSHA256Hex([]string{"k1", "k2"})) require.Equal(t, "8fb659f5dd266ffd8d0c96116db1d96fe10e3879f9cb6f7e9ace016696ff69f6", jsonSHA256Hex(testKey{K1: "v1", K2: "v2"})) require.Equal(t, "42c783a2c29f91127b064df368bda61788181d2dd1709b417f9506102ea8da67", jsonSHA256Hex(testKey{K1: "v3", K2: "v4"})) require.Panics(t, func() { jsonSHA256Hex(&unmarshalable{}) }) } type errorCollector struct { t *testing.T saw []error } func (e *errorCollector) report(err error) { e.saw = append(e.saw, err) } func (e *errorCollector) require(want []string, subs ...string) { require.Len(e.t, e.saw, len(want)) for i, w := range want { for i := 0; i < len(subs); i += 2 { w = strings.ReplaceAll(w, subs[i], subs[i+1]) } require.EqualError(e.t, e.saw[i], w) } } func timePtr(from time.Time) *metav1.Time { t := metav1.NewTime(from) return &t } type unmarshalable struct{} func (*unmarshalable) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("some MarshalJSON error") }