1package admin
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "net/http/httptest"
10 "testing"
11
12 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15
16 inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20func testLogger() zerowrap.Logger {
21 return zerowrap.Default()
22}
23
24func ctxWithScopes(scopes ...string) context.Context {
25 ctx := context.Background()
26 return context.WithValue(ctx, domain.ContextKeyScopes, scopes)
27}
28
29// Routes endpoint tests
30
31func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
32 configSvc := inmocks.NewMockConfigService(t)
33 authSvc := inmocks.NewMockAuthService(t)
34 containerSvc := inmocks.NewMockContainerService(t)
35 secretSvc := inmocks.NewMockSecretService(t)
36
37 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
38
39 tests := []struct {
40 name string
41 scopes []string
42 wantStatus int
43 }{
44 {
45 name: "routes read access granted",
46 scopes: []string{"admin:routes:read"},
47 wantStatus: http.StatusOK,
48 },
49 {
50 name: "routes wildcard access granted",
51 scopes: []string{"admin:routes:*"},
52 wantStatus: http.StatusOK,
53 },
54 {
55 name: "all admin access granted",
56 scopes: []string{"admin:*:*"},
57 wantStatus: http.StatusOK,
58 },
59 {
60 name: "only write access denied",
61 scopes: []string{"admin:routes:write"},
62 wantStatus: http.StatusForbidden,
63 },
64 {
65 name: "wrong resource denied",
66 scopes: []string{"admin:secrets:read"},
67 wantStatus: http.StatusForbidden,
68 },
69 {
70 name: "no scopes denied",
71 scopes: []string{},
72 wantStatus: http.StatusForbidden,
73 },
74 }
75
76 for _, tt := range tests {
77 t.Run(tt.name, func(t *testing.T) {
78 if tt.wantStatus == http.StatusOK {
79 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
80 }
81
82 req := httptest.NewRequest("GET", "/admin/routes", nil)
83 req = req.WithContext(ctxWithScopes(tt.scopes...))
84 rec := httptest.NewRecorder()
85
86 handler.ServeHTTP(rec, req)
87
88 assert.Equal(t, tt.wantStatus, rec.Code)
89 })
90 }
91}
92
93func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
94 configSvc := inmocks.NewMockConfigService(t)
95 authSvc := inmocks.NewMockAuthService(t)
96 containerSvc := inmocks.NewMockContainerService(t)
97 secretSvc := inmocks.NewMockSecretService(t)
98 registrySvc := inmocks.NewMockRegistryService(t)
99
100 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
101
102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
103
104 tests := []struct {
105 name string
106 scopes []string
107 wantStatus int
108 }{
109 {
110 name: "routes write access granted",
111 scopes: []string{"admin:routes:write"},
112 wantStatus: http.StatusCreated,
113 },
114 {
115 name: "routes wildcard access granted",
116 scopes: []string{"admin:routes:*"},
117 wantStatus: http.StatusCreated,
118 },
119 {
120 name: "all admin access granted",
121 scopes: []string{"admin:*:*"},
122 wantStatus: http.StatusCreated,
123 },
124 {
125 name: "only read access denied",
126 scopes: []string{"admin:routes:read"},
127 wantStatus: http.StatusForbidden,
128 },
129 {
130 name: "wrong resource denied",
131 scopes: []string{"admin:secrets:write"},
132 wantStatus: http.StatusForbidden,
133 },
134 }
135
136 for _, tt := range tests {
137 t.Run(tt.name, func(t *testing.T) {
138 if tt.wantStatus == http.StatusCreated {
139 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
140 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
141 }
142
143 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
144 req = req.WithContext(ctxWithScopes(tt.scopes...))
145 rec := httptest.NewRecorder()
146
147 handler.ServeHTTP(rec, req)
148
149 assert.Equal(t, tt.wantStatus, rec.Code)
150 })
151 }
152}
153
154func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
155 configSvc := inmocks.NewMockConfigService(t)
156 authSvc := inmocks.NewMockAuthService(t)
157 containerSvc := inmocks.NewMockContainerService(t)
158 secretSvc := inmocks.NewMockSecretService(t)
159 registrySvc := inmocks.NewMockRegistryService(t)
160
161 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
162
163 routeJSON := `{"image": "myapp:v2"}`
164
165 tests := []struct {
166 name string
167 scopes []string
168 wantStatus int
169 }{
170 {
171 name: "routes write access granted",
172 scopes: []string{"admin:routes:write"},
173 wantStatus: http.StatusOK,
174 },
175 {
176 name: "only read access denied",
177 scopes: []string{"admin:routes:read"},
178 wantStatus: http.StatusForbidden,
179 },
180 }
181
182 for _, tt := range tests {
183 t.Run(tt.name, func(t *testing.T) {
184 if tt.wantStatus == http.StatusOK {
185 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
186 registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
187 }
188
189 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
190 req = req.WithContext(ctxWithScopes(tt.scopes...))
191 rec := httptest.NewRecorder()
192
193 handler.ServeHTTP(rec, req)
194
195 assert.Equal(t, tt.wantStatus, rec.Code)
196 })
197 }
198}
199
200func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
201 configSvc := inmocks.NewMockConfigService(t)
202 authSvc := inmocks.NewMockAuthService(t)
203 containerSvc := inmocks.NewMockContainerService(t)
204 secretSvc := inmocks.NewMockSecretService(t)
205
206 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
207
208 tests := []struct {
209 name string
210 scopes []string
211 wantStatus int
212 }{
213 {
214 name: "routes write access granted",
215 scopes: []string{"admin:routes:write"},
216 wantStatus: http.StatusOK,
217 },
218 {
219 name: "only read access denied",
220 scopes: []string{"admin:routes:read"},
221 wantStatus: http.StatusForbidden,
222 },
223 }
224
225 for _, tt := range tests {
226 t.Run(tt.name, func(t *testing.T) {
227 if tt.wantStatus == http.StatusOK {
228 configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Maybe()
229 }
230
231 req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil)
232 req = req.WithContext(ctxWithScopes(tt.scopes...))
233 rec := httptest.NewRecorder()
234
235 handler.ServeHTTP(rec, req)
236
237 assert.Equal(t, tt.wantStatus, rec.Code)
238 })
239 }
240}
241
242// Secrets endpoint tests
243
244func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
245 configSvc := inmocks.NewMockConfigService(t)
246 authSvc := inmocks.NewMockAuthService(t)
247 containerSvc := inmocks.NewMockContainerService(t)
248 secretSvc := inmocks.NewMockSecretService(t)
249
250 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
251
252 tests := []struct {
253 name string
254 scopes []string
255 wantStatus int
256 }{
257 {
258 name: "secrets read access granted",
259 scopes: []string{"admin:secrets:read"},
260 wantStatus: http.StatusOK,
261 },
262 {
263 name: "secrets wildcard access granted",
264 scopes: []string{"admin:secrets:*"},
265 wantStatus: http.StatusOK,
266 },
267 {
268 name: "all admin access granted",
269 scopes: []string{"admin:*:*"},
270 wantStatus: http.StatusOK,
271 },
272 {
273 name: "only write access denied",
274 scopes: []string{"admin:secrets:write"},
275 wantStatus: http.StatusForbidden,
276 },
277 {
278 name: "wrong resource denied",
279 scopes: []string{"admin:routes:read"},
280 wantStatus: http.StatusForbidden,
281 },
282 }
283
284 for _, tt := range tests {
285 t.Run(tt.name, func(t *testing.T) {
286 if tt.wantStatus == http.StatusOK {
287 secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe()
288 }
289
290 req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil)
291 req = req.WithContext(ctxWithScopes(tt.scopes...))
292 rec := httptest.NewRecorder()
293
294 handler.ServeHTTP(rec, req)
295
296 assert.Equal(t, tt.wantStatus, rec.Code)
297 })
298 }
299}
300
301func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
302 configSvc := inmocks.NewMockConfigService(t)
303 authSvc := inmocks.NewMockAuthService(t)
304 containerSvc := inmocks.NewMockContainerService(t)
305 secretSvc := inmocks.NewMockSecretService(t)
306
307 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
308
309 secretsJSON := `{"API_KEY": "secret123"}`
310
311 tests := []struct {
312 name string
313 scopes []string
314 wantStatus int
315 }{
316 {
317 name: "secrets write access granted",
318 scopes: []string{"admin:secrets:write"},
319 wantStatus: http.StatusOK,
320 },
321 {
322 name: "only read access denied",
323 scopes: []string{"admin:secrets:read"},
324 wantStatus: http.StatusForbidden,
325 },
326 {
327 name: "wrong resource denied",
328 scopes: []string{"admin:routes:write"},
329 wantStatus: http.StatusForbidden,
330 },
331 }
332
333 for _, tt := range tests {
334 t.Run(tt.name, func(t *testing.T) {
335 if tt.wantStatus == http.StatusOK {
336 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe()
337 }
338
339 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON))
340 req = req.WithContext(ctxWithScopes(tt.scopes...))
341 rec := httptest.NewRecorder()
342
343 handler.ServeHTTP(rec, req)
344
345 assert.Equal(t, tt.wantStatus, rec.Code)
346 })
347 }
348}
349
350func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
351 configSvc := inmocks.NewMockConfigService(t)
352 authSvc := inmocks.NewMockAuthService(t)
353 containerSvc := inmocks.NewMockContainerService(t)
354 secretSvc := inmocks.NewMockSecretService(t)
355
356 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
357
358 tests := []struct {
359 name string
360 scopes []string
361 wantStatus int
362 }{
363 {
364 name: "secrets write access granted",
365 scopes: []string{"admin:secrets:write"},
366 wantStatus: http.StatusOK,
367 },
368 {
369 name: "only read access denied",
370 scopes: []string{"admin:secrets:read"},
371 wantStatus: http.StatusForbidden,
372 },
373 }
374
375 for _, tt := range tests {
376 t.Run(tt.name, func(t *testing.T) {
377 if tt.wantStatus == http.StatusOK {
378 secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe()
379 }
380
381 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil)
382 req = req.WithContext(ctxWithScopes(tt.scopes...))
383 rec := httptest.NewRecorder()
384
385 handler.ServeHTTP(rec, req)
386
387 assert.Equal(t, tt.wantStatus, rec.Code)
388 })
389 }
390}
391
392// Attachment secrets endpoint tests
393
394func TestHandler_AttachmentSecretsPost(t *testing.T) {
395 configSvc := inmocks.NewMockConfigService(t)
396 authSvc := inmocks.NewMockAuthService(t)
397 containerSvc := inmocks.NewMockContainerService(t)
398 secretSvc := inmocks.NewMockSecretService(t)
399
400 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
401
402 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403
404 body := `{"REDIS_URL": "redis://localhost:6379"}`
405 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
406 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
407 rec := httptest.NewRecorder()
408
409 handler.ServeHTTP(rec, req)
410
411 assert.Equal(t, http.StatusOK, rec.Code)
412
413 var response struct {
414 Status string `json:"status"`
415 }
416 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
417 assert.Equal(t, "updated", response.Status)
418}
419
420func TestHandler_AttachmentSecretsDelete(t *testing.T) {
421 configSvc := inmocks.NewMockConfigService(t)
422 authSvc := inmocks.NewMockAuthService(t)
423 containerSvc := inmocks.NewMockContainerService(t)
424 secretSvc := inmocks.NewMockSecretService(t)
425
426 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
427
428 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429
430 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
431 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
432 rec := httptest.NewRecorder()
433
434 handler.ServeHTTP(rec, req)
435
436 assert.Equal(t, http.StatusOK, rec.Code)
437
438 var response struct {
439 Status string `json:"status"`
440 }
441 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
442 assert.Equal(t, "deleted", response.Status)
443}
444
445// Status endpoint tests
446
447func TestHandler_Status_RequiresReadScope(t *testing.T) {
448 configSvc := inmocks.NewMockConfigService(t)
449 authSvc := inmocks.NewMockAuthService(t)
450 containerSvc := inmocks.NewMockContainerService(t)
451 secretSvc := inmocks.NewMockSecretService(t)
452
453 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
454
455 tests := []struct {
456 name string
457 scopes []string
458 wantStatus int
459 }{
460 {
461 name: "status read access granted",
462 scopes: []string{"admin:status:read"},
463 wantStatus: http.StatusOK,
464 },
465 {
466 name: "all admin access granted",
467 scopes: []string{"admin:*:*"},
468 wantStatus: http.StatusOK,
469 },
470 {
471 name: "wrong resource denied",
472 scopes: []string{"admin:routes:read"},
473 wantStatus: http.StatusForbidden,
474 },
475 }
476
477 for _, tt := range tests {
478 t.Run(tt.name, func(t *testing.T) {
479 if tt.wantStatus == http.StatusOK {
480 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
481 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
482 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
483 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
484 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
485 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
486 }
487
488 req := httptest.NewRequest("GET", "/admin/status", nil)
489 req = req.WithContext(ctxWithScopes(tt.scopes...))
490 rec := httptest.NewRecorder()
491
492 handler.ServeHTTP(rec, req)
493
494 assert.Equal(t, tt.wantStatus, rec.Code)
495 })
496 }
497}
498
499// Config endpoint tests
500
501func TestHandler_Config_RequiresReadScope(t *testing.T) {
502 configSvc := inmocks.NewMockConfigService(t)
503 authSvc := inmocks.NewMockAuthService(t)
504 containerSvc := inmocks.NewMockContainerService(t)
505 secretSvc := inmocks.NewMockSecretService(t)
506
507 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
508
509 tests := []struct {
510 name string
511 scopes []string
512 wantStatus int
513 }{
514 {
515 name: "config read access granted",
516 scopes: []string{"admin:config:read"},
517 wantStatus: http.StatusOK,
518 },
519 {
520 name: "all admin access granted",
521 scopes: []string{"admin:*:*"},
522 wantStatus: http.StatusOK,
523 },
524 {
525 name: "wrong resource denied",
526 scopes: []string{"admin:routes:read"},
527 wantStatus: http.StatusForbidden,
528 },
529 }
530
531 for _, tt := range tests {
532 t.Run(tt.name, func(t *testing.T) {
533 if tt.wantStatus == http.StatusOK {
534 configSvc.EXPECT().GetServerPort().Return(8080).Maybe()
535 configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe()
536 configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe()
537 configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe()
538 configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe()
539 configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe()
540 configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe()
541 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe()
542 configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
543 }
544
545 req := httptest.NewRequest("GET", "/admin/config", nil)
546 req = req.WithContext(ctxWithScopes(tt.scopes...))
547 rec := httptest.NewRecorder()
548
549 handler.ServeHTTP(rec, req)
550
551 assert.Equal(t, tt.wantStatus, rec.Code)
552 })
553 }
554}
555
556// Reload endpoint tests
557
558func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
559 configSvc := inmocks.NewMockConfigService(t)
560 authSvc := inmocks.NewMockAuthService(t)
561 containerSvc := inmocks.NewMockContainerService(t)
562 secretSvc := inmocks.NewMockSecretService(t)
563
564 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
565
566 tests := []struct {
567 name string
568 scopes []string
569 wantStatus int
570 }{
571 {
572 name: "config write access granted",
573 scopes: []string{"admin:config:write"},
574 wantStatus: http.StatusOK,
575 },
576 {
577 name: "all admin access granted",
578 scopes: []string{"admin:*:*"},
579 wantStatus: http.StatusOK,
580 },
581 {
582 name: "only read access denied",
583 scopes: []string{"admin:config:read"},
584 wantStatus: http.StatusForbidden,
585 },
586 {
587 name: "wrong resource denied",
588 scopes: []string{"admin:routes:write"},
589 wantStatus: http.StatusForbidden,
590 },
591 }
592
593 for _, tt := range tests {
594 t.Run(tt.name, func(t *testing.T) {
595 if tt.wantStatus == http.StatusOK {
596 configSvc.EXPECT().Reload(mock.Anything).Return(nil).Maybe()
597 }
598
599 req := httptest.NewRequest("POST", "/admin/reload", nil)
600 req = req.WithContext(ctxWithScopes(tt.scopes...))
601 rec := httptest.NewRecorder()
602
603 handler.ServeHTTP(rec, req)
604
605 assert.Equal(t, tt.wantStatus, rec.Code)
606 })
607 }
608}
609
610// Functional tests
611
612func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
613 configSvc := inmocks.NewMockConfigService(t)
614 authSvc := inmocks.NewMockAuthService(t)
615 containerSvc := inmocks.NewMockContainerService(t)
616 secretSvc := inmocks.NewMockSecretService(t)
617
618 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
619
620 expectedRoutes := []domain.Route{
621 {Domain: "app1.example.com", Image: "app1:latest"},
622 {Domain: "app2.example.com", Image: "app2:v1.0"},
623 }
624 configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes)
625
626 req := httptest.NewRequest("GET", "/admin/routes", nil)
627 req = req.WithContext(ctxWithScopes("admin:routes:read"))
628 rec := httptest.NewRecorder()
629
630 handler.ServeHTTP(rec, req)
631
632 assert.Equal(t, http.StatusOK, rec.Code)
633
634 var response struct {
635 Routes []struct {
636 Domain string `json:"domain"`
637 Image string `json:"image"`
638 HTTPS bool `json:"https"`
639 } `json:"routes"`
640 }
641 err := json.NewDecoder(rec.Body).Decode(&response)
642 assert.NoError(t, err)
643
644 assert.Len(t, response.Routes, 2)
645}
646
647func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
648 configSvc := inmocks.NewMockConfigService(t)
649 authSvc := inmocks.NewMockAuthService(t)
650 containerSvc := inmocks.NewMockContainerService(t)
651 secretSvc := inmocks.NewMockSecretService(t)
652
653 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
654
655 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
656 Domain: "app.example.com", Image: "app:latest",
657 }, nil)
658
659 req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil)
660 req = req.WithContext(ctxWithScopes("admin:routes:read"))
661 rec := httptest.NewRecorder()
662
663 handler.ServeHTTP(rec, req)
664
665 assert.Equal(t, http.StatusOK, rec.Code)
666
667 var route struct {
668 Domain string `json:"domain"`
669 Image string `json:"image"`
670 HTTPS bool `json:"https"`
671 }
672 err := json.NewDecoder(rec.Body).Decode(&route)
673 assert.NoError(t, err)
674 assert.Equal(t, "app.example.com", route.Domain)
675 assert.Equal(t, "app:latest", route.Image)
676}
677
678func TestHandler_RoutesGet_NotFound(t *testing.T) {
679 configSvc := inmocks.NewMockConfigService(t)
680 authSvc := inmocks.NewMockAuthService(t)
681 containerSvc := inmocks.NewMockContainerService(t)
682 secretSvc := inmocks.NewMockSecretService(t)
683
684 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
685
686 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
687
688 req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil)
689 req = req.WithContext(ctxWithScopes("admin:routes:read"))
690 rec := httptest.NewRecorder()
691
692 handler.ServeHTTP(rec, req)
693
694 assert.Equal(t, http.StatusNotFound, rec.Code)
695}
696
697func TestHandler_RoutesPost_MissingFields(t *testing.T) {
698 tests := []struct {
699 name string
700 body string
701 mockErr error
702 }{
703 {
704 name: "missing domain",
705 body: `{"image": "app:latest"}`,
706 mockErr: domain.ErrRouteDomainEmpty,
707 },
708 {
709 name: "missing image",
710 body: `{"domain": "app.example.com"}`,
711 mockErr: domain.ErrRouteImageEmpty,
712 },
713 {
714 name: "empty object",
715 body: `{}`,
716 mockErr: domain.ErrRouteDomainEmpty,
717 },
718 }
719
720 for _, tt := range tests {
721 t.Run(tt.name, func(t *testing.T) {
722 configSvc := inmocks.NewMockConfigService(t)
723 authSvc := inmocks.NewMockAuthService(t)
724 containerSvc := inmocks.NewMockContainerService(t)
725 secretSvc := inmocks.NewMockSecretService(t)
726 registrySvc := inmocks.NewMockRegistryService(t)
727
728 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
729
730 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
731 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
732
733 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
734 req = req.WithContext(ctxWithScopes("admin:routes:write"))
735 rec := httptest.NewRecorder()
736
737 handler.ServeHTTP(rec, req)
738
739 assert.Equal(t, http.StatusBadRequest, rec.Code)
740 })
741 }
742}
743
744func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
745 configSvc := inmocks.NewMockConfigService(t)
746 authSvc := inmocks.NewMockAuthService(t)
747 containerSvc := inmocks.NewMockContainerService(t)
748 secretSvc := inmocks.NewMockSecretService(t)
749 registrySvc := inmocks.NewMockRegistryService(t)
750
751 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
752
753 tests := []struct {
754 name string
755 image string
756 body string
757 manifestName string
758 manifestRef string
759 }{
760 {
761 name: "tagged image not found",
762 image: "myapp:latest",
763 body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
764 manifestName: "myapp",
765 manifestRef: "latest",
766 },
767 {
768 name: "digest image not found",
769 image: "myapp@sha256:abc123",
770 body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
771 manifestName: "myapp",
772 manifestRef: "sha256:abc123",
773 },
774 {
775 name: "implicit latest tag not found",
776 image: "myapp",
777 body: `{"domain": "app.example.com", "image": "myapp"}`,
778 manifestName: "myapp",
779 manifestRef: "latest",
780 },
781 }
782
783 for _, tt := range tests {
784 t.Run(tt.name, func(t *testing.T) {
785 registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
786
787 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
788 req = req.WithContext(ctxWithScopes("admin:routes:write"))
789 rec := httptest.NewRecorder()
790
791 handler.ServeHTTP(rec, req)
792
793 assert.Equal(t, http.StatusBadRequest, rec.Code)
794 assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
795 })
796 }
797}
798
799func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
800 configSvc := inmocks.NewMockConfigService(t)
801 authSvc := inmocks.NewMockAuthService(t)
802 containerSvc := inmocks.NewMockContainerService(t)
803 secretSvc := inmocks.NewMockSecretService(t)
804
805 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
806
807 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
808 req = req.WithContext(ctxWithScopes("admin:routes:write"))
809 rec := httptest.NewRecorder()
810
811 handler.ServeHTTP(rec, req)
812
813 assert.Equal(t, http.StatusBadRequest, rec.Code)
814}
815
816func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
817 configSvc := inmocks.NewMockConfigService(t)
818 authSvc := inmocks.NewMockAuthService(t)
819 containerSvc := inmocks.NewMockContainerService(t)
820 secretSvc := inmocks.NewMockSecretService(t)
821
822 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
823
824 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
825 req = req.WithContext(ctxWithScopes("admin:routes:write"))
826 rec := httptest.NewRecorder()
827
828 handler.ServeHTTP(rec, req)
829
830 assert.Equal(t, http.StatusBadRequest, rec.Code)
831}
832
833func TestHandler_Secrets_MissingDomain(t *testing.T) {
834 configSvc := inmocks.NewMockConfigService(t)
835 authSvc := inmocks.NewMockAuthService(t)
836 containerSvc := inmocks.NewMockContainerService(t)
837 secretSvc := inmocks.NewMockSecretService(t)
838
839 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
840
841 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
842 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
843 rec := httptest.NewRecorder()
844
845 handler.ServeHTTP(rec, req)
846
847 assert.Equal(t, http.StatusBadRequest, rec.Code)
848}
849
850func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
851 configSvc := inmocks.NewMockConfigService(t)
852 authSvc := inmocks.NewMockAuthService(t)
853 containerSvc := inmocks.NewMockContainerService(t)
854 secretSvc := inmocks.NewMockSecretService(t)
855
856 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
857
858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
859 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
860 rec := httptest.NewRecorder()
861
862 handler.ServeHTTP(rec, req)
863
864 assert.Equal(t, http.StatusBadRequest, rec.Code)
865}
866
867func TestHandler_MethodNotAllowed(t *testing.T) {
868 configSvc := inmocks.NewMockConfigService(t)
869 authSvc := inmocks.NewMockAuthService(t)
870 containerSvc := inmocks.NewMockContainerService(t)
871 secretSvc := inmocks.NewMockSecretService(t)
872
873 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
874
875 tests := []struct {
876 method string
877 path string
878 }{
879 {"DELETE", "/admin/status"},
880 {"PUT", "/admin/status"},
881 {"GET", "/admin/reload"},
882 {"DELETE", "/admin/reload"},
883 {"POST", "/admin/config"},
884 {"DELETE", "/admin/config"},
885 }
886
887 for _, tt := range tests {
888 t.Run(tt.method+" "+tt.path, func(t *testing.T) {
889 req := httptest.NewRequest(tt.method, tt.path, nil)
890 req = req.WithContext(ctxWithScopes("admin:*:*"))
891 rec := httptest.NewRecorder()
892
893 handler.ServeHTTP(rec, req)
894
895 assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
896 })
897 }
898}
899
900func TestHandler_Restart_InvalidDomain(t *testing.T) {
901 configSvc := inmocks.NewMockConfigService(t)
902 authSvc := inmocks.NewMockAuthService(t)
903 containerSvc := inmocks.NewMockContainerService(t)
904 secretSvc := inmocks.NewMockSecretService(t)
905
906 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
907
908 tests := []struct {
909 name string
910 path string
911 status int
912 }{
913 {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest},
914 {"empty domain", "/admin/restart/", http.StatusBadRequest},
915 }
916
917 for _, tt := range tests {
918 t.Run(tt.name, func(t *testing.T) {
919 req := httptest.NewRequest("POST", tt.path, nil)
920 req = req.WithContext(ctxWithScopes("admin:config:write"))
921 rec := httptest.NewRecorder()
922
923 handler.ServeHTTP(rec, req)
924
925 assert.Equal(t, tt.status, rec.Code)
926 })
927 }
928}
929
930func TestHandler_Restart_ContainerNotFound(t *testing.T) {
931 configSvc := inmocks.NewMockConfigService(t)
932 authSvc := inmocks.NewMockAuthService(t)
933 containerSvc := inmocks.NewMockContainerService(t)
934 secretSvc := inmocks.NewMockSecretService(t)
935
936 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
937
938 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
939
940 req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil)
941 req = req.WithContext(ctxWithScopes("admin:config:write"))
942 rec := httptest.NewRecorder()
943
944 handler.ServeHTTP(rec, req)
945
946 assert.Equal(t, http.StatusNotFound, rec.Code)
947}
948
949func TestHandler_Restart_GenericError(t *testing.T) {
950 configSvc := inmocks.NewMockConfigService(t)
951 authSvc := inmocks.NewMockAuthService(t)
952 containerSvc := inmocks.NewMockContainerService(t)
953 secretSvc := inmocks.NewMockSecretService(t)
954
955 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
956
957 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
958
959 req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil)
960 req = req.WithContext(ctxWithScopes("admin:config:write"))
961 rec := httptest.NewRecorder()
962
963 handler.ServeHTTP(rec, req)
964
965 assert.Equal(t, http.StatusInternalServerError, rec.Code)
966 // Verify internal error details are NOT leaked
967 assert.NotContains(t, rec.Body.String(), "docker daemon error")
968 assert.NotContains(t, rec.Body.String(), "secret details")
969}
970
971func TestHandler_Restart_Success(t *testing.T) {
972 configSvc := inmocks.NewMockConfigService(t)
973 authSvc := inmocks.NewMockAuthService(t)
974 containerSvc := inmocks.NewMockContainerService(t)
975 secretSvc := inmocks.NewMockSecretService(t)
976
977 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
978
979 tests := []struct {
980 name string
981 path string
982 withAttachments bool
983 }{
984 {
985 name: "without attachments",
986 path: "/admin/restart/test.example.com",
987 withAttachments: false,
988 },
989 {
990 name: "with attachments",
991 path: "/admin/restart/test.example.com?attachments=true",
992 withAttachments: true,
993 },
994 }
995
996 for _, tt := range tests {
997 t.Run(tt.name, func(t *testing.T) {
998 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
999
1000 req := httptest.NewRequest("POST", tt.path, nil)
1001 req = req.WithContext(ctxWithScopes("admin:config:write"))
1002 rec := httptest.NewRecorder()
1003
1004 handler.ServeHTTP(rec, req)
1005
1006 assert.Equal(t, http.StatusOK, rec.Code)
1007 var response struct {
1008 Status string `json:"status"`
1009 Domain string `json:"domain"`
1010 }
1011 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1012 assert.Equal(t, "restarted", response.Status)
1013 assert.Equal(t, "test.example.com", response.Domain)
1014 })
1015 }
1016}
1017
1018func TestHandler_Tags_InvalidRepository(t *testing.T) {
1019 configSvc := inmocks.NewMockConfigService(t)
1020 authSvc := inmocks.NewMockAuthService(t)
1021 containerSvc := inmocks.NewMockContainerService(t)
1022 secretSvc := inmocks.NewMockSecretService(t)
1023
1024 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1025
1026 tests := []struct {
1027 name string
1028 path string
1029 status int
1030 }{
1031 {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest},
1032 {"empty repo", "/admin/tags/", http.StatusBadRequest},
1033 }
1034
1035 for _, tt := range tests {
1036 t.Run(tt.name, func(t *testing.T) {
1037 req := httptest.NewRequest("GET", tt.path, nil)
1038 req = req.WithContext(ctxWithScopes("admin:status:read"))
1039 rec := httptest.NewRecorder()
1040
1041 handler.ServeHTTP(rec, req)
1042
1043 assert.Equal(t, tt.status, rec.Code)
1044 })
1045 }
1046}
1047
1048func TestHandler_Tags_Success(t *testing.T) {
1049 configSvc := inmocks.NewMockConfigService(t)
1050 authSvc := inmocks.NewMockAuthService(t)
1051 containerSvc := inmocks.NewMockContainerService(t)
1052 secretSvc := inmocks.NewMockSecretService(t)
1053 registrySvc := inmocks.NewMockRegistryService(t)
1054
1055 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1056
1057 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1058
1059 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1060 req = req.WithContext(ctxWithScopes("admin:status:read"))
1061 rec := httptest.NewRecorder()
1062
1063 handler.ServeHTTP(rec, req)
1064
1065 assert.Equal(t, http.StatusOK, rec.Code)
1066 var response struct {
1067 Repository string `json:"repository"`
1068 Tags []string `json:"tags"`
1069 }
1070 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1071 assert.Equal(t, "myapp", response.Repository)
1072 assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1073}
1074
1075func TestHandler_Tags_Error(t *testing.T) {
1076 configSvc := inmocks.NewMockConfigService(t)
1077 authSvc := inmocks.NewMockAuthService(t)
1078 containerSvc := inmocks.NewMockContainerService(t)
1079 secretSvc := inmocks.NewMockSecretService(t)
1080 registrySvc := inmocks.NewMockRegistryService(t)
1081
1082 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1083
1084 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1085
1086 req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1087 req = req.WithContext(ctxWithScopes("admin:status:read"))
1088 rec := httptest.NewRecorder()
1089
1090 handler.ServeHTTP(rec, req)
1091
1092 assert.Equal(t, http.StatusInternalServerError, rec.Code)
1093 assert.NotContains(t, rec.Body.String(), "registry error")
1094}
1095
1096func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1097 configSvc := inmocks.NewMockConfigService(t)
1098 authSvc := inmocks.NewMockAuthService(t)
1099 containerSvc := inmocks.NewMockContainerService(t)
1100 secretSvc := inmocks.NewMockSecretService(t)
1101
1102 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1103
1104 body := `{"REDIS_URL": "redis://localhost:6379"}`
1105 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1106 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1107 rec := httptest.NewRecorder()
1108
1109 handler.ServeHTTP(rec, req)
1110
1111 assert.Equal(t, http.StatusForbidden, rec.Code)
1112}
1113
1114func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1115 configSvc := inmocks.NewMockConfigService(t)
1116 authSvc := inmocks.NewMockAuthService(t)
1117 containerSvc := inmocks.NewMockContainerService(t)
1118 secretSvc := inmocks.NewMockSecretService(t)
1119
1120 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1121
1122 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1123 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
1124 rec := httptest.NewRecorder()
1125
1126 handler.ServeHTTP(rec, req)
1127
1128 assert.Equal(t, http.StatusForbidden, rec.Code)
1129}
1130
1131func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1132 configSvc := inmocks.NewMockConfigService(t)
1133 authSvc := inmocks.NewMockAuthService(t)
1134 containerSvc := inmocks.NewMockContainerService(t)
1135 secretSvc := inmocks.NewMockSecretService(t)
1136
1137 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1138
1139 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1140 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1141 rec := httptest.NewRecorder()
1142
1143 handler.ServeHTTP(rec, req)
1144
1145 assert.Equal(t, http.StatusBadRequest, rec.Code)
1146 assert.Contains(t, rec.Body.String(), "invalid attachment path")
1147}
1148
1149func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1150 configSvc := inmocks.NewMockConfigService(t)
1151 authSvc := inmocks.NewMockAuthService(t)
1152 containerSvc := inmocks.NewMockContainerService(t)
1153 secretSvc := inmocks.NewMockSecretService(t)
1154
1155 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1156
1157 body := `{not valid json`
1158 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
1159 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
1160 rec := httptest.NewRecorder()
1161
1162 handler.ServeHTTP(rec, req)
1163
1164 assert.Equal(t, http.StatusBadRequest, rec.Code)
1165 assert.Contains(t, rec.Body.String(), "invalid JSON")
1166}
1167
1168func TestHandler_NotFound(t *testing.T) {
1169 configSvc := inmocks.NewMockConfigService(t)
1170 authSvc := inmocks.NewMockAuthService(t)
1171 containerSvc := inmocks.NewMockContainerService(t)
1172 secretSvc := inmocks.NewMockSecretService(t)
1173
1174 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1175
1176 req := httptest.NewRequest("GET", "/admin/unknown", nil)
1177 req = req.WithContext(ctxWithScopes("admin:*:*"))
1178 rec := httptest.NewRecorder()
1179
1180 handler.ServeHTTP(rec, req)
1181
1182 assert.Equal(t, http.StatusNotFound, rec.Code)
1183}