v2.31.0-dev23 58a92f3

client_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close