Skip to content

Commit 6fd8bd1

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 6fd8bd1

6 files changed

Lines changed: 771 additions & 1 deletion

File tree

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: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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{
55+
Certificates: []tls.Certificate{generateSelfSignedCert(t)},
56+
MinVersion: tls.VersionTLS12, // baseline; cfgFn will raise this if the profile requires it
57+
}
58+
cfgFn(serverCfg)
59+
60+
ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
61+
require.NoError(t, err)
62+
t.Cleanup(func() { ln.Close() })
63+
64+
go func() {
65+
for {
66+
conn, err := ln.Accept()
67+
if err != nil {
68+
return // listener closed
69+
}
70+
go func() {
71+
defer conn.Close()
72+
_ = conn.(*tls.Conn).Handshake()
73+
}()
74+
}
75+
}()
76+
77+
return ln.Addr().String()
78+
}
79+
80+
// dialTLS connects to addr with the given config and returns the negotiated
81+
// ConnectionState. The caller must check err before using the state.
82+
func dialTLS(addr string, clientCfg *tls.Config) (tls.ConnectionState, error) {
83+
conn, err := tls.Dial("tcp", addr, clientCfg)
84+
if err != nil {
85+
return tls.ConnectionState{}, err
86+
}
87+
defer conn.Close()
88+
return conn.ConnectionState(), nil
89+
}
90+
91+
// setCustomProfile configures the package-level custom TLS profile for the duration
92+
// of the test and restores the original state via t.Cleanup.
93+
func setCustomProfile(t *testing.T, cipherNames []string, curveNames []string, minVersion string) {
94+
t.Helper()
95+
96+
origProfile := configuredProfile
97+
origCustom := customTLSProfile
98+
t.Cleanup(func() {
99+
configuredProfile = origProfile
100+
customTLSProfile = origCustom
101+
})
102+
103+
configuredProfile = "custom"
104+
customTLSProfile = tlsProfile{
105+
ciphers: cipherSlice{},
106+
curves: curveSlice{},
107+
}
108+
109+
for _, name := range cipherNames {
110+
require.NoError(t, customTLSProfile.ciphers.Append(name))
111+
}
112+
for _, name := range curveNames {
113+
require.NoError(t, customTLSProfile.curves.Append(name))
114+
}
115+
if minVersion != "" {
116+
require.NoError(t, customTLSProfile.minTLSVersion.Set(minVersion))
117+
}
118+
}
119+
120+
// TestCustomTLSProfileCipherNegotiation verifies that when a custom profile
121+
// specifies a single cipher suite, that cipher is actually negotiated.
122+
func TestCustomTLSProfileCipherNegotiation(t *testing.T) {
123+
const cipher = "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"
124+
cipherID := cipherSuiteId(cipher)
125+
require.NotZero(t, cipherID)
126+
127+
setCustomProfile(t, []string{cipher}, []string{"prime256v1"}, "TLSv1.2")
128+
129+
cfgFn, err := GetTLSConfigFunc()
130+
require.NoError(t, err)
131+
132+
addr := startTLSServer(t, cfgFn)
133+
134+
// Client is restricted to TLS 1.2 with the same single cipher.
135+
clientCfg := &tls.Config{
136+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
137+
MaxVersion: tls.VersionTLS12,
138+
CipherSuites: []uint16{cipherID},
139+
}
140+
141+
state, err := dialTLS(addr, clientCfg)
142+
require.NoError(t, err)
143+
require.Equal(t, cipherID, state.CipherSuite, "expected cipher %s to be negotiated", cipher)
144+
}
145+
146+
// TestCustomTLSProfileCipherRejection verifies that the server rejects a
147+
// connection when the client offers only a cipher not in the custom profile.
148+
func TestCustomTLSProfileCipherRejection(t *testing.T) {
149+
// Server is configured with AES-256 only.
150+
setCustomProfile(t,
151+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"},
152+
[]string{"prime256v1"},
153+
"TLSv1.2",
154+
)
155+
156+
cfgFn, err := GetTLSConfigFunc()
157+
require.NoError(t, err)
158+
159+
addr := startTLSServer(t, cfgFn)
160+
161+
// Client offers only AES-128, which the server does not allow.
162+
clientCfg := &tls.Config{
163+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
164+
MaxVersion: tls.VersionTLS12,
165+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
166+
}
167+
168+
_, err = dialTLS(addr, clientCfg)
169+
require.Error(t, err, "connection should fail when client offers no cipher from the custom profile")
170+
}
171+
172+
// TestCustomTLSProfileMinVersionEnforcement verifies that a custom profile
173+
// configured with a TLS 1.3 minimum rejects TLS 1.2-only clients.
174+
func TestCustomTLSProfileMinVersionEnforcement(t *testing.T) {
175+
setCustomProfile(t,
176+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
177+
[]string{"prime256v1"},
178+
"TLSv1.3",
179+
)
180+
181+
cfgFn, err := GetTLSConfigFunc()
182+
require.NoError(t, err)
183+
184+
addr := startTLSServer(t, cfgFn)
185+
186+
// Client advertises TLS 1.2 as its maximum; server requires TLS 1.3.
187+
clientCfg := &tls.Config{
188+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
189+
MaxVersion: tls.VersionTLS12,
190+
}
191+
192+
_, err = dialTLS(addr, clientCfg)
193+
require.Error(t, err, "connection should fail when server requires TLS 1.3 and client only supports TLS 1.2")
194+
}
195+
196+
// TestCustomTLSProfileCurveNegotiation verifies that a connection succeeds when
197+
// the client's curve preferences overlap with the custom profile's curve list.
198+
func TestCustomTLSProfileCurveNegotiation(t *testing.T) {
199+
// Server allows only prime256v1 (P-256).
200+
setCustomProfile(t,
201+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
202+
[]string{"prime256v1"},
203+
"TLSv1.2",
204+
)
205+
206+
cfgFn, err := GetTLSConfigFunc()
207+
require.NoError(t, err)
208+
209+
addr := startTLSServer(t, cfgFn)
210+
211+
// Client also only uses prime256v1 — there is an overlap.
212+
clientCfg := &tls.Config{
213+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
214+
MaxVersion: tls.VersionTLS12,
215+
CurvePreferences: []tls.CurveID{tls.CurveP256},
216+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
217+
}
218+
219+
_, err = dialTLS(addr, clientCfg)
220+
require.NoError(t, err)
221+
}
222+
223+
// TestCustomTLSProfileCurveRejection verifies that a connection fails when the
224+
// client's supported curves do not overlap with the custom profile's curve list.
225+
// TLS 1.2 is used because the curve negotiation failure is deterministic there;
226+
// TLS 1.3 can fall back via HelloRetryRequest.
227+
func TestCustomTLSProfileCurveRejection(t *testing.T) {
228+
// Server allows only prime256v1 (P-256).
229+
setCustomProfile(t,
230+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
231+
[]string{"prime256v1"},
232+
"TLSv1.2",
233+
)
234+
235+
cfgFn, err := GetTLSConfigFunc()
236+
require.NoError(t, err)
237+
238+
addr := startTLSServer(t, cfgFn)
239+
240+
// Client only supports X25519, which is not in the server's curve list.
241+
clientCfg := &tls.Config{
242+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
243+
MaxVersion: tls.VersionTLS12,
244+
CurvePreferences: []tls.CurveID{tls.X25519},
245+
CipherSuites: []uint16{cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")},
246+
}
247+
248+
_, err = dialTLS(addr, clientCfg)
249+
require.Error(t, err, "connection should fail when client and server share no common curve")
250+
}

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)