Skip to content

Commit 1459482

Browse files
tmshortclaude
andcommitted
test: add TLS profile unit and e2e tests
- Unit tests in tlsprofiles package verify cipher negotiation, cipher rejection, min-version enforcement, and curve acceptance/rejection by starting a local TLS server with a custom profile and connecting to it with a restricted client config. - e2e feature (tls.feature) patches the catalogd deployment with specific custom TLS settings for each scenario, asserts the expected connection behaviour, then restores the original args on cleanup. Covers min-version enforcement (TLSv1.3), cipher negotiation and rejection (TLS 1.2 + ECDHE_ECDSA), and curve enforcement (prime256v1 accepted, secp521r1 rejected). - GODOG_ARGS variable added to the e2e Makefile target so a single feature file can be run with: make test-e2e GODOG_ARGS=features/tls.feature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Todd Short <tshort@redhat.com>
1 parent 29debc7 commit 1459482

File tree

6 files changed

+768
-1
lines changed

6 files changed

+768
-1
lines changed

Makefile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,15 @@ $(eval $(call install-sh,standard,operator-controller-standard.yaml))
255255
test: manifests generate fmt lint test-unit test-e2e test-regression #HELP Run all tests.
256256

257257
E2E_TIMEOUT ?= 10m
258+
# GODOG_ARGS can be used to pass extra arguments to the Godog test runner,
259+
# for example to run a single feature file:
260+
# make test-e2e GODOG_ARGS=features/tls.feature
261+
# or to run scenarios matching a tag:
262+
# make test-e2e GODOG_ARGS="--godog.tags=@smoke"
263+
GODOG_ARGS ?=
258264
.PHONY: e2e
259265
e2e: #EXHELP Run the e2e tests.
260-
go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT)
266+
go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT) -- $(GODOG_ARGS)
261267

