v2.30.0-pre2 f03ab25

bootstrap_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"encoding/json"
6	"errors"
7	"net/http"
8	"net/http/httptest"
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
35	payload := dto.BootstrapRequest{
36		Domain:      "app.example.com",
37		Image:       "myapp:latest",
38		Attachments: []string{"postgres:16"},
39		Env:         map[string]string{"APP_ENV": "prod"},
40		AttachmentEnv: map[string]map[string]string{
41			"postgres": {"POSTGRES_PASSWORD": "secret"},
42		},
43	}
44
45	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
46	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
47	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
48	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
49	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
50
51	body, err := json.Marshal(payload)
52	require.NoError(t, err)
53
54	req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
55	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
56	rec := httptest.NewRecorder()
57
58	handler.ServeHTTP(rec, req)
59
60	assert.Equal(t, http.StatusOK, rec.Code)
61	registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
62
63	var response dto.BootstrapResponse
64	require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
65	assert.Equal(t, payload.Domain, response.Domain)
66	assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
67	assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
68	assert.Equal(t, []dto.BootstrapStep{
69		{Name: "route", Status: "configured"},
70		{Name: "attachment:postgres:16", Status: "created"},
71		{Name: "env", Status: "updated"},
72		{Name: "attachment_env:postgres", Status: "updated"},
73	}, response.Steps)
74}
75
76func TestBootstrap_Idempotent_Rerun(t *testing.T) {
77	configSvc := inmocks.NewMockConfigService(t)
78	authSvc := inmocks.NewMockAuthService(t)
79	containerSvc := inmocks.NewMockContainerService(t)
80	secretSvc := inmocks.NewMockSecretService(t)
81
82	handler := newTestHandler(t, func(d *HandlerDeps) {
83		d.ConfigSvc = configSvc
84		d.AuthSvc = authSvc
85		d.ContainerSvc = containerSvc
86		d.SecretSvc = secretSvc
87	})
88
89	payload := dto.BootstrapRequest{
90		Domain:      "app.example.com",
91		Image:       "myapp:latest",
92		Attachments: []string{"postgres:16"},
93		Env:         map[string]string{"APP_ENV": "prod"},
94		AttachmentEnv: map[string]map[string]string{
95			"postgres": {"POSTGRES_PASSWORD": "secret"},
96		},
97	}
98
99	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)
100	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
101	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
102	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
103	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
104
105	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
106	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
107	secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
108	secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
109
110	body, err := json.Marshal(payload)
111	require.NoError(t, err)
112
113	for i := range 2 {
114		req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
115		req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write"))
116		rec := httptest.NewRecorder()
117
118		handler.ServeHTTP(rec, req)
119
120		assert.Equal(t, http.StatusOK, rec.Code)
121
122		var response dto.BootstrapResponse
123		require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
124		assert.Equal(t, payload.Domain, response.Domain)
125		assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
126		require.Len(t, response.Steps, 4)
127		if i == 0 {
128			assert.Equal(t, []dto.BootstrapStep{
129				{Name: "route", Status: "configured"},
130				{Name: "attachment:postgres:16", Status: "created"},
131				{Name: "env", Status: "updated"},
132				{Name: "attachment_env:postgres", Status: "updated"},
133			}, response.Steps)
134		} else {
135			assert.Equal(t, []dto.BootstrapStep{
136				{Name: "route", Status: "configured"},
137				{Name: "attachment:postgres:16", Status: "noop"},
138				{Name: "env", Status: "updated"},
139				{Name: "attachment_env:postgres", Status: "updated"},
140			}, response.Steps)
141		}
142	}
143}
144
145func TestBootstrap_MissingDomain(t *testing.T) {
146	configSvc := inmocks.NewMockConfigService(t)
147	authSvc := inmocks.NewMockAuthService(t)
148	containerSvc := inmocks.NewMockContainerService(t)
149	secretSvc := inmocks.NewMockSecretService(t)
150
151	handler := newTestHandler(t, func(d *HandlerDeps) {
152		d.ConfigSvc = configSvc
153		d.AuthSvc = authSvc
154		d.ContainerSvc = containerSvc
155		d.SecretSvc = secretSvc
156	})
157
158	body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
159	require.NoError(t, err)
160
161	req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
162	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
163	rec := httptest.NewRecorder()
164
165	handler.ServeHTTP(rec, req)
166
167	assert.Equal(t, http.StatusBadRequest, rec.Code)
168	assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
169}
170
171func TestBootstrap_MissingImage(t *testing.T) {
172	configSvc := inmocks.NewMockConfigService(t)
173	authSvc := inmocks.NewMockAuthService(t)
174	containerSvc := inmocks.NewMockContainerService(t)
175	secretSvc := inmocks.NewMockSecretService(t)
176
177	handler := newTestHandler(t, func(d *HandlerDeps) {
178		d.ConfigSvc = configSvc
179		d.AuthSvc = authSvc
180		d.ContainerSvc = containerSvc
181		d.SecretSvc = secretSvc
182	})
183
184	body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
185	require.NoError(t, err)
186
187	req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
188	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
189	rec := httptest.NewRecorder()
190
191	handler.ServeHTTP(rec, req)
192
193	assert.Equal(t, http.StatusBadRequest, rec.Code)
194	assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
195}
196
197func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
198	configSvc := inmocks.NewMockConfigService(t)
199	authSvc := inmocks.NewMockAuthService(t)
200	containerSvc := inmocks.NewMockContainerService(t)
201	secretSvc := inmocks.NewMockSecretService(t)
202
203	handler := newTestHandler(t, func(d *HandlerDeps) {
204		d.ConfigSvc = configSvc
205		d.AuthSvc = authSvc
206		d.ContainerSvc = containerSvc
207		d.SecretSvc = secretSvc
208	})
209
210	payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
211	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
212	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane"}).Return(nil).Once()
213
214	body, err := json.Marshal(payload)
215	require.NoError(t, err)
216
217	req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
218	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
219	rec := httptest.NewRecorder()
220
221	handler.ServeHTTP(rec, req)
222
223	assert.Equal(t, http.StatusOK, rec.Code)
224
225	var response dto.BootstrapResponse
226	require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
227	assert.Equal(t, payload.Domain, response.Domain)
228	assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
229	assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
230}
231
232func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
233	tests := []struct {
234		name    string
235		mockErr error
236	}{
237		{
238			name:    "empty domain from service",
239			mockErr: domain.ErrRouteDomainEmpty,
240		},
241		{
242			name:    "empty image from service",
243			mockErr: domain.ErrRouteImageEmpty,
244		},
245	}
246
247	for _, tt := range tests {
248		t.Run(tt.name, func(t *testing.T) {
249			configSvc := inmocks.NewMockConfigService(t)
250			authSvc := inmocks.NewMockAuthService(t)
251			containerSvc := inmocks.NewMockContainerService(t)
252			secretSvc := inmocks.NewMockSecretService(t)
253
254			handler := newTestHandler(t, func(d *HandlerDeps) {
255				d.ConfigSvc = configSvc
256				d.AuthSvc = authSvc
257				d.ContainerSvc = containerSvc
258				d.SecretSvc = secretSvc
259			})
260
261			payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
262			configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
263			configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(tt.mockErr).Once()
264
265			body, err := json.Marshal(payload)
266			require.NoError(t, err)
267
268			req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
269			req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
270			rec := httptest.NewRecorder()
271
272			handler.ServeHTTP(rec, req)
273
274			assert.Equal(t, http.StatusBadRequest, rec.Code)
275			assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
276		})
277	}
278}
279
280func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
281	configSvc := inmocks.NewMockConfigService(t)
282	authSvc := inmocks.NewMockAuthService(t)
283	containerSvc := inmocks.NewMockContainerService(t)
284	secretSvc := inmocks.NewMockSecretService(t)
285
286	handler := newTestHandler(t, func(d *HandlerDeps) {
287		d.ConfigSvc = configSvc
288		d.AuthSvc = authSvc
289		d.ContainerSvc = containerSvc
290		d.SecretSvc = secretSvc
291	})
292
293	payload := dto.BootstrapRequest{
294		Domain:      "app.example.com",
295		Image:       "myapp:latest",
296		Attachments: []string{"postgres:16"},
297	}
298
299	configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
300	configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
301	configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
302
303	body, err := json.Marshal(payload)
304	require.NoError(t, err)
305
306	req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
307	req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
308	rec := httptest.NewRecorder()
309
310	handler.ServeHTTP(rec, req)
311
312	assert.Equal(t, http.StatusInternalServerError, rec.Code)
313
314	var response dto.BootstrapResponse
315	require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
316	assert.Equal(t, []dto.BootstrapStep{
317		{Name: "route", Status: "configured"},
318		{Name: "attachment:postgres:16", Status: "failed"},
319	}, response.Steps)
320	secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
321	secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
322}

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