Skip to content

Commit 48bbad0

Browse files
tmshortclaude
andcommitted
tests: add unit and e2e tests for HTTPS_PROXY support
Unit tests verify that BuildHTTPClient correctly tunnels HTTPS connections through a proxy and fails when the proxy rejects the CONNECT request. E2e tests cover two scenarios: a dead proxy that blocks catalog fetches (asserted via "proxyconnect" in the Retrying condition), and a live recording proxy that asserts catalog traffic actually routes through it via CONNECT. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Todd Short <tshort@redhat.com>
1 parent 29debc7 commit 48bbad0

7 files changed

Lines changed: 703 additions & 2 deletions

File tree

Makefile

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

257-
E2E_TIMEOUT ?= 10m
257+
E2E_TIMEOUT ?= 15m
258258
.PHONY: e2e
259259
e2e: #EXHELP Run the e2e tests.
260260
go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT)

internal/shared/util/http/httputil.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ func BuildHTTPClient(cpw *CertPoolWatcher) (*http.Client, error) {
2020
}
2121
tlsTransport := &http.Transport{
2222
TLSClientConfig: tlsConfig,
23+
// Proxy must be set explicitly; a nil Proxy field means "no proxy" and
24+
// ignores HTTPS_PROXY/NO_PROXY env vars. Only http.DefaultTransport sets
25+
// this by default; custom transports must opt in.
26+
Proxy: http.ProxyFromEnvironment,
2327
}
2428
httpClient.Transport = tlsTransport
2529

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package http_test
2+
3+
import (
4+
"context"
5+
"encoding/pem"
6+
"io"
7+
"net"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/url"
11+
"os"
12+
"path/filepath"
13+
"testing"
14+
15+
"github.com/stretchr/testify/require"
16+
"sigs.k8s.io/controller-runtime/pkg/log"
17+
18+
httputil "github.com/operator-framework/operator-controller/internal/shared/util/http"
19+
)
20+
21+
// startRecordingProxy starts a plain-HTTP CONNECT proxy that tunnels HTTPS
22+
// connections and records the target host of each CONNECT request.
23+
func startRecordingProxy(proxied chan<- string) *httptest.Server {
24+
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25+
if r.Method != http.MethodConnect {
26+
http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed)
27+
return
28+
}
29+
// Non-blocking: if there are unexpected extra CONNECT requests (retries,
30+
// parallel connections) we record the first one and drop the rest rather
31+
// than blocking the proxy handler goroutine.
32+
select {
33+
case proxied <- r.Host:
34+
default:
35+
}
36+
37+
dst, err := net.Dial("tcp", r.Host)
38+
if err != nil {
39+
http.Error(w, err.Error(), http.StatusBadGateway)
40+
return
41+
}
42+
defer dst.Close()
43+
44+
hj, ok := w.(http.Hijacker)
45+
if !ok {
46+
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
47+
return
48+
}
49+
conn, _, err := hj.Hijack()
50+
if err != nil {
51+
http.Error(w, err.Error(), http.StatusInternalServerError)
52+
return
53+
}
54+
defer conn.Close()
55+
56+
if _, err = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
57+
return
58+
}
59+
60+
done := make(chan struct{}, 2)
61+
tunnel := func(dst, src net.Conn) {
62+
defer func() { done <- struct{}{} }()
63+
_, _ = io.Copy(dst, src)
64+
}
65+
go tunnel(dst, conn)
66+
go tunnel(conn, dst)
67+
<-done
68+
}))
69+
}
70+
71+
// certPoolWatcherForTLSServer creates a CertPoolWatcher that trusts the given
72+
// TLS test server's certificate.
73+
func certPoolWatcherForTLSServer(t *testing.T, server *httptest.Server) *httputil.CertPoolWatcher {
74+
t.Helper()
75+
76+
dir := t.TempDir()
77+
certPath := filepath.Join(dir, "server.pem")
78+
79+
certDER := server.TLS.Certificates[0].Certificate[0]
80+
f, err := os.Create(certPath)
81+
require.NoError(t, err)
82+
require.NoError(t, pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
83+
require.NoError(t, f.Close())
84+
85+
cpw, err := httputil.NewCertPoolWatcher(dir, log.FromContext(context.Background()))
86+
require.NoError(t, err)
87+
require.NotNil(t, cpw)
88+
t.Cleanup(cpw.Done)
89+
require.NoError(t, cpw.Start(context.Background()))
90+
return cpw
91+
}
92+
93+
// TestBuildHTTPClientTransportUsesProxyFromEnvironment verifies that the
94+
// transport returned by BuildHTTPClient has Proxy set to http.ProxyFromEnvironment
95+
// so that HTTPS_PROXY and NO_PROXY env vars are honoured at runtime.
96+
func TestBuildHTTPClientTransportUsesProxyFromEnvironment(t *testing.T) {
97+
// Use system certs (empty dir) — we only need a valid CertPoolWatcher.
98+
cpw, err := httputil.NewCertPoolWatcher("", log.FromContext(context.Background()))
99+
require.NoError(t, err)
100+
t.Cleanup(cpw.Done)
101+
require.NoError(t, cpw.Start(context.Background()))
102+
103+
client, err := httputil.BuildHTTPClient(cpw)
104+
require.NoError(t, err)
105+
106+
transport, ok := client.Transport.(*http.Transport)
107+
require.True(t, ok)
108+
require.NotNil(t, transport.Proxy,
109+
"BuildHTTPClient must set transport.Proxy so that HTTPS_PROXY env vars are respected; "+
110+
"a nil Proxy field means no proxy regardless of environment")
111+
}
112+
113+
// TestBuildHTTPClientProxyTunnelsConnections verifies end-to-end that the
114+
// HTTP client produced by BuildHTTPClient correctly tunnels HTTPS connections
115+
// through an HTTP CONNECT proxy.
116+
//
117+
// The test overrides transport.Proxy with http.ProxyURL rather than relying on
118+
// HTTPS_PROXY: httptest servers bind to 127.0.0.1, which http.ProxyFromEnvironment
119+
// silently excludes from proxying, and env-var changes within the same process
120+
// are unreliable due to sync.Once caching. Using http.ProxyURL directly exercises
121+
// the same tunnelling code path that HTTPS_PROXY triggers in production.
122+
func TestBuildHTTPClientProxyTunnelsConnections(t *testing.T) {
123+
targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
124+
w.WriteHeader(http.StatusOK)
125+
}))
126+
defer targetServer.Close()
127+
128+
proxied := make(chan string, 1)
129+
proxyServer := startRecordingProxy(proxied)
130+
defer proxyServer.Close()
131+
132+
proxyURL, err := url.Parse(proxyServer.URL)
133+
require.NoError(t, err)
134+
135+
cpw := certPoolWatcherForTLSServer(t, targetServer)
136+
client, err := httputil.BuildHTTPClient(cpw)
137+
require.NoError(t, err)
138+
139+
// Point the transport directly at our test proxy, bypassing the loopback
140+
// exclusion and env-var caching of http.ProxyFromEnvironment.
141+
transport, ok := client.Transport.(*http.Transport)
142+
require.True(t, ok)
143+
transport.Proxy = http.ProxyURL(proxyURL)
144+
145+
resp, err := client.Get(targetServer.URL)
146+
require.NoError(t, err)
147+
resp.Body.Close()
148+
149+
select {
150+
case host := <-proxied:
151+
require.Equal(t, targetServer.Listener.Addr().String(), host,
152+
"proxy must have received a CONNECT request for the target server address")
153+
default:
154+
t.Fatal("HTTPS connection to target server did not go through the proxy")
155+
}
156+
}
157+
158+
// TestBuildHTTPClientProxyBlocksWhenRejected verifies that when the proxy
159+
// rejects the CONNECT tunnel, the client request fails rather than silently
160+
// falling back to a direct connection.
161+
func TestBuildHTTPClientProxyBlocksWhenRejected(t *testing.T) {
162+
targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
163+
w.WriteHeader(http.StatusOK)
164+
}))
165+
defer targetServer.Close()
166+
167+
// A proxy that returns 403 Forbidden for every CONNECT request.
168+
rejectingProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
169+
if r.Method == http.MethodConnect {
170+
http.Error(w, "proxy access denied", http.StatusForbidden)
171+
return
172+
}
173+
http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed)
174+
}))
175+
defer rejectingProxy.Close()
176+
177+
proxyURL, err := url.Parse(rejectingProxy.URL)
178+
require.NoError(t, err)
179+
180+
cpw := certPoolWatcherForTLSServer(t, targetServer)
181+
client, err := httputil.BuildHTTPClient(cpw)
182+
require.NoError(t, err)
183+
184+
transport, ok := client.Transport.(*http.Transport)
185+
require.True(t, ok)
186+
transport.Proxy = http.ProxyURL(proxyURL)
187+
188+
resp, err := client.Get(targetServer.URL)
189+
if resp != nil {
190+
resp.Body.Close()
191+
}
192+
require.Error(t, err, "request should fail when the proxy rejects the CONNECT tunnel")
193+
}

