ContainerImage.Pinniped/cmd/pinniped/cmd/exchange_credential_test.go
Matt Moyer 541336b997
Fix docstring for exchange credential CLI.
Signed-off-by: Matt Moyer <moyerm@vmware.com>
2020-09-22 10:03:32 -05:00

297 lines
10 KiB
Go

// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"bytes"
"context"
"fmt"
"io"
"testing"
"time"
"github.com/sclevine/spec"
"github.com/sclevine/spec/report"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
"go.pinniped.dev/internal/here"
"go.pinniped.dev/internal/testutil"
)
var (
knownGoodUsageForExchangeCredential = here.Doc(`
Usage:
exchange-credential [flags]
Flags:
-h, --help help for exchange-credential
`)
knownGoodHelpForExchangeCredential = here.Doc(`
Exchange a credential which proves your identity for a time-limited,
cluster-specific access credential.
Designed to be conveniently used as an credential plugin for kubectl.
See the help message for 'pinniped get-kubeconfig' for more
information about setting up a kubeconfig file using Pinniped.
Requires all of the following environment variables, which are
typically set in the kubeconfig:
- PINNIPED_TOKEN: the token to send to Pinniped for exchange
- PINNIPED_NAMESPACE: the namespace of the identity provider to authenticate
against
- PINNIPED_IDP_TYPE: the type of identity provider to authenticate
against (e.g., "webhook")
- PINNIPED_IDP_NAME: the name of the identity provider to authenticate
against
- PINNIPED_CA_BUNDLE: the CA bundle to trust when calling
Pinniped's HTTPS endpoint
- PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential
exchange API
For more information about credential plugins in general, see
https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins
Usage:
exchange-credential [flags]
Flags:
-h, --help help for exchange-credential
`)
)
func TestNewCredentialExchangeCmd(t *testing.T) {
spec.Run(t, "newCredentialExchangeCmd", func(t *testing.T, when spec.G, it spec.S) {
var r *require.Assertions
var stdout, stderr *bytes.Buffer
it.Before(func() {
r = require.New(t)
stdout, stderr = bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{})
})
it("calls runFunc and does not print usage or help when correct arguments and flags are used", func() {
c := newExchangeCredentialCmd([]string{}, stdout, stderr)
runFuncCalled := false
c.runFunc = func(out, err io.Writer) {
runFuncCalled = true
}
r.NoError(c.cmd.Execute())
r.True(runFuncCalled)
r.Empty(stdout.String())
r.Empty(stderr.String())
})
it("fails when args are passed", func() {
c := newExchangeCredentialCmd([]string{"some-arg"}, stdout, stderr)
runFuncCalled := false
c.runFunc = func(out, err io.Writer) {
runFuncCalled = true
}
errorMessage := `unknown command "some-arg" for "exchange-credential"`
r.EqualError(c.cmd.Execute(), errorMessage)
r.False(runFuncCalled)
output := "Error: " + errorMessage + "\n" + knownGoodUsageForExchangeCredential
r.Equal(output, stdout.String())
r.Empty(stderr.String())
})
it("prints a nice help message", func() {
c := newExchangeCredentialCmd([]string{"--help"}, stdout, stderr)
runFuncCalled := false
c.runFunc = func(out, err io.Writer) {
runFuncCalled = true
}
r.NoError(c.cmd.Execute())
r.False(runFuncCalled)
r.Equal(knownGoodHelpForExchangeCredential, stdout.String())
r.Empty(stderr.String())
})
}, spec.Parallel(), spec.Report(report.Terminal{}))
}
func TestExchangeCredential(t *testing.T) {
spec.Run(t, "cmd.exchangeCredential", func(t *testing.T, when spec.G, it spec.S) {
var r *require.Assertions
var buffer *bytes.Buffer
var tokenExchanger tokenExchanger
var fakeEnv map[string]string
var envGetter envGetter = func(envVarName string) (string, bool) {
value, present := fakeEnv[envVarName]
if !present {
return "", false
}
return value, true
}
it.Before(func() {
r = require.New(t)
buffer = new(bytes.Buffer)
fakeEnv = map[string]string{
"PINNIPED_NAMESPACE": "namespace from env",
"PINNIPED_IDP_TYPE": "Webhook",
"PINNIPED_IDP_NAME": "webhook name from env",
"PINNIPED_TOKEN": "token from env",
"PINNIPED_CA_BUNDLE": "ca bundle from env",
"PINNIPED_K8S_API_ENDPOINT": "k8s api from env",
}
})
when("env vars are missing", func() {
it("returns an error when PINNIPED_NAMESPACE is missing", func() {
delete(fakeEnv, "PINNIPED_NAMESPACE")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_NAMESPACE")
})
it("returns an error when PINNIPED_IDP_TYPE is missing", func() {
delete(fakeEnv, "PINNIPED_IDP_TYPE")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_IDP_TYPE")
})
it("returns an error when PINNIPED_IDP_NAME is missing", func() {
delete(fakeEnv, "PINNIPED_IDP_NAME")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_IDP_NAME")
})
it("returns an error when PINNIPED_TOKEN is missing", func() {
delete(fakeEnv, "PINNIPED_TOKEN")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_TOKEN")
})
it("returns an error when PINNIPED_CA_BUNDLE is missing", func() {
delete(fakeEnv, "PINNIPED_CA_BUNDLE")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_CA_BUNDLE")
})
it("returns an error when PINNIPED_K8S_API_ENDPOINT is missing", func() {
delete(fakeEnv, "PINNIPED_K8S_API_ENDPOINT")
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_K8S_API_ENDPOINT")
})
})
when("env vars are invalid", func() {
it("returns an error when PINNIPED_IDP_TYPE is missing", func() {
fakeEnv["PINNIPED_IDP_TYPE"] = "invalid"
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, `invalid IDP type: "invalid", supported values are "webhook"`)
})
})
when("the token exchange fails", func() {
it.Before(func() {
tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) {
return nil, fmt.Errorf("some error")
}
})
it("returns an error", func() {
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.EqualError(err, "failed to get credential: some error")
})
})
when("the JSON encoder fails", func() {
it.Before(func() {
tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) {
return &clientauthenticationv1beta1.ExecCredential{
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
Token: "some token",
},
}, nil
}
})
it("returns an error", func() {
err := exchangeCredential(envGetter, tokenExchanger, &testutil.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second)
r.EqualError(err, "failed to marshal response to stdout: some IO error")
})
})
when("the token exchange times out", func() {
it.Before(func() {
tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) {
select {
case <-time.After(100 * time.Millisecond):
return &clientauthenticationv1beta1.ExecCredential{
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
Token: "some token",
},
}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
})
it("returns an error", func() {
err := exchangeCredential(envGetter, tokenExchanger, buffer, 1*time.Millisecond)
r.EqualError(err, "failed to get credential: context deadline exceeded")
})
})
when("the token exchange succeeds", func() {
var actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint string
it.Before(func() {
tokenExchanger = func(ctx context.Context, namespace string, idp corev1.TypedLocalObjectReference, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) {
actualNamespace, actualToken, actualCaBundle, actualAPIEndpoint = namespace, token, caBundle, apiEndpoint
now := metav1.NewTime(time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC))
return &clientauthenticationv1beta1.ExecCredential{
TypeMeta: metav1.TypeMeta{
Kind: "ExecCredential",
APIVersion: "client.authentication.k8s.io/v1beta1",
},
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
ExpirationTimestamp: &now,
ClientCertificateData: "some certificate",
ClientKeyData: "some key",
Token: "some token",
},
}, nil
}
})
it("writes the execCredential to the given writer", func() {
err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second)
r.NoError(err)
r.Equal(fakeEnv["PINNIPED_NAMESPACE"], actualNamespace)
r.Equal(fakeEnv["PINNIPED_TOKEN"], actualToken)
r.Equal(fakeEnv["PINNIPED_CA_BUNDLE"], actualCaBundle)
r.Equal(fakeEnv["PINNIPED_K8S_API_ENDPOINT"], actualAPIEndpoint)
expected := `{
"kind": "ExecCredential",
"apiVersion": "client.authentication.k8s.io/v1beta1",
"spec": {},
"status": {
"expirationTimestamp":"2020-07-29T01:02:03Z",
"clientCertificateData": "some certificate",
"clientKeyData":"some key",
"token": "some token"
}
}`
r.JSONEq(expected, buffer.String())
})
})
}, spec.Parallel(), spec.Report(report.Terminal{}))
}