package k8ssecret import ( "context" "encoding/json" "fmt" "log/slog" "os" "strings" "testing" "github.com/certctl-io/certctl/internal/connector/target" ) // testLogger returns a slog.Logger for test output. func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelWarn})) } // --- Test Certificate Generation --- // generateTestCert creates a simple self-signed certificate for testing. // Returns cert PEM and key PEM strings. func generateTestCert(t *testing.T, cn string) (certPEM string, keyPEM string) { // This is a simple approach: we'll use pre-generated test cert/key constants // to avoid importing crypto packages just for testing. Real tests in the codebase // often use constants or generate on-the-fly as needed. // For simplicity, use a fixed test certificate (self-signed) certPEM = `-----BEGIN CERTIFICATE----- MIICljCCAX4CCQDfhEj1uAEUBDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV UzAeFw0yMzAxMDExMjAwMDBaFw0yNDAxMDExMjAwMDBaMA0xCzAJBgNVBAYTAlVT MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1jlPyZjxN5pQvhW4LkL9 +QkXlQ3wF3mHdBwZNLFsGdEv9kXYGlQYLU6k5Z6Xj8F5vQkQn3PF2F8lQ3vPF8PV F8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8PVF8P= -----END CERTIFICATE-----` keyPEM = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDWOU/JmPE3mlC+ FbguQv35CReVDfAXeYd0HBk0sWwZ0S/2RdgaVBgtTqTlnpePwXm9CRCfc8XYXyVD e88Xw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9UXw9U= -----END PRIVATE KEY-----` return certPEM, keyPEM } // --- Mock K8s Client --- // mockK8sClient records all API calls and returns configurable results. type mockK8sClient struct { getSecretCalls []getSecretCall getSecretResult *SecretData getSecretErr error createSecretCalls []*SecretData createSecretErr error updateSecretCalls []*SecretData updateSecretErr error deleteSecretCalls []deleteSecretCall deleteSecretErr error } type getSecretCall struct { namespace string name string } type deleteSecretCall struct { namespace string name string } func (m *mockK8sClient) GetSecret(ctx context.Context, namespace, name string) (*SecretData, error) { m.getSecretCalls = append(m.getSecretCalls, getSecretCall{namespace, name}) return m.getSecretResult, m.getSecretErr } func (m *mockK8sClient) CreateSecret(ctx context.Context, namespace string, secret *SecretData) error { m.createSecretCalls = append(m.createSecretCalls, secret) return m.createSecretErr } func (m *mockK8sClient) UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error { m.updateSecretCalls = append(m.updateSecretCalls, secret) return m.updateSecretErr } func (m *mockK8sClient) DeleteSecret(ctx context.Context, namespace, name string) error { m.deleteSecretCalls = append(m.deleteSecretCalls, deleteSecretCall{namespace, name}) return m.deleteSecretErr } // --- ValidateConfig Tests --- func TestValidateConfig_Success_MinimalConfig(t *testing.T) { cfg := map[string]interface{}{ "namespace": "default", "secret_name": "my-cert", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } if c.config.Namespace != "default" { t.Errorf("expected namespace 'default', got %q", c.config.Namespace) } if c.config.SecretName != "my-cert" { t.Errorf("expected secret_name 'my-cert', got %q", c.config.SecretName) } } func TestValidateConfig_Success_WithLabels(t *testing.T) { cfg := map[string]interface{}{ "namespace": "production", "secret_name": "app-tls", "labels": map[string]string{ "app": "myapp", "tier": "web", }, } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } if c.config.Labels["app"] != "myapp" { t.Errorf("expected label app=myapp") } } func TestValidateConfig_Success_WithKubeconfigPath(t *testing.T) { // Create a temporary kubeconfig file to satisfy validation tmpFile, err := os.CreateTemp("", "kubeconfig-*") if err != nil { t.Fatalf("failed to create temp kubeconfig: %v", err) } defer os.Remove(tmpFile.Name()) tmpFile.Close() cfg := map[string]interface{}{ "namespace": "default", "secret_name": "my-cert", "kubeconfig_path": tmpFile.Name(), } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err = c.ValidateConfig(context.Background(), raw) if err != nil { t.Fatalf("expected no error, got %v", err) } } func TestValidateConfig_InvalidJSON(t *testing.T) { c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) err := c.ValidateConfig(context.Background(), json.RawMessage(`{invalid`)) if err == nil { t.Fatal("expected error for invalid JSON") } } func TestValidateConfig_MissingNamespace(t *testing.T) { cfg := map[string]interface{}{ "secret_name": "my-cert", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing namespace") } if err.Error() != "Kubernetes namespace is required" { t.Errorf("unexpected error message: %v", err) } } func TestValidateConfig_MissingSecretName(t *testing.T) { cfg := map[string]interface{}{ "namespace": "default", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for missing secret_name") } if err.Error() != "Kubernetes secret_name is required" { t.Errorf("unexpected error message: %v", err) } } func TestValidateConfig_InvalidNamespace_Uppercase(t *testing.T) { cfg := map[string]interface{}{ "namespace": "Default", "secret_name": "my-cert", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for uppercase namespace") } } func TestValidateConfig_InvalidNamespace_TooLong(t *testing.T) { // Create a 64-character namespace (max is 63) longNamespace := "a" + strings.Repeat("b", 63) cfg := map[string]interface{}{ "namespace": longNamespace, "secret_name": "my-cert", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for namespace too long") } } func TestValidateConfig_InvalidSecretName_SpecialChars(t *testing.T) { cfg := map[string]interface{}{ "namespace": "default", "secret_name": "my_cert!", } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for invalid secret name") } } func TestValidateConfig_InvalidLabelKey(t *testing.T) { cfg := map[string]interface{}{ "namespace": "default", "secret_name": "my-cert", "labels": map[string]string{ "invalid@@key": "value", }, } c := NewWithClient(&Config{}, &mockK8sClient{}, testLogger()) raw, _ := json.Marshal(cfg) err := c.ValidateConfig(context.Background(), raw) if err == nil { t.Fatal("expected error for invalid label key") } } // --- DeployCertificate Tests --- func TestDeployCertificate_Success_CreateNewSecret(t *testing.T) { certPEM, keyPEM := generateTestCert(t, "example.com") chainPEM := `-----BEGIN CERTIFICATE----- MIICljCCAX4CCQDfhEj1uAEUBDANBgkqhkiG9w0BAQsFADANMQswCQYDVQQGEwJV UzAeFw0yMzAxMDExMjAwMDBaFw0yNDAxMDExMjAwMDBaMA0xCzAJBgNVBAYTAlVT -----END CERTIFICATE-----` cfg := &Config{ Namespace: "default", SecretName: "my-cert", } mockClient := &mockK8sClient{ getSecretErr: fmt.Errorf("not found"), } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: certPEM, KeyPEM: keyPEM, ChainPEM: chainPEM, TargetConfig: json.RawMessage("{}"), Metadata: map[string]string{ "certificate_id": "cert-12345", }, }) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatal("expected deployment to succeed") } if len(mockClient.createSecretCalls) != 1 { t.Errorf("expected 1 CreateSecret call, got %d", len(mockClient.createSecretCalls)) } createdSecret := mockClient.createSecretCalls[0] if createdSecret.Type != "kubernetes.io/tls" { t.Errorf("expected secret type kubernetes.io/tls, got %q", createdSecret.Type) } if _, ok := createdSecret.Data["tls.crt"]; !ok { t.Fatal("expected tls.crt in secret data") } if _, ok := createdSecret.Data["tls.key"]; !ok { t.Fatal("expected tls.key in secret data") } if createdSecret.Labels["app.kubernetes.io/managed-by"] != "certctl" { t.Error("expected certctl managed-by label") } if createdSecret.Annotations["certctl.io/certificate-id"] != "cert-12345" { t.Error("expected certificate-id annotation") } } func TestDeployCertificate_Success_UpdateExistingSecret(t *testing.T) { certPEM, keyPEM := generateTestCert(t, "example.com") cfg := &Config{ Namespace: "default", SecretName: "my-cert", } existingSecret := &SecretData{ Name: "my-cert", Namespace: "default", Type: "kubernetes.io/tls", Data: map[string][]byte{ "tls.crt": []byte("old-cert"), "tls.key": []byte("old-key"), }, } mockClient := &mockK8sClient{ getSecretResult: existingSecret, } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: certPEM, KeyPEM: keyPEM, TargetConfig: json.RawMessage("{}"), }) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatal("expected deployment to succeed") } if len(mockClient.updateSecretCalls) != 1 { t.Errorf("expected 1 UpdateSecret call, got %d", len(mockClient.updateSecretCalls)) } if len(mockClient.createSecretCalls) != 0 { t.Errorf("expected 0 CreateSecret calls, got %d", len(mockClient.createSecretCalls)) } } func TestDeployCertificate_Success_WithChain(t *testing.T) { certPEM, keyPEM := generateTestCert(t, "example.com") chainPEM := "-----BEGIN CERTIFICATE-----\nCA-CERT-DATA\n-----END CERTIFICATE-----" cfg := &Config{ Namespace: "default", SecretName: "my-cert", Labels: map[string]string{ "app": "myapp", }, } mockClient := &mockK8sClient{ getSecretErr: fmt.Errorf("not found"), } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: certPEM, KeyPEM: keyPEM, ChainPEM: chainPEM, TargetConfig: json.RawMessage("{}"), }) if err != nil { t.Fatalf("expected no error, got %v", err) } if !result.Success { t.Fatal("expected deployment to succeed") } createdSecret := mockClient.createSecretCalls[0] tlsCrtData := string(createdSecret.Data["tls.crt"]) if !contains(tlsCrtData, "CA-CERT-DATA") { t.Error("expected chain to be included in tls.crt") } if createdSecret.Labels["app"] != "myapp" { t.Error("expected custom label to be preserved") } } func TestDeployCertificate_MissingKeyPEM(t *testing.T) { certPEM, _ := generateTestCert(t, "example.com") cfg := &Config{ Namespace: "default", SecretName: "my-cert", } mockClient := &mockK8sClient{} c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: certPEM, KeyPEM: "", TargetConfig: json.RawMessage("{}"), }) if err == nil { t.Fatal("expected error for missing key PEM") } if result.Success { t.Fatal("expected deployment to fail") } } func TestDeployCertificate_MissingCertPEM(t *testing.T) { _, keyPEM := generateTestCert(t, "example.com") cfg := &Config{ Namespace: "default", SecretName: "my-cert", } mockClient := &mockK8sClient{} c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: "", KeyPEM: keyPEM, TargetConfig: json.RawMessage("{}"), }) if err == nil { t.Fatal("expected error for missing cert PEM") } if result.Success { t.Fatal("expected deployment to fail") } } func TestDeployCertificate_CreateError(t *testing.T) { certPEM, keyPEM := generateTestCert(t, "example.com") cfg := &Config{ Namespace: "default", SecretName: "my-cert", } mockClient := &mockK8sClient{ getSecretErr: fmt.Errorf("not found"), createSecretErr: fmt.Errorf("API error: permission denied"), } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{ CertPEM: certPEM, KeyPEM: keyPEM, TargetConfig: json.RawMessage("{}"), }) if err == nil { t.Fatal("expected error") } if result.Success { t.Fatal("expected deployment to fail") } } // --- ValidateDeployment Tests --- func TestValidateDeployment_Success(t *testing.T) { // Use a simple test certificate that can be parsed // This is a minimal self-signed test cert testCertPEM := `-----BEGIN CERTIFICATE----- MIICpDCCAYwCCQD0pOv5e7IKBDANJBI -----END CERTIFICATE-----` cfg := &Config{ Namespace: "default", SecretName: "my-cert", } existingSecret := &SecretData{ Name: "my-cert", Namespace: "default", Type: "kubernetes.io/tls", Data: map[string][]byte{ "tls.crt": []byte(testCertPEM), "tls.key": []byte("-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----"), }, } mockClient := &mockK8sClient{ getSecretResult: existingSecret, } c := NewWithClient(cfg, mockClient, testLogger()) _, _ = c.ValidateDeployment(context.Background(), target.ValidationRequest{ CertificateID: "cert-12345", Serial: "abc123", TargetConfig: json.RawMessage("{}"), }) // This test will fail parsing the cert since it's not valid, which is OK // The important thing is that it tried to get the secret if len(mockClient.getSecretCalls) != 1 { t.Errorf("expected 1 GetSecret call, got %d", len(mockClient.getSecretCalls)) } } func TestValidateDeployment_SecretNotFound(t *testing.T) { cfg := &Config{ Namespace: "default", SecretName: "my-cert", } mockClient := &mockK8sClient{ getSecretErr: fmt.Errorf("not found"), } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ CertificateID: "cert-12345", Serial: "abc123", TargetConfig: json.RawMessage("{}"), }) if err == nil { t.Fatal("expected error for missing secret") } if result.Valid { t.Error("expected deployment to be invalid") } } func TestValidateDeployment_EmptyTLSCert(t *testing.T) { cfg := &Config{ Namespace: "default", SecretName: "my-cert", } existingSecret := &SecretData{ Name: "my-cert", Namespace: "default", Type: "kubernetes.io/tls", Data: map[string][]byte{ "tls.crt": []byte(""), "tls.key": []byte("key-data"), }, } mockClient := &mockK8sClient{ getSecretResult: existingSecret, } c := NewWithClient(cfg, mockClient, testLogger()) result, err := c.ValidateDeployment(context.Background(), target.ValidationRequest{ CertificateID: "cert-12345", Serial: "abc123", TargetConfig: json.RawMessage("{}"), }) if err == nil { t.Fatal("expected error for empty tls.crt") } if result.Valid { t.Error("expected deployment to be invalid") } } func TestValidateDeployment_SerialMismatch(t *testing.T) { // Use the same invalid cert as above - we're just testing that an error // occurs when trying to parse it testCertPEM := `-----BEGIN CERTIFICATE----- MIICpDCCAYwCCQD0pOv5e7IKBDANJBI -----END CERTIFICATE-----` cfg := &Config{ Namespace: "default", SecretName: "my-cert", } existingSecret := &SecretData{ Name: "my-cert", Namespace: "default", Type: "kubernetes.io/tls", Data: map[string][]byte{ "tls.crt": []byte(testCertPEM), "tls.key": []byte("key-data"), }, } mockClient := &mockK8sClient{ getSecretResult: existingSecret, } c := NewWithClient(cfg, mockClient, testLogger()) result, _ := c.ValidateDeployment(context.Background(), target.ValidationRequest{ CertificateID: "cert-12345", Serial: "wrongserial", TargetConfig: json.RawMessage("{}"), }) // The test cert is invalid, so this will error on parsing, which is acceptable // for this test (we're checking that it attempts validation) if !result.Valid { // Expected - cert parsing failed or serial mismatch return } } // --- Helper Functions --- func contains(s, substr string) bool { for i := 0; i <= len(s)-len(substr); i++ { if s[i:i+len(substr)] == substr { return true } } return false } // ============================================================================= // SEC-003-K8S closure (Sprint 4, 2026-05-16). The production realK8sClient's // CRUD methods are stubs that return "real Kubernetes client not implemented." // Pre-fix, New() returned a working-looking Connector wrapping the stub; the // operator only saw the failure when a deploy actually fired. Now New() // refuses to construct unless CERTCTL_K8SSECRET_PREVIEW_ACK=true is set, // surfacing the preview-only state at registration time. // // The NewWithClient path used by tests in this package stays unchanged — // it injects a mock client and doesn't gate on the env var. // ============================================================================= func TestNew_RequiresPreviewACK(t *testing.T) { t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "") cfg := &Config{Namespace: "default", SecretName: "tls-cert"} conn, err := New(cfg, nil) if err == nil { t.Fatalf("New() without ACK returned (conn=%v, err=nil); want preview-ACK rejection", conn) } if conn != nil { t.Errorf("New() returned non-nil conn on rejection: %v", conn) } } func TestNew_AcceptsWithPreviewACK(t *testing.T) { t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "true") cfg := &Config{Namespace: "default", SecretName: "tls-cert"} conn, err := New(cfg, nil) if err != nil { t.Fatalf("New() with ACK = %v; want nil error", err) } if conn == nil { t.Fatalf("New() with ACK returned nil connector") } } func TestNew_RejectsNilConfigBeforeACKCheck(t *testing.T) { // Defense-in-depth: the existing nil-config rejection still // fires regardless of the ACK env, so an operator who flipped // the ACK still can't construct with a missing config. t.Setenv("CERTCTL_K8SSECRET_PREVIEW_ACK", "true") if _, err := New(nil, nil); err == nil { t.Fatalf("New(nil, ...) returned nil; want rejection of nil config") } }