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