blob: e42f0947b6ef5403e9717bc80d57b054f1029572 [file] [log] [blame]
// Copyright Istio Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package plugin
import (
"fmt"
"os"
"testing"
"time"
)
import (
"github.com/google/uuid"
)
import (
"github.com/apache/dubbo-go-pixiu/pkg/test/util/retry"
)
var (
// thirdPartyJwt is generated in a testing K8s cluster, using the "istio-token" projected volume.
// Token is issued at 2020-04-04 22:13:54 Pacific Daylight time.
// Expiration time is 2020-04-05 10:13:54 Pacific Daylight time.
thirdPartyJwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9." +
"eyJhdWQiOlsieW9uZ2dhbmdsLWlzdGlvLTQuc3ZjLmlkLmdvb2ciXSwiZXhwIjoxNTg2MTA2ODM0LCJpYXQiOjE1" +
"ODYwNjM2MzQsImlzcyI6Imh0dHBzOi8vY29udGFpbmVyLmdvb2dsZWFwaXMuY29tL3YxL3Byb2plY3RzL3lvbmdn" +
"YW5nbC1pc3Rpby00L2xvY2F0aW9ucy91cy1jZW50cmFsMS1hL2NsdXN0ZXJzL2NsdXN0ZXItMyIsImt1YmVybmV0" +
"ZXMuaW8iOnsibmFtZXNwYWNlIjoiZm9vIiwicG9kIjp7Im5hbWUiOiJodHRwYmluLTY0Nzc2YmY3OGQtanFsNWIi" +
"LCJ1aWQiOiI5YWQ3NTcxYi03NjBhLTExZWEtODllNy00MjAxMGE4MDAxYzEifSwic2VydmljZWFjY291bnQiOnsi" +
"bmFtZSI6Imh0dHBiaW4iLCJ1aWQiOiI5OWY2NWY1MC03NjBhLTExZWEtODllNy00MjAxMGE4MDAxYzEifX0sIm5i" +
"ZiI6MTU4NjA2MzYzNCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmZvbzpodHRwYmluIn0.XWSCdarBR0cx" +
"MlVV5X9pvgI9m0lyO-17B45aBKJBIQjvjluKXqxnCuIeD3X2ItLkCUzmKUa3ftTjUUov1MJ89MdBngNfUP7IfwnD" +
"2dBl7Jtju0-Ks7aTFOkgtoMYqNnQ1VSDTAOfNpdZVUnsR_oY8obXSQR_H4uMcaNOGED2RX5HLBWFlvymtn4JXuyg" +
"_rpOrJ8dv-snrmO3LT9y-zaUnZqSceDC8skzStrJIRvsRkO8GEcoQd5VwDn-UVgOcqWb-S-vgSjdtwBsnGPXsh_I" +
"NZCq3ftr0Qu8-IxsIjpMjhLmAGTH1bR324aqLTYhAXp6fk06Pe3T9stCY5acSeadKA"
// firstPartyJwt is generated in a testing K8s cluster. It is the default service account JWT.
// No expiration time.
firstPartyJwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9." +
"eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9u" +
"YW1lc3BhY2UiOiJmb28iLCJrdWJlcm5ldGVzLmlvL3NlcnZpY2VhY2NvdW50L3NlY3JldC5uYW1lIjoiaHR0cGJp" +
"bi10b2tlbi14cWRncCIsImt1YmVybmV0ZXMuaW8vc2VydmljZWFjY291bnQvc2VydmljZS1hY2NvdW50Lm5hbWUi" +
"OiJodHRwYmluIiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9zZXJ2aWNlLWFjY291bnQudWlkIjoiOTlm" +
"NjVmNTAtNzYwYS0xMWVhLTg5ZTctNDIwMTBhODAwMWMxIiwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmZv" +
"bzpodHRwYmluIn0.4kIl9TRjXEw6DfhtR-LdpxsAlYjJgC6Ly1DY_rqYY4h0haxcXB3kYZ3b2He-3fqOBryz524W" +
"KkZscZgvs5L-sApmvlqdUG61TMAl7josB0x4IMHm1NS995LNEaXiI4driffwfopvqc_z3lVKfbF9j-mBgnCepxz3" +
"UyWo5irFa3qcwbOUB9kuuUNGBdtbFBN5yIYLpfa9E-MtTX_zJ9fQ9j2pi8Z4ljii0tEmPmRxokHkmG_xNJjUkxKU" +
"WZf4bLDdCEjVFyshNae-FdxiUVyeyYorTYzwZZYQch9MJeedg4keKKUOvCCJUlKixd2qAe-H7r15RPmo4AU5O5YL" +
"65xiNg"
)
func TestShouldRotate(t *testing.T) {
jwtExp := time.Date(2020, time.April, 5, 10, 13, 54, 0, time.FixedZone("PDT", -int((7*time.Hour).Seconds())))
testCases := map[string]struct {
jwt string
now time.Time
expectedRotate bool
}{
"remaining life time is in grace period": {
jwt: thirdPartyJwt,
now: jwtExp.Add(time.Duration(-10) * time.Minute),
expectedRotate: true,
},
"remaining life time is not in grace period": {
jwt: thirdPartyJwt,
now: jwtExp.Add(time.Duration(-30) * time.Minute),
expectedRotate: false,
},
"no cached credential": {
now: time.Now(),
expectedRotate: true,
},
"no expiration in token": {
jwt: firstPartyJwt,
now: time.Now(),
expectedRotate: true,
},
"invalid token": {
jwt: "invalid-token",
now: time.Now(),
expectedRotate: true,
},
}
for id, tc := range testCases {
t.Run(id, func(t *testing.T) {
p := GCEPlugin{
tokenCache: tc.jwt,
}
if rotate := p.shouldRotate(tc.now); rotate != tc.expectedRotate {
t.Errorf("%s, shouldRotate(%s)=%t, expected %t",
id, tc.now.String(), rotate, tc.expectedRotate)
}
})
}
}
func creatJWTFile(path string) error {
if path == "" {
return nil
}
f, err := os.Create(path)
if err != nil {
return err
}
f.Close()
return nil
}
func getJWTFromFile(path string) (string, error) {
if path == "" {
return "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}
func getTokenFromServer(t *testing.T, p *GCEPlugin, callReps int) ([]string, []error) {
t.Helper()
var tokens []string
var errs []error
for i := 0; i < callReps; i++ {
token, err := p.GetPlatformCredential()
tokens = append(tokens, token)
errs = append(errs, err)
}
return tokens, errs
}
func verifyError(t *testing.T, id string, errs []error, expectedErr error) {
t.Helper()
for _, err := range errs {
if err == nil && expectedErr != nil || err != nil && expectedErr == nil {
t.Errorf("%s, GetPlatformCredential() returns err: %v, want: %v", id, err, expectedErr)
} else if err != nil && expectedErr != nil && err.Error() != expectedErr.Error() {
t.Errorf("%s, GetPlatformCredential() returns err: %v, want: %v", id, err, expectedErr)
}
}
}
func verifyToken(t *testing.T, id, jwtPath string, tokens []string, expectedToken string) {
t.Helper()
for i, token := range tokens {
// if expectedToken is not set, mock metadata server returns an auto-generated fake token.
wantToken := fmt.Sprintf("%s%d", fakeTokenPrefix, i+1)
if expectedToken != "" {
wantToken = expectedToken
}
if token != wantToken {
t.Errorf("%s, #%d call to GetPlatformCredential() returns token: %s, want: %s",
id, i, token, wantToken)
}
if jwtPath != "" && i+1 == len(tokens) {
// If this is the last round, and JWT path is set, check JWT in the file.
jwtFile, err := getJWTFromFile(jwtPath)
if err != nil {
t.Fatalf("%s, failed to read token from file %s: %v", id, jwtPath, err)
}
if jwtFile != wantToken {
t.Errorf("%s, %s has token %s, want %s", id, jwtPath, jwtFile, wantToken)
}
}
}
}
func TestGCEPlugin(t *testing.T) {
testCases := map[string]struct {
jwt string
jwtPath string
expectedToken string
expectedCall int
expectedErr error
}{
"get VM credential": {
jwt: thirdPartyJwt,
jwtPath: fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
expectedToken: thirdPartyJwt,
expectedCall: 1,
},
"jwt path not set": {
jwt: thirdPartyJwt,
expectedCall: 1,
expectedErr: fmt.Errorf("jwtPath is unset"),
},
"fetch credential multiple times": {
expectedCall: 5,
jwtPath: fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
},
}
SetTokenRotation(false)
ms, err := StartMetadataServer()
if err != nil {
t.Fatalf("StartMetadataServer() returns err: %v", err)
}
t.Cleanup(func() {
ms.Stop()
SetTokenRotation(true)
})
for id, tc := range testCases {
p := GCEPlugin{
tokenCache: tc.jwt,
jwtPath: tc.jwtPath,
}
if err := creatJWTFile(tc.jwtPath); err != nil {
t.Fatalf("%s, creatJWTFile() returns err: %v", id, err)
}
ms.Reset()
ms.setToken(tc.jwt)
tokens, errs := getTokenFromServer(t, &p, tc.expectedCall)
verifyError(t, id, errs, tc.expectedErr)
if tc.expectedErr == nil {
if ms.NumGetTokenCall() != tc.expectedCall {
t.Errorf("%s, metadata server receives %d calls, want %d",
id, ms.NumGetTokenCall(), tc.expectedCall)
}
if tc.jwtPath != "" {
verifyToken(t, id, tc.jwtPath, tokens, tc.expectedToken)
}
}
}
}
func TestTokenRotationJob(t *testing.T) {
testCases := map[string]struct {
jwt string
jwtPath string
expectedToken string
expectedCall int
}{
// mock metadata server returns an expired token, that forces rotation job
// to fetch new token during each rotation.
"expired token needs rotation": {
jwt: thirdPartyJwt,
jwtPath: fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
expectedToken: thirdPartyJwt,
expectedCall: 3,
},
// mock metadata server returns a token which has no exp field, that forces rotation job
// to fetch new token during each rotation.
"token with no expiration time needs rotation": {
jwt: firstPartyJwt,
jwtPath: fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
expectedToken: firstPartyJwt,
expectedCall: 3,
},
// mock metadata server returns an invalid token, that forces rotation job
// to fetch new token during each rotation.
"invalid token needs rotation": {
jwt: "invalid-token-section-1.invalid-token-section-2",
jwtPath: fmt.Sprintf("/tmp/security-pkg-credentialfetcher-plugin-gcetest-%s", uuid.New().String()),
expectedCall: 3,
expectedToken: "invalid-token-section-1.invalid-token-section-2",
},
}
// starts rotation job every 0.5 second.
rotationInterval = 500 * time.Millisecond
SetTokenRotation(true)
ms, err := StartMetadataServer()
if err != nil {
t.Fatalf("StartMetadataServer() returns err: %v", err)
}
t.Cleanup(func() {
ms.Stop()
rotationInterval = 5 * time.Minute
})
for id, tc := range testCases {
// These tests should not run in parallel because they share one metadata server.
t.Run(id, func(t *testing.T) {
p := CreateGCEPlugin("", tc.jwtPath, "")
if err := creatJWTFile(tc.jwtPath); err != nil {
t.Fatalf("%s, creatJWTFile() returns err: %v", id, err)
}
ms.Reset()
ms.setToken(tc.jwt)
// Verify that rotation job is kicked multiple times.
retryTimeout := time.Duration(5+tc.expectedCall) * rotationInterval
retry.UntilSuccessOrFail(t, func() error {
callNumber := ms.NumGetTokenCall()
if callNumber < tc.expectedCall {
return fmt.Errorf("%s, got %d token fetch calls, expected %d",
id, callNumber, tc.expectedCall)
}
return nil
}, retry.Delay(time.Second), retry.Timeout(retryTimeout))
p.Stop()
})
}
}