detached 1e2458b

test: add bootstrap integration tests and JSON parity coverage

Integration tests for bootstrap happy path, idempotent rerun, validation
errors, and partial failure. Parity test ensures all 8 list commands
accept --json. Fixed bootstrap handler to return 400 for missing domain/image.

Part of #98.
commit
1e2458ba29f51e75bb8a00d1564a8027946357d8
author
brice · · 2026-03-13 20:47 UTC
parent
73b880a
signature
unsigned
3 files · +298 -0
A internal/adapters/in/cli/json_parity_test.go +32 -0
@@ -0,0 +1,32 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/cobra"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
11+ commands := []struct {
12+ name string
13+ builder func() *cobra.Command
14+ }{
15+ {"routes list", newRoutesListCmd},
16+ {"attachments list", newAttachmentsListCmd},
17+ {"secrets list", newSecretsListCmd},
18+ {"images list", newImagesListCmd},
19+ {"backup list", newBackupListCmd},
20+ {"auth token list", newTokenListCmd},
21+ {"remotes list", newRemotesListCmd},
22+ {"rollback list", newRollbackListCmd},
23+ }
24+
25+ for _, tc := range commands {
26+ t.Run(tc.name, func(t *testing.T) {
27+ cmd := tc.builder()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f, "command %q should have --json flag", tc.name)
30+ })
31+ }
32+}
@@ -0,0 +1,32 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/cobra"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
11+ commands := []struct {
12+ name string
13+ builder func() *cobra.Command
14+ }{
15+ {"routes list", newRoutesListCmd},
16+ {"attachments list", newAttachmentsListCmd},
17+ {"secrets list", newSecretsListCmd},
18+ {"images list", newImagesListCmd},
19+ {"backup list", newBackupListCmd},
20+ {"auth token list", newTokenListCmd},
21+ {"remotes list", newRemotesListCmd},
22+ {"rollback list", newRollbackListCmd},
23+ }
24+
25+ for _, tc := range commands {
26+ t.Run(tc.name, func(t *testing.T) {
27+ cmd := tc.builder()
28+ f := cmd.Flags().Lookup("json")
29+ assert.NotNil(t, f, "command %q should have --json flag", tc.name)
30+ })
31+ }
32+}
A internal/adapters/in/http/admin/bootstrap_test.go +252 -0
@@ -0,0 +1,252 @@
1+package admin
2+
3+import (
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+
20+func 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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
28+
29+ payload := dto.BootstrapRequest{
30+ Domain: "app.example.com",
31+ Image: "myapp:latest",
32+ Attachments: []string{"postgres:16"},
33+ Env: map[string]string{"APP_ENV": "prod"},
34+ AttachmentEnv: map[string]map[string]string{
35+ "postgres": {"POSTGRES_PASSWORD": "secret"},
36+ },
37+ }
38+
39+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
40+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
41+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
42+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
43+
44+ body, err := json.Marshal(payload)
45+ require.NoError(t, err)
46+
47+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
48+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
49+ rec := httptest.NewRecorder()
50+
51+ handler.ServeHTTP(rec, req)
52+
53+ assert.Equal(t, http.StatusOK, rec.Code)
54+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
55+
56+ var response dto.BootstrapResponse
57+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
58+ assert.Equal(t, payload.Domain, response.Domain)
59+ assert.Equal(t, payload.Image, response.Image)
60+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
61+ assert.Equal(t, []dto.BootstrapStep{
62+ {Name: "route", Status: "created"},
63+ {Name: "attachment:postgres:16", Status: "created"},
64+ {Name: "env", Status: "updated"},
65+ {Name: "attachment_env:postgres", Status: "updated"},
66+ }, response.Steps)
67+}
68+
69+func TestBootstrap_Idempotent_Rerun(t *testing.T) {
70+ configSvc := inmocks.NewMockConfigService(t)
71+ authSvc := inmocks.NewMockAuthService(t)
72+ containerSvc := inmocks.NewMockContainerService(t)
73+ secretSvc := inmocks.NewMockSecretService(t)
74+
75+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
76+
77+ payload := dto.BootstrapRequest{
78+ Domain: "app.example.com",
79+ Image: "myapp:latest",
80+ Attachments: []string{"postgres:16"},
81+ Env: map[string]string{"APP_ENV": "prod"},
82+ AttachmentEnv: map[string]map[string]string{
83+ "postgres": {"POSTGRES_PASSWORD": "secret"},
84+ },
85+ }
86+
87+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
88+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
89+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
90+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
91+
92+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(domain.ErrRouteExists).Once()
93+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
94+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
95+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
96+
97+ body, err := json.Marshal(payload)
98+ require.NoError(t, err)
99+
100+ for i := range 2 {
101+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
102+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
103+ rec := httptest.NewRecorder()
104+
105+ handler.ServeHTTP(rec, req)
106+
107+ assert.Equal(t, http.StatusOK, rec.Code)
108+
109+ var response dto.BootstrapResponse
110+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111+ assert.Equal(t, payload.Domain, response.Domain)
112+ assert.Equal(t, payload.Image, response.Image)
113+ require.Len(t, response.Steps, 4)
114+ if i == 0 {
115+ assert.Equal(t, []dto.BootstrapStep{
116+ {Name: "route", Status: "created"},
117+ {Name: "attachment:postgres:16", Status: "created"},
118+ {Name: "env", Status: "updated"},
119+ {Name: "attachment_env:postgres", Status: "updated"},
120+ }, response.Steps)
121+ } else {
122+ assert.Equal(t, []dto.BootstrapStep{
123+ {Name: "route", Status: "noop"},
124+ {Name: "attachment:postgres:16", Status: "noop"},
125+ {Name: "env", Status: "updated"},
126+ {Name: "attachment_env:postgres", Status: "updated"},
127+ }, response.Steps)
128+ }
129+ }
130+}
131+
132+func TestBootstrap_MissingDomain(t *testing.T) {
133+ configSvc := inmocks.NewMockConfigService(t)
134+ authSvc := inmocks.NewMockAuthService(t)
135+ containerSvc := inmocks.NewMockContainerService(t)
136+ secretSvc := inmocks.NewMockSecretService(t)
137+
138+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
139+
140+ body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
141+ require.NoError(t, err)
142+
143+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
144+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
145+ rec := httptest.NewRecorder()
146+
147+ handler.ServeHTTP(rec, req)
148+
149+ assert.Equal(t, http.StatusBadRequest, rec.Code)
150+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
151+}
152+
153+func TestBootstrap_MissingImage(t *testing.T) {
154+ configSvc := inmocks.NewMockConfigService(t)
155+ authSvc := inmocks.NewMockAuthService(t)
156+ containerSvc := inmocks.NewMockContainerService(t)
157+ secretSvc := inmocks.NewMockSecretService(t)
158+
159+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
160+
161+ body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
162+ require.NoError(t, err)
163+
164+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
165+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
166+ rec := httptest.NewRecorder()
167+
168+ handler.ServeHTTP(rec, req)
169+
170+ assert.Equal(t, http.StatusBadRequest, rec.Code)
171+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172+}
173+
174+func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ mockErr error
178+ }{
179+ {
180+ name: "empty domain from service",
181+ mockErr: domain.ErrRouteDomainEmpty,
182+ },
183+ {
184+ name: "empty image from service",
185+ mockErr: domain.ErrRouteImageEmpty,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ configSvc := inmocks.NewMockConfigService(t)
192+ authSvc := inmocks.NewMockAuthService(t)
193+ containerSvc := inmocks.NewMockContainerService(t)
194+ secretSvc := inmocks.NewMockSecretService(t)
195+
196+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197+
198+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()
200+
201+ body, err := json.Marshal(payload)
202+ require.NoError(t, err)
203+
204+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
205+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
206+ rec := httptest.NewRecorder()
207+
208+ handler.ServeHTTP(rec, req)
209+
210+ assert.Equal(t, http.StatusBadRequest, rec.Code)
211+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
212+ })
213+ }
214+}
215+
216+func TestBootstrap_PartialFailure_AttachmentError(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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
223+
224+ payload := dto.BootstrapRequest{
225+ Domain: "app.example.com",
226+ Image: "myapp:latest",
227+ Attachments: []string{"postgres:16"},
228+ }
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
231+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232+
233+ body, err := json.Marshal(payload)
234+ require.NoError(t, err)
235+
236+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
237+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
238+ rec := httptest.NewRecorder()
239+
240+ handler.ServeHTTP(rec, req)
241+
242+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
243+
244+ var response dto.BootstrapResponse
245+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
246+ assert.Equal(t, []dto.BootstrapStep{
247+ {Name: "route", Status: "created"},
248+ {Name: "attachment:postgres:16", Status: "failed"},
249+ }, response.Steps)
250+ secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
251+ secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
252+}
@@ -0,0 +1,252 @@
1+package admin
2+
3+import (
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+
20+func 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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
28+
29+ payload := dto.BootstrapRequest{
30+ Domain: "app.example.com",
31+ Image: "myapp:latest",
32+ Attachments: []string{"postgres:16"},
33+ Env: map[string]string{"APP_ENV": "prod"},
34+ AttachmentEnv: map[string]map[string]string{
35+ "postgres": {"POSTGRES_PASSWORD": "secret"},
36+ },
37+ }
38+
39+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
40+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
41+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
42+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
43+
44+ body, err := json.Marshal(payload)
45+ require.NoError(t, err)
46+
47+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
48+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
49+ rec := httptest.NewRecorder()
50+
51+ handler.ServeHTTP(rec, req)
52+
53+ assert.Equal(t, http.StatusOK, rec.Code)
54+ registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything)
55+
56+ var response dto.BootstrapResponse
57+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
58+ assert.Equal(t, payload.Domain, response.Domain)
59+ assert.Equal(t, payload.Image, response.Image)
60+ assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
61+ assert.Equal(t, []dto.BootstrapStep{
62+ {Name: "route", Status: "created"},
63+ {Name: "attachment:postgres:16", Status: "created"},
64+ {Name: "env", Status: "updated"},
65+ {Name: "attachment_env:postgres", Status: "updated"},
66+ }, response.Steps)
67+}
68+
69+func TestBootstrap_Idempotent_Rerun(t *testing.T) {
70+ configSvc := inmocks.NewMockConfigService(t)
71+ authSvc := inmocks.NewMockAuthService(t)
72+ containerSvc := inmocks.NewMockContainerService(t)
73+ secretSvc := inmocks.NewMockSecretService(t)
74+
75+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
76+
77+ payload := dto.BootstrapRequest{
78+ Domain: "app.example.com",
79+ Image: "myapp:latest",
80+ Attachments: []string{"postgres:16"},
81+ Env: map[string]string{"APP_ENV": "prod"},
82+ AttachmentEnv: map[string]map[string]string{
83+ "postgres": {"POSTGRES_PASSWORD": "secret"},
84+ },
85+ }
86+
87+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
88+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
89+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
90+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
91+
92+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(domain.ErrRouteExists).Once()
93+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
94+ secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
95+ secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
96+
97+ body, err := json.Marshal(payload)
98+ require.NoError(t, err)
99+
100+ for i := range 2 {
101+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
102+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
103+ rec := httptest.NewRecorder()
104+
105+ handler.ServeHTTP(rec, req)
106+
107+ assert.Equal(t, http.StatusOK, rec.Code)
108+
109+ var response dto.BootstrapResponse
110+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111+ assert.Equal(t, payload.Domain, response.Domain)
112+ assert.Equal(t, payload.Image, response.Image)
113+ require.Len(t, response.Steps, 4)
114+ if i == 0 {
115+ assert.Equal(t, []dto.BootstrapStep{
116+ {Name: "route", Status: "created"},
117+ {Name: "attachment:postgres:16", Status: "created"},
118+ {Name: "env", Status: "updated"},
119+ {Name: "attachment_env:postgres", Status: "updated"},
120+ }, response.Steps)
121+ } else {
122+ assert.Equal(t, []dto.BootstrapStep{
123+ {Name: "route", Status: "noop"},
124+ {Name: "attachment:postgres:16", Status: "noop"},
125+ {Name: "env", Status: "updated"},
126+ {Name: "attachment_env:postgres", Status: "updated"},
127+ }, response.Steps)
128+ }
129+ }
130+}
131+
132+func TestBootstrap_MissingDomain(t *testing.T) {
133+ configSvc := inmocks.NewMockConfigService(t)
134+ authSvc := inmocks.NewMockAuthService(t)
135+ containerSvc := inmocks.NewMockContainerService(t)
136+ secretSvc := inmocks.NewMockSecretService(t)
137+
138+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
139+
140+ body, err := json.Marshal(dto.BootstrapRequest{Image: "myapp:latest"})
141+ require.NoError(t, err)
142+
143+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
144+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
145+ rec := httptest.NewRecorder()
146+
147+ handler.ServeHTTP(rec, req)
148+
149+ assert.Equal(t, http.StatusBadRequest, rec.Code)
150+ assert.JSONEq(t, `{"error":"domain is required"}`+"\n", rec.Body.String())
151+}
152+
153+func TestBootstrap_MissingImage(t *testing.T) {
154+ configSvc := inmocks.NewMockConfigService(t)
155+ authSvc := inmocks.NewMockAuthService(t)
156+ containerSvc := inmocks.NewMockContainerService(t)
157+ secretSvc := inmocks.NewMockSecretService(t)
158+
159+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
160+
161+ body, err := json.Marshal(dto.BootstrapRequest{Domain: "app.example.com"})
162+ require.NoError(t, err)
163+
164+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
165+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
166+ rec := httptest.NewRecorder()
167+
168+ handler.ServeHTTP(rec, req)
169+
170+ assert.Equal(t, http.StatusBadRequest, rec.Code)
171+ assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172+}
173+
174+func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175+ tests := []struct {
176+ name string
177+ mockErr error
178+ }{
179+ {
180+ name: "empty domain from service",
181+ mockErr: domain.ErrRouteDomainEmpty,
182+ },
183+ {
184+ name: "empty image from service",
185+ mockErr: domain.ErrRouteImageEmpty,
186+ },
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.name, func(t *testing.T) {
191+ configSvc := inmocks.NewMockConfigService(t)
192+ authSvc := inmocks.NewMockAuthService(t)
193+ containerSvc := inmocks.NewMockContainerService(t)
194+ secretSvc := inmocks.NewMockSecretService(t)
195+
196+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197+
198+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()
200+
201+ body, err := json.Marshal(payload)
202+ require.NoError(t, err)
203+
204+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
205+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
206+ rec := httptest.NewRecorder()
207+
208+ handler.ServeHTTP(rec, req)
209+
210+ assert.Equal(t, http.StatusBadRequest, rec.Code)
211+ assert.JSONEq(t, `{"error":"`+tt.mockErr.Error()+`"}`+"\n", rec.Body.String())
212+ })
213+ }
214+}
215+
216+func TestBootstrap_PartialFailure_AttachmentError(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 := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
223+
224+ payload := dto.BootstrapRequest{
225+ Domain: "app.example.com",
226+ Image: "myapp:latest",
227+ Attachments: []string{"postgres:16"},
228+ }
229+
230+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
231+ configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232+
233+ body, err := json.Marshal(payload)
234+ require.NoError(t, err)
235+
236+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
237+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write"))
238+ rec := httptest.NewRecorder()
239+
240+ handler.ServeHTTP(rec, req)
241+
242+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
243+
244+ var response dto.BootstrapResponse
245+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
246+ assert.Equal(t, []dto.BootstrapStep{
247+ {Name: "route", Status: "created"},
248+ {Name: "attachment:postgres:16", Status: "failed"},
249+ }, response.Steps)
250+ secretSvc.AssertNotCalled(t, "Set", mock.Anything, mock.Anything, mock.Anything)
251+ secretSvc.AssertNotCalled(t, "SetAttachment", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
252+}
M internal/adapters/in/http/admin/handler.go +14 -0
@@ -305,6 +305,16 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
305305 return
306306 }
307307
308+ if req.Domain == "" {
309+ h.sendError(w, http.StatusBadRequest, "domain is required")
310+ return
311+ }
312+
313+ if req.Image == "" {
314+ h.sendError(w, http.StatusBadRequest, "image is required")
315+ return
316+ }
317+
308318 resp := dto.BootstrapResponse{
309319 Domain: req.Domain,
310320 Image: req.Image,
@@ -320,6 +330,10 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
320330 addStep("route", "created")
321331 case errors.Is(err, domain.ErrRouteExists):
322332 addStep("route", "noop")
333+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
334+ addStep("route", "failed")
335+ h.sendError(w, http.StatusBadRequest, err.Error())
336+ return
323337 default:
324338 addStep("route", "failed")
325339 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")
@@ -305,6 +305,16 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
305 return305 return
306 }306 }
307 307
308+ if req.Domain == "" {
309+ h.sendError(w, http.StatusBadRequest, "domain is required")
310+ return
311+ }
312+
313+ if req.Image == "" {
314+ h.sendError(w, http.StatusBadRequest, "image is required")
315+ return
316+ }
317+
308 resp := dto.BootstrapResponse{318 resp := dto.BootstrapResponse{
309 Domain: req.Domain,319 Domain: req.Domain,
310 Image: req.Image,320 Image: req.Image,
@@ -320,6 +330,10 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
320 addStep("route", "created")330 addStep("route", "created")
321 case errors.Is(err, domain.ErrRouteExists):331 case errors.Is(err, domain.ErrRouteExists):
322 addStep("route", "noop")332 addStep("route", "noop")
333+ case errors.Is(err, domain.ErrRouteDomainEmpty), errors.Is(err, domain.ErrRouteImageEmpty):
334+ addStep("route", "failed")
335+ h.sendError(w, http.StatusBadRequest, err.Error())
336+ return
323 default:337 default:
324 addStep("route", "failed")338 addStep("route", "failed")
325 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")339 log.Error().Err(err).Str("domain", req.Domain).Str("image", req.Image).Msg("failed to bootstrap route")

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