ContainerImage.Pinniped/internal/registry/credentialrequest/rest_test.go

401 lines
15 KiB
Go
Raw Normal View History

// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package credentialrequest
import (
"context"
"errors"
"fmt"
"testing"
"time"
Update all deps to latest where possible, bump Kube deps to v0.23.1 Highlights from this dep bump: 1. Made a copy of the v0.4.0 github.com/go-logr/stdr implementation for use in tests. We must bump this dep as Kube code uses a newer version now. We would have to rewrite hundreds of test log assertions without this copy. 2. Use github.com/felixge/httpsnoop to undo the changes made by ory/fosite#636 for CLI based login flows. This is required for backwards compatibility with older versions of our CLI. A separate change after this will update the CLI to be more flexible (it is purposefully not part of this change to confirm that we did not break anything). For all browser login flows, we now redirect using http.StatusSeeOther instead of http.StatusFound. 3. Drop plog.RemoveKlogGlobalFlags as klog no longer mutates global process flags 4. Only bump github.com/ory/x to v0.0.297 instead of the latest v0.0.321 because v0.0.298+ pulls in a newer version of go.opentelemetry.io/otel/semconv which breaks k8s.io/apiserver. We should update k8s.io/apiserver to use the newer code. 5. Migrate all code from k8s.io/apimachinery/pkg/util/clock to k8s.io/utils/clock and k8s.io/utils/clock/testing 6. Delete testutil.NewDeleteOptionsRecorder and migrate to the new kubetesting.NewDeleteActionWithOptions 7. Updated ExpectedAuthorizeCodeSessionJSONFromFuzzing caused by fosite's new rotated_secrets OAuth client field. This new field is currently not relevant to us as we have no private clients. Signed-off-by: Monis Khan <mok@vmware.com>
2021-12-10 22:22:36 +00:00
"github.com/go-logr/logr"
"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/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/authentication/user"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"k8s.io/klog/v2"
"k8s.io/utils/pointer"
loginapi "go.pinniped.dev/generated/latest/apis/concierge/login"
"go.pinniped.dev/internal/issuer"
"go.pinniped.dev/internal/mocks/credentialrequestmocks"
"go.pinniped.dev/internal/mocks/issuermocks"
"go.pinniped.dev/internal/testutil"
)
func TestNew(t *testing.T) {
r := NewREST(nil, nil, schema.GroupResource{Group: "bears", Resource: "panda"})
require.NotNil(t, r)
require.False(t, r.NamespaceScoped())
require.Equal(t, []string{"pinniped"}, r.Categories())
require.IsType(t, &loginapi.TokenCredentialRequest{}, r.New())
require.IsType(t, &loginapi.TokenCredentialRequestList{}, r.NewList())
ctx := context.Background()
// check the simple invariants of our no-op list
list, err := r.List(ctx, nil)
require.NoError(t, err)
require.NotNil(t, list)
require.IsType(t, &loginapi.TokenCredentialRequestList{}, list)
require.Equal(t, "0", list.(*loginapi.TokenCredentialRequestList).ResourceVersion)
require.NotNil(t, list.(*loginapi.TokenCredentialRequestList).Items)
require.Len(t, list.(*loginapi.TokenCredentialRequestList).Items, 0)
// make sure we can turn lists into tables if needed
table, err := r.ConvertToTable(ctx, list, nil)
require.NoError(t, err)
require.NotNil(t, table)
require.Equal(t, "0", table.ResourceVersion)
require.Nil(t, table.Rows)
// exercise group resource - force error by passing a runtime.Object that does not have an embedded object meta
_, err = r.ConvertToTable(ctx, &metav1.APIGroup{}, nil)
require.Error(t, err, "the resource panda.bears does not support being converted to a Table")
}
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) // nolint: staticcheck // old test with lots of log statements
klog.SetLogger(logr.New(logger)) // this is unfortunately a global logger, so can't run these tests in parallel :(
})
it.After(func() {
Update all deps to latest where possible, bump Kube deps to v0.23.1 Highlights from this dep bump: 1. Made a copy of the v0.4.0 github.com/go-logr/stdr implementation for use in tests. We must bump this dep as Kube code uses a newer version now. We would have to rewrite hundreds of test log assertions without this copy. 2. Use github.com/felixge/httpsnoop to undo the changes made by ory/fosite#636 for CLI based login flows. This is required for backwards compatibility with older versions of our CLI. A separate change after this will update the CLI to be more flexible (it is purposefully not part of this change to confirm that we did not break anything). For all browser login flows, we now redirect using http.StatusSeeOther instead of http.StatusFound. 3. Drop plog.RemoveKlogGlobalFlags as klog no longer mutates global process flags 4. Only bump github.com/ory/x to v0.0.297 instead of the latest v0.0.321 because v0.0.298+ pulls in a newer version of go.opentelemetry.io/otel/semconv which breaks k8s.io/apiserver. We should update k8s.io/apiserver to use the newer code. 5. Migrate all code from k8s.io/apimachinery/pkg/util/clock to k8s.io/utils/clock and k8s.io/utils/clock/testing 6. Delete testutil.NewDeleteOptionsRecorder and migrate to the new kubetesting.NewDeleteActionWithOptions 7. Updated ExpectedAuthorizeCodeSessionJSONFromFuzzing caused by fosite's new rotated_secrets OAuth client field. This new field is currently not relevant to us as we have no private clients. Signed-off-by: Monis Khan <mok@vmware.com>
2021-12-10 22:22:36 +00:00
klog.ClearLogger()
ctrl.Finish()
})
it("CreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
}, nil)
clientCertIssuer := issuermocks.NewMockClientCertIssuer(ctrl)
clientCertIssuer.EXPECT().IssueClientCertPEM(
"test-user",
[]string{"test-group-1", "test-group-2"},
5*time.Minute,
).Return([]byte("test-cert"), []byte("test-key"), nil)
storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
r.NoError(err)
r.IsType(&loginapi.TokenCredentialRequest{}, response)
expires := response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp
r.NotNil(expires)
r.InDelta(time.Now().Add(5*time.Minute).Unix(), expires.Unix(), 5)
response.(*loginapi.TokenCredentialRequest).Status.Credential.ExpirationTimestamp = metav1.Time{}
r.Equal(response, &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: &loginapi.ClusterCredential{
ExpirationTimestamp: metav1.Time{},
ClientCertificateData: "test-cert",
ClientKeyData: "test-key",
},
},
})
requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:true`)
})
it("CreateFailsWithValidTokenWhenCertIssuerFails", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
}, nil)
clientCertIssuer := issuermocks.NewMockClientCertIssuer(ctrl)
clientCertIssuer.EXPECT().
IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return(nil, nil, fmt.Errorf("some certificate authority error"))
storage := NewREST(requestAuthenticator, clientCertIssuer, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"failure" failureType:cert issuer,msg:some certificate authority error`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenGivenATokenAndTheWebhookReturnsNilUser", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).Return(nil, nil)
storage := NewREST(requestAuthenticator, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:<none>,hasExtra:false,authenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookFails", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(nil, errors.New("some webhook error"))
2020-09-11 20:08:54 +00:00
storage := NewREST(requestAuthenticator, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"failure" failureType:token authentication,msg:some webhook error`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAnEmptyUsername", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{Name: ""}, nil)
storage := NewREST(requestAuthenticator, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:,hasExtra:false,authenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAUserWithUID", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
UID: "test-uid",
Groups: []string{"test-group-1", "test-group-2"},
}, nil)
storage := NewREST(requestAuthenticator, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:test-uid,hasExtra:false,authenticated:false`)
})
it("CreateSucceedsWithAnUnauthenticatedStatusWhenWebhookReturnsAUserWithExtra", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req).
Return(&user.DefaultInfo{
Name: "test-user",
Groups: []string{"test-group-1", "test-group-2"},
Extra: map[string][]string{"test-key": {"test-val-1", "test-val-2"}},
}, nil)
storage := NewREST(requestAuthenticator, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, req)
requireSuccessfulResponseWithAuthenticationFailureMessage(t, err, response)
requireOneLogStatement(r, logger, `"success" userID:,hasExtra:true,authenticated:false`)
})
it("CreateFailsWhenGivenTheWrongInputType", func() {
notACredentialRequest := runtime.Unknown{}
response, err := NewREST(nil, nil, schema.GroupResource{}).Create(
genericapirequest.NewContext(),
&notACredentialRequest,
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{})
requireAPIError(t, response, err, apierrors.IsBadRequest, "not a TokenCredentialRequest")
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:not a TokenCredentialRequest`)
})
it("CreateFailsWhenTokenValueIsEmptyInRequest", func() {
storage := NewREST(nil, nil, schema.GroupResource{})
response, err := callCreate(context.Background(), storage, credentialRequest(loginapi.TokenCredentialRequestSpec{
Token: "",
}))
requireAPIError(t, response, err, apierrors.IsInvalid,
2020-08-20 17:54:15 +00:00
`.pinniped.dev "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(nil, nil, schema.GroupResource{})
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() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()).
Return(&user.DefaultInfo{Name: "test-user"}, nil)
storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{})
response, err := storage.Create(
context.Background(),
req,
func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest)
credentialRequest.Spec.Token = "foobaz"
return nil
},
&metav1.CreateOptions{})
r.NoError(err)
r.NotEmpty(response)
})
it("CreateDoesNotAllowValidationFunctionToSeeTheActualRequestToken", func() {
req := validCredentialRequest()
requestAuthenticator := credentialrequestmocks.NewMockTokenCredentialRequestAuthenticator(ctrl)
requestAuthenticator.EXPECT().AuthenticateTokenCredentialRequest(gomock.Any(), req.DeepCopy()).
Return(&user.DefaultInfo{Name: "test-user"}, nil)
storage := NewREST(requestAuthenticator, successfulIssuer(ctrl), schema.GroupResource{})
validationFunctionWasCalled := false
var validationFunctionSawTokenValue string
response, err := storage.Create(
context.Background(),
req,
func(ctx context.Context, obj runtime.Object) error {
credentialRequest, _ := obj.(*loginapi.TokenCredentialRequest)
validationFunctionWasCalled = true
validationFunctionSawTokenValue = credentialRequest.Spec.Token
return nil
},
&metav1.CreateOptions{})
r.NoError(err)
r.NotEmpty(response)
r.True(validationFunctionWasCalled)
r.Empty(validationFunctionSawTokenValue)
})
it("CreateFailsWhenRequestOptionsDryRunIsNotEmpty", func() {
response, err := NewREST(nil, nil, schema.GroupResource{}).Create(
genericapirequest.NewContext(),
validCredentialRequest(),
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{
DryRun: []string{"some dry run flag"},
})
requireAPIError(t, response, err, apierrors.IsInvalid,
2020-08-20 17:54:15 +00:00
`.pinniped.dev "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:dryRun not supported`)
})
it("CreateFailsWhenNamespaceIsNotEmpty", func() {
response, err := NewREST(nil, nil, schema.GroupResource{}).Create(
genericapirequest.WithNamespace(genericapirequest.NewContext(), "some-ns"),
validCredentialRequest(),
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{})
requireAPIError(t, response, err, apierrors.IsBadRequest, `namespace is not allowed on TokenCredentialRequest: some-ns`)
requireOneLogStatement(r, logger, `"failure" failureType:request validation,msg:namespace is not allowed`)
})
}, 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, obj runtime.Object) (runtime.Object, error) {
return storage.Create(
ctx,
obj,
rest.ValidateAllObjectFunc,
&metav1.CreateOptions{
DryRun: []string{},
})
}
func validCredentialRequest() *loginapi.TokenCredentialRequest {
return validCredentialRequestWithToken("some token")
}
func validCredentialRequestWithToken(token string) *loginapi.TokenCredentialRequest {
return credentialRequest(loginapi.TokenCredentialRequestSpec{Token: token})
}
func credentialRequest(spec loginapi.TokenCredentialRequestSpec) *loginapi.TokenCredentialRequest {
return &loginapi.TokenCredentialRequest{
TypeMeta: metav1.TypeMeta{},
ObjectMeta: metav1.ObjectMeta{
Name: "request name",
},
Spec: spec,
}
}
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, &loginapi.TokenCredentialRequest{
Status: loginapi.TokenCredentialRequestStatus{
Credential: nil,
Message: pointer.StringPtr("authentication failed"),
},
})
}
func successfulIssuer(ctrl *gomock.Controller) issuer.ClientCertIssuer {
clientCertIssuer := issuermocks.NewMockClientCertIssuer(ctrl)
clientCertIssuer.EXPECT().
IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()).
Return([]byte("test-cert"), []byte("test-key"), nil)
return clientCertIssuer
}