test/e2e/features/proxy.feature

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
Feature: HTTPS proxy support for outbound catalog requests
2+
3+
OLM's operator-controller fetches catalog data from catalogd over HTTPS.
4+
When HTTPS_PROXY is set in the operator-controller's environment, all
5+
outbound HTTPS requests must be routed through the configured proxy.
6+
7+
Background:
8+
Given OLM is available
9+
And ClusterCatalog "test" serves bundles
10+
And ServiceAccount "olm-sa" with needed permissions is available in test namespace
11+
12+
@HTTPProxy
13+
Scenario: operator-controller respects HTTPS_PROXY when fetching catalog data
14+
Given the "operator-controller" deployment is configured with HTTPS_PROXY "http://127.0.0.1:39999"
15+
When ClusterExtension is applied
16+
"""
17+
apiVersion: olm.operatorframework.io/v1
18+
kind: ClusterExtension
19+
metadata:
20+
name: ${NAME}
21+
spec:
22+
namespace: ${TEST_NAMESPACE}
23+
serviceAccount:
24+
name: olm-sa
25+
source:
26+
sourceType: Catalog
27+
catalog:
28+
packageName: test
29+
selector:
30+
matchLabels:
31+
"olm.operatorframework.io/metadata.name": test-catalog
32+
"""
33+
Then ClusterExtension reports Progressing as True with Reason Retrying and Message includes:
34+
"""
35+
proxyconnect
36+
"""
37+
38+
@HTTPProxy
39+
Scenario: operator-controller routes catalog traffic through a configured HTTPS proxy
40+
Given the "operator-controller" deployment is configured with HTTPS_PROXY pointing to a recording proxy
41+
When ClusterExtension is applied
42+
"""
43+
apiVersion: olm.operatorframework.io/v1
44+
kind: ClusterExtension
45+
metadata:
46+
name: ${NAME}
47+
spec:
48+
namespace: ${TEST_NAMESPACE}
49+
serviceAccount:
50+
name: olm-sa
51+
source:
52+
sourceType: Catalog
53+
catalog:
54+
packageName: test
55+
selector:
56+
matchLabels:
57+
"olm.operatorframework.io/metadata.name": test-catalog
58+
"""
59+
Then ClusterExtension is rolled out
60+
And ClusterExtension is available
61+
And the recording proxy received a CONNECT request for the catalogd service

