v2.31.0-dev17 bd898f0

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 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}

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 8

Tags 106

↑↓ moveenter openesc close