c6c2c525a6
Also fix some tests that were broken by bumping golang and dependencies in the previous commits. Note that in addition to changes made to satisfy the linter which do not impact the behavior of the code, this commit also adds ReadHeaderTimeout to all usages of http.Server to satisfy the linter (and because it seemed like a good suggestion).
570 lines
19 KiB
Go
570 lines
19 KiB
Go
// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package localuserauthenticator
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/crypto/bcrypt"
|
|
authenticationv1beta1 "k8s.io/api/authentication/v1beta1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
utilnet "k8s.io/apimachinery/pkg/util/net"
|
|
kubeinformers "k8s.io/client-go/informers"
|
|
corev1informers "k8s.io/client-go/informers/core/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
kubernetesfake "k8s.io/client-go/kubernetes/fake"
|
|
|
|
"go.pinniped.dev/internal/certauthority"
|
|
"go.pinniped.dev/internal/dynamiccert"
|
|
"go.pinniped.dev/internal/net/phttp"
|
|
)
|
|
|
|
func TestWebhook(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const namespace = "local-user-authenticator"
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
user, otherUser, colonUser, noGroupUser, oneGroupUser, passwordUndefinedUser, emptyPasswordUser, invalidPasswordHashUser, undefinedGroupsUser :=
|
|
"some-user", "other-user", "colon-user", "no-group-user", "one-group-user", "password-undefined-user", "empty-password-user", "invalid-password-hash-user", "undefined-groups-user"
|
|
password, otherPassword, colonPassword, noGroupPassword, oneGroupPassword, undefinedGroupsPassword :=
|
|
"some-password", "other-password", "some-:-password", "no-group-password", "one-group-password", "undefined-groups-password"
|
|
|
|
group0, group1 := "some-group-0", "some-group-1"
|
|
groups := group0 + " , " + group1
|
|
|
|
kubeClient := kubernetesfake.NewSimpleClientset()
|
|
addSecretToFakeClientTracker(t, kubeClient, user, password, groups)
|
|
addSecretToFakeClientTracker(t, kubeClient, otherUser, otherPassword, groups)
|
|
addSecretToFakeClientTracker(t, kubeClient, colonUser, colonPassword, groups)
|
|
addSecretToFakeClientTracker(t, kubeClient, noGroupUser, noGroupPassword, "")
|
|
addSecretToFakeClientTracker(t, kubeClient, oneGroupUser, oneGroupPassword, group0)
|
|
addSecretToFakeClientTracker(t, kubeClient, emptyPasswordUser, "", groups)
|
|
|
|
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: passwordUndefinedUser,
|
|
Namespace: namespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"groups": []byte(groups),
|
|
},
|
|
}))
|
|
|
|
undefinedGroupsUserPasswordHash, err := bcrypt.GenerateFromPassword([]byte(undefinedGroupsPassword), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: undefinedGroupsUser,
|
|
Namespace: namespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"passwordHash": undefinedGroupsUserPasswordHash,
|
|
},
|
|
}))
|
|
|
|
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: invalidPasswordHashUser,
|
|
Namespace: namespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"groups": []byte(groups),
|
|
"passwordHash": []byte("not a valid password hash"),
|
|
},
|
|
}))
|
|
|
|
secretInformer := createSecretInformer(ctx, t, kubeClient)
|
|
|
|
certProvider, caBundle, serverName := newCertProvider(t)
|
|
w := newWebhook(certProvider, secretInformer)
|
|
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
defer func() { _ = l.Close() }()
|
|
require.NoError(t, w.start(ctx, l))
|
|
|
|
client := newClient(t, caBundle, serverName)
|
|
|
|
goodURL := fmt.Sprintf("https://%s/authenticate", l.Addr().String())
|
|
goodRequestHeaders := map[string][]string{
|
|
"Content-Type": {"application/json; charset=UTF-8"},
|
|
"Accept": {"application/json, */*"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
method string
|
|
headers map[string][]string
|
|
body func() (io.ReadCloser, error)
|
|
|
|
wantStatus int
|
|
wantHeaders map[string][]string
|
|
wantBody *authenticationv1beta1.TokenReview
|
|
}{
|
|
{
|
|
name: "success for a user who belongs to multiple groups",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
|
|
},
|
|
{
|
|
name: "success for a user who belongs to one groups",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(oneGroupUser + ":" + oneGroupPassword) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(oneGroupUser, []string{group0}),
|
|
},
|
|
{
|
|
name: "success for a user who belongs to no groups",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(noGroupUser + ":" + noGroupPassword) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(noGroupUser, nil),
|
|
},
|
|
{
|
|
name: "wrong username for password",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(otherUser + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "when a user has no password hash in the secret",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "when a user has an invalid password hash in the secret",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(invalidPasswordHashUser + ":foo") },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "success for a user has no groups defined in the secret",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) {
|
|
return newTokenReviewBody(undefinedGroupsUser + ":" + undefinedGroupsPassword)
|
|
},
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(undefinedGroupsUser, nil),
|
|
},
|
|
{
|
|
name: "when a user has empty string as their password",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "wrong password for username",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + otherPassword) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "non-existent password for username",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + "some-non-existent-password") },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "non-existent username",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-non-existent-user" + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: unauthenticatedResponseJSON(),
|
|
},
|
|
{
|
|
name: "bad token format (missing colon)",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user) },
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "password contains colon",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(colonUser + ":" + colonPassword) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(colonUser, []string{group0, group1}),
|
|
},
|
|
{
|
|
name: "bad TokenReview group",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) {
|
|
return newTokenReviewBodyWithGVK(
|
|
user+":"+password,
|
|
&schema.GroupVersionKind{
|
|
Group: "bad group",
|
|
Version: authenticationv1beta1.SchemeGroupVersion.Version,
|
|
Kind: "TokenReview",
|
|
},
|
|
)
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "bad TokenReview version",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) {
|
|
return newTokenReviewBodyWithGVK(
|
|
user+":"+password,
|
|
&schema.GroupVersionKind{
|
|
Group: authenticationv1beta1.SchemeGroupVersion.Group,
|
|
Version: "bad version",
|
|
Kind: "TokenReview",
|
|
},
|
|
)
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "bad TokenReview kind",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) {
|
|
return newTokenReviewBodyWithGVK(
|
|
user+":"+password,
|
|
&schema.GroupVersionKind{
|
|
Group: authenticationv1beta1.SchemeGroupVersion.Group,
|
|
Version: authenticationv1beta1.SchemeGroupVersion.Version,
|
|
Kind: "wrong-kind",
|
|
},
|
|
)
|
|
},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "bad path",
|
|
url: fmt.Sprintf("https://%s/tuna", l.Addr().String()),
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
|
wantStatus: http.StatusNotFound,
|
|
},
|
|
{
|
|
name: "bad method",
|
|
url: goodURL,
|
|
method: http.MethodGet,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
},
|
|
{
|
|
name: "bad content type",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: map[string][]string{
|
|
"Content-Type": {"application/xml"},
|
|
"Accept": {"application/json"},
|
|
},
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
|
wantStatus: http.StatusUnsupportedMediaType,
|
|
},
|
|
{
|
|
name: "bad accept",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
"Accept": {"application/xml"},
|
|
},
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
|
wantStatus: http.StatusUnsupportedMediaType,
|
|
},
|
|
{
|
|
name: "success when there are multiple accepts and one of them is json",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
"Accept": {"something/else, application/xml, application/json"},
|
|
},
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
|
|
},
|
|
{
|
|
name: "success when there are multiple accepts and one of them is */*",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
"Accept": {"something/else, */*, application/foo"},
|
|
},
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
|
|
},
|
|
{
|
|
name: "success when there are multiple accepts and one of them is application/*",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: map[string][]string{
|
|
"Content-Type": {"application/json"},
|
|
"Accept": {"something/else, application/*, application/foo"},
|
|
},
|
|
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
|
wantStatus: http.StatusOK,
|
|
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
|
wantBody: authenticatedResponseJSON(user, []string{group0, group1}),
|
|
},
|
|
{
|
|
name: "bad body",
|
|
url: goodURL,
|
|
method: http.MethodPost,
|
|
headers: goodRequestHeaders,
|
|
body: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil },
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
parsedURL, err := url.Parse(test.url)
|
|
require.NoError(t, err)
|
|
|
|
body, err := test.body()
|
|
require.NoError(t, err)
|
|
|
|
rsp, err := client.Do(&http.Request{
|
|
Method: test.method,
|
|
URL: parsedURL,
|
|
Header: test.headers,
|
|
Body: body,
|
|
})
|
|
require.NoError(t, err)
|
|
defer func() { _ = rsp.Body.Close() }()
|
|
|
|
require.Equal(t, test.wantStatus, rsp.StatusCode)
|
|
|
|
if test.wantHeaders != nil {
|
|
for k, v := range test.wantHeaders {
|
|
require.Equal(t, v, rsp.Header.Values(k))
|
|
}
|
|
}
|
|
|
|
responseBody, err := io.ReadAll(rsp.Body)
|
|
require.NoError(t, err)
|
|
if test.wantBody != nil {
|
|
require.NoError(t, err)
|
|
|
|
var tr authenticationv1beta1.TokenReview
|
|
require.NoError(t, json.Unmarshal(responseBody, &tr))
|
|
require.Equal(t, test.wantBody, &tr)
|
|
} else {
|
|
require.Empty(t, responseBody)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func createSecretInformer(ctx context.Context, t *testing.T, kubeClient kubernetes.Interface) corev1informers.SecretInformer {
|
|
t.Helper()
|
|
|
|
kubeInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 0)
|
|
|
|
secretInformer := kubeInformers.Core().V1().Secrets()
|
|
|
|
// We need to call Informer() on the secretInformer to lazily instantiate the
|
|
// informer factory before syncing it.
|
|
secretInformer.Informer()
|
|
|
|
kubeInformers.Start(ctx.Done())
|
|
|
|
informerTypesSynced := kubeInformers.WaitForCacheSync(ctx.Done())
|
|
require.True(t, informerTypesSynced[reflect.TypeOf(&corev1.Secret{})])
|
|
|
|
return secretInformer
|
|
}
|
|
|
|
// newClientProvider returns a dynamiccert.Provider configured
|
|
// with valid serving cert, the CA bundle that can be used to verify the serving
|
|
// cert, and the server name that can be used to verify the TLS peer.
|
|
func newCertProvider(t *testing.T) (dynamiccert.Private, []byte, string) {
|
|
t.Helper()
|
|
|
|
serverName := "local-user-authenticator"
|
|
|
|
ca, err := certauthority.New(serverName+" CA", time.Hour*24)
|
|
require.NoError(t, err)
|
|
|
|
cert, err := ca.IssueServerCert([]string{serverName}, nil, time.Hour*24)
|
|
require.NoError(t, err)
|
|
|
|
certPEM, keyPEM, err := certauthority.ToPEM(cert)
|
|
require.NoError(t, err)
|
|
|
|
certProvider := dynamiccert.NewServingCert(t.Name())
|
|
err = certProvider.SetCertKeyContent(certPEM, keyPEM)
|
|
require.NoError(t, err)
|
|
|
|
return certProvider, ca.Bundle(), serverName
|
|
}
|
|
|
|
// newClient creates an http.Client that can be used to make an HTTPS call to a
|
|
// service whose serving certs can be verified by the provided CA bundle.
|
|
func newClient(t *testing.T, caBundle []byte, serverName string) *http.Client {
|
|
t.Helper()
|
|
|
|
rootCAs := x509.NewCertPool()
|
|
ok := rootCAs.AppendCertsFromPEM(caBundle)
|
|
require.True(t, ok)
|
|
|
|
c := phttp.Secure(rootCAs)
|
|
|
|
tlsConfig, err := utilnet.TLSClientConfig(c.Transport)
|
|
require.NoError(t, err)
|
|
tlsConfig.ServerName = serverName
|
|
|
|
return c
|
|
}
|
|
|
|
// newTokenReviewBody creates an io.ReadCloser that contains a JSON-encodeed
|
|
// TokenReview request with expected APIVersion and Kind fields.
|
|
func newTokenReviewBody(token string) (io.ReadCloser, error) {
|
|
return newTokenReviewBodyWithGVK(
|
|
token,
|
|
&schema.GroupVersionKind{
|
|
Group: authenticationv1beta1.SchemeGroupVersion.Group,
|
|
Version: authenticationv1beta1.SchemeGroupVersion.Version,
|
|
Kind: "TokenReview",
|
|
},
|
|
)
|
|
}
|
|
|
|
// newTokenReviewBodyWithGVK creates an io.ReadCloser that contains a
|
|
// JSON-encoded TokenReview request. The TypeMeta fields of the TokenReview are
|
|
// filled in with the provided gvk.
|
|
func newTokenReviewBodyWithGVK(token string, gvk *schema.GroupVersionKind) (io.ReadCloser, error) {
|
|
buf := bytes.NewBuffer([]byte{})
|
|
tr := authenticationv1beta1.TokenReview{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: gvk.GroupVersion().String(),
|
|
Kind: gvk.Kind,
|
|
},
|
|
Spec: authenticationv1beta1.TokenReviewSpec{
|
|
Token: token,
|
|
},
|
|
}
|
|
err := json.NewEncoder(buf).Encode(&tr)
|
|
return io.NopCloser(buf), err
|
|
}
|
|
|
|
func unauthenticatedResponseJSON() *authenticationv1beta1.TokenReview {
|
|
return &authenticationv1beta1.TokenReview{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "TokenReview",
|
|
APIVersion: "authentication.k8s.io/v1beta1",
|
|
},
|
|
Status: authenticationv1beta1.TokenReviewStatus{
|
|
Authenticated: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func authenticatedResponseJSON(user string, groups []string) *authenticationv1beta1.TokenReview {
|
|
return &authenticationv1beta1.TokenReview{
|
|
TypeMeta: metav1.TypeMeta{
|
|
Kind: "TokenReview",
|
|
APIVersion: "authentication.k8s.io/v1beta1",
|
|
},
|
|
Status: authenticationv1beta1.TokenReviewStatus{
|
|
Authenticated: true,
|
|
User: authenticationv1beta1.UserInfo{
|
|
Username: user,
|
|
Groups: groups,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func addSecretToFakeClientTracker(t *testing.T, kubeClient *kubernetesfake.Clientset, username, password, groups string) {
|
|
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
|
require.NoError(t, err)
|
|
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: username,
|
|
Namespace: namespace,
|
|
},
|
|
Data: map[string][]byte{
|
|
"passwordHash": passwordHash,
|
|
"groups": []byte(groups),
|
|
},
|
|
}
|
|
|
|
require.NoError(t, kubeClient.Tracker().Add(secret))
|
|
}
|