1package proxy
2
3import (
4 "bytes"
5 "io"
6 "net"
7 "net/http"
8 "net/http/httptest"
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
21func testLogger() zerowrap.Logger {
22 return zerowrap.Default()
23}
24
25func TestHandler_ConcurrentConnectionLimit_503WhenFull(t *testing.T) {
26 proxySvc := inmocks.NewMockProxyService(t)
27
28 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
29 MaxConcurrentConns: 1,
30 })
31
32 handler := NewHandler(proxySvc, nil, testLogger())
33
34 // Simulate an in-flight connection.
35 handler.activeConns.Add(1)
36 defer handler.activeConns.Add(-1)
37
38 req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
39 w := httptest.NewRecorder()
40 handler.ServeHTTP(w, req)
41
42 assert.Equal(t, http.StatusServiceUnavailable, w.Code)
43}
44
45func TestHandler_RoutesToRegistry(t *testing.T) {
46 proxySvc := inmocks.NewMockProxyService(t)
47
48 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
49 RegistryPort: 5000,
50 })
51 proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
52 proxySvc.EXPECT().TrackRegistryRequest().Return()
53 proxySvc.EXPECT().ReleaseRegistryRequest().Return()
54
55 handler := NewHandler(proxySvc, nil, testLogger()).WithRegistryForwarding(true)
56
57 req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
58 req.Host = "registry.example.com"
59 w := httptest.NewRecorder()
60 handler.ServeHTTP(w, req)
61
62 assert.True(t, w.Code == http.StatusBadGateway || w.Code == http.StatusServiceUnavailable)
63}
64
65// TestHandler_DeniesRegistryForwardingWhenAuthDisabled proves a public
66// registry-domain request cannot reach the internal registry unless
67// forwarding was explicitly enabled (auth on).
68func TestHandler_DeniesRegistryForwardingWhenAuthDisabled(t *testing.T) {
69 proxySvc := inmocks.NewMockProxyService(t)
70 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{RegistryPort: 5000})
71 proxySvc.EXPECT().IsRegistryDomain("registry.example.com").Return(true)
72
73 handler := NewHandler(proxySvc, nil, testLogger())
74
75 req := httptest.NewRequest(http.MethodGet, "http://registry.example.com/v2/", nil)
76 req.Host = "registry.example.com"
77 w := httptest.NewRecorder()
78 handler.ServeHTTP(w, req)
79
80 assert.Equal(t, http.StatusNotFound, w.Code)
81 proxySvc.AssertNotCalled(t, "TrackRegistryRequest")
82}
83
84func TestHandler_NormalizesRequestHostForLookup(t *testing.T) {
85 tests := []struct {
86 name string
87 host string
88 wantHost string
89 }{
90 {name: "mixed case", host: "App.Example.com", wantHost: "app.example.com"},
91 {name: "explicit port", host: "App.Example.com:8080", wantHost: "app.example.com"},
92 {name: "trailing dot", host: "App.Example.com.", wantHost: "app.example.com"},
93 }
94
95 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {
97 proxySvc := inmocks.NewMockProxyService(t)
98 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
99 proxySvc.EXPECT().IsRegistryDomain(tt.wantHost).Return(false)
100 proxySvc.EXPECT().GetTarget(mock.Anything, tt.wantHost).Return(nil, domain.ErrNoTargetAvailable)
101
102 handler := NewHandler(proxySvc, nil, testLogger())
103 srv := httptest.NewServer(handler)
104 defer srv.Close()
105 req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
106 require.NoError(t, err)
107 req.Host = tt.host
108 resp, err := srv.Client().Do(req)
109 require.NoError(t, err)
110 defer resp.Body.Close()
111
112 assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113 })
114 }
115}
116
117func TestHandler_RejectsInvalidRequestHost(t *testing.T) {
118 proxySvc := inmocks.NewMockProxyService(t)
119 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
120
121 handler := NewHandler(proxySvc, nil, testLogger())
122 srv := httptest.NewServer(handler)
123 defer srv.Close()
124 for _, host := range []string{"app.example.com:bad", "app.example.com:0", "app.example.com:99999"} {
125 req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil)
126 require.NoError(t, err)
127 req.Host = host
128 resp, err := srv.Client().Do(req)
129 require.NoError(t, err)
130 defer resp.Body.Close()
131
132 assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
133 }
134}
135
136func TestHandler_Returns404WhenNoTarget(t *testing.T) {
137 proxySvc := inmocks.NewMockProxyService(t)
138
139 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
140 proxySvc.EXPECT().IsRegistryDomain("unknown.example.com").Return(false)
141 proxySvc.EXPECT().GetTarget(mock.Anything, "unknown.example.com").Return(nil, domain.ErrNoTargetAvailable)
142
143 handler := NewHandler(proxySvc, nil, testLogger())
144
145 req := httptest.NewRequest(http.MethodGet, "http://unknown.example.com/", nil)
146 req.Host = "unknown.example.com"
147 w := httptest.NewRecorder()
148 handler.ServeHTTP(w, req)
149
150 assert.Equal(t, http.StatusNotFound, w.Code)
151}
152
153func TestHandler_ProxiesToTarget(t *testing.T) {
154 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
155 w.WriteHeader(http.StatusOK)
156 _, _ = w.Write([]byte("ok"))
157 }))
158 defer backend.Close()
159
160 proxySvc := inmocks.NewMockProxyService(t)
161
162 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{})
163 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
164
165 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
166
167 target := &domain.ProxyTarget{
168 Host: "127.0.0.1",
169 Port: backendPort,
170 ContainerID: "c-1",
171 Scheme: "http",
172 }
173 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(target, nil)
174 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
175
176 handler := NewHandler(proxySvc, nil, testLogger())
177
178 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
179 req.Host = "app.example.com"
180 w := httptest.NewRecorder()
181 handler.ServeHTTP(w, req)
182
183 assert.Equal(t, http.StatusOK, w.Code)
184 assert.Equal(t, "ok", w.Body.String())
185}
186
187func TestHandler_ReadsMaxBodySizeConfig(t *testing.T) {
188 // Backend that reads the full request body, triggering MaxBytesReader.
189 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190 _, _ = io.ReadAll(r.Body)
191 w.WriteHeader(http.StatusOK)
192 }))
193 defer backend.Close()
194
195 proxySvc := inmocks.NewMockProxyService(t)
196 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
197
198 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
199 MaxBodySize: 1024,
200 })
201 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
202 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
203 Host: "127.0.0.1",
204 Port: backendPort,
205 ContainerID: "c-1",
206 Scheme: "http",
207 }, nil)
208 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
209
210 handler := NewHandler(proxySvc, nil, testLogger())
211
212 // Send a body larger than the 1024-byte limit.
213 largeBody := bytes.NewReader(make([]byte, 2048))
214 req := httptest.NewRequest(http.MethodPost, "http://app.example.com/test", largeBody)
215 req.Host = "app.example.com"
216 w := httptest.NewRecorder()
217 handler.ServeHTTP(w, req)
218
219 assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
220}
221
222func TestHandler_UpdatedConfigReflected(t *testing.T) {
223 proxySvc := inmocks.NewMockProxyService(t)
224
225 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
226 MaxConcurrentConns: 0,
227 }).Once()
228 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false).Once()
229 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(nil, domain.ErrNoTargetAvailable).Once()
230
231 handler := NewHandler(proxySvc, nil, testLogger())
232
233 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
234 req.Host = "app.example.com"
235 rec := httptest.NewRecorder()
236 handler.ServeHTTP(rec, req)
237 assert.Equal(t, http.StatusNotFound, rec.Code)
238
239 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
240 MaxConcurrentConns: 1,
241 }).Once()
242
243 handler.activeConns.Add(1)
244 defer handler.activeConns.Add(-1)
245
246 req2 := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
247 req2.Host = "app.example.com"
248 rec2 := httptest.NewRecorder()
249 handler.ServeHTTP(rec2, req2)
250 assert.Equal(t, http.StatusServiceUnavailable, rec2.Code)
251}
252
253func TestHandler_NoConcurrencyLimitWhenZero(t *testing.T) {
254 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
255 w.WriteHeader(http.StatusOK)
256 }))
257 defer backend.Close()
258
259 proxySvc := inmocks.NewMockProxyService(t)
260 backendPort := backend.Listener.Addr().(*net.TCPAddr).Port
261
262 proxySvc.EXPECT().ProxyConfig().Return(in.ProxyServiceConfig{
263 MaxConcurrentConns: 0,
264 })
265 proxySvc.EXPECT().IsRegistryDomain("app.example.com").Return(false)
266 proxySvc.EXPECT().GetTarget(mock.Anything, "app.example.com").Return(&domain.ProxyTarget{
267 Host: "127.0.0.1",
268 Port: backendPort,
269 ContainerID: "c-1",
270 Scheme: "http",
271 }, nil)
272 proxySvc.EXPECT().TrackInFlight("c-1").Return(func() {})
273
274 handler := NewHandler(proxySvc, nil, testLogger())
275
276 req := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil)
277 req.Host = "app.example.com"
278 w := httptest.NewRecorder()
279 handler.ServeHTTP(w, req)
280
281 assert.Equal(t, http.StatusOK, w.Code)
282}