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