v3.3.2 9697f9d

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

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