v2.31.0-dev12 6a38a35

bootstrap_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"io"
8	"net/http"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"github.com/bnema/gordon/internal/adapters/dto"
16	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17	"github.com/bnema/gordon/internal/domain"
18)
19
20func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
21	configSvc := inmocks.NewMockConfigService(t)
22	authSvc := inmocks.NewMockAuthService(t)
23	containerSvc := inmocks.NewMockContainerService(t)
24	secretSvc := inmocks.NewMockSecretService(t)
25	registrySvc := inmocks.NewMockRegistryService(t)
26
27	handler := newTestHandler(t, func(d *HandlerDeps) {
28		d.ConfigSvc = configSvc
29		d.AuthSvc = authSvc
30		d.ContainerSvc = containerSvc
31		d.SecretSvc = secretSvc
32		d.RegistrySvc = registrySvc
33	})
34	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
35
36	payload := dto.BootstrapRequest{
37		Domain:      "app.example.com",
38		Image:       "myapp:latest",
39		Attachments: []string{"postgres:16"},
40		Env:         map[string]string{"APP_ENV": "prod"},
41		AttachmentEnv: map[string]map[string]string{
42			"postgres": {"POSTGRES_PASSWORD": "secret"},
43		},
44	}
45
46	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
47	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
48	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
49	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
50	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
51
52	body, err := json.Marshal(payload)
53	require.NoError(t, err)
54
55	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
56	require.NoError(t, err)
57
58	resp, err := server.Client().Do(req)
59	require.NoError(t, err)
60	defer resp.Body.Close()
61
62	assert.Equal(t, http.StatusOK, resp.StatusCode)
63	registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
64
65	var response dto.BootstrapResponse
66	require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
67	assert.Equal(t, payload.Domain, response.Domain)
68	assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
69	assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
70	assert.Equal(t, []dto.BootstrapStep{
71		{Name: "route", Status: "configured"},
72		{Name: "attachment:postgres:16", Status: "created"},
73		{Name: "env", Status: "updated"},
74		{Name: "attachment_env:postgres", Status: "updated"},
75	}, response.Steps)
76}
77
78func TestHandleBootstrap_DefaultsHTTPSWhenOmitted(t *testing.T) {
79	configSvc := inmocks.NewMockConfigService(t)
80	authSvc := inmocks.NewMockAuthService(t)
81	containerSvc := inmocks.NewMockContainerService(t)
82	secretSvc := inmocks.NewMockSecretService(t)
83
84	handler := newTestHandler(t, func(d *HandlerDeps) {
85		d.ConfigSvc = configSvc
86		d.AuthSvc = authSvc
87		d.ContainerSvc = containerSvc
88		d.SecretSvc = secretSvc
89	})
90	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
91
92	payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
93	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
94	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
95
96	body, err := json.Marshal(payload)
97	require.NoError(t, err)
98
99	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
100	require.NoError(t, err)
101
102	resp, err := server.Client().Do(req)
103	require.NoError(t, err)
104	defer resp.Body.Close()
105
106	assert.Equal(t, http.StatusOK, resp.StatusCode)
107
108	var response dto.BootstrapResponse
109	require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
110	assert.Equal(t, payload.Domain, response.Domain)
111	assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
112	assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
113}
114
115func TestBootstrap_Idempotent_Rerun(t *testing.T) {
116	configSvc := inmocks.NewMockConfigService(t)
117	authSvc := inmocks.NewMockAuthService(t)
118	containerSvc := inmocks.NewMockContainerService(t)
119	secretSvc := inmocks.NewMockSecretService(t)
120
121	handler := newTestHandler(t, func(d *HandlerDeps) {
122		d.ConfigSvc = configSvc
123		d.AuthSvc = authSvc
124		d.ContainerSvc = containerSvc
125		d.SecretSvc = secretSvc
126	})
127	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:secrets:write", "admin:config:write")
128
129	payload := dto.BootstrapRequest{
130		Domain:      "app.example.com",
131		Image:       "myapp:latest",
132		Attachments: []string{"postgres:16"},
133		Env:         map[string]string{"APP_ENV": "prod"},
134		AttachmentEnv: map[string]map[string]string{
135			"postgres": {"POSTGRES_PASSWORD": "secret"},
136		},
137	}
138
139	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)
140	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
141	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
142	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
143	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
144
145	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
146	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
147	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
148	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
149
150	body, err := json.Marshal(payload)
151	require.NoError(t, err)
152
153	for i := range 2 {
154		req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
155		require.NoError(t, err)
156
157		resp, err := server.Client().Do(req)
158		require.NoError(t, err)
159		defer resp.Body.Close()
160
161		assert.Equal(t, http.StatusOK, resp.StatusCode)
162
163		var response dto.BootstrapResponse
164		require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
165		assert.Equal(t, payload.Domain, response.Domain)
166		assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
167		require.Len(t, response.Steps, 4)
168		if i == 0 {
169			assert.Equal(t, []dto.BootstrapStep{
170				{Name: "route", Status: "configured"},
171				{Name: "attachment:postgres:16", Status: "created"},
172				{Name: "env", Status: "updated"},
173				{Name: "attachment_env:postgres", Status: "updated"},
174			}, response.Steps)
175		} else {
176			assert.Equal(t, []dto.BootstrapStep{
177				{Name: "route", Status: "configured"},
178				{Name: "attachment:postgres:16", Status: "noop"},
179				{Name: "env", Status: "updated"},
180				{Name: "attachment_env:postgres", Status: "updated"},
181			}, response.Steps)
182		}
183	}
184}
185
186func TestBootstrap_MissingDomain(t *testing.T) {
187	configSvc := inmocks.NewMockConfigService(t)
188	authSvc := inmocks.NewMockAuthService(t)
189	containerSvc := inmocks.NewMockContainerService(t)
190	secretSvc := inmocks.NewMockSecretService(t)
191
192	handler := newTestHandler(t, func(d *HandlerDeps) {
193		d.ConfigSvc = configSvc
194		d.AuthSvc = authSvc
195		d.ContainerSvc = containerSvc
196		d.SecretSvc = secretSvc
197	})
198	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
199
200	body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
201	require.NoError(t, err)
202
203	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
204	require.NoError(t, err)
205
206	resp, err := server.Client().Do(req)
207	require.NoError(t, err)
208	defer resp.Body.Close()
209
210	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
211	bodyBytes, err := io.ReadAll(resp.Body)
212	require.NoError(t, err)
213	assert.JSONEq(t, `{"error":"domain is required"}`+"\n", string(bodyBytes))
214}
215
216func TestBootstrap_MissingImage(t *testing.T) {
217	configSvc := inmocks.NewMockConfigService(t)
218	authSvc := inmocks.NewMockAuthService(t)
219	containerSvc := inmocks.NewMockContainerService(t)
220	secretSvc := inmocks.NewMockSecretService(t)
221
222	handler := newTestHandler(t, func(d *HandlerDeps) {
223		d.ConfigSvc = configSvc
224		d.AuthSvc = authSvc
225		d.ContainerSvc = containerSvc
226		d.SecretSvc = secretSvc
227	})
228	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
229
230	body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
231	require.NoError(t, err)
232
233	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
234	require.NoError(t, err)
235
236	resp, err := server.Client().Do(req)
237	require.NoError(t, err)
238	defer resp.Body.Close()
239
240	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
241	bodyBytes, err := io.ReadAll(resp.Body)
242	require.NoError(t, err)
243	assert.JSONEq(t, `{"error":"image is required"}`+"\n", string(bodyBytes))
244}
245
246func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
247	configSvc := inmocks.NewMockConfigService(t)
248	authSvc := inmocks.NewMockAuthService(t)
249	containerSvc := inmocks.NewMockContainerService(t)
250	secretSvc := inmocks.NewMockSecretService(t)
251
252	handler := newTestHandler(t, func(d *HandlerDeps) {
253		d.ConfigSvc = configSvc
254		d.AuthSvc = authSvc
255		d.ContainerSvc = containerSvc
256		d.SecretSvc = secretSvc
257	})
258	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
259
260	payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
261	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
262	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane", HTTPS: true}).Return(nil).Once()
263
264	body, err := json.Marshal(payload)
265	require.NoError(t, err)
266
267	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
268	require.NoError(t, err)
269
270	resp, err := server.Client().Do(req)
271	require.NoError(t, err)
272	defer resp.Body.Close()
273
274	assert.Equal(t, http.StatusOK, resp.StatusCode)
275
276	var response dto.BootstrapResponse
277	require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
278	assert.Equal(t, payload.Domain, response.Domain)
279	assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
280	assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
281}
282
283func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
284	tests := []struct {
285		name    string
286		mockErr error
287	}{
288		{
289			name:    "empty domain from service",
290			mockErr: domain.ErrRouteDomainEmpty,
291		},
292		{
293			name:    "empty image from service",
294			mockErr: domain.ErrRouteImageEmpty,
295		},
296	}
297
298	for _, tt := range tests {
299		t.Run(tt.name, func(t *testing.T) {
300			configSvc := inmocks.NewMockConfigService(t)
301			authSvc := inmocks.NewMockAuthService(t)
302			containerSvc := inmocks.NewMockContainerService(t)
303			secretSvc := inmocks.NewMockSecretService(t)
304
305			handler := newTestHandler(t, func(d *HandlerDeps) {
306				d.ConfigSvc = configSvc
307				d.AuthSvc = authSvc
308				d.ContainerSvc = containerSvc
309				d.SecretSvc = secretSvc
310			})
311			server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
312
313			payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
314			configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
315			configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(tt.mockErr).Once()
316
317			body, err := json.Marshal(payload)
318			require.NoError(t, err)
319
320			req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
321			require.NoError(t, err)
322
323			resp, err := server.Client().Do(req)
324			require.NoError(t, err)
325			defer resp.Body.Close()
326
327			assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
328			bodyBytes, err := io.ReadAll(resp.Body)
329			require.NoError(t, err)
330			assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", string(bodyBytes))
331		})
332	}
333}
334
335func TestBootstrap_InvalidRouteDomain(t *testing.T) {
336	configSvc := inmocks.NewMockConfigService(t)
337	authSvc := inmocks.NewMockAuthService(t)
338	containerSvc := inmocks.NewMockContainerService(t)
339	secretSvc := inmocks.NewMockSecretService(t)
340
341	handler := newTestHandler(t, func(d *HandlerDeps) {
342		d.ConfigSvc = configSvc
343		d.AuthSvc = authSvc
344		d.ContainerSvc = containerSvc
345		d.SecretSvc = secretSvc
346	})
347	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
348
349	payload := dto.BootstrapRequest{Domain: "invalid domain", Image: "myapp:latest"}
350	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
351	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once()
352
353	body, err := json.Marshal(payload)
354	require.NoError(t, err)
355
356	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
357	require.NoError(t, err)
358
359	resp, err := server.Client().Do(req)
360	require.NoError(t, err)
361	defer resp.Body.Close()
362
363	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
364	bodyBytes, err := io.ReadAll(resp.Body)
365	require.NoError(t, err)
366	assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`+"\n", string(bodyBytes))
367}
368
369func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
370	configSvc := inmocks.NewMockConfigService(t)
371	authSvc := inmocks.NewMockAuthService(t)
372	containerSvc := inmocks.NewMockContainerService(t)
373	secretSvc := inmocks.NewMockSecretService(t)
374
375	handler := newTestHandler(t, func(d *HandlerDeps) {
376		d.ConfigSvc = configSvc
377		d.AuthSvc = authSvc
378		d.ContainerSvc = containerSvc
379		d.SecretSvc = secretSvc
380	})
381	server := newScopedTestServer(t, handler, "admin:routes:write", "admin:config:write")
382
383	payload := dto.BootstrapRequest{
384		Domain:      "app.example.com",
385		Image:       "myapp:latest",
386		Attachments: []string{"postgres:16"},
387	}
388
389	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
390	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once()
391	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
392
393	body, err := json.Marshal(payload)
394	require.NoError(t, err)
395
396	req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/bootstrap", bytes.NewReader(body))
397	require.NoError(t, err)
398
399	resp, err := server.Client().Do(req)
400	require.NoError(t, err)
401	defer resp.Body.Close()
402
403	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
404
405	var response dto.BootstrapResponse
406	require.NoError(t, json.NewDecoder(resp.Body).Decode(&response))
407	assert.Equal(t, []dto.BootstrapStep{
408		{Name: "route", Status: "configured"},
409		{Name: "attachment:postgres:16", Status: "failed"},
410	}, response.Steps)
411	secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
412	secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
413}

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