262268
E2E_REGISTRY_NAME := docker-registry
263269
E2E_REGISTRY_NAMESPACE := operator-controller-e2e
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
package tlsprofiles
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"crypto/rand"
7+
"crypto/tls"
8+
"crypto/x509"
9+
"crypto/x509/pkix"
10+
"encoding/pem"
11+
"math/big"
12+
"net"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// generateSelfSignedCert generates a self-signed ECDSA P-256 certificate for use in tests.
20+
func generateSelfSignedCert(t *testing.T) tls.Certificate {
21+
t.Helper()
22+
23+
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
24+
require.NoError(t, err)
25+
26+
template := x509.Certificate{
27+
SerialNumber: big.NewInt(1),
28+
Subject: pkix.Name{Organization: []string{"Test"}},
29+
NotBefore: time.Now().Add(-time.Hour),
30+
NotAfter: time.Now().Add(time.Hour),
31+
KeyUsage: x509.KeyUsageDigitalSignature,
32+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
33+
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
34+
}
35+
36+
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
37+
require.NoError(t, err)
38+
39+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
40+
privDER, err := x509.MarshalECPrivateKey(priv)
41+
require.NoError(t, err)
42+
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
43+
44+
cert, err := tls.X509KeyPair(certPEM, keyPEM)
45+
require.NoError(t, err)
46+
return cert
47+
}
48+
49+
// startTLSServer starts a TLS listener with cfgFn applied and serves connections in
50+
// the background. The listener is closed when the test completes.
51+
func startTLSServer(t *testing.T, cfgFn func(*tls.Config)) string {
52+
t.Helper()
53+
54+
serverCfg := &tls.Config{Certificates: []tls.Certificate{generateSelfSignedCert(t)}}
55+
cfgFn(serverCfg)
56+
57+
ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
58+
require.NoError(t, err)
59+
t.Cleanup(func() { ln.Close() })
60+
61+
go func() {
62+
for {
63+
conn, err := ln.Accept()
64+
if err != nil {
65+
return // listener closed
66+
}
67+
go func() {
68+
defer conn.Close()
69+
_ = conn.(*tls.Conn).Handshake()
70+
}()
71+
}
72+
}()
73+
74+
return ln.Addr().String()
75+
}
76+
77+
// dialTLS connects to addr with the given config and returns the negotiated
78+
// ConnectionState. The caller must check err before using the state.
79+
func dialTLS(addr string, clientCfg *tls.Config) (tls.ConnectionState, error) {
80+
conn, err := tls.Dial("tcp", addr, clientCfg)
81+
if err != nil {
82+
return tls.ConnectionState{}, err
83+
}
84+
defer conn.Close()
85+
return conn.ConnectionState(), nil
86+
}
87+
88+
// setCustomProfile configures the package-level custom TLS profile for the duration
89+
// of the test and restores the original state via t.Cleanup.
90+
func setCustomProfile(t *testing.T, cipherNames []string, curveNames []string, minVersion string) {
91+
t.Helper()
92+
93+
origProfile := configuredProfile
94+
origCustom := customTLSProfile
95+
t.Cleanup(func() {
96+
configuredProfile = origProfile
97+
customTLSProfile = origCustom
98+
})
99+
100+
configuredProfile = "custom"
101+
customTLSProfile = tlsProfile{
102+
ciphers: cipherSlice{},
103+
curves: curveSlice{},
104+
}
105+
106+
for _, name := range cipherNames {
107+
require.NoError(t, customTLSProfile.ciphers.Append(name))
108+
}
109+
for _, name := range curveNames {
110+
require.NoError(t, customTLSProfile.curves.Append(name))
111+
}
112+
if minVersion != "" {
113+
require.NoError(t, customTLSProfile.minTLSVersion.Set(minVersion))
114+
}
115+
}
116+
117+
// TestCustomTLSProfileCipherNegotiation verifies that when a custom profile
118+
// specifies a single cipher suite, that cipher is actually negotiated.
119+
func TestCustomTLSProfileCipherNegotiation(t *testing.T) {
120+
const cipher = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
121+
cipherID := cipherSuiteId(cipher)
122+
require.NotZero(t, cipherID)
123+
124+
setCustomProfile(t, []string{cipher}, []string{"prime256v1"}, "TLSv1.2")
125+
126+
cfgFn, err := GetTLSConfigFunc()
127+
require.NoError(t, err)
128+
129+
addr := startTLSServer(t, cfgFn)
130+
131+
// Client is restricted to TLS 1.2 with the same single cipher.
132+
clientCfg := &tls.Config{
133+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
134+
MaxVersion: tls.VersionTLS12,
135+
CipherSuites: []uint16{cipherID},
136+
}
137+
138+
state, err := dialTLS(addr, clientCfg)
139+
require.NoError(t, err)
140+
require.Equal(t, cipherID, state.CipherSuite, "expected cipher %s to be negotiated", cipher)
141+
}
142+
143+
// TestCustomTLSProfileCipherRejection verifies that the server rejects a
144+
// connection when the client offers only a cipher not in the custom profile.
145+
func TestCustomTLSProfileCipherRejection(t *testing.T) {
146+
// Server is configured with AES-256 only.
147+
setCustomProfile(t,
148+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"},
149+
[]string{"prime256v1"},
150+
"TLSv1.2",
151+
)
152+
153+
cfgFn, err := GetTLSConfigFunc()
154+
require.NoError(t, err)
155+
156+
addr := startTLSServer(t, cfgFn)
157+
158+
// Client offers only AES-128, which the server does not allow.
159+
clientCfg := &tls.Config{
160+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
161+
MaxVersion: tls.VersionTLS12,
162+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
163+
}
164+
165+
_, err = dialTLS(addr, clientCfg)
166+
require.Error(t, err, "connection should fail when client offers no cipher from the custom profile")
167+
}
168+
169+
// TestCustomTLSProfileMinVersionEnforcement verifies that a custom profile
170+
// configured with a TLS 1.3 minimum rejects TLS 1.2-only clients.
171+
func TestCustomTLSProfileMinVersionEnforcement(t *testing.T) {
172+
setCustomProfile(t,
173+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
174+
[]string{"prime256v1"},
175+
"TLSv1.3",
176+
)
177+
178+
cfgFn, err := GetTLSConfigFunc()
179+
require.NoError(t, err)
180+
181+
addr := startTLSServer(t, cfgFn)
182+
183+
// Client advertises TLS 1.2 as its maximum; server requires TLS 1.3.
184+
clientCfg := &tls.Config{
185+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
186+
MaxVersion: tls.VersionTLS12,
187+
}
188+
189+
_, err = dialTLS(addr, clientCfg)
190+
require.Error(t, err, "connection should fail when server requires TLS 1.3 and client only supports TLS 1.2")
191+
}
192+
193+
// TestCustomTLSProfileCurveNegotiation verifies that a connection succeeds when
194+
// the client's curve preferences overlap with the custom profile's curve list.
195+
func TestCustomTLSProfileCurveNegotiation(t *testing.T) {
196+
// Server allows only prime256v1 (P-256).
197+
setCustomProfile(t,
198+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
199+
[]string{"prime256v1"},
200+
"TLSv1.2",
201+
)
202+
203+
cfgFn, err := GetTLSConfigFunc()
204+
require.NoError(t, err)
205+
206+
addr := startTLSServer(t, cfgFn)
207+
208+
// Client also only uses prime256v1 — there is an overlap.
209+
clientCfg := &tls.Config{
210+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
211+
MaxVersion: tls.VersionTLS12,
212+
CurvePreferences: []tls.CurveID{tls.CurveP256},
213+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
214+
}
215+
216+
_, err = dialTLS(addr, clientCfg)
217+
require.NoError(t, err)
218+
}
219+
220+
// TestCustomTLSProfileCurveRejection verifies that a connection fails when the
221+
// client's supported curves do not overlap with the custom profile's curve list.
222+
// TLS 1.2 is used because the curve negotiation failure is deterministic there;
223+
// TLS 1.3 can fall back via HelloRetryRequest.
224+
func TestCustomTLSProfileCurveRejection(t *testing.T) {
225+
// Server allows only prime256v1 (P-256).
226+
setCustomProfile(t,
227+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
228+
[]string{"prime256v1"},
229+
"TLSv1.2",
230+
)
231+
232+
cfgFn, err := GetTLSConfigFunc()
233+
require.NoError(t, err)
234+
235+
addr := startTLSServer(t, cfgFn)
236+
237+
// Client only supports X25519, which is not in the server's curve list.
238+
clientCfg := &tls.Config{
239+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
240+
MaxVersion: tls.VersionTLS12,
241+
CurvePreferences: []tls.CurveID{tls.X25519},
242+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
243+
}
244+
245+
_, err = dialTLS(addr, clientCfg)
246+
require.Error(t, err, "connection should fail when client and server share no common curve")
247+
}

