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/stretchr/testify/assert"
17 "github.com/stretchr/testify/require"
18
19 "github.com/bnema/gordon/internal/adapters/dto"
20)
21
22func withFastRetry(t *testing.T) {
23 t.Helper()
24 prevAttempts := retryMaxAttempts
25 prevDelay := retryBaseDelay
26 retryMaxAttempts = 3
27 retryBaseDelay = 10 * time.Millisecond
28 t.Cleanup(func() {
29 retryMaxAttempts = prevAttempts
30 retryBaseDelay = prevDelay
31 })
32}
33
34func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
35 withFastRetry(t)
36
37 var attempts int32
38 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39 require.Equal(t, "/admin/reload", r.URL.Path)
40 require.Equal(t, http.MethodPost, r.Method)
41 atomic.AddInt32(&attempts, 1)
42 w.WriteHeader(http.StatusBadGateway)
43 _, _ = w.Write([]byte(`upstream down`))
44 }))
45 defer srv.Close()
46
47 client := NewClient(srv.URL)
48 err := client.Reload(context.Background())
49
50 var outcomeErr *OutcomeUnknownError
51 require.ErrorAs(t, err, &outcomeErr)
52 assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
53}
54
55func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) {
56 withFastRetry(t)
57
58 var tokenAttempts int32
59 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60 require.Equal(t, "/auth/token", r.URL.Path)
61 atomic.AddInt32(&tokenAttempts, 1)
62 w.WriteHeader(http.StatusForbidden)
63 _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
64 }))
65 defer srv.Close()
66
67 client := NewClient(srv.URL, WithToken("invalid-token"))
68 status, err := client.GetStatus(context.Background())
69
70 require.Nil(t, status)
71 assert.ErrorContains(t, err, "ephemeral token exchange: 403 Forbidden")
72 assert.EqualValues(t, 1, atomic.LoadInt32(&tokenAttempts))
73}
74
75func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
76 withFastRetry(t)
77
78 var attempts int32
79 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
80 require.Equal(t, "/admin/status", r.URL.Path)
81 require.Equal(t, http.MethodGet, r.Method)
82 if atomic.AddInt32(&attempts, 1) < 3 {
83 w.WriteHeader(http.StatusGatewayTimeout)
84 _, _ = w.Write([]byte(`{"error":"upstream timeout"}`))
85 return
86 }
87 w.Header().Set("Content-Type", "application/json")
88 _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
89 }))
90 defer srv.Close()
91
92 client := NewClient(srv.URL)
93 status, err := client.GetStatus(context.Background())
94
95 require.NoError(t, err)
96 require.NotNil(t, status)
97 assert.Equal(t, 2, status.Apps)
98 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
99}
100
101func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
102 withFastRetry(t)
103
104 var attempts int32
105 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
106 if atomic.AddInt32(&attempts, 1) < 3 {
107 conn, _, err := w.(http.Hijacker).Hijack()
108 require.NoError(t, err)
109 require.NoError(t, conn.Close())
110 return
111 }
112 w.Header().Set("Content-Type", "application/json")
113 _, _ = w.Write([]byte(`{"apps":2,"container_status":{}}`))
114 }))
115 defer srv.Close()
116
117 client := NewClient(srv.URL)
118 status, err := client.GetStatus(context.Background())
119
120 require.NoError(t, err)
121 require.NotNil(t, status)
122 assert.Equal(t, 2, status.Apps)
123 assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
124}
125
126func TestClientGetStatusReportsTransportContextAfterRetryExhaustion(t *testing.T) {
127 withFastRetry(t)
128
129 var attempts int32
130 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
131 atomic.AddInt32(&attempts, 1)
132 conn, _, err := w.(http.Hijacker).Hijack()
133 require.NoError(t, err)
134 require.NoError(t, conn.Close())
135 }))
136 defer srv.Close()
137
138 client := NewClient(srv.URL)
139 status, err := client.GetStatus(context.Background())
140
141 require.Nil(t, status)
142 assert.ErrorContains(t, err, "GET /status transport request")
143 assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts))
144}
145
146func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
147 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
148 require.Equal(t, "/admin/status", r.URL.Path)
149 w.Header().Set("Content-Type", "application/json")
150 _, _ = w.Write([]byte(`{"apps":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
151 }))
152 defer srv.Close()
153
154 _, err := NewClient(srv.URL).GetStatus(context.Background())
155 require.Error(t, err)
156
157 _, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
158 require.NoError(t, err)
159}
160
161func TestClientGetTrafficStatus(t *testing.T) {
162 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
163 require.Equal(t, "/admin/traffic/status", r.URL.Path)
164 require.Equal(t, http.MethodGet, r.Method)
165 w.Header().Set("Content-Type", "application/json")
166 _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`))
167 }))
168 defer srv.Close()
169
170 client := NewClient(srv.URL)
171 status, err := client.GetTrafficStatus(context.Background())
172 require.NoError(t, err)
173 require.NotNil(t, status)
174 assert.Equal(t, "ok", status.LastReloadStatus)
175 assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
176}
177
178func TestClientGetCA(t *testing.T) {
179 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180 require.Equal(t, "/admin/ca", r.URL.Path)
181 require.Equal(t, http.MethodGet, r.Method)
182 w.Header().Set("Content-Type", "application/json")
183 _, _ = w.Write([]byte(`{"root_cn":"Gordon Root","fingerprint":"AB:CD","intermediate_expiry":"2027-01-02T03:04:05Z","root_pem":"PEM"}`))
184 }))
185 defer srv.Close()
186
187 ca, err := NewClient(srv.URL).GetCA(context.Background())
188 require.NoError(t, err)
189 assert.Equal(t, "Gordon Root", ca.RootCN)
190 assert.Equal(t, "AB:CD", ca.Fingerprint)
191 assert.Equal(t, "PEM", ca.RootPEM)
192 assert.Equal(t, 2027, ca.IntermediateExpiry.Year())
193}
194
195func TestClientGetCADisabled(t *testing.T) {
196 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
197 w.Header().Set("Content-Type", "application/json")
198 w.WriteHeader(http.StatusNotFound)
199 _, _ = w.Write([]byte(`{"error":"internal TLS is disabled on this server"}`))
200 }))
201 defer srv.Close()
202
203 _, err := NewClient(srv.URL).GetCA(context.Background())
204 require.Error(t, err)
205 assert.Contains(t, err.Error(), "internal TLS is disabled")
206}
207
208func TestClientGetTLSStatus(t *testing.T) {
209 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210 require.Equal(t, "/admin/tls/status", r.URL.Path)
211 require.Equal(t, http.MethodGet, r.Method)
212 w.Header().Set("Content-Type", "application/json")
213 _, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`))
214 }))
215 defer srv.Close()
216
217 client := NewClient(srv.URL)
218 status, err := client.GetTLSStatus(context.Background())
219 require.NoError(t, err)
220 require.NotNil(t, status)
221 assert.True(t, status.ACMEEnabled)
222 assert.Equal(t, "http-01", status.EffectiveMode)
223}
224
225func TestClientListImages(t *testing.T) {
226 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
227 require.Equal(t, "/admin/images", r.URL.Path)
228 require.Equal(t, http.MethodGet, r.Method)
229 w.Header().Set("Content-Type", "application/json")
230 _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
231 }))
232 defer srv.Close()
233
234 client := NewClient(srv.URL)
235 images, err := client.ListImages(context.Background())
236 require.NoError(t, err)
237 require.Len(t, images, 1)
238 assert.Equal(t, "registry.example.com/app", images[0].Repository)
239 assert.Equal(t, "latest", images[0].Tag)
240}
241
242func TestClientPruneImages(t *testing.T) {
243 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244 require.Equal(t, "/admin/images/prune", r.URL.Path)
245 require.Equal(t, http.MethodPost, r.Method)
246
247 var req dto.ImagePruneRequest
248 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
249 require.NotNil(t, req.KeepLast)
250 assert.Equal(t, 4, *req.KeepLast)
251 require.NotNil(t, req.PruneDangling)
252 assert.True(t, *req.PruneDangling)
253 require.NotNil(t, req.PruneRegistry)
254 assert.True(t, *req.PruneRegistry)
255
256 w.Header().Set("Content-Type", "application/json")
257 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
258 }))
259 defer srv.Close()
260
261 keepLast := 4
262 pruneDangling := true
263 pruneRegistry := true
264 client := NewClient(srv.URL)
265 resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
266 KeepLast: &keepLast,
267 PruneDangling: &pruneDangling,
268 PruneRegistry: &pruneRegistry,
269 })
270 require.NoError(t, err)
271 require.NotNil(t, resp)
272 assert.Equal(t, 2, resp.Runtime.DeletedCount)
273 assert.Equal(t, 3, resp.Registry.TagsRemoved)
274}
275
276func TestParseResponse_CapsErrorBodySize(t *testing.T) {
277 largeBody := strings.Repeat("x", 10*1024*1024)
278 resp := &http.Response{
279 StatusCode: 500,
280 Status: "500 Internal Server Error",
281 Body: io.NopCloser(strings.NewReader(largeBody)),
282 Header: make(http.Header),
283 }
284
285 err := parseResponse(resp, nil)
286
287 require.Error(t, err)
288 assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
289}
290
291func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
292 largeBody := strings.Repeat("x", 10*1024*1024)
293 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
294 w.WriteHeader(http.StatusInternalServerError)
295 _, _ = w.Write([]byte(largeBody))
296 }))
297 defer srv.Close()
298
299 c := &Client{
300 baseURL: srv.URL,
301 httpClient: srv.Client(),
302 }
303
304 _, err := c.StreamProcessLogs(context.Background(), 100)
305
306 require.Error(t, err)
307 assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
308}
309
310func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
311 withFastRetry(t)
312
313 largeBody := strings.Repeat("x", 10*1024*1024)
314 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
315 w.WriteHeader(http.StatusBadGateway)
316 _, _ = w.Write([]byte(largeBody))
317 }))
318 defer srv.Close()
319
320 c := &Client{
321 baseURL: srv.URL,
322 httpClient: srv.Client(),
323 }
324
325 _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
326
327 require.Error(t, err)
328 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
329}
330
331func TestExchangeRegistryToken_Success(t *testing.T) {
332 const subject = "testuser"
333 const gordonToken = "gordon-jwt-token"
334 const shortToken = "short-lived-123"
335
336 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
337 require.Equal(t, "/auth/token", r.URL.Path)
338 require.Equal(t, http.MethodGet, r.Method)
339 require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
340 require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
341
342 u, p, ok := r.BasicAuth()
343 require.True(t, ok, "expected Basic Auth")
344 assert.Equal(t, subject, u)
345 assert.Equal(t, gordonToken, p)
346
347 w.Header().Set("Content-Type", "application/json")
348 _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
349 }))
350 defer srv.Close()
351
352 client := NewClient(srv.URL, WithToken(gordonToken))
353 token, err := client.ExchangeRegistryToken(context.Background(), subject)
354 require.NoError(t, err)
355 assert.Equal(t, shortToken, token)
356}
357
358func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
359 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
360 w.WriteHeader(http.StatusUnauthorized)
361 _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
362 }))
363 defer srv.Close()
364
365 client := NewClient(srv.URL, WithToken("bad-token"))
366 _, err := client.ExchangeRegistryToken(context.Background(), "user")
367 require.Error(t, err)
368 assert.Contains(t, err.Error(), "401")
369}
370
371func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
372 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
373 w.Header().Set("Content-Type", "application/json")
374 _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
375 }))
376 defer srv.Close()
377
378 client := NewClient(srv.URL, WithToken("some-token"))
379 _, err := client.ExchangeRegistryToken(context.Background(), "user")
380 require.Error(t, err)
381 assert.Contains(t, err.Error(), "empty")
382}
383
384func TestHTTPError_ErrorString(t *testing.T) {
385 err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
386 assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
387}
388
389func TestHTTPError_Unwrap(t *testing.T) {
390 httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
391 wrapped := fmt.Errorf("deploy intent: %w", httpErr)
392
393 var target *HTTPError
394 assert.True(t, errors.As(wrapped, &target))
395 assert.Equal(t, 403, target.StatusCode)
396}
397
398func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
399 resp := &http.Response{
400 StatusCode: 403,
401 Status: "403 Forbidden",
402 Body: io.NopCloser(strings.NewReader("")),
403 }
404 err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
405
406 var httpErr *HTTPError
407 require.True(t, errors.As(err, &httpErr))
408 assert.Equal(t, 403, httpErr.StatusCode)
409 assert.Equal(t, "insufficient scope", httpErr.Body)
410}
411
412func TestParseErrorResponse_AppErrorMessage(t *testing.T) {
413 resp := &http.Response{StatusCode: 400, Status: "400 Bad Request"}
414 err := parseErrorResponse(resp, []byte(`{"error":"invalid-manifest","message":"invalid app manifest: service \"metrics\" readiness.port 9090 matches no declared container port"}`))
415
416 var httpErr *HTTPError
417 require.True(t, errors.As(err, &httpErr))
418 assert.Equal(t, `invalid app manifest: service "metrics" readiness.port 9090 matches no declared container port`, httpErr.Body)
419 assert.Contains(t, err.Error(), `service "metrics" readiness.port 9090`)
420}
421
422func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
423 resp := &http.Response{
424 StatusCode: 500,
425 Status: "500 Internal Server Error",
426 Body: io.NopCloser(strings.NewReader("")),
427 }
428 err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
429
430 var httpErr *HTTPError
431 require.True(t, errors.As(err, &httpErr))
432 assert.Equal(t, 500, httpErr.StatusCode)
433 assert.Equal(t, "container exited during startup", httpErr.Body)
434 assert.True(t, httpErr.Structured)
435 assert.Equal(t, "health check failed", httpErr.Cause)
436 assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
437 assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
438}
439
440func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
441 resp := &http.Response{
442 StatusCode: 500,
443 Status: "500 Internal Server Error",
444 Body: io.NopCloser(strings.NewReader("")),
445 }
446 err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
447
448 var httpErr *HTTPError
449 require.True(t, errors.As(err, &httpErr))
450 assert.Equal(t, "failed to deploy container", httpErr.Body)
451 assert.False(t, httpErr.Structured)
452 assert.Empty(t, httpErr.Cause)
453 assert.Empty(t, httpErr.Hint)
454 assert.Empty(t, httpErr.Logs)
455}
456
457func TestParseErrorResponse_NonJSON(t *testing.T) {
458 resp := &http.Response{
459 StatusCode: 500,
460 Status: "500 Internal Server Error",
461 Body: io.NopCloser(strings.NewReader("")),
462 }
463 err := parseErrorResponse(resp, []byte("plain text error"))
464
465 var httpErr *HTTPError
466 require.True(t, errors.As(err, &httpErr))
467 assert.Equal(t, 500, httpErr.StatusCode)
468 assert.Equal(t, "plain text error", httpErr.Body)
469}
470
471func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
472 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
473 require.Equal(t, "/admin/backups/volumes/shop", r.URL.Path)
474 require.Equal(t, http.MethodPost, r.Method)
475 w.Header().Set("Content-Type", "application/json")
476 w.WriteHeader(http.StatusPartialContent)
477 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
478 Status: "partial",
479 Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
480 Error: "one volume failed",
481 }))
482 }))
483 defer srv.Close()
484
485 client := NewClient(srv.URL)
486 result, err := client.RunVolumeBackups(context.Background(), "shop", "api", "data")
487
488 require.Error(t, err)
489 assert.Contains(t, err.Error(), "one volume failed")
490 require.NotNil(t, result)
491 assert.Equal(t, "partial", result.Status)
492 require.Len(t, result.Backups, 1)
493 assert.Equal(t, "v1", result.Backups[0].ID)
494}
495
496func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
497 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
498 w.Header().Set("Content-Type", "application/json")
499 // Token field empty, but access_token has a value
500 json.NewEncoder(w).Encode(map[string]any{
501 "access_token": "fallback-token-456",
502 "expires_in": 300,
503 })
504 }))
505 defer srv.Close()
506
507 client := NewClient(srv.URL, WithToken("long-lived"))
508 token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
509 require.NoError(t, err)
510 assert.Equal(t, "fallback-token-456", token)
511}