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 TestClientListImages(t *testing.T) {
120 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121 require.Equal(t, "/admin/images", r.URL.Path)
122 require.Equal(t, http.MethodGet, r.Method)
123 w.Header().Set("Content-Type", "application/json")
124 _, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
125 }))
126 defer srv.Close()
127
128 client := NewClient(srv.URL)
129 images, err := client.ListImages(context.Background())
130 require.NoError(t, err)
131 require.Len(t, images, 1)
132 assert.Equal(t, "registry.example.com/app", images[0].Repository)
133 assert.Equal(t, "latest", images[0].Tag)
134}
135
136func TestClientPruneImages(t *testing.T) {
137 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
138 require.Equal(t, "/admin/images/prune", r.URL.Path)
139 require.Equal(t, http.MethodPost, r.Method)
140
141 var req dto.ImagePruneRequest
142 require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
143 require.NotNil(t, req.KeepLast)
144 assert.Equal(t, 4, *req.KeepLast)
145 require.NotNil(t, req.PruneDangling)
146 assert.True(t, *req.PruneDangling)
147 require.NotNil(t, req.PruneRegistry)
148 assert.True(t, *req.PruneRegistry)
149
150 w.Header().Set("Content-Type", "application/json")
151 _, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
152 }))
153 defer srv.Close()
154
155 keepLast := 4
156 pruneDangling := true
157 pruneRegistry := true
158 client := NewClient(srv.URL)
159 resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
160 KeepLast: &keepLast,
161 PruneDangling: &pruneDangling,
162 PruneRegistry: &pruneRegistry,
163 })
164 require.NoError(t, err)
165 require.NotNil(t, resp)
166 assert.Equal(t, 2, resp.Runtime.DeletedCount)
167 assert.Equal(t, 3, resp.Registry.TagsRemoved)
168}
169
170func TestClientFindAttachmentTargetsByImage(t *testing.T) {
171 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172 require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path)
173 require.Equal(t, "", r.URL.RawQuery)
174 require.Equal(t, http.MethodGet, r.Method)
175 w.Header().Set("Content-Type", "application/json")
176 _, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`))
177 }))
178 defer srv.Close()
179
180 client := NewClient(srv.URL)
181 targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16")
182 require.NoError(t, err)
183 assert.Equal(t, []string{"app.example.com", "workers"}, targets)
184}
185
186func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) {
187 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
188 require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path)
189 require.Equal(t, http.MethodGet, r.Method)
190 w.Header().Set("Content-Type", "application/json")
191 _, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`))
192 }))
193 defer srv.Close()
194
195 client := NewClient(srv.URL)
196 targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag")
197 require.NoError(t, err)
198 assert.Equal(t, []string{"workers"}, targets)
199}
200
201func TestParseResponse_CapsErrorBodySize(t *testing.T) {
202 largeBody := strings.Repeat("x", 10*1024*1024)
203 resp := &http.Response{
204 StatusCode: 500,
205 Status: "500 Internal Server Error",
206 Body: io.NopCloser(strings.NewReader(largeBody)),
207 Header: make(http.Header),
208 }
209
210 err := parseResponse(resp, nil)
211
212 require.Error(t, err)
213 assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
214}
215
216func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
217 largeBody := strings.Repeat("x", 10*1024*1024)
218 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
219 w.WriteHeader(http.StatusInternalServerError)
220 _, _ = w.Write([]byte(largeBody))
221 }))
222 defer srv.Close()
223
224 c := &Client{
225 baseURL: srv.URL,
226 httpClient: srv.Client(),
227 }
228
229 _, err := c.StreamProcessLogs(context.Background(), 100)
230
231 require.Error(t, err)
232 assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
233}
234
235func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
236 withFastRetry(t)
237
238 largeBody := strings.Repeat("x", 10*1024*1024)
239 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
240 w.WriteHeader(http.StatusBadGateway)
241 _, _ = w.Write([]byte(largeBody))
242 }))
243 defer srv.Close()
244
245 c := &Client{
246 baseURL: srv.URL,
247 httpClient: srv.Client(),
248 }
249
250 _, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
251
252 require.Error(t, err)
253 assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
254}
255
256func TestExchangeRegistryToken_Success(t *testing.T) {
257 const subject = "testuser"
258 const gordonToken = "gordon-jwt-token"
259 const shortToken = "short-lived-123"
260
261 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
262 require.Equal(t, "/auth/token", r.URL.Path)
263 require.Equal(t, http.MethodGet, r.Method)
264 require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
265 require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
266
267 u, p, ok := r.BasicAuth()
268 require.True(t, ok, "expected Basic Auth")
269 assert.Equal(t, subject, u)
270 assert.Equal(t, gordonToken, p)
271
272 w.Header().Set("Content-Type", "application/json")
273 _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
274 }))
275 defer srv.Close()
276
277 client := NewClient(srv.URL, WithToken(gordonToken))
278 token, err := client.ExchangeRegistryToken(context.Background(), subject)
279 require.NoError(t, err)
280 assert.Equal(t, shortToken, token)
281}
282
283func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
284 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
285 w.WriteHeader(http.StatusUnauthorized)
286 _, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
287 }))
288 defer srv.Close()
289
290 client := NewClient(srv.URL, WithToken("bad-token"))
291 _, err := client.ExchangeRegistryToken(context.Background(), "user")
292 require.Error(t, err)
293 assert.Contains(t, err.Error(), "401")
294}
295
296func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
297 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
298 w.Header().Set("Content-Type", "application/json")
299 _, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
300 }))
301 defer srv.Close()
302
303 client := NewClient(srv.URL, WithToken("some-token"))
304 _, err := client.ExchangeRegistryToken(context.Background(), "user")
305 require.Error(t, err)
306 assert.Contains(t, err.Error(), "empty")
307}
308
309func TestHTTPError_ErrorString(t *testing.T) {
310 err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
311 assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
312}
313
314func TestHTTPError_Unwrap(t *testing.T) {
315 httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
316 wrapped := fmt.Errorf("deploy intent: %w", httpErr)
317
318 var target *HTTPError
319 assert.True(t, errors.As(wrapped, &target))
320 assert.Equal(t, 403, target.StatusCode)
321}
322
323func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
324 resp := &http.Response{
325 StatusCode: 403,
326 Status: "403 Forbidden",
327 Body: io.NopCloser(strings.NewReader("")),
328 }
329 err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
330
331 var httpErr *HTTPError
332 require.True(t, errors.As(err, &httpErr))
333 assert.Equal(t, 403, httpErr.StatusCode)
334 assert.Equal(t, "insufficient scope", httpErr.Body)
335}
336
337func TestParseErrorResponse_NonJSON(t *testing.T) {
338 resp := &http.Response{
339 StatusCode: 500,
340 Status: "500 Internal Server Error",
341 Body: io.NopCloser(strings.NewReader("")),
342 }
343 err := parseErrorResponse(resp, []byte("plain text error"))
344
345 var httpErr *HTTPError
346 require.True(t, errors.As(err, &httpErr))
347 assert.Equal(t, 500, httpErr.StatusCode)
348 assert.Equal(t, "plain text error", httpErr.Body)
349}
350
351func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
352 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353 w.Header().Set("Content-Type", "application/json")
354 // Token field empty, but access_token has a value
355 json.NewEncoder(w).Encode(map[string]any{
356 "access_token": "fallback-token-456",
357 "expires_in": 300,
358 })
359 }))
360 defer srv.Close()
361
362 client := NewClient(srv.URL, WithToken("long-lived"))
363 token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
364 require.NoError(t, err)
365 assert.Equal(t, "fallback-token-456", token)
366}