summaryrefslogtreecommitdiffhomepage
path: root/cmd/k8s-operator/idp_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/k8s-operator/idp_test.go')
-rw-r--r--cmd/k8s-operator/idp_test.go606
1 files changed, 606 insertions, 0 deletions
diff --git a/cmd/k8s-operator/idp_test.go b/cmd/k8s-operator/idp_test.go
new file mode 100644
index 000000000..70b7cf90f
--- /dev/null
+++ b/cmd/k8s-operator/idp_test.go
@@ -0,0 +1,606 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !plan9
+
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "go.uber.org/zap"
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ rbacv1 "k8s.io/api/rbac/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "k8s.io/client-go/tools/record"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ tsapi "tailscale.com/k8s-operator/apis/v1alpha1"
+ "tailscale.com/tstime"
+)
+
+func TestIDPReconciler_BasicFlow(t *testing.T) {
+ // Test basic creation flow similar to Recorder
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:k8s"},
+ },
+ }
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatalf("failed to create IDP: %v", err)
+ }
+
+ req := reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ }
+
+ _, err := r.Reconcile(context.Background(), req)
+ if err != nil {
+ t.Fatalf("reconciliation failed: %v", err)
+ }
+
+ // Verify resources were created
+ verifyResourcesCreated(t, fc, "test-idp", "tailscale")
+}
+
+func TestTSIDPEnv(t *testing.T) {
+ tests := []struct {
+ name string
+ idp *tsapi.IDP
+ wantEnv map[string]string
+ }{
+ {
+ name: "basic",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 443,
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-test",
+ "TSIDP_PORT": "443",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ },
+ },
+ {
+ name: "with-funnel-and-local-port",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-mcp",
+ Port: 8443,
+ EnableFunnel: true,
+ LocalPort: &[]int32{9080}[0],
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-mcp",
+ "TSIDP_PORT": "8443",
+ "TSIDP_FUNNEL": "true",
+ "TSIDP_LOCAL_PORT": "9080",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ },
+ },
+ {
+ name: "with-custom-env",
+ idp: &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{Name: "test-idp"},
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-mcp",
+ Port: 8443,
+ EnableFunnel: true,
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ Container: tsapi.IDPContainer{
+ Env: []tsapi.Env{
+ {Name: tsapi.Name("CUSTOM_VAR"), Value: "custom-value"},
+ },
+ },
+ },
+ },
+ },
+ },
+ wantEnv: map[string]string{
+ "TS_STATE": "kube:test-idp-state",
+ "TSIDP_VERBOSE": "true",
+ "TS_HOSTNAME": "idp-mcp",
+ "TSIDP_PORT": "8443",
+ "TSIDP_FUNNEL": "true",
+ "TSIDP_FUNNEL_CLIENTS_STORE": "kube:test-idp-funnel-clients",
+ "CUSTOM_VAR": "custom-value",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ env := idpEnv(tt.idp, "")
+
+ envMap := make(map[string]string)
+ for _, e := range env {
+ if e.Value != "" {
+ envMap[e.Name] = e.Value
+ }
+ }
+
+ for key, expected := range tt.wantEnv {
+ if got, exists := envMap[key]; !exists {
+ t.Errorf("expected env var %s not found", key)
+ } else if got != expected {
+ t.Errorf("env var %s: expected %q, got %q", key, expected, got)
+ }
+ }
+
+ var hasAuthKey bool
+ for _, e := range env {
+ if e.Name == "TS_AUTHKEY" && e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
+ hasAuthKey = true
+ break
+ }
+ }
+ if !hasAuthKey {
+ t.Error("expected TS_AUTHKEY to be set via secret reference")
+ }
+ })
+ }
+}
+
+func TestIDPStatusConditions(t *testing.T) {
+ // Test that invalid specs produce proper status conditions
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ Finalizers: []string{FinalizerName},
+ },
+ Spec: tsapi.IDPSpec{
+ Tags: tsapi.Tags{"invalid-tag"}, // Missing tag: prefix
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithObjects(idp).
+ WithStatusSubresource(idp).
+ Build()
+
+ fr := record.NewFakeRecorder(10)
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: fr,
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ updatedIDP := &tsapi.IDP{}
+ if err := fc.Get(context.Background(), client.ObjectKey{Name: idp.Name, Namespace: idp.Namespace}, updatedIDP); err != nil {
+ t.Fatal(err)
+ }
+
+ if len(updatedIDP.Status.Conditions) != 1 {
+ t.Fatalf("expected 1 condition, got %d", len(updatedIDP.Status.Conditions))
+ }
+
+ cond := updatedIDP.Status.Conditions[0]
+ if cond.Type != string(tsapi.IDPReady) || cond.Status != metav1.ConditionFalse || cond.Reason != reasonIDPInvalid {
+ t.Fatalf("expected condition IDPReady false with reason IDPInvalid, got %v", cond)
+ }
+
+ if !strings.Contains(cond.Message, "must start with 'tag:'") {
+ t.Errorf("expected validation error in condition message, got %q", cond.Message)
+ }
+
+ select {
+ case event := <-fr.Events:
+ if !strings.Contains(event, "IDPInvalid") {
+ t.Errorf("expected IDPInvalid event, got %q", event)
+ }
+ default:
+ t.Error("expected event to be recorded")
+ }
+}
+
+func TestIDPValidation(t *testing.T) {
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ }
+
+ tests := []struct {
+ name string
+ idp *tsapi.IDP
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "valid",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:k8s", "tag:mcp"},
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid-tag-missing-prefix",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"invalid-tag"},
+ },
+ },
+ wantErr: true,
+ errMsg: "must start with 'tag:'",
+ },
+ {
+ name: "invalid-tag-empty-name",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:"},
+ },
+ },
+ wantErr: true,
+ errMsg: "tag names must not be empty",
+ },
+ {
+ name: "invalid-tag-special-chars",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Tags: tsapi.Tags{"tag:test@123"},
+ },
+ },
+ wantErr: true,
+ errMsg: "tag names can only contain numbers, letters, or dashes",
+ },
+ {
+ name: "hostname-too-long",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "this-hostname-is-way-too-long-and-exceeds-the-63-character-limit-for-dns-names",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be 63 characters or less",
+ },
+ {
+ name: "hostname-invalid-chars",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp_test",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be a valid DNS label",
+ },
+ {
+ name: "hostname-starts-with-dash",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "-idp-test",
+ },
+ },
+ wantErr: true,
+ errMsg: "must be a valid DNS label",
+ },
+ {
+ name: "invalid-port-zero",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 0,
+ },
+ },
+ wantErr: false, // Port 0 means default (443)
+ },
+ {
+ name: "invalid-port-too-high",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ Port: 65536,
+ },
+ },
+ wantErr: true,
+ errMsg: "out of valid range",
+ },
+ {
+ name: "funnel-with-non-443-port",
+ idp: &tsapi.IDP{
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ EnableFunnel: true,
+ Port: 8443,
+ },
+ },
+ wantErr: true,
+ errMsg: "port must be 443 or unset",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := r.validate(context.Background(), tt.idp)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if err != nil && tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("validate() error = %v, expected to contain %q", err, tt.errMsg)
+ }
+ })
+ }
+}
+
+func TestIDPServiceAccountHandling(t *testing.T) {
+ // Test custom ServiceAccount name works
+ t.Run("custom_service_account_name", func(t *testing.T) {
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ ServiceAccount: tsapi.IDPServiceAccount{
+ Name: "custom-sa",
+ },
+ },
+ },
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ // Verify custom ServiceAccount was created
+ sa := &corev1.ServiceAccount{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: "custom-sa",
+ Namespace: "tailscale",
+ }, sa); err != nil {
+ t.Errorf("expected custom ServiceAccount to be created: %v", err)
+ }
+ })
+
+ // Test ServiceAccount conflict detection
+ t.Run("service_account_conflict", func(t *testing.T) {
+ existingSA := &corev1.ServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "existing-sa",
+ Namespace: "tailscale",
+ OwnerReferences: []metav1.OwnerReference{
+ {
+ APIVersion: "v1",
+ Kind: "Pod",
+ Name: "other-pod",
+ UID: "12345",
+ },
+ },
+ },
+ }
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ StatefulSet: tsapi.IDPStatefulSet{
+ Pod: tsapi.IDPPod{
+ ServiceAccount: tsapi.IDPServiceAccount{
+ Name: "existing-sa",
+ },
+ },
+ },
+ },
+ }
+
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ WithObjects(existingSA).
+ Build()
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ req := reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: idp.Name,
+ Namespace: idp.Namespace,
+ },
+ }
+
+ _, err := r.Reconcile(context.Background(), req)
+ if err == nil {
+ t.Error("expected error for ServiceAccount conflict")
+ }
+ })
+}
+
+func TestIDPDeletion(t *testing.T) {
+ // Test deletion flow - similar to Recorder
+ fc := fake.NewClientBuilder().
+ WithScheme(tsapi.GlobalScheme).
+ WithStatusSubresource(&tsapi.IDP{}).
+ Build()
+
+ idp := &tsapi.IDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-idp",
+ Namespace: "default",
+ Finalizers: []string{FinalizerName},
+ },
+ Spec: tsapi.IDPSpec{
+ Hostname: "idp-test",
+ },
+ }
+
+ r := &IDPReconciler{
+ Client: fc,
+ l: zap.L().Sugar(),
+ recorder: record.NewFakeRecorder(100),
+ tsNamespace: "tailscale",
+ clock: tstime.DefaultClock{},
+ tsClient: &fakeTSClient{},
+ }
+
+ if err := fc.Create(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create resources
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+
+ // Delete IDP
+ if err := fc.Delete(context.Background(), idp); err != nil {
+ t.Fatal(err)
+ }
+
+ // Reconcile deletion
+ expectReconciled(t, r, idp.Namespace, idp.Name)
+}
+
+func verifyResourcesCreated(t *testing.T, fc client.Client, name, namespace string) {
+ t.Helper()
+
+ sa := &corev1.ServiceAccount{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, sa); err != nil {
+ t.Errorf("expected ServiceAccount to be created: %v", err)
+ }
+
+ role := &rbacv1.Role{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, role); err != nil {
+ t.Errorf("expected Role to be created: %v", err)
+ }
+
+ rb := &rbacv1.RoleBinding{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, rb); err != nil {
+ t.Errorf("expected RoleBinding to be created: %v", err)
+ }
+
+ sts := &appsv1.StatefulSet{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, sts); err != nil {
+ t.Errorf("expected StatefulSet to be created: %v", err)
+ }
+
+ svc := &corev1.Service{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, svc); err != nil {
+ t.Errorf("expected Service to be created: %v", err)
+ }
+
+ authSecret := &corev1.Secret{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name,
+ Namespace: namespace,
+ }, authSecret); err != nil {
+ t.Errorf("expected auth Secret to be created: %v", err)
+ }
+
+ funnelSecret := &corev1.Secret{}
+ if err := fc.Get(context.Background(), types.NamespacedName{
+ Name: name + "-funnel-clients",
+ Namespace: namespace,
+ }, funnelSecret); err != nil {
+ t.Errorf("expected funnel clients Secret to be created: %v", err)
+ } else {
+ if data, ok := funnelSecret.Data["funnel-clients"]; !ok {
+ t.Error("expected funnel-clients data key in secret")
+ } else if string(data) != "{}" {
+ t.Errorf("expected funnel-clients data to be '{}', got '%s'", string(data))
+ }
+ }
+}