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 TestClientGetTLSStatus(t *testing.T) {
179 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180 require.Equal(t, "/admin/tls/status", r.URL.Path)
181 require.Equal(t, http.MethodGet, r.Method)
182 w.Header().Set("Content-Type", "application/json")
183 _, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`))
184 }))
185 defer srv.Close()
186
187 client := NewClient(srv.URL)
188 status, err := client.GetTLSStatus(context.Background())
189 require.NoError(t, err)
190 require.NotNil(t, status)
191 assert.True(t, status.ACMEEnabled)
192 assert.Equal(t, "http-01", status.EffectiveMode)
193}
194
195func TestClientListImages(t *testing.T) {
196 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
197 require.Equal(t, "/admin/images", r.URL.Path)
198 require.Equal(t, http.MethodGet, r.Method)
199 w.Header().Set("Content-Type", "application/json")
200 _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
201 }))
202 defer srv.Close()
203
204 client := NewClient(srv.URL)
205 images, err := client.ListImages(context.Background())
206 require.NoError(t, err)
207 require.Len(t, images, 1)
208 assert.Equal(t, "registry.example.com/app", images[0].Repository)
209 assert.Equal(t, "latest", images[0].Tag)
210}
211
212func TestClientPruneImages(t *testing.T) {
213 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214 require.Equal(t, "/admin/images/prune", r.URL.Path)
215 require.Equal(t, http.MethodPost, r.Method)
216
217 var req dto.ImagePruneRequest
218 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
219 require.NotNil(t, req.KeepLast)
220 assert.Equal(t, 4, *req.KeepLast)
221 require.NotNil(t, req.PruneDangling)
222 assert.True(t, *req.PruneDangling)
223 require.NotNil(t, req.PruneRegistry)
224 assert.True(t, *req.PruneRegistry)
225
226 w.Header().Set("Content-Type", "application/json")
227 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
228 }))
229 defer srv.Close()
230
231 keepLast := 4
232 pruneDangling := true
233 pruneRegistry := true
234 client := NewClient(srv.URL)
235 resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
236 KeepLast: &keepLast,
237 PruneDangling: &pruneDangling,
238 PruneRegistry: &pruneRegistry,
239 })
240 require.NoError(t, err)
241 require.NotNil(t, resp)
242 assert.Equal(t, 2, resp.Runtime.DeletedCount)
243 assert.Equal(t, 3, resp.Registry.TagsRemoved)
244}
245
246func TestParseResponse_CapsErrorBodySize(t *testing.T) {
247 largeBody := strings.Repeat("x", 10*1024*1024)
248 resp := &http.Response{
249 StatusCode: 500,
250 Status: "500 Internal Server Error",
251 Body: io.NopCloser(strings.NewReader(largeBody)),
252 Header: make(http.Header),
253 }
254
255 err := parseResponse(resp, nil)
256
257 require.Error(t, err)
258 assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
259}
260
261func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
262 largeBody := strings.Repeat("x", 10*1024*1024)
263 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
264 w.WriteHeader(http.StatusInternalServerError)
265 _, _ = w.Write([]byte(largeBody))
266 }))
267 defer srv.Close()
268
269 c := &Client{
270 baseURL: srv.URL,
271 httpClient: srv.Client(),
272 }
273
274 _, err := c.StreamProcessLogs(context.Background(), 100)
275
276 require.Error(t, err)
277 assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
278}
279
280func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
281 withFastRetry(t)
282
283 largeBody := strings.Repeat("x", 10*1024*1024)
284 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 w.WriteHeader(http.StatusBadGateway)
286 _, _ = w.Write([]byte(largeBody))
287 }))
288 defer srv.Close()
289
290 c := &Client{
291 baseURL: srv.URL,
292 httpClient: srv.Client(),
293 }
294
295 _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
296
297 require.Error(t, err)
298 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
299}
300
301func TestExchangeRegistryToken_Success(t *testing.T) {
302 const subject = "testuser"
303 const gordonToken = "gordon-jwt-token"
304 const shortToken = "short-lived-123"
305
306 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
307 require.Equal(t, "/auth/token", r.URL.Path)
308 require.Equal(t, http.MethodGet, r.Method)
309 require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
310 require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
311
312 u, p, ok := r.BasicAuth()
313 require.True(t, ok, "expected Basic Auth")
314 assert.Equal(t, subject, u)
315 assert.Equal(t, gordonToken, p)
316
317 w.Header().Set("Content-Type", "application/json")
318 _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
319 }))
320 defer srv.Close()
321
322 client := NewClient(srv.URL, WithToken(gordonToken))
323 token, err := client.ExchangeRegistryToken(context.Background(), subject)
324 require.NoError(t, err)
325 assert.Equal(t, shortToken, token)
326}
327
328func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
329 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
330 w.WriteHeader(http.StatusUnauthorized)
331 _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
332 }))
333 defer srv.Close()
334
335 client := NewClient(srv.URL, WithToken("bad-token"))
336 _, err := client.ExchangeRegistryToken(context.Background(), "user")
337 require.Error(t, err)
338 assert.Contains(t, err.Error(), "401")
339}
340
341func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
342 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
343 w.Header().Set("Content-Type", "application/json")
344 _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
345 }))
346 defer srv.Close()
347
348 client := NewClient(srv.URL, WithToken("some-token"))
349 _, err := client.ExchangeRegistryToken(context.Background(), "user")
350 require.Error(t, err)
351 assert.Contains(t, err.Error(), "empty")
352}
353
354func TestHTTPError_ErrorString(t *testing.T) {
355 err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
356 assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
357}
358
359func TestHTTPError_Unwrap(t *testing.T) {
360 httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
361 wrapped := fmt.Errorf("deploy intent: %w", httpErr)
362
363 var target *HTTPError
364 assert.True(t, errors.As(wrapped, &target))
365 assert.Equal(t, 403, target.StatusCode)
366}
367
368func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
369 resp := &http.Response{
370 StatusCode: 403,
371 Status: "403 Forbidden",
372 Body: io.NopCloser(strings.NewReader("")),
373 }
374 err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
375
376 var httpErr *HTTPError
377 require.True(t, errors.As(err, &httpErr))
378 assert.Equal(t, 403, httpErr.StatusCode)
379 assert.Equal(t, "insufficient scope", httpErr.Body)
380}
381
382func TestParseErrorResponse_AppErrorMessage(t *testing.T) {
383 resp := &http.Response{StatusCode: 400, Status: "400 Bad Request"}
384 err := parseErrorResponse(resp, []byte(`{"error":"invalid-manifest","message":"invalid app manifest: service \"metrics\" readiness.port 9090 matches no declared container port"}`))
385
386 var httpErr *HTTPError
387 require.True(t, errors.As(err, &httpErr))
388 assert.Equal(t, `invalid app manifest: service "metrics" readiness.port 9090 matches no declared container port`, httpErr.Body)
389 assert.Contains(t, err.Error(), `service "metrics" readiness.port 9090`)
390}
391
392func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
393 resp := &http.Response{
394 StatusCode: 500,
395 Status: "500 Internal Server Error",
396 Body: io.NopCloser(strings.NewReader("")),
397 }
398 err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
399
400 var httpErr *HTTPError
401 require.True(t, errors.As(err, &httpErr))
402 assert.Equal(t, 500, httpErr.StatusCode)
403 assert.Equal(t, "container exited during startup", httpErr.Body)
404 assert.True(t, httpErr.Structured)
405 assert.Equal(t, "health check failed", httpErr.Cause)
406 assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
407 assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
408}
409
410func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
411 resp := &http.Response{
412 StatusCode: 500,
413 Status: "500 Internal Server Error",
414 Body: io.NopCloser(strings.NewReader("")),
415 }
416 err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
417
418 var httpErr *HTTPError
419 require.True(t, errors.As(err, &httpErr))
420 assert.Equal(t, "failed to deploy container", httpErr.Body)
421 assert.False(t, httpErr.Structured)
422 assert.Empty(t, httpErr.Cause)
423 assert.Empty(t, httpErr.Hint)
424 assert.Empty(t, httpErr.Logs)
425}
426
427func TestParseErrorResponse_NonJSON(t *testing.T) {
428 resp := &http.Response{
429 StatusCode: 500,
430 Status: "500 Internal Server Error",
431 Body: io.NopCloser(strings.NewReader("")),
432 }
433 err := parseErrorResponse(resp, []byte("plain text error"))
434
435 var httpErr *HTTPError
436 require.True(t, errors.As(err, &httpErr))
437 assert.Equal(t, 500, httpErr.StatusCode)
438 assert.Equal(t, "plain text error", httpErr.Body)
439}
440
441func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
442 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
443 require.Equal(t, "/admin/backups/volumes/shop", r.URL.Path)
444 require.Equal(t, http.MethodPost, r.Method)
445 w.Header().Set("Content-Type", "application/json")
446 w.WriteHeader(http.StatusPartialContent)
447 require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
448 Status: "partial",
449 Backups: []dto.VolumeBackupJob{{ID: "v1", App: "shop", Service: "api", VolumeName: "data", Status: "completed"}},
450 Error: "one volume failed",
451 }))
452 }))
453 defer srv.Close()
454
455 client := NewClient(srv.URL)
456 result, err := client.RunVolumeBackups(context.Background(), "shop", "api", "data")
457
458 require.Error(t, err)
459 assert.Contains(t, err.Error(), "one volume failed")
460 require.NotNil(t, result)
461 assert.Equal(t, "partial", result.Status)
462 require.Len(t, result.Backups, 1)
463 assert.Equal(t, "v1", result.Backups[0].ID)
464}
465
466func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
467 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
468 w.Header().Set("Content-Type", "application/json")
469 // Token field empty, but access_token has a value
470 json.NewEncoder(w).Encode(map[string]any{
471 "access_token": "fallback-token-456",
472 "expires_in": 300,
473 })
474 }))
475 defer srv.Close()
476
477 client := NewClient(srv.URL, WithToken("long-lived"))
478 token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
479 require.NoError(t, err)
480 assert.Equal(t, "fallback-token-456", token)
481}