test/e2e/features/tls.feature

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Feature: TLS profile enforcement on metrics endpoints
2+
3+
Background:
4+
Given OLM is available
5+
6+
# Each scenario patches the deployment with the TLS settings under test and
7+
# restores the original configuration during cleanup, so scenarios are independent.
8+
9+
# All three scenarios test catalogd only: the enforcement logic lives in the shared
10+
# tlsprofiles package, so one component is sufficient. TLS 1.2 is used for cipher
11+
# and curve enforcement because Go's crypto/tls does not allow the server to restrict
12+
# TLS 1.3 cipher suites — CipherSuites config only applies to TLS 1.2. The e2e cert
13+
# uses ECDSA, so ECDHE_ECDSA cipher families are required.
14+
Scenario: catalogd metrics endpoint enforces configured minimum TLS version
15+
Given the "catalogd" deployment is configured with custom TLS minimum version "TLSv1.3"
16+
Then the "catalogd" metrics endpoint accepts a TLS 1.3 connection
17+
And the "catalogd" metrics endpoint rejects a TLS 1.2 connection
18+
19+
Scenario: catalogd metrics endpoint negotiates and enforces configured cipher suite
20+
Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1"
21+
Then the "catalogd" metrics endpoint negotiates cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" over TLS 1.2
22+
And the "catalogd" metrics endpoint rejects a TLS 1.2 connection offering only cipher "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
23+
24+
Scenario: catalogd metrics endpoint enforces configured curve preferences
25+
Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1"
26+
Then the "catalogd" metrics endpoint accepts a TLS 1.2 connection with cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" and curve "prime256v1"
27+
And the "catalogd" metrics endpoint rejects a TLS 1.2 connection with cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" and only curve "secp521r1"

