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}