1package remote
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "strings"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/bnema/gordon/internal/domain"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 "github.com/bnema/gordon/internal/adapters/dto"
21)
22
23func withFastRetry(t *testing.T) {
24 t.Helper()
25 prevAttempts := retryMaxAttempts
26 prevDelay := retryBaseDelay
27 retryMaxAttempts = 3
28 retryBaseDelay = 10 * time.Millisecond
29 t.Cleanup(func() {
30 retryMaxAttempts = prevAttempts
31 retryBaseDelay = prevDelay
32 })
33}
34
35func TestClientRestartRetriesOn5xx(t *testing.T) {
36 withFastRetry(t)
37
38 var attempts int32
39 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
40 require.Equal(t, "/admin/restart/test.example.com", r.URL.Path)
41 require.Equal(t, http.MethodPost, r.Method)
42 current := atomic.AddInt32(&attempts, 1)
43 if current < 3 {
44 w.WriteHeader(http.StatusServiceUnavailable)
45 _, _ = w.Write([]byte(`{"error":"temporary outage"}`))
46 return
47 }
48 w.Header().Set("Content-Type", "application/json")
49 _, _ = w.Write([]byte(`{"status":"restarted","domain":"test.example.com"}`))
50 }))
51 defer srv.Close()
52
53 client := NewClient(srv.URL)
54 result, err := client.Restart(context.Background(), "test.example.com", false)
55 require.NoError(t, err)
56 require.NotNil(t, result)
57 assert.Equal(t, "test.example.com", result.Domain)
58 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
59}
60
61func TestClientReloadRetriesOn5xx(t *testing.T) {
62 withFastRetry(t)
63
64 var attempts int32
65 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
66 require.Equal(t, "/admin/reload", r.URL.Path)
67 require.Equal(t, http.MethodPost, r.Method)
68 current := atomic.AddInt32(&attempts, 1)
69 if current < 2 {
70 w.WriteHeader(http.StatusBadGateway)
71 _, _ = w.Write([]byte(`upstream down`))
72 return
73 }
74 w.Header().Set("Content-Type", "application/json")
75 _, _ = w.Write([]byte(`{"status":"reloaded"}`))
76 }))
77 defer srv.Close()
78
79 client := NewClient(srv.URL)
80 err := client.Reload(context.Background())
81 require.NoError(t, err)
82 assert.EqualValues(t, 2, atomic.LoadInt32(&attempts))
83}
84
85func TestClientDeployReturnsErrorAfterRetryExhaustion(t *testing.T) {
86 withFastRetry(t)
87
88 var attempts int32
89 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90 require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path)
91 require.Equal(t, http.MethodPost, r.Method)
92 atomic.AddInt32(&attempts, 1)
93 w.WriteHeader(http.StatusBadGateway)
94 _, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
95 }))
96 defer srv.Close()
97
98 client := NewClient(srv.URL)
99 _, err := client.Deploy(context.Background(), "test.example.com")
100 require.Error(t, err)
101 assert.Contains(t, err.Error(), "502 Bad Gateway: upstream unavailable")
102 assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts))
103}
104
105func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
106 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107 require.Equal(t, "/admin/status", r.URL.Path)
108 w.Header().Set("Content-Type", "application/json")
109 _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
110 }))
111 defer srv.Close()
112
113 _, err := NewClient(srv.URL).GetStatus(context.Background())
114 require.Error(t, err)
115
116 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
117 require.NoError(t, err)
118}
119
120func TestClientGetTLSStatus(t *testing.T) {
121 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122 require.Equal(t, "/admin/tls/status", r.URL.Path)
123 require.Equal(t, http.MethodGet, r.Method)
124 w.Header().Set("Content-Type", "application/json")
125 _, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`))
126 }))
127 defer srv.Close()
128
129 client := NewClient(srv.URL)
130 status, err := client.GetTLSStatus(context.Background())
131 require.NoError(t, err)
132 require.NotNil(t, status)
133 assert.True(t, status.ACMEEnabled)
134 assert.Equal(t, "http-01", status.EffectiveMode)
135}
136
137func TestClientListImages(t *testing.T) {
138 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139 require.Equal(t, "/admin/images", r.URL.Path)
140 require.Equal(t, http.MethodGet, r.Method)
141 w.Header().Set("Content-Type", "application/json")
142 _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
143 }))
144 defer srv.Close()
145
146 client := NewClient(srv.URL)
147 images, err := client.ListImages(context.Background())
148 require.NoError(t, err)
149 require.Len(t, images, 1)
150 assert.Equal(t, "registry.example.com/app", images[0].Repository)
151 assert.Equal(t, "latest", images[0].Tag)
152}
153
154func TestClientPruneImages(t *testing.T) {
155 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
156 require.Equal(t, "/admin/images/prune", r.URL.Path)
157 require.Equal(t, http.MethodPost, r.Method)
158
159 var req dto.ImagePruneRequest
160 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
161 require.NotNil(t, req.KeepLast)
162 assert.Equal(t, 4, *req.KeepLast)
163 require.NotNil(t, req.PruneDangling)
164 assert.True(t, *req.PruneDangling)
165 require.NotNil(t, req.PruneRegistry)
166 assert.True(t, *req.PruneRegistry)
167
168 w.Header().Set("Content-Type", "application/json")
169 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
170 }))
171 defer srv.Close()
172
173 keepLast := 4
174 pruneDangling := true
175 pruneRegistry := true
176 client := NewClient(srv.URL)
177 resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
178 KeepLast: &keepLast,
179 PruneDangling: &pruneDangling,
180 PruneRegistry: &pruneRegistry,
181 })
182 require.NoError(t, err)
183 require.NotNil(t, resp)
184 assert.Equal(t, 2, resp.Runtime.DeletedCount)
185 assert.Equal(t, 3, resp.Registry.TagsRemoved)
186}
187
188func TestClientFindAttachmentTargetsByImage(t *testing.T) {
189 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
190 require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path)
191 require.Equal(t, "", r.URL.RawQuery)
192 require.Equal(t, http.MethodGet, r.Method)
193 w.Header().Set("Content-Type", "application/json")
194 _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`))
195 }))
196 defer srv.Close()
197
198 client := NewClient(srv.URL)
199 targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16")
200 require.NoError(t, err)
201 assert.Equal(t, []string{"app.example.com", "workers"}, targets)
202}
203
204func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) {
205 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206 require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path)
207 require.Equal(t, http.MethodGet, r.Method)
208 w.WriteHeader(http.StatusNotFound)
209 _, _ = w.Write([]byte(`{"error":"route not found"}`))
210 }))
211 defer srv.Close()
212
213 client := NewClient(srv.URL)
214 route, err := client.GetRoute(context.Background(), "missing.example.test")
215 require.Nil(t, route)
216 require.Error(t, err)
217 assert.ErrorIs(t, err, domain.ErrRouteNotFound)
218}
219
220func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) {
221 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
222 require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path)
223 require.Equal(t, http.MethodGet, r.Method)
224 w.Header().Set("Content-Type", "application/json")
225 _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`))
226 }))
227 defer srv.Close()
228
229 client := NewClient(srv.URL)
230 targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag")
231 require.NoError(t, err)
232 assert.Equal(t, []string{"workers"}, targets)
233}
234
235func TestParseResponse_CapsErrorBodySize(t *testing.T) {
236 largeBody := strings.Repeat("x", 10*1024*1024)
237 resp := &http.Response{
238 StatusCode: 500,
239 Status: "500 Internal Server Error",
240 Body: io.NopCloser(strings.NewReader(largeBody)),
241 Header: make(http.Header),
242 }
243
244 err := parseResponse(resp, nil)
245
246 require.Error(t, err)
247 assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
248}
249
250func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
251 largeBody := strings.Repeat("x", 10*1024*1024)
252 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
253 w.WriteHeader(http.StatusInternalServerError)
254 _, _ = w.Write([]byte(largeBody))
255 }))
256 defer srv.Close()
257
258 c := &Client{
259 baseURL: srv.URL,
260 httpClient: srv.Client(),
261 }
262
263 _, err := c.StreamProcessLogs(context.Background(), 100)
264
265 require.Error(t, err)
266 assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
267}
268
269func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
270 withFastRetry(t)
271
272 largeBody := strings.Repeat("x", 10*1024*1024)
273 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
274 w.WriteHeader(http.StatusBadGateway)
275 _, _ = w.Write([]byte(largeBody))
276 }))
277 defer srv.Close()
278
279 c := &Client{
280 baseURL: srv.URL,
281 httpClient: srv.Client(),
282 }
283
284 _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
285
286 require.Error(t, err)
287 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
288}
289
290func TestExchangeRegistryToken_Success(t *testing.T) {
291 const subject = "testuser"
292 const gordonToken = "gordon-jwt-token"
293 const shortToken = "short-lived-123"
294
295 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
296 require.Equal(t, "/auth/token", r.URL.Path)
297 require.Equal(t, http.MethodGet, r.Method)
298 require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
299 require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
300
301 u, p, ok := r.BasicAuth()
302 require.True(t, ok, "expected Basic Auth")
303 assert.Equal(t, subject, u)
304 assert.Equal(t, gordonToken, p)
305
306 w.Header().Set("Content-Type", "application/json")
307 _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
308 }))
309 defer srv.Close()
310
311 client := NewClient(srv.URL, WithToken(gordonToken))
312 token, err := client.ExchangeRegistryToken(context.Background(), subject)
313 require.NoError(t, err)
314 assert.Equal(t, shortToken, token)
315}
316
317func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
318 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
319 w.WriteHeader(http.StatusUnauthorized)
320 _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
321 }))
322 defer srv.Close()
323
324 client := NewClient(srv.URL, WithToken("bad-token"))
325 _, err := client.ExchangeRegistryToken(context.Background(), "user")
326 require.Error(t, err)
327 assert.Contains(t, err.Error(), "401")
328}
329
330func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
331 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332 w.Header().Set("Content-Type", "application/json")
333 _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
334 }))
335 defer srv.Close()
336
337 client := NewClient(srv.URL, WithToken("some-token"))
338 _, err := client.ExchangeRegistryToken(context.Background(), "user")
339 require.Error(t, err)
340 assert.Contains(t, err.Error(), "empty")
341}
342
343func TestHTTPError_ErrorString(t *testing.T) {
344 err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
345 assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
346}
347
348func TestHTTPError_Unwrap(t *testing.T) {
349 httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
350 wrapped := fmt.Errorf("deploy intent: %w", httpErr)
351
352 var target *HTTPError
353 assert.True(t, errors.As(wrapped, &target))
354 assert.Equal(t, 403, target.StatusCode)
355}
356
357func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
358 resp := &http.Response{
359 StatusCode: 403,
360 Status: "403 Forbidden",
361 Body: io.NopCloser(strings.NewReader("")),
362 }
363 err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
364
365 var httpErr *HTTPError
366 require.True(t, errors.As(err, &httpErr))
367 assert.Equal(t, 403, httpErr.StatusCode)
368 assert.Equal(t, "insufficient scope", httpErr.Body)
369}
370
371func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
372 resp := &http.Response{
373 StatusCode: 500,
374 Status: "500 Internal Server Error",
375 Body: io.NopCloser(strings.NewReader("")),
376 }
377 err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
378
379 var httpErr *HTTPError
380 require.True(t, errors.As(err, &httpErr))
381 assert.Equal(t, 500, httpErr.StatusCode)
382 assert.Equal(t, "container exited during startup", httpErr.Body)
383 assert.True(t, httpErr.Structured)
384 assert.Equal(t, "health check failed", httpErr.Cause)
385 assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
386 assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
387}
388
389func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
390 resp := &http.Response{
391 StatusCode: 500,
392 Status: "500 Internal Server Error",
393 Body: io.NopCloser(strings.NewReader("")),
394 }
395 err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
396
397 var httpErr *HTTPError
398 require.True(t, errors.As(err, &httpErr))
399 assert.Equal(t, "failed to deploy container", httpErr.Body)
400 assert.False(t, httpErr.Structured)
401 assert.Empty(t, httpErr.Cause)
402 assert.Empty(t, httpErr.Hint)
403 assert.Empty(t, httpErr.Logs)
404}
405
406func TestParseErrorResponse_NonJSON(t *testing.T) {
407 resp := &http.Response{
408 StatusCode: 500,
409 Status: "500 Internal Server Error",
410 Body: io.NopCloser(strings.NewReader("")),
411 }
412 err := parseErrorResponse(resp, []byte("plain text error"))
413
414 var httpErr *HTTPError
415 require.True(t, errors.As(err, &httpErr))
416 assert.Equal(t, 500, httpErr.StatusCode)
417 assert.Equal(t, "plain text error", httpErr.Body)
418}
419
420func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
421 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
422 w.Header().Set("Content-Type", "application/json")
423 // Token field empty, but access_token has a value
424 json.NewEncoder(w).Encode(map[string]any{
425 "access_token": "fallback-token-456",
426 "expires_in": 300,
427 })
428 }))
429 defer srv.Close()
430
431 client := NewClient(srv.URL, WithToken("long-lived"))
432 token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
433 require.NoError(t, err)
434 assert.Equal(t, "fallback-token-456", token)
435}