ContainerImage.Pinniped/internal/registry/credentialrequest/rest_test.go
Matt Moyer 1b9a70d089
Switch back to an exec-based approach to grab the controller-manager CA. (#65)
This switches us back to an approach where we use the Pod "exec" API to grab the keys we need, rather than forcing our code to run on the control plane node. It will help us fail gracefully (or dynamically switch to alternate implementations) when the cluster is not self-hosted.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
Co-authored-by: Ryan Richard <richardry@vmware.com>
2020-08-19 13:21:07 -05:00

505 lines
18 KiB
Go

/*
Copyright 2020 VMware, Inc.
SPDX-License-Identifier: Apache-2.0
*/
package credentialrequest
import (
"context"
"crypto/x509/pkix"
"errors"
"fmt"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/sclevine/spec"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/klog/v2"
"github.com/suzerain-io/placeholder-name/internal/mocks/mockcertissuer"
"github.com/suzerain-io/placeholder-name/internal/testutil"
placeholderapi "github.com/suzerain-io/placeholder-name/kubernetes/1.19/api/apis/placeholder"
)
type contextKey struct{}
type FakeToken struct {
calledWithToken string
calledWithContext context.Context
timeout time.Duration
reachedTimeout bool
cancelled bool
webhookStartedRunningNotificationChan chan bool
returnResponse *authenticator.Response
returnUnauthenticated bool
returnErr error
}
func (f *FakeToken) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
f.calledWithToken = token
f.calledWithContext = ctx
if f.webhookStartedRunningNotificationChan != nil {
f.webhookStartedRunningNotificationChan <- true
}
afterCh := time.After(f.timeout)
select {
case <-afterCh:
f.reachedTimeout = true
case <-ctx.Done():
f.cancelled = true
}
return f.returnResponse, !f.returnUnauthenticated, f.returnErr
}
func TestCreate(t *testing.T) {
spec.Run(t, "create", func(t *testing.T, when spec.G, it spec.S) {
var r *require.Assertions
var ctrl *gomock.Controller
var logger *testutil.TranscriptLogger
it.Before(func() {
r = require.New(t)
ctrl = gomock.NewController(t)
logger = testutil.NewTranscriptLogger(t)
klog.SetLogger(logger) // this is unfortunately a global logger, so can't run these tests in parallel :(
})
it.After(func() {
klog.SetLogger(nil)
ctrl.Finish()
})
it("CreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "test-user",
UID: "test-user-uid",
Groups: []string{"test-group-1", "test-group-2"},
},
},
returnUnauthenticated: false,
}
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
issuer.EXPECT().IssuePEM(
pkix.Name{
CommonName: "test-user",
Organization: []string{"test-group-1", "test-group-2"}},
[]string{},
1*time.Hour,
).Return([]byte("test-cert"), []byte("test-key"), nil)
storage := NewREST(&webhook, issuer)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken))
r.NoError(err)
r.IsType(&placeholderapi.CredentialRequest{}, response)
expires := response.(*placeholderapi.CredentialRequest).Status.Credential.ExpirationTimestamp
r.NotNil(expires)
r.InDelta(time.Now().Add(1*time.Hour).Unix(), expires.Unix(), 5)
response.(*placeholderapi.CredentialRequest).Status.Credential.ExpirationTimestamp = metav1.Time{}
r.Equal(response, &placeholderapi.CredentialRequest{
Status: placeholderapi.CredentialRequestStatus{
Credential: &placeholderapi.CredentialRequestCredential{
ExpirationTimestamp: metav1.Time{},
ClientCertificateData: "test-cert",
ClientKeyData: "test-key",
},
},
})
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"success" userID:test-user-uid,idpAuthenticated:true`)
})
it("CreateFailsWithValidTokenWhenCertIssuerFails", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
},
},
returnUnauthenticated: false,
}
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
issuer.EXPECT().
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil, fmt.Errorf("some certificate authority error"))
storage := NewREST(&webhook, issuer)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken))
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsUnauthenticatedWithUserId", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{UID: "test-user-uid"},
},
returnUnauthenticated: true,
}
storage := NewREST(&webhook, nil)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken))
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"success" userID:test-user-uid,idpAuthenticated:false,placeholderNameAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsUnauthenticatedWithNilUser", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{User: nil},
returnUnauthenticated: true,
}
storage := NewREST(&webhook, nil)
requestToken := "a token"
response, err := callCreate(context.Background(), storage, validCredentialRequestWithToken(requestToken))
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
r.Equal(requestToken, webhook.calledWithToken)
requireOneLogStatement(r, logger, `"success" userID:<none>,idpAuthenticated:false,placeholderNameAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails", func() {
webhook := FakeToken{
returnErr: errors.New("some webhook error"),
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest())
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"failure" failureType:webhook authentication,msg:some webhook error`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookDoesNotReturnAnyUserInfo", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{},
returnUnauthenticated: false,
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest())
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:<none>,idpAuthenticated:true,placeholderNameAuthenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() {
webhook := FakeToken{
returnResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: "",
},
},
}
storage := NewREST(&webhook, nil)
response, err := callCreate(context.Background(), storage, validCredentialRequest())
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:,idpAuthenticated:true,placeholderNameAuthenticated:false`)
})
it("CreateDoesNotPassAdditionalContextInfoToTheWebhook", func() {
webhook := FakeToken{
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
_, err := callCreate(ctx, storage, validCredentialRequest())
r.NoError(err)
r.Nil(webhook.calledWithContext.Value("context-key"))
})
it("CreateFailsWhenGivenTheWrongInputType", func() {
notACredentialRequest := runtime.Unknown{}
response, err := NewREST(&FakeToken{}, nil).Create(
genericapirequest.NewContext(),
&notACredentialRequest,
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{})
requireAPIError(t, response, err, apierrors.IsBadRequest, "not a CredentialRequest")
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:not a CredentialRequest`)
})
it("CreateFailsWhenTokenIsNilInRequest", func() {
storage := NewREST(&FakeToken{}, nil)
response, err := callCreate(context.Background(), storage, credentialRequest(placeholderapi.CredentialRequestSpec{
Type: placeholderapi.TokenCredentialType,
Token: nil,
}))
requireAPIError(t, response, err, apierrors.IsInvalid,
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.token.value: Required value: token must be supplied`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:token must be supplied`)
})
it("CreateFailsWhenTypeInRequestIsMissing", func() {
storage := NewREST(&FakeToken{}, nil)
response, err := callCreate(context.Background(), storage, credentialRequest(placeholderapi.CredentialRequestSpec{
Type: "",
Token: &placeholderapi.CredentialRequestTokenCredential{Value: "a token"},
}))
requireAPIError(t, response, err, apierrors.IsInvalid,
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.type: Required value: type must be supplied`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:type must be supplied`)
})
it("CreateFailsWhenTypeInRequestIsNotLegal", func() {
storage := NewREST(&FakeToken{}, nil)
response, err := callCreate(context.Background(), storage, credentialRequest(placeholderapi.CredentialRequestSpec{
Type: "this in an invalid type",
Token: &placeholderapi.CredentialRequestTokenCredential{Value: "a token"},
}))
requireAPIError(t, response, err, apierrors.IsInvalid,
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.type: Invalid value: "this in an invalid type": unrecognized type`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:unrecognized type`)
})
it("CreateFailsWhenTokenValueIsEmptyInRequest", func() {
storage := NewREST(&FakeToken{}, nil)
response, err := callCreate(context.Background(), storage, credentialRequest(placeholderapi.CredentialRequestSpec{
Type: placeholderapi.TokenCredentialType,
Token: &placeholderapi.CredentialRequestTokenCredential{Value: ""},
}))
requireAPIError(t, response, err, apierrors.IsInvalid,
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.token.value: Required value: token must be supplied`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:token must be supplied`)
})
it("CreateFailsWhenValidationFails", func() {
storage := NewREST(&FakeToken{}, nil)
response, err := storage.Create(
context.Background(),
validCredentialRequest(),
func(ctx context.Context, obj runtime.Object) error {
return fmt.Errorf("some validation error")
},
&metav1.CreateOptions{})
r.Nil(response)
r.EqualError(err, "some validation error")
requireOneLogStatement(r, logger, `"failure" failureType:validation webhook,msg:some validation error`)
})
it("CreateDoesNotAllowValidationFunctionToMutateRequest", func() {
webhook := FakeToken{
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
requestToken := "a token"
response, err := storage.Create(
context.Background(),
validCredentialRequestWithToken(requestToken),
func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*placeholderapi.CredentialRequest)
credentialRequest.Spec.Token.Value = "foobaz"
return nil
},
&metav1.CreateOptions{})
r.NoError(err)
r.NotEmpty(response)
r.Equal(requestToken, webhook.calledWithToken) // i.e. not called with foobaz
})
it("CreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken", func() {
webhook := FakeToken{
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
validationFunctionWasCalled := false
var validationFunctionSawTokenValue string
response, err := storage.Create(
context.Background(),
validCredentialRequest(),
func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*placeholderapi.CredentialRequest)
validationFunctionWasCalled = true
validationFunctionSawTokenValue = credentialRequest.Spec.Token.Value
return nil
},
&metav1.CreateOptions{})
r.NoError(err)
r.NotEmpty(response)
r.True(validationFunctionWasCalled)
r.Empty(validationFunctionSawTokenValue)
})
it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() {
response, err := NewREST(&FakeToken{}, nil).Create(
genericapirequest.NewContext(),
validCredentialRequest(),
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{
DryRun: []string{"some dry run flag"},
})
requireAPIError(t, response, err, apierrors.IsInvalid,
`.placeholder.suzerain-io.github.io "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`)
})
it("CreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself", func() {
webhookStartedRunningNotificationChan := make(chan bool)
webhook := FakeToken{
timeout: time.Second * 2,
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx, cancel := context.WithCancel(context.Background())
c := make(chan bool)
go func() {
_, err := callCreate(ctx, storage, validCredentialRequest())
c <- true
r.NoError(err)
}()
r.False(webhook.cancelled)
r.False(webhook.reachedTimeout)
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
cancel() // cancel the context that was passed to storage.Create() above
<-c // wait for the above call to storage.Create() to be finished
r.True(webhook.cancelled)
r.False(webhook.reachedTimeout)
r.Equal(context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled
})
it("CreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself", func() {
webhookStartedRunningNotificationChan := make(chan bool)
webhook := FakeToken{
timeout: 0,
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
returnResponse: webhookSuccessResponse(),
}
storage := NewREST(&webhook, successfulIssuer(ctrl))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c := make(chan bool)
go func() {
_, err := callCreate(ctx, storage, validCredentialRequest())
c <- true
r.NoError(err)
}()
r.False(webhook.cancelled)
r.False(webhook.reachedTimeout)
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
<-c // wait for the above call to storage.Create() to be finished
r.False(webhook.cancelled)
r.True(webhook.reachedTimeout)
r.Equal(context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled (in this case by the "defer")
})
}, spec.Sequential())
}
func requireOneLogStatement(r *require.Assertions, logger *testutil.TranscriptLogger, messageContains string) {
transcript := logger.Transcript()
r.Len(transcript, 1)
r.Equal("info", transcript[0].Level)
r.Contains(transcript[0].Message, messageContains)
}
func callCreate(ctx context.Context, storage *REST, credentialRequest *placeholderapi.CredentialRequest) (runtime.Object, error) {
return storage.Create(
ctx,
credentialRequest,
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{
DryRun: []string{},
})
}
func validCredentialRequest() *placeholderapi.CredentialRequest {
return validCredentialRequestWithToken("some token")
}
func validCredentialRequestWithToken(token string) *placeholderapi.CredentialRequest {
return credentialRequest(placeholderapi.CredentialRequestSpec{
Type: placeholderapi.TokenCredentialType,
Token: &placeholderapi.CredentialRequestTokenCredential{Value: token},
})
}
func credentialRequest(spec placeholderapi.CredentialRequestSpec) *placeholderapi.CredentialRequest {
return &placeholderapi.CredentialRequest{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: "request name",
},
Spec: spec,
}
}
func webhookSuccessResponse() *authenticator.Response {
return &authenticator.Response{User: &user.DefaultInfo{
Name: "some-user",
UID: "",
Groups: []string{},
Extra: nil,
}}
}
func requireAPIError(t *testing.T, response runtime.Object, err error, expectedErrorTypeChecker func(err error) bool, expectedErrorMessage string) {
t.Helper()
require.Nil(t, response)
require.True(t, expectedErrorTypeChecker(err))
var status apierrors.APIStatus
errors.As(err, &status)
require.Contains(t, status.Status().Message, expectedErrorMessage)
}
func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err error, response runtime.Object) {
t.Helper()
require.NoError(t, err)
require.Equal(t, response, &placeholderapi.CredentialRequest{
Status: placeholderapi.CredentialRequestStatus{
Credential: nil,
Message: stringPtr("authentication failed"),
},
})
}
func successfulIssuer(ctrl *gomock.Controller) CertIssuer {
issuer := mockcertissuer.NewMockCertIssuer(ctrl)
issuer.EXPECT().
IssuePEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return([]byte("test-cert"), []byte("test-key"), nil)
return issuer
}
func stringPtr(s string) *string {
return &s
}