test/e2e/steps/hooks.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ type resource struct {
2727
namespace string
2828
}
2929

30+
// deploymentRestore records the original state of a deployment container so
31+
// it can be rolled back after a test that modifies deployment configuration.
32+
type deploymentRestore struct {
33+
name string // deployment name
34+
namespace string
35+
containerName string
36+
originalEnv []string // "NAME=VALUE" pairs
37+
}
38+
3039
type scenarioContext struct {
3140
id string
3241
namespace string
@@ -39,7 +48,9 @@ type scenarioContext struct {
3948
metricsResponse map[string]string
4049
leaderPods map[string]string // component name -> leader pod name
4150

42-
extensionObjects []client.Object
51+
extensionObjects []client.Object
52+
deploymentRestores []deploymentRestore
53+
proxy *recordingProxy
4354
}
4455

4556
// GatherClusterExtensionObjects collects all resources related to the ClusterExtension container in
@@ -182,6 +193,24 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context
182193
_ = p.Kill()
183194
}
184195
}
196+
197+
// Stop the in-process recording proxy if one was started.
198+
if sc.proxy != nil {
199+
sc.proxy.stop()
200+
}
201+
202+
// Restore any deployments that were modified during the scenario. This runs
203+
// unconditionally (even on test failure) so that a misconfigured deployment
204+
// does not bleed into subsequent scenarios. Restore in reverse (LIFO) order
205+
// so that multiple patches to the same deployment unwind back to the true
206+
// original state.
207+
for i := len(sc.deploymentRestores) - 1; i >= 0; i-- {
208+
restore := sc.deploymentRestores[i]
209+
if err := restoreDeployment(restore); err != nil {
210+
logger.Info("Error restoring deployment", "deployment", restore.name, "namespace", restore.namespace, "error", err)
211+
}
212+
}
213+
185214
if err != nil {
186215
return ctx, err
187216
}

0 commit comments

Comments
 (0)