v2.31.1 f37fe67

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	"github.com/bnema/gordon/internal/domain"
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 TestClientRestartDoesNotRetryGatewayResponseAfterSideEffect(t *testing.T) {
36	withFastRetry(t)
37
38	var mutations 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		atomic.AddInt32(&mutations, 1)
43		w.WriteHeader(http.StatusServiceUnavailable)
44		_, _ = w.Write([]byte(`{"error":"upstream response unavailable"}`))
45	}))
46	defer srv.Close()
47
48	client := NewClient(srv.URL)
49	result, err := client.Restart(context.Background(), "test.example.com", false)
50
51	require.Nil(t, result)
52	var outcomeErr *OutcomeUnknownError
53	require.ErrorAs(t, err, &outcomeErr)
54	assert.Contains(t, err.Error(), "inspect current state before retrying")
55	assert.EqualValues(t, 1, atomic.LoadInt32(&mutations))
56}
57
58func TestClientRestartDoesNotRetryAmbiguousTransportFailure(t *testing.T) {
59	withFastRetry(t)
60
61	var mutations int32
62	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
63		atomic.AddInt32(&mutations, 1)
64		conn, _, err := w.(http.Hijacker).Hijack()
65		require.NoError(t, err)
66		require.NoError(t, conn.Close())
67	}))
68	defer srv.Close()
69
70	client := NewClient(srv.URL)
71	result, err := client.Restart(context.Background(), "test.example.com", true)
72
73	require.Nil(t, result)
74	var outcomeErr *OutcomeUnknownError
75	require.ErrorAs(t, err, &outcomeErr)
76	assert.EqualValues(t, 1, atomic.LoadInt32(&mutations))
77}
78
79func TestClientReloadDoesNotRetryGatewayResponse(t *testing.T) {
80	withFastRetry(t)
81
82	var attempts int32
83	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84		require.Equal(t, "/admin/reload", r.URL.Path)
85		require.Equal(t, http.MethodPost, r.Method)
86		atomic.AddInt32(&attempts, 1)
87		w.WriteHeader(http.StatusBadGateway)
88		_, _ = w.Write([]byte(`upstream down`))
89	}))
90	defer srv.Close()
91
92	client := NewClient(srv.URL)
93	err := client.Reload(context.Background())
94
95	var outcomeErr *OutcomeUnknownError
96	require.ErrorAs(t, err, &outcomeErr)
97	assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
98}
99
100func TestClientDeployDoesNotRetryGatewayResponse(t *testing.T) {
101	withFastRetry(t)
102
103	var attempts int32
104	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
105		require.Equal(t, "/admin/deploy/test.example.com", r.URL.Path)
106		require.Equal(t, http.MethodPost, r.Method)
107		atomic.AddInt32(&attempts, 1)
108		w.WriteHeader(http.StatusBadGateway)
109		_, _ = w.Write([]byte(`{"error":"upstream unavailable"}`))
110	}))
111	defer srv.Close()
112
113	client := NewClient(srv.URL)
114	result, err := client.Deploy(context.Background(), "test.example.com")
115
116	require.Nil(t, result)
117	var outcomeErr *OutcomeUnknownError
118	require.ErrorAs(t, err, &outcomeErr)
119	assert.ErrorContains(t, err, "502 Bad Gateway: upstream unavailable")
120	assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
121}
122
123func TestClientDeployConfirmedRejectionIsNotOutcomeUnknown(t *testing.T) {
124	var attempts int32
125	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
126		atomic.AddInt32(&attempts, 1)
127		w.WriteHeader(http.StatusUnprocessableEntity)
128		_, _ = w.Write([]byte(`{"error":"invalid deployment"}`))
129	}))
130	defer srv.Close()
131
132	client := NewClient(srv.URL)
133	result, err := client.Deploy(context.Background(), "test.example.com")
134
135	require.Nil(t, result)
136	var outcomeErr *OutcomeUnknownError
137	assert.NotErrorAs(t, err, &outcomeErr)
138	var httpErr *HTTPError
139	require.ErrorAs(t, err, &httpErr)
140	assert.Equal(t, http.StatusUnprocessableEntity, httpErr.StatusCode)
141	assert.EqualValues(t, 1, atomic.LoadInt32(&attempts))
142}
143
144func TestClientDeployAuthenticationRejectionIsNotOutcomeUnknown(t *testing.T) {
145	var deployAttempts int32
146	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147		if r.URL.Path == "/auth/token" {
148			w.WriteHeader(http.StatusForbidden)
149			_, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
150			return
151		}
152		atomic.AddInt32(&deployAttempts, 1)
153		w.WriteHeader(http.StatusNoContent)
154	}))
155	defer srv.Close()
156
157	client := NewClient(srv.URL, WithToken("invalid-token"))
158	result, err := client.Deploy(context.Background(), "test.example.com")
159
160	require.Nil(t, result)
161	var outcomeErr *OutcomeUnknownError
162	assert.NotErrorAs(t, err, &outcomeErr)
163	assert.ErrorContains(t, err, "ephemeral token exchange: 403 Forbidden")
164	assert.Zero(t, atomic.LoadInt32(&deployAttempts))
165}
166
167func TestClientGetStatusDoesNotRetryAuthenticationRejection(t *testing.T) {
168	withFastRetry(t)
169
170	var tokenAttempts int32
171	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172		require.Equal(t, "/auth/token", r.URL.Path)
173		atomic.AddInt32(&tokenAttempts, 1)
174		w.WriteHeader(http.StatusForbidden)
175		_, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
176	}))
177	defer srv.Close()
178
179	client := NewClient(srv.URL, WithToken("invalid-token"))
180	status, err := client.GetStatus(context.Background())
181
182	require.Nil(t, status)
183	assert.ErrorContains(t, err, "ephemeral token exchange: 403 Forbidden")
184	assert.EqualValues(t, 1, atomic.LoadInt32(&tokenAttempts))
185}
186
187func TestClientGetStatusRetriesTransientGatewayResponse(t *testing.T) {
188	withFastRetry(t)
189
190	var attempts int32
191	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
192		require.Equal(t, "/admin/status", r.URL.Path)
193		require.Equal(t, http.MethodGet, r.Method)
194		if atomic.AddInt32(&attempts, 1) < 3 {
195			w.WriteHeader(http.StatusGatewayTimeout)
196			_, _ = w.Write([]byte(`{"error":"upstream timeout"}`))
197			return
198		}
199		w.Header().Set("Content-Type", "application/json")
200		_, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))
201	}))
202	defer srv.Close()
203
204	client := NewClient(srv.URL)
205	status, err := client.GetStatus(context.Background())
206
207	require.NoError(t, err)
208	require.NotNil(t, status)
209	assert.Equal(t, 2, status.Routes)
210	assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
211}
212
213func TestClientGetStatusRetriesTransientTransportFailure(t *testing.T) {
214	withFastRetry(t)
215
216	var attempts int32
217	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
218		if atomic.AddInt32(&attempts, 1) < 3 {
219			conn, _, err := w.(http.Hijacker).Hijack()
220			require.NoError(t, err)
221			require.NoError(t, conn.Close())
222			return
223		}
224		w.Header().Set("Content-Type", "application/json")
225		_, _ = w.Write([]byte(`{"routes":2,"container_status":{}}`))
226	}))
227	defer srv.Close()
228
229	client := NewClient(srv.URL)
230	status, err := client.GetStatus(context.Background())
231
232	require.NoError(t, err)
233	require.NotNil(t, status)
234	assert.Equal(t, 2, status.Routes)
235	assert.EqualValues(t, 3, atomic.LoadInt32(&attempts))
236}
237
238func TestClientGetStatusReportsTransportContextAfterRetryExhaustion(t *testing.T) {
239	withFastRetry(t)
240
241	var attempts int32
242	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
243		atomic.AddInt32(&attempts, 1)
244		conn, _, err := w.(http.Hijacker).Hijack()
245		require.NoError(t, err)
246		require.NoError(t, conn.Close())
247	}))
248	defer srv.Close()
249
250	client := NewClient(srv.URL)
251	status, err := client.GetStatus(context.Background())
252
253	require.Nil(t, status)
254	assert.ErrorContains(t, err, "GET /status transport request")
255	assert.EqualValues(t, retryMaxAttempts, atomic.LoadInt32(&attempts))
256}
257
258func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) {
259	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260		require.Equal(t, "/admin/status", r.URL.Path)
261		w.Header().Set("Content-Type", "application/json")
262		_, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
263	}))
264	defer srv.Close()
265
266	_, err := NewClient(srv.URL).GetStatus(context.Background())
267	require.Error(t, err)
268
269	_, err = NewClient(srv.URL, WithInsecureTLS(true)).GetStatus(context.Background())
270	require.NoError(t, err)
271}
272
273func TestClientGetTrafficStatus(t *testing.T) {
274	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
275		require.Equal(t, "/admin/traffic/status", r.URL.Path)
276		require.Equal(t, http.MethodGet, r.Method)
277		w.Header().Set("Content-Type", "application/json")
278		_, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`))
279	}))
280	defer srv.Close()
281
282	client := NewClient(srv.URL)
283	status, err := client.GetTrafficStatus(context.Background())
284	require.NoError(t, err)
285	require.NotNil(t, status)
286	assert.Equal(t, "ok", status.LastReloadStatus)
287	assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections)
288}
289
290func TestClientGetTLSStatus(t *testing.T) {
291	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
292		require.Equal(t, "/admin/tls/status", r.URL.Path)
293		require.Equal(t, http.MethodGet, r.Method)
294		w.Header().Set("Content-Type", "application/json")
295		_, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`))
296	}))
297	defer srv.Close()
298
299	client := NewClient(srv.URL)
300	status, err := client.GetTLSStatus(context.Background())
301	require.NoError(t, err)
302	require.NotNil(t, status)
303	assert.True(t, status.ACMEEnabled)
304	assert.Equal(t, "http-01", status.EffectiveMode)
305}
306
307func TestClientListImages(t *testing.T) {
308	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
309		require.Equal(t, "/admin/images", r.URL.Path)
310		require.Equal(t, http.MethodGet, r.Method)
311		w.Header().Set("Content-Type", "application/json")
312		_, _ = w.Write([]byte(`{"images":[{"repository":"registry.example.com/app","tag":"latest","size":1234,"created":"2026-02-08T12:00:00Z","id":"sha256:abc","dangling":false}]}`))
313	}))
314	defer srv.Close()
315
316	client := NewClient(srv.URL)
317	images, err := client.ListImages(context.Background())
318	require.NoError(t, err)
319	require.Len(t, images, 1)
320	assert.Equal(t, "registry.example.com/app", images[0].Repository)
321	assert.Equal(t, "latest", images[0].Tag)
322}
323
324func TestClientPruneImages(t *testing.T) {
325	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326		require.Equal(t, "/admin/images/prune", r.URL.Path)
327		require.Equal(t, http.MethodPost, r.Method)
328
329		var req dto.ImagePruneRequest
330		require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
331		require.NotNil(t, req.KeepLast)
332		assert.Equal(t, 4, *req.KeepLast)
333		require.NotNil(t, req.PruneDangling)
334		assert.True(t, *req.PruneDangling)
335		require.NotNil(t, req.PruneRegistry)
336		assert.True(t, *req.PruneRegistry)
337
338		w.Header().Set("Content-Type", "application/json")
339		_, _ = w.Write([]byte(`{"runtime":{"deleted_count":2,"space_reclaimed":2048},"registry":{"tags_removed":3,"blobs_removed":1,"space_reclaimed":4096}}`))
340	}))
341	defer srv.Close()
342
343	keepLast := 4
344	pruneDangling := true
345	pruneRegistry := true
346	client := NewClient(srv.URL)
347	resp, err := client.PruneImages(context.Background(), dto.ImagePruneRequest{
348		KeepLast:      &keepLast,
349		PruneDangling: &pruneDangling,
350		PruneRegistry: &pruneRegistry,
351	})
352	require.NoError(t, err)
353	require.NotNil(t, resp)
354	assert.Equal(t, 2, resp.Runtime.DeletedCount)
355	assert.Equal(t, 3, resp.Registry.TagsRemoved)
356}
357
358func TestClientFindAttachmentTargetsByImage(t *testing.T) {
359	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
360		require.Equal(t, "/admin/attachments/by-image/postgres:16", r.URL.Path)
361		require.Equal(t, "", r.URL.RawQuery)
362		require.Equal(t, http.MethodGet, r.Method)
363		w.Header().Set("Content-Type", "application/json")
364		_, _ = w.Write([]byte(`{"image":"postgres:16","targets":["app.example.com","workers"]}`))
365	}))
366	defer srv.Close()
367
368	client := NewClient(srv.URL)
369	targets, err := client.FindAttachmentTargetsByImage(context.Background(), "postgres:16")
370	require.NoError(t, err)
371	assert.Equal(t, []string{"app.example.com", "workers"}, targets)
372}
373
374func TestClientGetRoute_Maps404ToDomainErrRouteNotFound(t *testing.T) {
375	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
376		require.Equal(t, "/admin/routes/missing.example.test", r.URL.Path)
377		require.Equal(t, http.MethodGet, r.Method)
378		w.WriteHeader(http.StatusNotFound)
379		_, _ = w.Write([]byte(`{"error":"route not found"}`))
380	}))
381	defer srv.Close()
382
383	client := NewClient(srv.URL)
384	route, err := client.GetRoute(context.Background(), "missing.example.test")
385	require.Nil(t, route)
386	require.Error(t, err)
387	assert.ErrorIs(t, err, domain.ErrRouteNotFound)
388}
389
390func TestClientFindAttachmentTargetsByImage_WithSlashContainingImage(t *testing.T) {
391	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
392		require.Equal(t, "/admin/attachments/by-image/registry/org/image:tag", r.URL.Path)
393		require.Equal(t, http.MethodGet, r.Method)
394		w.Header().Set("Content-Type", "application/json")
395		_, _ = w.Write([]byte(`{"image":"registry/org/image:tag","targets":["workers"]}`))
396	}))
397	defer srv.Close()
398
399	client := NewClient(srv.URL)
400	targets, err := client.FindAttachmentTargetsByImage(context.Background(), "registry/org/image:tag")
401	require.NoError(t, err)
402	assert.Equal(t, []string{"workers"}, targets)
403}
404
405func TestParseResponse_CapsErrorBodySize(t *testing.T) {
406	largeBody := strings.Repeat("x", 10*1024*1024)
407	resp := &http.Response{
408		StatusCode: 500,
409		Status:     "500 Internal Server Error",
410		Body:       io.NopCloser(strings.NewReader(largeBody)),
411		Header:     make(http.Header),
412	}
413
414	err := parseResponse(resp, nil)
415
416	require.Error(t, err)
417	assert.Less(t, len(err.Error()), 2*1024, "error body should be capped to ~1KB")
418}
419
420func TestStreamLogs_CapsErrorBodySize(t *testing.T) {
421	largeBody := strings.Repeat("x", 10*1024*1024)
422	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
423		w.WriteHeader(http.StatusInternalServerError)
424		_, _ = w.Write([]byte(largeBody))
425	}))
426	defer srv.Close()
427
428	c := &Client{
429		baseURL:    srv.URL,
430		httpClient: srv.Client(),
431	}
432
433	_, err := c.StreamProcessLogs(context.Background(), 100)
434
435	require.Error(t, err)
436	assert.Less(t, len(err.Error()), 2*1024, "streaming error body should be capped to ~1KB")
437}
438
439func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) {
440	withFastRetry(t)
441
442	largeBody := strings.Repeat("x", 10*1024*1024)
443	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
444		w.WriteHeader(http.StatusBadGateway)
445		_, _ = w.Write([]byte(largeBody))
446	}))
447	defer srv.Close()
448
449	c := &Client{
450		baseURL:    srv.URL,
451		httpClient: srv.Client(),
452	}
453
454	_, err := c.requestWithRetry(context.Background(), http.MethodPost, "/test", nil)
455
456	require.Error(t, err)
457	assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB")
458}
459
460func TestExchangeRegistryToken_Success(t *testing.T) {
461	const subject = "testuser"
462	const gordonToken = "gordon-jwt-token"
463	const shortToken = "short-lived-123"
464
465	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
466		require.Equal(t, "/auth/token", r.URL.Path)
467		require.Equal(t, http.MethodGet, r.Method)
468		require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope"))
469		require.Equal(t, "gordon-registry", r.URL.Query().Get("service"))
470
471		u, p, ok := r.BasicAuth()
472		require.True(t, ok, "expected Basic Auth")
473		assert.Equal(t, subject, u)
474		assert.Equal(t, gordonToken, p)
475
476		w.Header().Set("Content-Type", "application/json")
477		_, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`))
478	}))
479	defer srv.Close()
480
481	client := NewClient(srv.URL, WithToken(gordonToken))
482	token, err := client.ExchangeRegistryToken(context.Background(), subject)
483	require.NoError(t, err)
484	assert.Equal(t, shortToken, token)
485}
486
487func TestExchangeRegistryToken_Unauthorized(t *testing.T) {
488	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
489		w.WriteHeader(http.StatusUnauthorized)
490		_, _ = w.Write([]byte(`{"error":"invalid credentials"}`))
491	}))
492	defer srv.Close()
493
494	client := NewClient(srv.URL, WithToken("bad-token"))
495	_, err := client.ExchangeRegistryToken(context.Background(), "user")
496	require.Error(t, err)
497	assert.Contains(t, err.Error(), "401")
498}
499
500func TestExchangeRegistryToken_EmptyToken(t *testing.T) {
501	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
502		w.Header().Set("Content-Type", "application/json")
503		_, _ = w.Write([]byte(`{"token":"","expires_in":300}`))
504	}))
505	defer srv.Close()
506
507	client := NewClient(srv.URL, WithToken("some-token"))
508	_, err := client.ExchangeRegistryToken(context.Background(), "user")
509	require.Error(t, err)
510	assert.Contains(t, err.Error(), "empty")
511}
512
513func TestHTTPError_ErrorString(t *testing.T) {
514	err := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
515	assert.Equal(t, "403 Forbidden: insufficient scope", err.Error())
516}
517
518func TestHTTPError_Unwrap(t *testing.T) {
519	httpErr := &HTTPError{StatusCode: 403, Status: "403 Forbidden", Body: "insufficient scope"}
520	wrapped := fmt.Errorf("deploy intent: %w", httpErr)
521
522	var target *HTTPError
523	assert.True(t, errors.As(wrapped, &target))
524	assert.Equal(t, 403, target.StatusCode)
525}
526
527func TestParseErrorResponse_ReturnsHTTPError(t *testing.T) {
528	resp := &http.Response{
529		StatusCode: 403,
530		Status:     "403 Forbidden",
531		Body:       io.NopCloser(strings.NewReader("")),
532	}
533	err := parseErrorResponse(resp, []byte(`{"error":"insufficient scope"}`))
534
535	var httpErr *HTTPError
536	require.True(t, errors.As(err, &httpErr))
537	assert.Equal(t, 403, httpErr.StatusCode)
538	assert.Equal(t, "insufficient scope", httpErr.Body)
539}
540
541func TestParseErrorResponse_DeployFailureFields(t *testing.T) {
542	resp := &http.Response{
543		StatusCode: 500,
544		Status:     "500 Internal Server Error",
545		Body:       io.NopCloser(strings.NewReader("")),
546	}
547	err := parseErrorResponse(resp, []byte(`{"error":"container exited during startup","cause":"health check failed","hint":"check DATABASE_URL","logs":["booting app","connection refused"]}`))
548
549	var httpErr *HTTPError
550	require.True(t, errors.As(err, &httpErr))
551	assert.Equal(t, 500, httpErr.StatusCode)
552	assert.Equal(t, "container exited during startup", httpErr.Body)
553	assert.True(t, httpErr.Structured)
554	assert.Equal(t, "health check failed", httpErr.Cause)
555	assert.Equal(t, "check DATABASE_URL", httpErr.Hint)
556	assert.Equal(t, []string{"booting app", "connection refused"}, httpErr.Logs)
557}
558
559func TestParseErrorResponse_ErrorOnlyJSONNotStructured(t *testing.T) {
560	resp := &http.Response{
561		StatusCode: 500,
562		Status:     "500 Internal Server Error",
563		Body:       io.NopCloser(strings.NewReader("")),
564	}
565	err := parseErrorResponse(resp, []byte(`{"error":"failed to deploy container"}`))
566
567	var httpErr *HTTPError
568	require.True(t, errors.As(err, &httpErr))
569	assert.Equal(t, "failed to deploy container", httpErr.Body)
570	assert.False(t, httpErr.Structured)
571	assert.Empty(t, httpErr.Cause)
572	assert.Empty(t, httpErr.Hint)
573	assert.Empty(t, httpErr.Logs)
574}
575
576func TestParseErrorResponse_NonJSON(t *testing.T) {
577	resp := &http.Response{
578		StatusCode: 500,
579		Status:     "500 Internal Server Error",
580		Body:       io.NopCloser(strings.NewReader("")),
581	}
582	err := parseErrorResponse(resp, []byte("plain text error"))
583
584	var httpErr *HTTPError
585	require.True(t, errors.As(err, &httpErr))
586	assert.Equal(t, 500, httpErr.StatusCode)
587	assert.Equal(t, "plain text error", httpErr.Body)
588}
589
590func TestRunVolumeBackups_PartialContentReturnsResultAndError(t *testing.T) {
591	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
592		require.Equal(t, "/admin/backups/volumes/app.example.com", r.URL.Path)
593		require.Equal(t, http.MethodPost, r.Method)
594		w.Header().Set("Content-Type", "application/json")
595		w.WriteHeader(http.StatusPartialContent)
596		require.NoError(t, json.NewEncoder(w).Encode(dto.VolumeBackupRunResponse{
597			Status:  "partial",
598			Backups: []dto.VolumeBackupJob{{ID: "v1", Domain: "app.example.com", VolumeName: "gordon-app-data", Status: "completed"}},
599			Error:   "one volume failed",
600		}))
601	}))
602	defer srv.Close()
603
604	client := NewClient(srv.URL)
605	result, err := client.RunVolumeBackups(context.Background(), "app.example.com", "")
606
607	require.Error(t, err)
608	assert.Contains(t, err.Error(), "one volume failed")
609	require.NotNil(t, result)
610	assert.Equal(t, "partial", result.Status)
611	require.Len(t, result.Backups, 1)
612	assert.Equal(t, "v1", result.Backups[0].ID)
613}
614
615func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) {
616	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
617		w.Header().Set("Content-Type", "application/json")
618		// Token field empty, but access_token has a value
619		json.NewEncoder(w).Encode(map[string]any{
620			"access_token": "fallback-token-456",
621			"expires_in":   300,
622		})
623	}))
624	defer srv.Close()
625
626	client := NewClient(srv.URL, WithToken("long-lived"))
627	token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot")
628	require.NoError(t, err)
629	assert.Equal(t, "fallback-token-456", token)
630}

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