test/e2e/steps/hooks.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ type resource struct {
2727
namespace string
2828
}
2929

30+
// deploymentRestore records the original container args of a deployment so that
31+
// it can be patched back to its pre-test state during scenario cleanup.
32+
type deploymentRestore struct {
33+
namespace string
34+
deploymentName string
35+
originalArgs []string
36+
}
37+
3038
type scenarioContext struct {
3139
id string
3240
namespace string
@@ -38,6 +46,7 @@ type scenarioContext struct {
3846
backGroundCmds []*exec.Cmd
3947
metricsResponse map[string]string
4048
leaderPods map[string]string // component name -> leader pod name
49+
deploymentRestores []deploymentRestore
4150

4251
extensionObjects []client.Object
4352
}
@@ -182,6 +191,21 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context
182191
_ = p.Kill()
183192
}
184193
}
194+
195+
// Always restore deployments whose args were modified during the scenario,
196+
// even when the scenario failed, so that a misconfigured TLS profile does
197+
// not leak into subsequent scenarios.
198+
for _, dr := range sc.deploymentRestores {
199+
if err2 := patchDeploymentArgs(dr.namespace, dr.deploymentName, dr.originalArgs); err2 != nil {
200+
logger.Info("Error restoring deployment args", "name", dr.deploymentName, "error", err2)
201+
continue
202+
}
203+
if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace,
204+
fmt.Sprintf("deployment/%s", dr.deploymentName), "--timeout=2m"); err2 != nil {
205+
logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.deploymentName)
206+
}
207+
}
208+
185209
if err != nil {
186210
return ctx, err
187211
}

test/e2e/steps/steps.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ func RegisterSteps(sc *godog.ScenarioContext) {
150150

151151
sc.Step(`^(?i)the current ClusterExtension is tracked for cleanup$`, TrackCurrentClusterExtensionForCleanup)
152152

153+
// TLS profile enforcement steps — deployment configuration
154+
sc.Step(`^(?i)the "([^"]+)" deployment is configured with custom TLS minimum version "([^"]+)"$`, ConfigureDeploymentWithCustomTLSVersion)
155+
sc.Step(`^(?i)the "([^"]+)" deployment is configured with custom TLS version "([^"]+)", ciphers "([^"]+)", and curves "([^"]+)"$`, ConfigureDeploymentWithCustomTLSFull)
156+
157+
// TLS profile enforcement steps — connection assertions
158+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint accepts a TLS 1\.3 connection$`, MetricsEndpointAcceptsTLS13)
159+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection$`, MetricsEndpointRejectsTLS12)
160+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint negotiates cipher "([^"]+)" over TLS 1\.2$`, MetricsEndpointNegotiatesTLS12Cipher)
161+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection offering only cipher "([^"]+)"$`, MetricsEndpointRejectsTLS12ConnectionWithCipher)
162+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint accepts a TLS 1\.2 connection with cipher "([^"]+)" and curve "([^"]+)"$`, MetricsEndpointAcceptsTLS12ConnectionWithCurve)
163+
sc.Step(`^(?i)the "([^"]+)" metrics endpoint rejects a TLS 1\.2 connection with cipher "([^"]+)" and only curve "([^"]+)"$`, MetricsEndpointRejectsTLS12ConnectionWithCurve)
164+
153165
// Upgrade-specific steps
154166
sc.Step(`^(?i)the latest stable OLM release is installed$`, LatestStableOLMReleaseIsInstalled)
155167
sc.Step(`^(?i)OLM is upgraded$`, OLMIsUpgraded)

0 commit comments

Comments
 (0)