Skip to content

Commit d92c0f6

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 d92c0f6

File tree

5 files changed

+802
-0
lines changed

5 files changed

+802
-0
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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+
cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")
163+
require.NotZero(t, cipherID, "cipher suite must be available on this platform")
164+
clientCfg := &tls.Config{
165+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
166+
MaxVersion: tls.VersionTLS12,
167+
CipherSuites: []uint16{cipherID},
168+
}
169+
170+
_, err = dialTLS(addr, clientCfg)
171+
require.Error(t, err, "connection should fail when client offers no cipher from the custom profile")
172+
}
173+
174+
// TestCustomTLSProfileMinVersionEnforcement verifies that a custom profile
175+
// configured with a TLS 1.3 minimum rejects TLS 1.2-only clients.
176+
func TestCustomTLSProfileMinVersionEnforcement(t *testing.T) {
177+
setCustomProfile(t,
178+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
179+
[]string{"prime256v1"},
180+
"TLSv1.3",
181+
)
182+
183+
cfgFn, err := GetTLSConfigFunc()
184+
require.NoError(t, err)
185+
186+
addr := startTLSServer(t, cfgFn)
187+
188+
// Client advertises TLS 1.2 as its maximum; server requires TLS 1.3.
189+
clientCfg := &tls.Config{
190+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
191+
MaxVersion: tls.VersionTLS12,
192+
}
193+
194+
_, err = dialTLS(addr, clientCfg)
195+
require.Error(t, err, "connection should fail when server requires TLS 1.3 and client only supports TLS 1.2")
196+
}
197+
198+
// TestCustomTLSProfileCurveNegotiation verifies that a connection succeeds when
199+
// the client's curve preferences overlap with the custom profile's curve list.
200+
func TestCustomTLSProfileCurveNegotiation(t *testing.T) {
201+
// Server allows only prime256v1 (P-256).
202+
setCustomProfile(t,
203+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
204+
[]string{"prime256v1"},
205+
"TLSv1.2",
206+
)
207+
208+
cfgFn, err := GetTLSConfigFunc()
209+
require.NoError(t, err)
210+
211+
addr := startTLSServer(t, cfgFn)
212+
213+
// Client also only uses prime256v1 — there is an overlap.
214+
cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")
215+
require.NotZero(t, cipherID, "cipher suite must be available on this platform")
216+
clientCfg := &tls.Config{
217+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
218+
MaxVersion: tls.VersionTLS12,
219+
CurvePreferences: []tls.CurveID{tls.CurveP256},
220+
CipherSuites: []uint16{cipherID},
221+
}
222+
223+
_, err = dialTLS(addr, clientCfg)
224+
require.NoError(t, err)
225+
}
226+
227+
// TestCustomTLSProfileCurveRejection verifies that a connection fails when the
228+
// client's supported curves do not overlap with the custom profile's curve list.
229+
// TLS 1.2 is used because the curve negotiation failure is deterministic there;
230+
// TLS 1.3 can fall back via HelloRetryRequest.
231+
func TestCustomTLSProfileCurveRejection(t *testing.T) {
232+
// Server allows only prime256v1 (P-256).
233+
setCustomProfile(t,
234+
[]string{"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256"},
235+
[]string{"prime256v1"},
236+
"TLSv1.2",
237+
)
238+
239+
cfgFn, err := GetTLSConfigFunc()
240+
require.NoError(t, err)
241+
242+
addr := startTLSServer(t, cfgFn)
243+
244+
// Client only supports X25519, which is not in the server's curve list.
245+
cipherID := cipherSuiteId("TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256")
246+
require.NotZero(t, cipherID, "cipher suite must be available on this platform")
247+
clientCfg := &tls.Config{
248+
InsecureSkipVerify: true, //nolint:gosec // self-signed cert used only in tests
249+
MaxVersion: tls.VersionTLS12,
250+
CurvePreferences: []tls.CurveID{tls.X25519},
251+
CipherSuites: []uint16{cipherID},
252+
}
253+
254+
_, err = dialTLS(addr, clientCfg)
255+
require.Error(t, err, "connection should fail when client and server share no common curve")
256+
}

test/e2e/features/tls.feature

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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+
@TLSProfile
15+
Scenario: catalogd metrics endpoint enforces configured minimum TLS version
16+
Given the "catalogd" deployment is configured with custom TLS minimum version "TLSv1.3"
17+
Then the "catalogd" metrics endpoint accepts a TLS 1.3 connection
18+
And the "catalogd" metrics endpoint rejects a TLS 1.2 connection
19+
20+
@TLSProfile
21+
Scenario: catalogd metrics endpoint negotiates and enforces configured cipher suite
22+
Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1"
23+
Then the "catalogd" metrics endpoint negotiates cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" over TLS 1.2
24+
And the "catalogd" metrics endpoint rejects a TLS 1.2 connection offering only cipher "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384"
25+
26+
@TLSProfile
27+
Scenario: catalogd metrics endpoint enforces configured curve preferences
28+
Given the "catalogd" deployment is configured with custom TLS version "TLSv1.2", ciphers "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", and curves "prime256v1"
29+
Then the "catalogd" metrics endpoint accepts a TLS 1.2 connection with cipher "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256" and curve "prime256v1"
30+
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: 26 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,23 @@ 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. Restore in reverse order so that
198+
// multiple patches to the same deployment unwind back to the true original.
199+
for i := len(sc.deploymentRestores) - 1; i >= 0; i-- {
200+
dr := sc.deploymentRestores[i]
201+
if err2 := patchDeploymentArgs(dr.namespace, dr.deploymentName, dr.originalArgs); err2 != nil {
202+
logger.Info("Error restoring deployment args", "name", dr.deploymentName, "error", err2)
203+
continue
204+
}
205+
if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace,
206+
fmt.Sprintf("deployment/%s", dr.deploymentName), "--timeout=2m"); err2 != nil {
207+
logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.deploymentName)
208+
}
209+
}
210+
185211
if err != nil {
186212
return ctx, err
187213
}

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)