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}