detached 9cfe7dc

fix(admin): handle tags not-found and extend restart tests

commit
9cfe7dc12184c6109c07bedd32fd234861f5af65
author
brice · · 2026-02-02 19:11 UTC
parent
776f5de
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +159 -1
M internal/adapters/in/http/admin/handler.go +5 -0
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/gordon/internal/boundaries/in"
1616 "github.com/bnema/gordon/internal/boundaries/out"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/registry"
1819 "github.com/bnema/gordon/pkg/validation"
1920 )
2021
@@ -837,6 +838,10 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string
837838
838839 tags, err := h.registrySvc.ListTags(ctx, repository)
839840 if err != nil {
841+ if errors.Is(err, registry.ErrRepositoryNotFound) {
842+ h.sendError(w, http.StatusNotFound, "repository not found")
843+ return
844+ }
840845 h.sendError(w, http.StatusInternalServerError, "failed to list tags")
841846 return
842847 }
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
16 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/internal/usecase/registry"
18 "github.com/bnema/gordon/pkg/validation"19 "github.com/bnema/gordon/pkg/validation"
19 )20 )
20 21
@@ -837,6 +838,10 @@ func (h *Handler) handleTags(w http.ResponseWriter, r *http.Request, path string
837 838
838 tags, err := h.registrySvc.ListTags(ctx, repository)839 tags, err := h.registrySvc.ListTags(ctx, repository)
839 if err != nil {840 if err != nil {
841+ if errors.Is(err, registry.ErrRepositoryNotFound) {
842+ h.sendError(w, http.StatusNotFound, "repository not found")
843+ return
844+ }
840 h.sendError(w, http.StatusInternalServerError, "failed to list tags")845 h.sendError(w, http.StatusInternalServerError, "failed to list tags")
841 return846 return
842 }847 }
M internal/adapters/in/http/admin/handler_test.go +95 -0
@@ -915,6 +915,53 @@ func TestHandler_Restart_GenericError(t *testing.T) {
915915 assert.NotContains(t, rec.Body.String(), "secret details")
916916 }
917917
918+func TestHandler_Restart_Success(t *testing.T) {
919+ configSvc := inmocks.NewMockConfigService(t)
920+ authSvc := inmocks.NewMockAuthService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ secretSvc := inmocks.NewMockSecretService(t)
923+
924+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
925+
926+ tests := []struct {
927+ name string
928+ path string
929+ withAttachments bool
930+ }{
931+ {
932+ name: "without attachments",
933+ path: "/admin/restart/test.example.com",
934+ withAttachments: false,
935+ },
936+ {
937+ name: "with attachments",
938+ path: "/admin/restart/test.example.com?attachments=true",
939+ withAttachments: true,
940+ },
941+ }
942+
943+ for _, tt := range tests {
944+ t.Run(tt.name, func(t *testing.T) {
945+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
946+
947+ req := httptest.NewRequest("POST", tt.path, nil)
948+ req = req.WithContext(ctxWithScopes("admin:config:write"))
949+ rec := httptest.NewRecorder()
950+
951+ handler.ServeHTTP(rec, req)
952+
953+ assert.Equal(t, http.StatusOK, rec.Code)
954+ var response struct {
955+ Status string `json:"status"`
956+ Domain string `json:"domain"`
957+ }
958+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
959+ assert.Equal(t, "restarted", response.Status)
960+ assert.Equal(t, "test.example.com", response.Domain)
961+ })
962+ }
963+}
964+
918965 func TestHandler_Tags_InvalidRepository(t *testing.T) {
919966 configSvc := inmocks.NewMockConfigService(t)
920967 authSvc := inmocks.NewMockAuthService(t)
@@ -945,6 +992,54 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
945992 }
946993 }
947994
995+func TestHandler_Tags_Success(t *testing.T) {
996+ configSvc := inmocks.NewMockConfigService(t)
997+ authSvc := inmocks.NewMockAuthService(t)
998+ containerSvc := inmocks.NewMockContainerService(t)
999+ secretSvc := inmocks.NewMockSecretService(t)
1000+ registrySvc := inmocks.NewMockRegistryService(t)
1001+
1002+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1003+
1004+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1005+
1006+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1007+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1008+ rec := httptest.NewRecorder()
1009+
1010+ handler.ServeHTTP(rec, req)
1011+
1012+ assert.Equal(t, http.StatusOK, rec.Code)
1013+ var response struct {
1014+ Repository string `json:"repository"`
1015+ Tags []string `json:"tags"`
1016+ }
1017+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1018+ assert.Equal(t, "myapp", response.Repository)
1019+ assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1020+}
1021+
1022+func TestHandler_Tags_Error(t *testing.T) {
1023+ configSvc := inmocks.NewMockConfigService(t)
1024+ authSvc := inmocks.NewMockAuthService(t)
1025+ containerSvc := inmocks.NewMockContainerService(t)
1026+ secretSvc := inmocks.NewMockSecretService(t)
1027+ registrySvc := inmocks.NewMockRegistryService(t)
1028+
1029+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1030+
1031+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1032+
1033+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1034+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1035+ rec := httptest.NewRecorder()
1036+
1037+ handler.ServeHTTP(rec, req)
1038+
1039+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1040+ assert.NotContains(t, rec.Body.String(), "registry error")
1041+}
1042+
9481043 func TestHandler_NotFound(t *testing.T) {
9491044 configSvc := inmocks.NewMockConfigService(t)
9501045 authSvc := inmocks.NewMockAuthService(t)
@@ -915,6 +915,53 @@ func TestHandler_Restart_GenericError(t *testing.T) {
915 assert.NotContains(t, rec.Body.String(), "secret details")915 assert.NotContains(t, rec.Body.String(), "secret details")
916 }916 }
917 917
918+func TestHandler_Restart_Success(t *testing.T) {
919+ configSvc := inmocks.NewMockConfigService(t)
920+ authSvc := inmocks.NewMockAuthService(t)
921+ containerSvc := inmocks.NewMockContainerService(t)
922+ secretSvc := inmocks.NewMockSecretService(t)
923+
924+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
925+
926+ tests := []struct {
927+ name string
928+ path string
929+ withAttachments bool
930+ }{
931+ {
932+ name: "without attachments",
933+ path: "/admin/restart/test.example.com",
934+ withAttachments: false,
935+ },
936+ {
937+ name: "with attachments",
938+ path: "/admin/restart/test.example.com?attachments=true",
939+ withAttachments: true,
940+ },
941+ }
942+
943+ for _, tt := range tests {
944+ t.Run(tt.name, func(t *testing.T) {
945+ containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil)
946+
947+ req := httptest.NewRequest("POST", tt.path, nil)
948+ req = req.WithContext(ctxWithScopes("admin:config:write"))
949+ rec := httptest.NewRecorder()
950+
951+ handler.ServeHTTP(rec, req)
952+
953+ assert.Equal(t, http.StatusOK, rec.Code)
954+ var response struct {
955+ Status string `json:"status"`
956+ Domain string `json:"domain"`
957+ }
958+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
959+ assert.Equal(t, "restarted", response.Status)
960+ assert.Equal(t, "test.example.com", response.Domain)
961+ })
962+ }
963+}
964+
918 func TestHandler_Tags_InvalidRepository(t *testing.T) {965 func TestHandler_Tags_InvalidRepository(t *testing.T) {
919 configSvc := inmocks.NewMockConfigService(t)966 configSvc := inmocks.NewMockConfigService(t)
920 authSvc := inmocks.NewMockAuthService(t)967 authSvc := inmocks.NewMockAuthService(t)
@@ -945,6 +992,54 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
945 }992 }
946 }993 }
947 994
995+func TestHandler_Tags_Success(t *testing.T) {
996+ configSvc := inmocks.NewMockConfigService(t)
997+ authSvc := inmocks.NewMockAuthService(t)
998+ containerSvc := inmocks.NewMockContainerService(t)
999+ secretSvc := inmocks.NewMockSecretService(t)
1000+ registrySvc := inmocks.NewMockRegistryService(t)
1001+
1002+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1003+
1004+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1005+
1006+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1007+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1008+ rec := httptest.NewRecorder()
1009+
1010+ handler.ServeHTTP(rec, req)
1011+
1012+ assert.Equal(t, http.StatusOK, rec.Code)
1013+ var response struct {
1014+ Repository string `json:"repository"`
1015+ Tags []string `json:"tags"`
1016+ }
1017+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1018+ assert.Equal(t, "myapp", response.Repository)
1019+ assert.Equal(t, []string{"latest", "v1.0"}, response.Tags)
1020+}
1021+
1022+func TestHandler_Tags_Error(t *testing.T) {
1023+ configSvc := inmocks.NewMockConfigService(t)
1024+ authSvc := inmocks.NewMockAuthService(t)
1025+ containerSvc := inmocks.NewMockContainerService(t)
1026+ secretSvc := inmocks.NewMockSecretService(t)
1027+ registrySvc := inmocks.NewMockRegistryService(t)
1028+
1029+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1030+
1031+ registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1032+
1033+ req := httptest.NewRequest("GET", "/admin/tags/myapp", nil)
1034+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1035+ rec := httptest.NewRecorder()
1036+
1037+ handler.ServeHTTP(rec, req)
1038+
1039+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1040+ assert.NotContains(t, rec.Body.String(), "registry error")
1041+}
1042+
948 func TestHandler_NotFound(t *testing.T) {1043 func TestHandler_NotFound(t *testing.T) {
949 configSvc := inmocks.NewMockConfigService(t)1044 configSvc := inmocks.NewMockConfigService(t)
950 authSvc := inmocks.NewMockAuthService(t)1045 authSvc := inmocks.NewMockAuthService(t)
M internal/usecase/container/service.go +6 -1
@@ -223,7 +223,12 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
223223
224224 s.mu.RLock()
225225 container, exists := s.containers[domainName]
226- attachmentIDs := s.attachments[domainName]
226+ attachments := s.attachments[domainName]
227+ var attachmentIDs []string
228+ if len(attachments) > 0 {
229+ attachmentIDs = make([]string, len(attachments))
230+ copy(attachmentIDs, attachments)
231+ }
227232 s.mu.RUnlock()
228233
229234 if !exists || container == nil {
@@ -223,7 +223,12 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
223 223
224 s.mu.RLock()224 s.mu.RLock()
225 container, exists := s.containers[domainName]225 container, exists := s.containers[domainName]
226- attachmentIDs := s.attachments[domainName]226+ attachments := s.attachments[domainName]
227+ var attachmentIDs []string
228+ if len(attachments) > 0 {
229+ attachmentIDs = make([]string, len(attachments))
230+ copy(attachmentIDs, attachments)
231+ }
227 s.mu.RUnlock()232 s.mu.RUnlock()
228 233
229 if !exists || container == nil {234 if !exists || container == nil {
M internal/usecase/container/service_test.go +47 -0
@@ -3,6 +3,7 @@ package container
33 import (
44 "context"
55 "errors"
6+ "fmt"
67 "testing"
78 "time"
89
@@ -601,6 +602,52 @@ func TestService_Restart_Success(t *testing.T) {
601602 assert.NoError(t, err)
602603 }
603604
605+func TestService_Restart_Error(t *testing.T) {
606+ runtime := mocks.NewMockContainerRuntime(t)
607+ envLoader := mocks.NewMockEnvLoader(t)
608+ eventBus := mocks.NewMockEventPublisher(t)
609+
610+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
611+ ctx := testContext()
612+
613+ svc.containers["test.example.com"] = &domain.Container{
614+ ID: "container-123",
615+ Name: "gordon-test.example.com",
616+ Status: "running",
617+ }
618+
619+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
620+
621+ err := svc.Restart(ctx, "test.example.com", false)
622+
623+ assert.Error(t, err)
624+ assert.Contains(t, err.Error(), "failed to restart container")
625+}
626+
627+func TestService_Restart_WithAttachments(t *testing.T) {
628+ runtime := mocks.NewMockContainerRuntime(t)
629+ envLoader := mocks.NewMockEnvLoader(t)
630+ eventBus := mocks.NewMockEventPublisher(t)
631+
632+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
633+ ctx := testContext()
634+
635+ svc.containers["test.example.com"] = &domain.Container{
636+ ID: "container-123",
637+ Name: "gordon-test.example.com",
638+ Status: "running",
639+ }
640+ svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
641+
642+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
643+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
644+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
645+
646+ err := svc.Restart(ctx, "test.example.com", true)
647+
648+ assert.NoError(t, err)
649+}
650+
604651 func TestNormalizeImageRef(t *testing.T) {
605652 tests := []struct {
606653 name string
@@ -3,6 +3,7 @@ package container
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "fmt"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -601,6 +602,52 @@ func TestService_Restart_Success(t *testing.T) {
601 assert.NoError(t, err)602 assert.NoError(t, err)
602 }603 }
603 604
605+func TestService_Restart_Error(t *testing.T) {
606+ runtime := mocks.NewMockContainerRuntime(t)
607+ envLoader := mocks.NewMockEnvLoader(t)
608+ eventBus := mocks.NewMockEventPublisher(t)
609+
610+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
611+ ctx := testContext()
612+
613+ svc.containers["test.example.com"] = &domain.Container{
614+ ID: "container-123",
615+ Name: "gordon-test.example.com",
616+ Status: "running",
617+ }
618+
619+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(errors.New("restart failed"))
620+
621+ err := svc.Restart(ctx, "test.example.com", false)
622+
623+ assert.Error(t, err)
624+ assert.Contains(t, err.Error(), "failed to restart container")
625+}
626+
627+func TestService_Restart_WithAttachments(t *testing.T) {
628+ runtime := mocks.NewMockContainerRuntime(t)
629+ envLoader := mocks.NewMockEnvLoader(t)
630+ eventBus := mocks.NewMockEventPublisher(t)
631+
632+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
633+ ctx := testContext()
634+
635+ svc.containers["test.example.com"] = &domain.Container{
636+ ID: "container-123",
637+ Name: "gordon-test.example.com",
638+ Status: "running",
639+ }
640+ svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"}
641+
642+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
643+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
644+ runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-2").Return(fmt.Errorf("boom"))
645+
646+ err := svc.Restart(ctx, "test.example.com", true)
647+
648+ assert.NoError(t, err)
649+}
650+
604 func TestNormalizeImageRef(t *testing.T) {651 func TestNormalizeImageRef(t *testing.T) {
605 tests := []struct {652 tests := []struct {
606 name string653 name string
A internal/usecase/registry/errors.go +6 -0
@@ -0,0 +1,6 @@
1+package registry
2+
3+import "errors"
4+
5+// ErrRepositoryNotFound indicates a registry repository does not exist.
6+var ErrRepositoryNotFound = errors.New("repository not found")
@@ -0,0 +1,6 @@
1+package registry
2+
3+import "errors"
4+
5+// ErrRepositoryNotFound indicates a registry repository does not exist.
6+var ErrRepositoryNotFound = errors.New("repository not found")

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