detached 01ffd59

test(proxy): migrate HTTP handler tests to adapter layer, add h2c integration tests

Moves concurrency/body-size tests from usecase to adapter where the
logic now lives. Adds end-to-end h2c test confirming HTTP/2 prior-
knowledge transport works. Adds usecase tests for new boundary methods
(IsRegistryDomain, TrackInFlight, ProxyConfig).
commit
01ffd59ae913a98f4f9bd6751ba36c4a8d19c43b
author
brice · · 2026-03-15 22:43 UTC
parent
ec22770
signature
unsigned
3 files · +509 -115
A internal/adapters/in/http/proxy/forwarder_test.go +201 -0
@@ -0,0 +1,201 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/in"
17+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
22+ var h2cProtos http.Protocols
23+ h2cProtos.SetUnencryptedHTTP2(true)
24+
25+ h2cBackend := &http.Server{
26+ Protocols: &h2cProtos,
27+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ w.Header().Set("X-Proto", r.Proto)
29+ w.WriteHeader(http.StatusOK)
30+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
31+ }),
32+ }
33+
34+ ln, err := net.Listen("tcp", "127.0.0.1:0")
35+ require.NoError(t, err)
36+ defer ln.Close()
37+
38+ go func() {
39+ _ = h2cBackend.Serve(ln)
40+ }()
41+ defer h2cBackend.Close()
42+
43+ _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
44+ require.NoError(t, splitErr)
45+ port, atoiErr := strconv.Atoi(portStr)
46+ require.NoError(t, atoiErr)
47+
48+ proxySvc := inmocks.NewMockProxyService(t)
49+
50+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
51+ proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
52+ proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
53+ Host: "127.0.0.1",
54+ Port: port,
55+ ContainerID: "grpc-1",
56+ Scheme: "http",
57+ Protocol: "h2c",
58+ }, nil)
59+ proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
60+
61+ handler := NewHandler(proxySvc, zerowrap.Default())
62+
63+ req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
64+ req.Host = "grpc.example.com"
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusOK, rec.Code)
70+ assert.Contains(t, rec.Body.String(), "HTTP/2")
71+}
72+
73+func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
74+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
76+ }))
77+ defer backend.Close()
78+
79+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
80+
81+ proxySvc := inmocks.NewMockProxyService(t)
82+
83+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
84+ proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
85+ proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
86+ Host: "127.0.0.1",
87+ Port: backendPort,
88+ ContainerID: "web-1",
89+ Scheme: "http",
90+ }, nil)
91+ proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
92+
93+ handler := NewHandler(proxySvc, zerowrap.Default())
94+
95+ req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
96+ req.Host = "web.example.com"
97+ rec := httptest.NewRecorder()
98+
99+ handler.ServeHTTP(rec, req)
100+
101+ assert.Equal(t, http.StatusOK, rec.Code)
102+ assert.Contains(t, rec.Body.String(), "HTTP/1.1")
103+}
104+
105+func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
106+ handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default())
107+
108+ h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
109+ httpTarget := &domain.ProxyTarget{Protocol: ""}
110+
111+ assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
112+ assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
113+}
114+
115+func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
116+ var receivedHost string
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ receivedHost = r.Host
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
124+
125+ proxySvc := inmocks.NewMockProxyService(t)
126+
127+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
128+ proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
129+ proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
130+ Host: "127.0.0.1",
131+ Port: backendPort,
132+ ContainerID: "",
133+ Scheme: "http",
134+ OriginalHost: "upstream.example.com",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, zerowrap.Default())
139+
140+ req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
141+ req.Host = "external.example.com"
142+ w := httptest.NewRecorder()
143+ handler.ServeHTTP(w, req)
144+
145+ assert.Equal(t, http.StatusOK, w.Code)
146+ assert.Contains(t, receivedHost, "upstream.example.com")
147+}
148+
149+func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
150+ proxySvc := inmocks.NewMockProxyService(t)
151+
152+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
153+ proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
154+ proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
155+ Host: "127.0.0.1",
156+ Port: 1,
157+ ContainerID: "c-down",
158+ Scheme: "http",
159+ }, nil)
160+ proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
161+
162+ handler := NewHandler(proxySvc, zerowrap.Default())
163+
164+ req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
165+ req.Host = "down.example.com"
166+ w := httptest.NewRecorder()
167+ handler.ServeHTTP(w, req)
168+
169+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
170+}
171+
172+func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
173+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
174+ w.WriteHeader(http.StatusOK)
175+ }))
176+ defer backend.Close()
177+
178+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
179+
180+ proxySvc := inmocks.NewMockProxyService(t)
181+
182+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
183+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
184+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
185+ Host: "127.0.0.1",
186+ Port: backendPort,
187+ ContainerID: "c-1",
188+ Scheme: "http",
189+ }, nil)
190+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
191+
192+ handler := NewHandler(proxySvc, zerowrap.Default())
193+
194+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
195+ req.Host = "app.example.com"
196+ w := httptest.NewRecorder()
197+ handler.ServeHTTP(w, req)
198+
199+ assert.Equal(t, http.StatusOK, w.Code)
200+ assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201+}
@@ -0,0 +1,201 @@
1+package proxy
2+
3+import (
4+ "fmt"
5+ "net"
6+ "net/http"
7+ "net/http/httptest"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/bnema/zerowrap"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "github.com/bnema/gordon/internal/boundaries/in"
17+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
18+ "github.com/bnema/gordon/internal/domain"
19+)
20+
21+func TestForwardToTarget_H2CEndToEnd(t *testing.T) {
22+ var h2cProtos http.Protocols
23+ h2cProtos.SetUnencryptedHTTP2(true)
24+
25+ h2cBackend := &http.Server{
26+ Protocols: &h2cProtos,
27+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28+ w.Header().Set("X-Proto", r.Proto)
29+ w.WriteHeader(http.StatusOK)
30+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
31+ }),
32+ }
33+
34+ ln, err := net.Listen("tcp", "127.0.0.1:0")
35+ require.NoError(t, err)
36+ defer ln.Close()
37+
38+ go func() {
39+ _ = h2cBackend.Serve(ln)
40+ }()
41+ defer h2cBackend.Close()
42+
43+ _, portStr, splitErr := net.SplitHostPort(ln.Addr().String())
44+ require.NoError(t, splitErr)
45+ port, atoiErr := strconv.Atoi(portStr)
46+ require.NoError(t, atoiErr)
47+
48+ proxySvc := inmocks.NewMockProxyService(t)
49+
50+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
51+ proxySvc.EXPECT().IsRegistryDomain("grpc.example.com").Return(false)
52+ proxySvc.EXPECT().GetTarget(mock.Anything, "grpc.example.com").Return(&domain.ProxyTarget{
53+ Host: "127.0.0.1",
54+ Port: port,
55+ ContainerID: "grpc-1",
56+ Scheme: "http",
57+ Protocol: "h2c",
58+ }, nil)
59+ proxySvc.EXPECT().TrackInFlight("grpc-1").Return(func() {})
60+
61+ handler := NewHandler(proxySvc, zerowrap.Default())
62+
63+ req := httptest.NewRequest(http.MethodGet, "http://grpc.example.com/test", nil)
64+ req.Host = "grpc.example.com"
65+ rec := httptest.NewRecorder()
66+
67+ handler.ServeHTTP(rec, req)
68+
69+ assert.Equal(t, http.StatusOK, rec.Code)
70+ assert.Contains(t, rec.Body.String(), "HTTP/2")
71+}
72+
73+func TestForwardToTarget_HTTP1StillWorks(t *testing.T) {
74+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+ _, _ = fmt.Fprintf(w, "proto=%s", r.Proto)
76+ }))
77+ defer backend.Close()
78+
79+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
80+
81+ proxySvc := inmocks.NewMockProxyService(t)
82+
83+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
84+ proxySvc.EXPECT().IsRegistryDomain("web.example.com").Return(false)
85+ proxySvc.EXPECT().GetTarget(mock.Anything, "web.example.com").Return(&domain.ProxyTarget{
86+ Host: "127.0.0.1",
87+ Port: backendPort,
88+ ContainerID: "web-1",
89+ Scheme: "http",
90+ }, nil)
91+ proxySvc.EXPECT().TrackInFlight("web-1").Return(func() {})
92+
93+ handler := NewHandler(proxySvc, zerowrap.Default())
94+
95+ req := httptest.NewRequest(http.MethodGet, "http://web.example.com/", nil)
96+ req.Host = "web.example.com"
97+ rec := httptest.NewRecorder()
98+
99+ handler.ServeHTTP(rec, req)
100+
101+ assert.Equal(t, http.StatusOK, rec.Code)
102+ assert.Contains(t, rec.Body.String(), "HTTP/1.1")
103+}
104+
105+func TestTransportForTarget_SelectsCorrectTransport(t *testing.T) {
106+ handler := NewHandler(inmocks.NewMockProxyService(t), zerowrap.Default())
107+
108+ h2cTarget := &domain.ProxyTarget{Protocol: "h2c"}
109+ httpTarget := &domain.ProxyTarget{Protocol: ""}
110+
111+ assert.Equal(t, handler.h2cTransport, handler.transportForTarget(h2cTarget))
112+ assert.Equal(t, handler.appTransport, handler.transportForTarget(httpTarget))
113+}
114+
115+func TestForwardToTarget_OriginalHostPreserved(t *testing.T) {
116+ var receivedHost string
117+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+ receivedHost = r.Host
119+ w.WriteHeader(http.StatusOK)
120+ }))
121+ defer backend.Close()
122+
123+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
124+
125+ proxySvc := inmocks.NewMockProxyService(t)
126+
127+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
128+ proxySvc.EXPECT().IsRegistryDomain("external.example.com").Return(false)
129+ proxySvc.EXPECT().GetTarget(mock.Anything, "external.example.com").Return(&domain.ProxyTarget{
130+ Host: "127.0.0.1",
131+ Port: backendPort,
132+ ContainerID: "",
133+ Scheme: "http",
134+ OriginalHost: "upstream.example.com",
135+ }, nil)
136+ proxySvc.EXPECT().TrackInFlight("").Return(func() {})
137+
138+ handler := NewHandler(proxySvc, zerowrap.Default())
139+
140+ req := httptest.NewRequest(http.MethodGet, "http://external.example.com/", nil)
141+ req.Host = "external.example.com"
142+ w := httptest.NewRecorder()
143+ handler.ServeHTTP(w, req)
144+
145+ assert.Equal(t, http.StatusOK, w.Code)
146+ assert.Contains(t, receivedHost, "upstream.example.com")
147+}
148+
149+func TestForwardToTarget_BackendDown_Returns503(t *testing.T) {
150+ proxySvc := inmocks.NewMockProxyService(t)
151+
152+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
153+ proxySvc.EXPECT().IsRegistryDomain("down.example.com").Return(false)
154+ proxySvc.EXPECT().GetTarget(mock.Anything, "down.example.com").Return(&domain.ProxyTarget{
155+ Host: "127.0.0.1",
156+ Port: 1,
157+ ContainerID: "c-down",
158+ Scheme: "http",
159+ }, nil)
160+ proxySvc.EXPECT().TrackInFlight("c-down").Return(func() {})
161+
162+ handler := NewHandler(proxySvc, zerowrap.Default())
163+
164+ req := httptest.NewRequest(http.MethodGet, "http://down.example.com/", nil)
165+ req.Host = "down.example.com"
166+ w := httptest.NewRecorder()
167+ handler.ServeHTTP(w, req)
168+
169+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
170+}
171+
172+func TestForwardToTarget_ProxyHeaderSet(t *testing.T) {
173+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
174+ w.WriteHeader(http.StatusOK)
175+ }))
176+ defer backend.Close()
177+
178+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
179+
180+ proxySvc := inmocks.NewMockProxyService(t)
181+
182+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
183+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
184+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
185+ Host: "127.0.0.1",
186+ Port: backendPort,
187+ ContainerID: "c-1",
188+ Scheme: "http",
189+ }, nil)
190+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
191+
192+ handler := NewHandler(proxySvc, zerowrap.Default())
193+
194+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
195+ req.Host = "app.example.com"
196+ w := httptest.NewRecorder()
197+ handler.ServeHTTP(w, req)
198+
199+ assert.Equal(t, http.StatusOK, w.Code)
200+ assert.Equal(t, "Gordon", w.Header().Get("X-Proxied-By"))
201+}
A internal/adapters/in/http/proxy/handler_test.go +192 -0
@@ -0,0 +1,192 @@
1+package proxy
2+
3+import (
4+ "net"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+
13+ "github.com/bnema/gordon/internal/boundaries/in"
14+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func testLogger() zerowrap.Logger {
19+ return zerowrap.Default()
20+}
21+
22+func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
23+ proxySvc := inmocks.NewMockProxyService(t)
24+
25+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
26+ MaxConcurrentConns: 1,
27+ })
28+
29+ handler := NewHandler(proxySvc, testLogger())
30+
31+ // Simulate an in-flight connection.
32+ handler.activeConns.Add(1)
33+ defer handler.activeConns.Add(-1)
34+
35+ req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
36+ w := httptest.NewRecorder()
37+ handler.ServeHTTP(w, req)
38+
39+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
40+}
41+
42+func TestHandler_RoutesToRegistry(t *testing.T) {
43+ proxySvc := inmocks.NewMockProxyService(t)
44+
45+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
46+ RegistryPort: 5000,
47+ })
48+ proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
49+ proxySvc.EXPECT().TrackRegistryRequest().Return()
50+ proxySvc.EXPECT().ReleaseRegistryRequest().Return()
51+
52+ handler := NewHandler(proxySvc, testLogger())
53+
54+ req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
55+ req.Host = "registry.example.com"
56+ w := httptest.NewRecorder()
57+ handler.ServeHTTP(w, req)
58+
59+ assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
60+}
61+
62+func TestHandler_Returns404WhenNoTarget(t *testing.T) {
63+ proxySvc := inmocks.NewMockProxyService(t)
64+
65+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
66+ proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
67+ proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
68+
69+ handler := NewHandler(proxySvc, testLogger())
70+
71+ req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
72+ req.Host = "unknown.example.com"
73+ w := httptest.NewRecorder()
74+ handler.ServeHTTP(w, req)
75+
76+ assert.Equal(t, http.StatusNotFound, w.Code)
77+}
78+
79+func TestHandler_ProxiesToTarget(t *testing.T) {
80+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
81+ w.WriteHeader(http.StatusOK)
82+ _, _ = w.Write([]byte("ok"))
83+ }))
84+ defer backend.Close()
85+
86+ proxySvc := inmocks.NewMockProxyService(t)
87+
88+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
89+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
90+
91+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
92+
93+ target := &domain.ProxyTarget{
94+ Host: "127.0.0.1",
95+ Port: backendPort,
96+ ContainerID: "c-1",
97+ Scheme: "http",
98+ }
99+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
100+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
101+
102+ handler := NewHandler(proxySvc, testLogger())
103+
104+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
105+ req.Host = "app.example.com"
106+ w := httptest.NewRecorder()
107+ handler.ServeHTTP(w, req)
108+
109+ assert.Equal(t, http.StatusOK, w.Code)
110+ assert.Equal(t, "ok", w.Body.String())
111+}
112+
113+func TestHandler_MaxBodySize_Applied(t *testing.T) {
114+ proxySvc := inmocks.NewMockProxyService(t)
115+
116+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
117+ MaxBodySize: 1024,
118+ })
119+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
120+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable)
121+
122+ handler := NewHandler(proxySvc, testLogger())
123+
124+ req := httptest.NewRequest(http.MethodPost, "http://app.example.com/test", nil)
125+ req.Host = "app.example.com"
126+ w := httptest.NewRecorder()
127+ handler.ServeHTTP(w, req)
128+
129+ assert.Equal(t, http.StatusNotFound, w.Code)
130+}
131+
132+func TestHandler_UpdatedConfigReflected(t *testing.T) {
133+ proxySvc := inmocks.NewMockProxyService(t)
134+
135+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
136+ MaxConcurrentConns: 0,
137+ }).Once()
138+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
139+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
140+
141+ handler := NewHandler(proxySvc, testLogger())
142+
143+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
144+ req.Host = "app.example.com"
145+ rec := httptest.NewRecorder()
146+ handler.ServeHTTP(rec, req)
147+ assert.Equal(t, http.StatusNotFound, rec.Code)
148+
149+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
150+ MaxConcurrentConns: 1,
151+ }).Once()
152+
153+ handler.activeConns.Add(1)
154+ defer handler.activeConns.Add(-1)
155+
156+ req2 := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
157+ req2.Host = "app.example.com"
158+ rec2 := httptest.NewRecorder()
159+ handler.ServeHTTP(rec2, req2)
160+ assert.Equal(t, http.StatusServiceUnavailable, rec2.Code)
161+}
162+
163+func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
164+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
165+ w.WriteHeader(http.StatusOK)
166+ }))
167+ defer backend.Close()
168+
169+ proxySvc := inmocks.NewMockProxyService(t)
170+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
171+
172+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
173+ MaxConcurrentConns: 0,
174+ })
175+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
176+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
177+ Host: "127.0.0.1",
178+ Port: backendPort,
179+ ContainerID: "c-1",
180+ Scheme: "http",
181+ }, nil)
182+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
183+
184+ handler := NewHandler(proxySvc, testLogger())
185+
186+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
187+ req.Host = "app.example.com"
188+ w := httptest.NewRecorder()
189+ handler.ServeHTTP(w, req)
190+
191+ assert.Equal(t, http.StatusOK, w.Code)
192+}
@@ -0,0 +1,192 @@
1+package proxy
2+
3+import (
4+ "net"
5+ "net/http"
6+ "net/http/httptest"
7+ "testing"
8+
9+ "github.com/bnema/zerowrap"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/mock"
12+
13+ "github.com/bnema/gordon/internal/boundaries/in"
14+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
15+ "github.com/bnema/gordon/internal/domain"
16+)
17+
18+func testLogger() zerowrap.Logger {
19+ return zerowrap.Default()
20+}
21+
22+func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
23+ proxySvc := inmocks.NewMockProxyService(t)
24+
25+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
26+ MaxConcurrentConns: 1,
27+ })
28+
29+ handler := NewHandler(proxySvc, testLogger())
30+
31+ // Simulate an in-flight connection.
32+ handler.activeConns.Add(1)
33+ defer handler.activeConns.Add(-1)
34+
35+ req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
36+ w := httptest.NewRecorder()
37+ handler.ServeHTTP(w, req)
38+
39+ assert.Equal(t, http.StatusServiceUnavailable, w.Code)
40+}
41+
42+func TestHandler_RoutesToRegistry(t *testing.T) {
43+ proxySvc := inmocks.NewMockProxyService(t)
44+
45+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
46+ RegistryPort: 5000,
47+ })
48+ proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
49+ proxySvc.EXPECT().TrackRegistryRequest().Return()
50+ proxySvc.EXPECT().ReleaseRegistryRequest().Return()
51+
52+ handler := NewHandler(proxySvc, testLogger())
53+
54+ req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
55+ req.Host = "registry.example.com"
56+ w := httptest.NewRecorder()
57+ handler.ServeHTTP(w, req)
58+
59+ assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
60+}
61+
62+func TestHandler_Returns404WhenNoTarget(t *testing.T) {
63+ proxySvc := inmocks.NewMockProxyService(t)
64+
65+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
66+ proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
67+ proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
68+
69+ handler := NewHandler(proxySvc, testLogger())
70+
71+ req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
72+ req.Host = "unknown.example.com"
73+ w := httptest.NewRecorder()
74+ handler.ServeHTTP(w, req)
75+
76+ assert.Equal(t, http.StatusNotFound, w.Code)
77+}
78+
79+func TestHandler_ProxiesToTarget(t *testing.T) {
80+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
81+ w.WriteHeader(http.StatusOK)
82+ _, _ = w.Write([]byte("ok"))
83+ }))
84+ defer backend.Close()
85+
86+ proxySvc := inmocks.NewMockProxyService(t)
87+
88+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
89+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
90+
91+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
92+
93+ target := &domain.ProxyTarget{
94+ Host: "127.0.0.1",
95+ Port: backendPort,
96+ ContainerID: "c-1",
97+ Scheme: "http",
98+ }
99+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
100+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
101+
102+ handler := NewHandler(proxySvc, testLogger())
103+
104+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
105+ req.Host = "app.example.com"
106+ w := httptest.NewRecorder()
107+ handler.ServeHTTP(w, req)
108+
109+ assert.Equal(t, http.StatusOK, w.Code)
110+ assert.Equal(t, "ok", w.Body.String())
111+}
112+
113+func TestHandler_MaxBodySize_Applied(t *testing.T) {
114+ proxySvc := inmocks.NewMockProxyService(t)
115+
116+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
117+ MaxBodySize: 1024,
118+ })
119+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
120+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable)
121+
122+ handler := NewHandler(proxySvc, testLogger())
123+
124+ req := httptest.NewRequest(http.MethodPost, "http://app.example.com/test", nil)
125+ req.Host = "app.example.com"
126+ w := httptest.NewRecorder()
127+ handler.ServeHTTP(w, req)
128+
129+ assert.Equal(t, http.StatusNotFound, w.Code)
130+}
131+
132+func TestHandler_UpdatedConfigReflected(t *testing.T) {
133+ proxySvc := inmocks.NewMockProxyService(t)
134+
135+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
136+ MaxConcurrentConns: 0,
137+ }).Once()
138+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
139+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
140+
141+ handler := NewHandler(proxySvc, testLogger())
142+
143+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
144+ req.Host = "app.example.com"
145+ rec := httptest.NewRecorder()
146+ handler.ServeHTTP(rec, req)
147+ assert.Equal(t, http.StatusNotFound, rec.Code)
148+
149+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
150+ MaxConcurrentConns: 1,
151+ }).Once()
152+
153+ handler.activeConns.Add(1)
154+ defer handler.activeConns.Add(-1)
155+
156+ req2 := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
157+ req2.Host = "app.example.com"
158+ rec2 := httptest.NewRecorder()
159+ handler.ServeHTTP(rec2, req2)
160+ assert.Equal(t, http.StatusServiceUnavailable, rec2.Code)
161+}
162+
163+func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
164+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
165+ w.WriteHeader(http.StatusOK)
166+ }))
167+ defer backend.Close()
168+
169+ proxySvc := inmocks.NewMockProxyService(t)
170+ backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
171+
172+ proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
173+ MaxConcurrentConns: 0,
174+ })
175+ proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
176+ proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
177+ Host: "127.0.0.1",
178+ Port: backendPort,
179+ ContainerID: "c-1",
180+ Scheme: "http",
181+ }, nil)
182+ proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
183+
184+ handler := NewHandler(proxySvc, testLogger())
185+
186+ req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
187+ req.Host = "app.example.com"
188+ w := httptest.NewRecorder()
189+ handler.ServeHTTP(w, req)
190+
191+ assert.Equal(t, http.StatusOK, w.Code)
192+}
M internal/usecase/proxy/service_test.go +116 -115
@@ -2,7 +2,6 @@ package proxy
22
33 import (
44 "context"
5- "net/http/httptest"
65 "testing"
76 "time"
87
@@ -398,120 +397,6 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
398397 assert.NoError(t, err)
399398 }
400399
401-func TestService_ConcurrentConnectionLimit(t *testing.T) {
402- runtime := outmocks.NewMockContainerRuntime(t)
403- containerSvc := inmocks.NewMockContainerService(t)
404- configSvc := inmocks.NewMockConfigService(t)
405-
406- // Allow only 1 concurrent connection
407- svc := NewService(runtime, containerSvc, configSvc, Config{
408- MaxConcurrentConns: 1,
409- })
410-
411- assert.Equal(t, 1, svc.config.MaxConcurrentConns)
412- assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
413-}
414-
415-func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
416- runtime := outmocks.NewMockContainerRuntime(t)
417- containerSvc := inmocks.NewMockContainerService(t)
418- configSvc := inmocks.NewMockConfigService(t)
419-
420- svc := NewService(runtime, containerSvc, configSvc, Config{
421- MaxConcurrentConns: 0,
422- })
423-
424- assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
425-}
426-
427-func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
428- runtime := outmocks.NewMockContainerRuntime(t)
429- containerSvc := inmocks.NewMockContainerService(t)
430- configSvc := inmocks.NewMockConfigService(t)
431-
432- svc := NewService(runtime, containerSvc, configSvc, Config{
433- MaxConcurrentConns: 1,
434- })
435-
436- // Simulate an in-flight connection by incrementing the atomic counter
437- svc.activeConns.Add(1)
438- defer svc.activeConns.Add(-1)
439-
440- // Next request should get 503
441- req := httptest.NewRequest("GET", "http://example.com/test", nil)
442- w := httptest.NewRecorder()
443- svc.ServeHTTP(w, req)
444-
445- assert.Equal(t, 503, w.Code)
446-}
447-
448-func TestServeHTTP_MaxBodySize(t *testing.T) {
449- tests := []struct {
450- name string
451- maxBodySize int64
452- bodySize int
453- expect413 bool
454- }{
455- {
456- name: "body within limit",
457- maxBodySize: 1024,
458- bodySize: 512,
459- expect413: false,
460- },
461- {
462- name: "body exceeds limit returns 413",
463- maxBodySize: 1024,
464- bodySize: 2048,
465- expect413: true,
466- },
467- {
468- name: "no limit configured allows any size",
469- maxBodySize: 0,
470- bodySize: 10240,
471- expect413: false,
472- },
473- }
474-
475- for _, tt := range tests {
476- t.Run(tt.name, func(t *testing.T) {
477- // Create fresh mocks for each test
478- runtime := outmocks.NewMockContainerRuntime(t)
479- containerSvc := inmocks.NewMockContainerService(t)
480- configSvc := inmocks.NewMockConfigService(t)
481-
482- svc := NewService(runtime, containerSvc, configSvc, Config{
483- MaxBodySize: tt.maxBodySize,
484- })
485-
486- // Mock GetExternalRoutes to return empty map (no external routes)
487- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
488-
489- // Mock container service Get to return not found (404)
490- containerSvc.EXPECT().Get(mock.Anything, "example.com").Return(nil, false)
491-
492- // Create a test request with a body of specified size
493- body := make([]byte, tt.bodySize)
494- for i := range body {
495- body[i] = 'a'
496- }
497-
498- req := httptest.NewRequest("POST", "http://example.com/test", nil)
499- req.Header.Set("Host", "example.com")
500- w := httptest.NewRecorder()
501-
502- // Note: We're testing ServeHTTP which applies the MaxBytesReader wrapper
503- // The actual limit enforcement happens during request body reading by httputil.ReverseProxy
504- // So we verify the MaxBytesReader is applied correctly
505- svc.ServeHTTP(w, req)
506-
507- // The MaxBytesReader is applied, but the actual error happens when ReverseProxy reads the body
508- // We can't easily test the full flow without a real backend, so we verify the wrapper is applied
509- // by checking that MaxBodySize is properly stored in the service config
510- assert.Equal(t, tt.maxBodySize, svc.config.MaxBodySize)
511- })
512- }
513-}
514-
515400 func TestRegistryInFlightTracking(t *testing.T) {
516401 svc := &Service{
517402 inFlight: make(map[string]int),
@@ -572,6 +457,122 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
572457 }
573458 }
574459
460+func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
461+ runtime := outmocks.NewMockContainerRuntime(t)
462+ containerSvc := inmocks.NewMockContainerService(t)
463+ configSvc := inmocks.NewMockConfigService(t)
464+
465+ svc := NewService(runtime, containerSvc, configSvc, Config{
466+ RegistryDomain: "old.registry.com",
467+ RegistryPort: 5000,
468+ MaxBodySize: 1024,
469+ MaxResponseSize: 2048,
470+ MaxConcurrentConns: 10,
471+ })
472+
473+ cfg := svc.ProxyConfig()
474+ assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
475+ assert.Equal(t, 5000, cfg.RegistryPort)
476+ assert.Equal(t, int64(1024), cfg.MaxBodySize)
477+ assert.Equal(t, int64(2048), cfg.MaxResponseSize)
478+ assert.Equal(t, 10, cfg.MaxConcurrentConns)
479+
480+ svc.UpdateConfig(Config{
481+ RegistryDomain: "new.registry.com",
482+ RegistryPort: 5001,
483+ MaxBodySize: 4096,
484+ MaxResponseSize: 8192,
485+ MaxConcurrentConns: 50,
486+ })
487+
488+ cfg = svc.ProxyConfig()
489+ assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
490+ assert.Equal(t, 5001, cfg.RegistryPort)
491+ assert.Equal(t, int64(4096), cfg.MaxBodySize)
492+ assert.Equal(t, int64(8192), cfg.MaxResponseSize)
493+ assert.Equal(t, 50, cfg.MaxConcurrentConns)
494+}
495+
496+func TestService_IsRegistryDomain(t *testing.T) {
497+ runtime := outmocks.NewMockContainerRuntime(t)
498+ containerSvc := inmocks.NewMockContainerService(t)
499+ configSvc := inmocks.NewMockConfigService(t)
500+
501+ svc := NewService(runtime, containerSvc, configSvc, Config{
502+ RegistryDomain: "registry.example.com",
503+ })
504+
505+ assert.True(t, svc.IsRegistryDomain("registry.example.com"))
506+ assert.False(t, svc.IsRegistryDomain("other.example.com"))
507+ assert.False(t, svc.IsRegistryDomain(""))
508+}
509+
510+func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
511+ runtime := outmocks.NewMockContainerRuntime(t)
512+ containerSvc := inmocks.NewMockContainerService(t)
513+ configSvc := inmocks.NewMockConfigService(t)
514+
515+ svc := NewService(runtime, containerSvc, configSvc, Config{})
516+
517+ assert.False(t, svc.IsRegistryDomain("registry.example.com"))
518+ assert.False(t, svc.IsRegistryDomain(""))
519+}
520+
521+func TestService_TrackInFlight(t *testing.T) {
522+ svc := &Service{
523+ inFlight: make(map[string]int),
524+ }
525+
526+ // Empty container ID returns noop
527+ release := svc.TrackInFlight("")
528+ release() // should not panic
529+
530+ // Track a container
531+ release1 := svc.TrackInFlight("c-1")
532+ svc.inFlightMu.Lock()
533+ assert.Equal(t, 1, svc.inFlight["c-1"])
534+ svc.inFlightMu.Unlock()
535+
536+ // Track same container again
537+ release2 := svc.TrackInFlight("c-1")
538+ svc.inFlightMu.Lock()
539+ assert.Equal(t, 2, svc.inFlight["c-1"])
540+ svc.inFlightMu.Unlock()
541+
542+ // Release one
543+ release2()
544+ svc.inFlightMu.Lock()
545+ assert.Equal(t, 1, svc.inFlight["c-1"])
546+ svc.inFlightMu.Unlock()
547+
548+ // Release last — should delete key
549+ release1()
550+ svc.inFlightMu.Lock()
551+ _, exists := svc.inFlight["c-1"]
552+ assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
553+ svc.inFlightMu.Unlock()
554+}
555+
556+func TestService_TrackRegistryRequest(t *testing.T) {
557+ svc := &Service{
558+ inFlight: make(map[string]int),
559+ }
560+
561+ assert.Equal(t, int64(0), svc.RegistryInFlight())
562+
563+ svc.TrackRegistryRequest()
564+ assert.Equal(t, int64(1), svc.RegistryInFlight())
565+
566+ svc.TrackRegistryRequest()
567+ assert.Equal(t, int64(2), svc.RegistryInFlight())
568+
569+ svc.ReleaseRegistryRequest()
570+ assert.Equal(t, int64(1), svc.RegistryInFlight())
571+
572+ svc.ReleaseRegistryRequest()
573+ assert.Equal(t, int64(0), svc.RegistryInFlight())
574+}
575+
575576 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576577 runtime := outmocks.NewMockContainerRuntime(t)
577578 containerSvc := inmocks.NewMockContainerService(t)
@@ -2,7 +2,6 @@ package proxy
2 2
3 import (3 import (
4 "context"4 "context"
5- "net/http/httptest"
6 "testing"5 "testing"
7 "time"6 "time"
8 7
@@ -398,120 +397,6 @@ func TestContainerDeployedHandler_Handle_NoDomain(t *testing.T) {
398 assert.NoError(t, err)397 assert.NoError(t, err)
399 }398 }
400 399
401-func TestService_ConcurrentConnectionLimit(t *testing.T) {
402- runtime := outmocks.NewMockContainerRuntime(t)
403- containerSvc := inmocks.NewMockContainerService(t)
404- configSvc := inmocks.NewMockConfigService(t)
405-
406- // Allow only 1 concurrent connection
407- svc := NewService(runtime, containerSvc, configSvc, Config{
408- MaxConcurrentConns: 1,
409- })
410-
411- assert.Equal(t, 1, svc.config.MaxConcurrentConns)
412- assert.Equal(t, int64(0), svc.activeConns.Load(), "active connections should start at 0")
413-}
414-
415-func TestService_ConcurrentConnectionLimit_Disabled(t *testing.T) {
416- runtime := outmocks.NewMockContainerRuntime(t)
417- containerSvc := inmocks.NewMockContainerService(t)
418- configSvc := inmocks.NewMockConfigService(t)
419-
420- svc := NewService(runtime, containerSvc, configSvc, Config{
421- MaxConcurrentConns: 0,
422- })
423-
424- assert.Equal(t, 0, svc.config.MaxConcurrentConns, "0 means no limit")
425-}
426-
427-func TestService_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
428- runtime := outmocks.NewMockContainerRuntime(t)
429- containerSvc := inmocks.NewMockContainerService(t)
430- configSvc := inmocks.NewMockConfigService(t)
431-
432- svc := NewService(runtime, containerSvc, configSvc, Config{
433- MaxConcurrentConns: 1,
434- })
435-
436- // Simulate an in-flight connection by incrementing the atomic counter
437- svc.activeConns.Add(1)
438- defer svc.activeConns.Add(-1)
439-
440- // Next request should get 503
441- req := httptest.NewRequest("GET", "http://example.com/test", nil)
442- w := httptest.NewRecorder()
443- svc.ServeHTTP(w, req)
444-
445- assert.Equal(t, 503, w.Code)
446-}
447-
448-func TestServeHTTP_MaxBodySize(t *testing.T) {
449- tests := []struct {
450- name string
451- maxBodySize int64
452- bodySize int
453- expect413 bool
454- }{
455- {
456- name: "body within limit",
457- maxBodySize: 1024,
458- bodySize: 512,
459- expect413: false,
460- },
461- {
462- name: "body exceeds limit returns 413",
463- maxBodySize: 1024,
464- bodySize: 2048,
465- expect413: true,
466- },
467- {
468- name: "no limit configured allows any size",
469- maxBodySize: 0,
470- bodySize: 10240,
471- expect413: false,
472- },
473- }
474-
475- for _, tt := range tests {
476- t.Run(tt.name, func(t *testing.T) {
477- // Create fresh mocks for each test
478- runtime := outmocks.NewMockContainerRuntime(t)
479- containerSvc := inmocks.NewMockContainerService(t)
480- configSvc := inmocks.NewMockConfigService(t)
481-
482- svc := NewService(runtime, containerSvc, configSvc, Config{
483- MaxBodySize: tt.maxBodySize,
484- })
485-
486- // Mock GetExternalRoutes to return empty map (no external routes)
487- configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
488-
489- // Mock container service Get to return not found (404)
490- containerSvc.EXPECT().Get(mock.Anything, "example.com").Return(nil, false)
491-
492- // Create a test request with a body of specified size
493- body := make([]byte, tt.bodySize)
494- for i := range body {
495- body[i] = 'a'
496- }
497-
498- req := httptest.NewRequest("POST", "http://example.com/test", nil)
499- req.Header.Set("Host", "example.com")
500- w := httptest.NewRecorder()
501-
502- // Note: We're testing ServeHTTP which applies the MaxBytesReader wrapper
503- // The actual limit enforcement happens during request body reading by httputil.ReverseProxy
504- // So we verify the MaxBytesReader is applied correctly
505- svc.ServeHTTP(w, req)
506-
507- // The MaxBytesReader is applied, but the actual error happens when ReverseProxy reads the body
508- // We can't easily test the full flow without a real backend, so we verify the wrapper is applied
509- // by checking that MaxBodySize is properly stored in the service config
510- assert.Equal(t, tt.maxBodySize, svc.config.MaxBodySize)
511- })
512- }
513-}
514-
515 func TestRegistryInFlightTracking(t *testing.T) {400 func TestRegistryInFlightTracking(t *testing.T) {
516 svc := &Service{401 svc := &Service{
517 inFlight: make(map[string]int),402 inFlight: make(map[string]int),
@@ -572,6 +457,122 @@ func TestDrainRegistryInFlightTimeout(t *testing.T) {
572 }457 }
573 }458 }
574 459
460+func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
461+ runtime := outmocks.NewMockContainerRuntime(t)
462+ containerSvc := inmocks.NewMockContainerService(t)
463+ configSvc := inmocks.NewMockConfigService(t)
464+
465+ svc := NewService(runtime, containerSvc, configSvc, Config{
466+ RegistryDomain: "old.registry.com",
467+ RegistryPort: 5000,
468+ MaxBodySize: 1024,
469+ MaxResponseSize: 2048,
470+ MaxConcurrentConns: 10,
471+ })
472+
473+ cfg := svc.ProxyConfig()
474+ assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
475+ assert.Equal(t, 5000, cfg.RegistryPort)
476+ assert.Equal(t, int64(1024), cfg.MaxBodySize)
477+ assert.Equal(t, int64(2048), cfg.MaxResponseSize)
478+ assert.Equal(t, 10, cfg.MaxConcurrentConns)
479+
480+ svc.UpdateConfig(Config{
481+ RegistryDomain: "new.registry.com",
482+ RegistryPort: 5001,
483+ MaxBodySize: 4096,
484+ MaxResponseSize: 8192,
485+ MaxConcurrentConns: 50,
486+ })
487+
488+ cfg = svc.ProxyConfig()
489+ assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
490+ assert.Equal(t, 5001, cfg.RegistryPort)
491+ assert.Equal(t, int64(4096), cfg.MaxBodySize)
492+ assert.Equal(t, int64(8192), cfg.MaxResponseSize)
493+ assert.Equal(t, 50, cfg.MaxConcurrentConns)
494+}
495+
496+func TestService_IsRegistryDomain(t *testing.T) {
497+ runtime := outmocks.NewMockContainerRuntime(t)
498+ containerSvc := inmocks.NewMockContainerService(t)
499+ configSvc := inmocks.NewMockConfigService(t)
500+
501+ svc := NewService(runtime, containerSvc, configSvc, Config{
502+ RegistryDomain: "registry.example.com",
503+ })
504+
505+ assert.True(t, svc.IsRegistryDomain("registry.example.com"))
506+ assert.False(t, svc.IsRegistryDomain("other.example.com"))
507+ assert.False(t, svc.IsRegistryDomain(""))
508+}
509+
510+func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
511+ runtime := outmocks.NewMockContainerRuntime(t)
512+ containerSvc := inmocks.NewMockContainerService(t)
513+ configSvc := inmocks.NewMockConfigService(t)
514+
515+ svc := NewService(runtime, containerSvc, configSvc, Config{})
516+
517+ assert.False(t, svc.IsRegistryDomain("registry.example.com"))
518+ assert.False(t, svc.IsRegistryDomain(""))
519+}
520+
521+func TestService_TrackInFlight(t *testing.T) {
522+ svc := &Service{
523+ inFlight: make(map[string]int),
524+ }
525+
526+ // Empty container ID returns noop
527+ release := svc.TrackInFlight("")
528+ release() // should not panic
529+
530+ // Track a container
531+ release1 := svc.TrackInFlight("c-1")
532+ svc.inFlightMu.Lock()
533+ assert.Equal(t, 1, svc.inFlight["c-1"])
534+ svc.inFlightMu.Unlock()
535+
536+ // Track same container again
537+ release2 := svc.TrackInFlight("c-1")
538+ svc.inFlightMu.Lock()
539+ assert.Equal(t, 2, svc.inFlight["c-1"])
540+ svc.inFlightMu.Unlock()
541+
542+ // Release one
543+ release2()
544+ svc.inFlightMu.Lock()
545+ assert.Equal(t, 1, svc.inFlight["c-1"])
546+ svc.inFlightMu.Unlock()
547+
548+ // Release last — should delete key
549+ release1()
550+ svc.inFlightMu.Lock()
551+ _, exists := svc.inFlight["c-1"]
552+ assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
553+ svc.inFlightMu.Unlock()
554+}
555+
556+func TestService_TrackRegistryRequest(t *testing.T) {
557+ svc := &Service{
558+ inFlight: make(map[string]int),
559+ }
560+
561+ assert.Equal(t, int64(0), svc.RegistryInFlight())
562+
563+ svc.TrackRegistryRequest()
564+ assert.Equal(t, int64(1), svc.RegistryInFlight())
565+
566+ svc.TrackRegistryRequest()
567+ assert.Equal(t, int64(2), svc.RegistryInFlight())
568+
569+ svc.ReleaseRegistryRequest()
570+ assert.Equal(t, int64(1), svc.RegistryInFlight())
571+
572+ svc.ReleaseRegistryRequest()
573+ assert.Equal(t, int64(0), svc.RegistryInFlight())
574+}
575+
575 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {576 func TestService_GetTarget_HostMode_UsesProxyPortLabel(t *testing.T) {
576 runtime := outmocks.NewMockContainerRuntime(t)577 runtime := outmocks.NewMockContainerRuntime(t)
577 containerSvc := inmocks.NewMockContainerService(t)578 containerSvc := inmocks.NewMockContainerService(t)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close