package admin import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/bnema/zerowrap" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/bnema/gordon/internal/adapters/dto" inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" "github.com/bnema/gordon/internal/domain" ) type stubImageService struct { listImagesFunc func(context.Context) ([]domain.ImageInfo, error) pruneFunc func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) } type previewContainerService struct { *inmocks.MockContainerService preview func(context.Context, string) (*domain.CleanupReport, error) } func (s *previewContainerService) PreviewRemovedRouteCleanup(ctx context.Context, routeDomain string) (*domain.CleanupReport, error) { if s.preview == nil { return nil, nil } return s.preview(ctx, routeDomain) } type noopReloadTrigger struct{} func (noopReloadTrigger) Trigger(context.Context) error { return nil } type reloadTriggerRecorder struct { calls int err error ctxErrAtCall error hasDeadline bool } func (r *reloadTriggerRecorder) Trigger(ctx context.Context) error { r.calls++ r.ctxErrAtCall = ctx.Err() _, r.hasDeadline = ctx.Deadline() return r.err } func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) { if s.listImagesFunc == nil { return nil, nil } return s.listImagesFunc(ctx) } func (s *stubImageService) Prune(ctx context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { if s.pruneFunc == nil { return domain.ImagePruneReport{}, nil } return s.pruneFunc(ctx, opts) } func testLogger() zerowrap.Logger { return zerowrap.Default() } func ctxWithScopes(scopes ...string) context.Context { ctx := context.Background() return context.WithValue(ctx, domain.ContextKeyScopes, scopes) } func newScopedTestServer(t *testing.T, handler http.Handler, scopes ...string) *httptest.Server { t.Helper() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r.WithContext(ctxWithScopes(scopes...))) })) t.Cleanup(server.Close) return server } func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { t.Helper() deps := HandlerDeps{ ConfigSvc: inmocks.NewMockConfigService(t), AuthSvc: inmocks.NewMockAuthService(t), ContainerSvc: inmocks.NewMockContainerService(t), HealthSvc: inmocks.NewMockHealthService(t), SecretSvc: inmocks.NewMockSecretService(t), Log: testLogger(), ReloadTrigger: noopReloadTrigger{}, } for _, opt := range opts { opt(&deps) } return NewHandler(deps) } func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) { volumeSvc := inmocks.NewMockVolumeService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) tests := []struct { name string scopes []string wantStatus int }{ {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK}, {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden}, {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once() } server := newScopedTestServer(t, handler, tt.scopes...) resp, err := http.Get(server.URL + "/admin/volumes") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, tt.wantStatus, resp.StatusCode) }) } } func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { volumeSvc := inmocks.NewMockVolumeService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) tests := []struct { name string scopes []string wantStatus int }{ {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK}, {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden}, {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once() } server := newScopedTestServer(t, handler, tt.scopes...) resp, err := http.Post(server.URL+"/admin/volumes/prune", "application/json", bytes.NewBufferString(`{"dry_run":true}`)) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, tt.wantStatus, resp.StatusCode) }) } } // Routes endpoint tests func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = inmocks.NewMockRegistryService(t) }) tests := []struct { name string scopes []string wantStatus int }{ { name: "routes read access granted", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusOK, }, { name: "routes wildcard access granted", scopes: []string{"admin:routes:*"}, wantStatus: http.StatusOK, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK, }, { name: "only write access denied", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusForbidden, }, { name: "wrong resource denied", scopes: []string{"admin:secrets:read"}, wantStatus: http.StatusForbidden, }, { name: "no scopes denied", scopes: []string{}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() } req := httptest.NewRequest("GET", "/admin/routes", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` tests := []struct { name string scopes []string wantStatus int }{ { name: "routes write access granted", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusCreated, }, { name: "routes wildcard access granted", scopes: []string{"admin:routes:*"}, wantStatus: http.StatusCreated, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusCreated, }, { name: "only read access denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, { name: "wrong resource denied", scopes: []string{"admin:secrets:write"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusCreated { configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() } req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON)) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_RoutesPost_InvalidRouteDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) server := newScopedTestServer(t, handler, "admin:routes:write") configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:latest", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once() req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"localhost","image":"myapp:latest"}`)) require.NoError(t, err) resp, err := server.Client().Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body)) } func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) routeJSON := `{"image": "myapp:v2"}` tests := []struct { name string scopes []string wantStatus int }{ { name: "routes write access granted", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusOK, }, { name: "only read access denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:v1", HTTPS: true}, nil).Maybe() configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe() } req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON)) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_RoutesPut_InvalidRouteDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) server := newScopedTestServer(t, handler, "admin:routes:write") configSvc.EXPECT().GetRoute(mock.Anything, "localhost").Return(&domain.Route{Domain: "localhost", Image: "myapp:v1", HTTPS: true}, nil).Once() configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "localhost", Image: "myapp:v2", HTTPS: true}).Return(domain.ErrRouteDomainInvalid).Once() req, err := http.NewRequest(http.MethodPut, server.URL+"/admin/routes/localhost", bytes.NewBufferString(`{"image":"myapp:v2"}`)) require.NoError(t, err) resp, err := server.Client().Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.JSONEq(t, `{"error":"`+domain.ErrRouteDomainInvalid.Error()+`"}`, string(body)) } func TestHandler_RoutesPost_AllowsRouteCreationWithoutRegistryManifest(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) route := domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true} handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) configSvc.EXPECT().AddRoute(mock.Anything, route).Return(nil).Once() server := newScopedTestServer(t, handler, "admin:routes:write") req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) require.NoError(t, err) resp, err := server.Client().Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body)) registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) } func TestHandleRoutesPost_DefaultsHTTPSWhenOmitted(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: true}).Return(nil).Once() server := newScopedTestServer(t, handler, "admin:routes:write") req, err := http.NewRequest(http.MethodPost, server.URL+"/admin/routes", bytes.NewBufferString(`{"domain":"app.example.com","image":"myapp:latest"}`)) require.NoError(t, err) resp, err := server.Client().Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusCreated, resp.StatusCode) assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:latest","https":true}`, string(body)) } func TestHandleRoutesPut_PreservesStoredHTTPSWhenOmitted(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) route := domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: false} handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once() configSvc.EXPECT().UpdateRoute(mock.Anything, route).Return(nil).Once() req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2"}`)) req = req.WithContext(ctxWithScopes("admin:routes:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":false}`, rec.Body.String()) registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) } func TestHandleRoutesPut_OverridesStoredHTTPSWhenProvided(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{Domain: "app.example.com", Image: "myapp:latest", HTTPS: false}, nil).Once() configSvc.EXPECT().UpdateRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:v2", HTTPS: true}).Return(nil).Once() req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(`{"image":"myapp:v2","https":true}`)) req = req.WithContext(ctxWithScopes("admin:routes:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"domain":"app.example.com","image":"myapp:v2","https":true}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_OneTarget(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres:16").Return([]string{"app.example.com"}).Once() req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"image":"postgres:16","targets":["app.example.com"]}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_MultipleTargets(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "postgres").Return([]string{"app.example.com", "workers"}).Once() req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"image":"postgres","targets":["app.example.com","workers"]}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_URLDecodedImage(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "registry/org/image:tag").Return([]string{"app.example.com"}).Once() req := httptest.NewRequest("GET", "/admin/attachments/by-image/registry%2Forg%2Fimage:tag", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"image":"registry/org/image:tag","targets":["app.example.com"]}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_EmptyResultSet(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().FindAttachmentTargetsByImage(mock.Anything, "missing:latest").Return([]string{}).Once() req := httptest.NewRequest("GET", "/admin/attachments/by-image/missing:latest", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"image":"missing:latest","targets":[]}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_MissingImagePath(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/attachments/by-image", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.JSONEq(t, `{"error":"image name required in path"}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_InvalidEncoding(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/attachments/by-image/placeholder", nil) req.URL = &url.URL{Path: "/admin/attachments/by-image/%zz"} req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.JSONEq(t, `{"error":"invalid image name encoding"}`, rec.Body.String()) } func TestHandler_AttachmentsByImageGet_RequiresReadScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/attachments/by-image/postgres:16", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) assert.JSONEq(t, `{"error":"insufficient permissions for config:read"}`, rec.Body.String()) } func TestHandler_RoutesByImageGet_URLDecodedImage(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().FindRoutesByImage(mock.Anything, "registry/org/image:tag").Return([]domain.Route{{ Domain: "app.example.com", Image: "registry/org/image:tag", }}).Once() req := httptest.NewRequest("GET", "/admin/routes/by-image/registry%2Forg%2Fimage:tag", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"image":"registry/org/image:tag","routes":[{"domain":"app.example.com","image":"registry/org/image:tag","https":false}]}`, rec.Body.String()) } func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string wantStatus int }{ { name: "routes write access granted", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusOK, }, { name: "only read access denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(&domain.CleanupReport{Domain: "app.example.com"}, nil).Once() } req := httptest.NewRequest("DELETE", "/admin/routes/app.example.com", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_RoutesDelete_ReconcilesRuntimeWhenRouteAlreadyMissing(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) report := &domain.CleanupReport{Domain: "app.example.com"} configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(domain.ErrRouteNotFound).Once() containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) server := newScopedTestServer(t, handler, "admin:routes:write") req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) require.NoError(t, err) respHTTP, err := http.DefaultClient.Do(req) require.NoError(t, err) defer respHTTP.Body.Close() require.Equal(t, http.StatusOK, respHTTP.StatusCode) var resp dto.RouteDeleteResponse require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) require.NotNil(t, resp.Cleanup) assert.Equal(t, "app.example.com", resp.Cleanup.Domain) } func TestHandler_RoutesDelete_ReturnsServerErrorWhenRuntimeCleanupFails(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(nil, errors.New("boom")).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) server := newScopedTestServer(t, handler, "admin:routes:write") req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) require.NoError(t, err) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) assert.Contains(t, string(body), "route removed but runtime cleanup failed") } func TestHandler_RoutesDelete_ReconcilesRuntimeAndReturnsCleanupReport(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) report := &domain.CleanupReport{ Domain: "app.example.com", RemovedContainers: []domain.CleanupContainer{{ ID: "container-1", Name: "gordon-app.example.com", }}, PreservedAttachments: []domain.CleanupAttachment{{ Name: "postgres", ContainerID: "attachment-1", Status: "running", }}, } removeCall := configSvc.EXPECT().RemoveRoute(mock.Anything, "app.example.com").Return(nil).Once() cleanupCall := containerSvc.EXPECT().ReconcileRemovedRoute(mock.Anything, "app.example.com").Return(report, nil).Once() mock.InOrder(removeCall, cleanupCall) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) server := newScopedTestServer(t, handler, "admin:routes:write") req, err := http.NewRequest(http.MethodDelete, server.URL+"/admin/routes/app.example.com", nil) require.NoError(t, err) respHTTP, err := http.DefaultClient.Do(req) require.NoError(t, err) defer respHTTP.Body.Close() require.Equal(t, http.StatusOK, respHTTP.StatusCode) var resp dto.RouteDeleteResponse require.NoError(t, json.NewDecoder(respHTTP.Body).Decode(&resp)) assert.Equal(t, "removed", resp.Status) require.NotNil(t, resp.Cleanup) assert.Equal(t, "app.example.com", resp.Cleanup.Domain) require.Len(t, resp.Cleanup.RemovedContainers, 1) assert.Equal(t, "container-1", resp.Cleanup.RemovedContainers[0].ID) require.Len(t, resp.Cleanup.PreservedAttachments, 1) assert.Equal(t, "attachment-1", resp.Cleanup.PreservedAttachments[0].ContainerID) } // Secrets endpoint tests func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string wantStatus int }{ { name: "secrets read access granted", scopes: []string{"admin:secrets:read"}, wantStatus: http.StatusOK, }, { name: "secrets wildcard access granted", scopes: []string{"admin:secrets:*"}, wantStatus: http.StatusOK, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK, }, { name: "only write access denied", scopes: []string{"admin:secrets:write"}, wantStatus: http.StatusForbidden, }, { name: "wrong resource denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { secretSvc.EXPECT().ListKeysWithAttachments(mock.Anything, "app.example.com").Return([]string{}, nil, nil).Maybe() } req := httptest.NewRequest("GET", "/admin/secrets/app.example.com", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) secretsJSON := `{"API_KEY": "secret123"}` tests := []struct { name string scopes []string wantStatus int }{ { name: "secrets write access granted", scopes: []string{"admin:secrets:write"}, wantStatus: http.StatusOK, }, { name: "only read access denied", scopes: []string{"admin:secrets:read"}, wantStatus: http.StatusForbidden, }, { name: "wrong resource denied", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { secretSvc.EXPECT().Set(mock.Anything, "app.example.com", mock.AnythingOfType("map[string]string")).Return(nil).Maybe() } req := httptest.NewRequest("POST", "/admin/secrets/app.example.com", bytes.NewBufferString(secretsJSON)) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string wantStatus int }{ { name: "secrets write access granted", scopes: []string{"admin:secrets:write"}, wantStatus: http.StatusOK, }, { name: "only read access denied", scopes: []string{"admin:secrets:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { secretSvc.EXPECT().Delete(mock.Anything, "app.example.com", "API_KEY").Return(nil).Maybe() } req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/API_KEY", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } // Attachment secrets endpoint tests func TestHandler_AttachmentSecretsPost(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil) body := `{"REDIS_URL": "redis://localhost:6379"}` req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) req = req.WithContext(ctxWithScopes("admin:secrets:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Status string `json:"status"` } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "updated", response.Status) } func TestHandler_AttachmentSecretsDelete(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil) req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) req = req.WithContext(ctxWithScopes("admin:secrets:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Status string `json:"status"` } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "deleted", response.Status) } func TestHandler_Bootstrap_HappyPath(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once() configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once() secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once() secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once() body, err := json.Marshal(dto.BootstrapRequest{ Domain: "app.example.com", Image: "myapp:latest", Attachments: []string{"postgres:16"}, Env: map[string]string{"APP_ENV": "prod"}, AttachmentEnv: map[string]map[string]string{ "postgres": {"POSTGRES_PASSWORD": "secret"}, }, }) assert.NoError(t, err) req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewReader(body)) req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:secrets:write", "admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) registrySvc.AssertNotCalled(t, "GetManifest", mock.Anything, mock.Anything, mock.Anything) var response dto.BootstrapResponse assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "app.example.com", response.Domain) assert.Equal(t, "reg.bnema.dev/myapp", response.Image) assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next) assert.Equal(t, []dto.BootstrapStep{ {Name: "route", Status: "configured"}, {Name: "attachment:postgres:16", Status: "created"}, {Name: "env", Status: "updated"}, {Name: "attachment_env:postgres", Status: "updated"}, }, response.Steps) } func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string body string wantStatus int }{ {name: "route only needs routes permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusOK}, {name: "env requires secrets permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","env":{"APP_ENV":"prod"}}`, wantStatus: http.StatusForbidden}, {name: "attachments require config permission", scopes: []string{"admin:routes:write"}, body: `{"domain":"app.example.com","image":"myapp:latest","attachments":["postgres:16"]}`, wantStatus: http.StatusForbidden}, {name: "missing routes permission", scopes: []string{"admin:secrets:write", "admin:config:write"}, body: `{"domain":"app.example.com","image":"myapp:latest"}`, wantStatus: http.StatusForbidden}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK || strings.Contains(tt.body, `"env"`) || strings.Contains(tt.body, `"attachments"`) { configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once() } if tt.wantStatus == http.StatusOK { configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp", HTTPS: true}).Return(nil).Once() } req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body)) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } // Status endpoint tests func TestHandler_Status_RequiresReadScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string wantStatus int }{ { name: "status read access granted", scopes: []string{"admin:status:read"}, wantStatus: http.StatusOK, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK, }, { name: "wrong resource denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe() configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe() configSvc.EXPECT().GetServerPort().Return(8080).Maybe() configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe() configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe() } req := httptest.NewRequest("GET", "/admin/status", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } // Config endpoint tests func TestHandler_Config_HidesSensitiveInventoryByDefault(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc }) configSvc.EXPECT().GetServerPort().Return(8080).Once() configSvc.EXPECT().GetRegistryPort().Return(5000).Once() configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Once() configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Once() configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Once() configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Once() configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Once() configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{"db.example.com": "http://10.0.0.5:5432"}).Once() server := newScopedTestServer(t, handler, "admin:config:read") resp, err := http.Get(server.URL + "/admin/config") require.NoError(t, err) defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) body := string(bodyBytes) var raw map[string]any require.NoError(t, json.Unmarshal([]byte(body), &raw)) serverConfig := raw["server"].(map[string]any) assert.NotContains(t, serverConfig, "data_dir") assert.NotContains(t, body, "/var/lib/gordon") assert.NotContains(t, body, "10.0.0.5") } func TestHandler_Config_RequiresReadScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string scopes []string wantStatus int }{ { name: "config read access granted", scopes: []string{"admin:config:read"}, wantStatus: http.StatusOK, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK, }, { name: "wrong resource denied", scopes: []string{"admin:routes:read"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.wantStatus == http.StatusOK { configSvc.EXPECT().GetServerPort().Return(8080).Maybe() configSvc.EXPECT().GetRegistryPort().Return(5000).Maybe() configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com").Maybe() configSvc.EXPECT().GetDataDir().Return("/var/lib/gordon").Maybe() configSvc.EXPECT().IsAutoRouteEnabled().Return(true).Maybe() configSvc.EXPECT().IsNetworkIsolationEnabled().Return(false).Maybe() configSvc.EXPECT().GetNetworkPrefix().Return("gordon").Maybe() configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{}).Maybe() configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() } req := httptest.NewRequest("GET", "/admin/config", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) }) } } // Reload endpoint tests func TestHandler_Reload_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) reloadTrigger := &reloadTriggerRecorder{} handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ReloadTrigger = reloadTrigger }) tests := []struct { name string scopes []string wantStatus int }{ { name: "config write access granted", scopes: []string{"admin:config:write"}, wantStatus: http.StatusOK, }, { name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK, }, { name: "only read access denied", scopes: []string{"admin:config:read"}, wantStatus: http.StatusForbidden, }, { name: "wrong resource denied", scopes: []string{"admin:routes:write"}, wantStatus: http.StatusForbidden, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reloadTrigger.calls = 0 req := httptest.NewRequest("POST", "/admin/reload", nil) req = req.WithContext(ctxWithScopes(tt.scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) if tt.wantStatus == http.StatusOK { assert.Equal(t, 1, reloadTrigger.calls) } else { assert.Zero(t, reloadTrigger.calls) } }) } } func TestHandler_Reload_ReturnsServerErrorOnTriggerFailure(t *testing.T) { reloadTrigger := &reloadTriggerRecorder{err: errors.New("reload failed")} handler := newTestHandler(t, func(d *HandlerDeps) { d.ReloadTrigger = reloadTrigger }) req := httptest.NewRequest("POST", "/admin/reload", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, 1, reloadTrigger.calls) } func TestHandler_Reload_UsesDetachedContextWithDeadline(t *testing.T) { reloadTrigger := &reloadTriggerRecorder{} handler := newTestHandler(t, func(d *HandlerDeps) { d.ReloadTrigger = reloadTrigger }) reqCtx, cancel := context.WithCancel(ctxWithScopes("admin:config:write")) cancel() req := httptest.NewRequest("POST", "/admin/reload", nil) req = req.WithContext(reqCtx) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, 1, reloadTrigger.calls) require.NoError(t, reloadTrigger.ctxErrAtCall) assert.True(t, reloadTrigger.hasDeadline) } // Functional tests func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) expectedRoutes := []domain.Route{ {Domain: "app1.example.com", Image: "app1:latest"}, {Domain: "app2.example.com", Image: "app2:v1.0"}, } configSvc.EXPECT().GetRoutes(mock.Anything).Return(expectedRoutes) req := httptest.NewRequest("GET", "/admin/routes", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Routes []struct { Domain string `json:"domain"` Image string `json:"image"` HTTPS bool `json:"https"` } `json:"routes"` } err := json.NewDecoder(rec.Body).Decode(&response) assert.NoError(t, err) assert.Len(t, response.Routes, 2) } func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{ Domain: "app.example.com", Image: "app:latest", }}).Once() syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once() listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once() mock.InOrder(syncCall, listCall) req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response dto.RoutesDetailResponse err := json.NewDecoder(rec.Body).Decode(&response) require.NoError(t, err) require.Len(t, response.Routes, 1) assert.Equal(t, "app.example.com", response.Routes[0].Domain) assert.Equal(t, "app:latest", response.Routes[0].Image) assert.Empty(t, response.Routes[0].ContainerID) assert.Empty(t, response.Routes[0].ContainerStatus) } func TestHandler_RoutesGet_SingleRoute(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ Domain: "app.example.com", Image: "app:latest", }, nil) req := httptest.NewRequest("GET", "/admin/routes/app.example.com", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var route struct { Domain string `json:"domain"` Image string `json:"image"` HTTPS bool `json:"https"` } err := json.NewDecoder(rec.Body).Decode(&route) assert.NoError(t, err) assert.Equal(t, "app.example.com", route.Domain) assert.Equal(t, "app:latest", route.Image) } func TestHandler_RoutesGet_NotFound(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound) req := httptest.NewRequest("GET", "/admin/routes/nonexistent.example.com", nil) req = req.WithContext(ctxWithScopes("admin:routes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestHandler_RouteCleanupPreview_RequiresRoutesConfigAndVolumesReadScopes(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) volumeSvc := inmocks.NewMockVolumeService(t) secretSvc := inmocks.NewMockSecretService(t) containerMock := inmocks.NewMockContainerService(t) containerSvc := &previewContainerService{ MockContainerService: containerMock, preview: func(_ context.Context, routeDomain string) (*domain.CleanupReport, error) { return &domain.CleanupReport{ Domain: routeDomain, OrphanedEntities: []domain.CleanupOrphanedEntity{{Kind: "route_container", ID: "ctr-1", Status: "running"}}, }, nil }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.VolumeSvc = volumeSvc d.SecretSvc = secretSvc }) volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{{Name: "gordon-app-example-com-data"}}, nil).Once() req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil) req = req.WithContext(ctxWithScopes("admin:routes:read", "admin:config:read", "admin:volumes:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var report dto.CleanupReport require.NoError(t, json.NewDecoder(rec.Body).Decode(&report)) require.Len(t, report.OrphanedEntities, 1) assert.Equal(t, "route_container", report.OrphanedEntities[0].Kind) require.Len(t, report.PreservedVolumes, 1) assert.Equal(t, "gordon-app-example-com-data", report.PreservedVolumes[0].Name) for _, scopes := range [][]string{ {"admin:routes:read", "admin:volumes:read"}, {"admin:routes:read", "admin:config:read"}, } { req := httptest.NewRequest("GET", "/admin/routes/app.example.com/cleanup", nil) req = req.WithContext(ctxWithScopes(scopes...)) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) } } func TestHandler_RoutesPost_MissingFields(t *testing.T) { tests := []struct { name string body string mockErr error }{ { name: "missing domain", body: `{"image": "app:latest"}`, mockErr: domain.ErrRouteDomainEmpty, }, { name: "missing image", body: `{"domain": "app.example.com"}`, mockErr: domain.ErrRouteImageEmpty, }, { name: "empty object", body: `{}`, mockErr: domain.ErrRouteDomainEmpty, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body)) req = req.WithContext(ctxWithScopes("admin:routes:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } } func TestHandler_RoutesPut_MissingDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) req = req.WithContext(ctxWithScopes("admin:routes:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("DELETE", "/admin/routes/", nil) req = req.WithContext(ctxWithScopes("admin:routes:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestHandler_Secrets_MissingDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/secrets/", nil) req = req.WithContext(ctxWithScopes("admin:secrets:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestHandler_SecretsDelete_MissingKey(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) req = req.WithContext(ctxWithScopes("admin:secrets:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) } func TestHandler_MethodNotAllowed(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { method string path string }{ {"DELETE", "/admin/status"}, {"PUT", "/admin/status"}, {"GET", "/admin/reload"}, {"DELETE", "/admin/reload"}, {"POST", "/admin/config"}, {"DELETE", "/admin/config"}, } for _, tt := range tests { t.Run(tt.method+" "+tt.path, func(t *testing.T) { req := httptest.NewRequest(tt.method, tt.path, nil) req = req.WithContext(ctxWithScopes("admin:*:*")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) }) } } func TestHandler_Restart_InvalidDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string path string status int }{ {"path traversal", "/admin/restart/../../etc/passwd", http.StatusBadRequest}, {"empty domain", "/admin/restart/", http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("POST", tt.path, nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.status, rec.Code) }) } } func TestHandler_Deploy_ConfigWriteOnlyOmitsDeployFailureLogs(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} deployErr := &domain.DeployFailureError{ Summary: "failed to deploy", Cause: "image pull failed", Hint: "push the image before retrying", Logs: []string{"PASSWORD=hunter2", "TOKEN=abc123"}, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() server := newScopedTestServer(t, handler, "admin:config:write") resp, err := http.Post(server.URL+"/admin/deploy/app.example.com", "application/json", nil) require.NoError(t, err) defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) body := string(bodyBytes) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying"}`, body) assert.NotContains(t, body, "hunter2") assert.NotContains(t, body, "abc123") } func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} deployErr := &domain.DeployFailureError{ Summary: "failed to deploy", Cause: "image pull failed", Hint: "push the image before retrying", Logs: []string{ "pulling manifest PASSWORD=hunter2", `{"TOKEN":"abc123"}`, }, Err: errors.New("manifest unknown: internal registry detail"), } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write", "admin:logs:read"))) })) defer s.Close() configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil) assert.NoError(t, err) resp, err := s.Client().Do(req) assert.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest PASSWORD=[REDACTED]","{\"TOKEN\":\"[REDACTED]\"}"]}`, string(body)) assert.NotContains(t, string(body), "internal registry detail") assert.NotContains(t, string(body), "hunter2") assert.NotContains(t, string(body), "abc123") } func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} deployErr := errors.New("manifest unknown: internal registry detail") handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler.ServeHTTP(w, r.WithContext(ctxWithScopes("admin:config:write"))) })) defer s.Close() configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, s.URL+"/admin/deploy/app.example.com", nil) assert.NoError(t, err) resp, err := s.Client().Do(req) assert.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) assert.JSONEq(t, `{"error":"failed to deploy container"}`, string(body)) assert.NotContains(t, string(body), "internal registry detail") } func TestHandler_Restart_ContainerNotFound(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound) req := httptest.NewRequest("POST", "/admin/restart/nonexistent.example.com", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestHandler_Restart_GenericError(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details")) req := httptest.NewRequest("POST", "/admin/restart/test.example.com", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) // Verify internal error details are NOT leaked assert.NotContains(t, rec.Body.String(), "docker daemon error") assert.NotContains(t, rec.Body.String(), "secret details") } func TestHandler_Restart_Success(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string path string withAttachments bool }{ { name: "without attachments", path: "/admin/restart/test.example.com", withAttachments: false, }, { name: "with attachments", path: "/admin/restart/test.example.com?attachments=true", withAttachments: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", tt.withAttachments).Return(nil) req := httptest.NewRequest("POST", tt.path, nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Status string `json:"status"` Domain string `json:"domain"` } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "restarted", response.Status) assert.Equal(t, "test.example.com", response.Domain) }) } } func TestHandler_Tags_InvalidRepository(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) tests := []struct { name string path string status int }{ {"path traversal", "/admin/tags/../../etc/passwd", http.StatusBadRequest}, {"empty repo", "/admin/tags/", http.StatusBadRequest}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest("GET", tt.path, nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.status, rec.Code) }) } } func TestHandler_Tags_Success(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil) req := httptest.NewRequest("GET", "/admin/tags/myapp", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Repository string `json:"repository"` Tags []string `json:"tags"` } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Equal(t, "myapp", response.Repository) assert.Equal(t, []string{"latest", "v1.0"}, response.Tags) } func TestHandler_Tags_Error(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error")) req := httptest.NewRequest("GET", "/admin/tags/myapp", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.NotContains(t, rec.Body.String(), "registry error") } func TestHandler_Tags_URLDecodedRepository(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) registrySvc := inmocks.NewMockRegistryService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.RegistrySvc = registrySvc }) registrySvc.EXPECT().ListTags(mock.Anything, "repo/app").Return([]string{"latest"}, nil) req := httptest.NewRequest("GET", "/admin/tags/repo%2Fapp", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"repository":"repo/app","tags":["latest"]}`, rec.Body.String()) } func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) body := `{"REDIS_URL": "redis://localhost:6379"}` req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) req = req.WithContext(ctxWithScopes("admin:secrets:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) } func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) req = req.WithContext(ctxWithScopes("admin:secrets:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) } func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) req = req.WithContext(ctxWithScopes("admin:secrets:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid attachment path") } func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) body := `{not valid json` req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body)) req = req.WithContext(ctxWithScopes("admin:secrets:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "invalid JSON") } func TestHandler_NotFound(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/unknown", nil) req = req.WithContext(ctxWithScopes("admin:*:*")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestHandler_BackupsStatus(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) backupSvc := inmocks.NewMockBackupService(t) backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted, FilePath: "/var/lib/gordon/backups/private.bak"}}, nil) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.BackupSvc = backupSvc }) req := httptest.NewRequest("GET", "/admin/backups/status", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "app.example.com") assert.NotContains(t, rec.Body.String(), "file_path") } func TestHandler_BackupsListDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) backupSvc := inmocks.NewMockBackupService(t) backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.BackupSvc = backupSvc }) req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "app.example.com") } func TestHandler_BackupsRunDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) backupSvc := inmocks.NewMockBackupService(t) backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.BackupSvc = backupSvc }) body := bytes.NewBufferString(`{"db":"postgres"}`) req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "completed") } func TestHandler_BackupsRunDomain_ChunkedBodyIsDecoded(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) backupSvc := inmocks.NewMockBackupService(t) backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.BackupSvc = backupSvc }) body := bytes.NewBufferString(`{"db":"postgres"}`) req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body) req.ContentLength = -1 req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "completed") } func TestHandler_LogsRequireLogsReadAndRedact(t *testing.T) { logSvc := inmocks.NewMockLogService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.LogSvc = logSvc }) t.Run("status read denied", func(t *testing.T) { server := newScopedTestServer(t, handler, "admin:status:read") resp, err := http.Get(server.URL + "/admin/logs") require.NoError(t, err) defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Contains(t, string(bodyBytes), "insufficient permissions for logs:read") }) t.Run("logs read allowed and redacted", func(t *testing.T) { logSvc.EXPECT().GetProcessLogs(mock.Anything, 50).Return([]string{"PASSWORD=hunter2", `{"API_KEY":"abc123"}`}, nil).Once() server := newScopedTestServer(t, handler, "admin:logs:read") resp, err := http.Get(server.URL + "/admin/logs") require.NoError(t, err) defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) require.NoError(t, err) body := string(bodyBytes) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Contains(t, body, "PASSWORD=[REDACTED]") assert.Contains(t, body, `\"API_KEY\":\"[REDACTED]\"`) assert.NotContains(t, body, "hunter2") assert.NotContains(t, body, "abc123") }) } func TestHandler_BackupsDetectDomain(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) backupSvc := inmocks.NewMockBackupService(t) backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.BackupSvc = backupSvc }) req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "postgres") } func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC) imageSvc := &stubImageService{ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { return []domain.ImageInfo{ { Repository: "myapp", Tag: "latest", Size: 1024, Created: createdAt, ID: "sha256:abc123", Dangling: false, }, { Repository: "", Tag: "", Size: 512, Created: createdAt, ID: "sha256:def456", Dangling: true, }, }, nil }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = imageSvc }) req := httptest.NewRequest("GET", "/admin/images", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) var response struct { Images []struct { Repository string `json:"repository"` Tag string `json:"tag"` Size int64 `json:"size"` Created time.Time `json:"created"` ID string `json:"id"` Dangling bool `json:"dangling"` } `json:"images"` } assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) assert.Len(t, response.Images, 2) assert.Equal(t, "myapp", response.Images[0].Repository) assert.Equal(t, "latest", response.Images[0].Tag) assert.Equal(t, int64(1024), response.Images[0].Size) assert.Equal(t, createdAt, response.Images[0].Created) assert.Equal(t, "sha256:abc123", response.Images[0].ID) assert.False(t, response.Images[0].Dangling) assert.True(t, response.Images[1].Dangling) } func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) tests := []struct { name string body string expectedKeepLast int }{ {name: "missing keep_last uses default", body: `{}`, expectedKeepLast: domain.DefaultImagePruneKeepLast}, {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false imageSvc := &stubImageService{ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { called = true assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) return domain.ImagePruneReport{ Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096}, Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048}, }, nil }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = imageSvc }) req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusOK, rec.Code) assert.True(t, called) assert.Contains(t, rec.Body.String(), "deleted_count") assert.Contains(t, rec.Body.String(), "tags_removed") }) } } func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) { t.Run("list failure maps to internal server error", func(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) imageSvc := &stubImageService{ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) { return nil, errors.New("runtime list failed: confidential details") }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = imageSvc }) req := httptest.NewRequest("GET", "/admin/images", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Contains(t, rec.Body.String(), "failed to list images") assert.NotContains(t, rec.Body.String(), "confidential details") }) t.Run("prune failure maps to internal server error", func(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) imageSvc := &stubImageService{ pruneFunc: func(context.Context, domain.ImagePruneOptions) (domain.ImagePruneReport, error) { return domain.ImagePruneReport{}, errors.New("prune failed: confidential details") }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = imageSvc }) req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`)) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Contains(t, rec.Body.String(), "failed to prune images") assert.NotContains(t, rec.Body.String(), "confidential details") }) } func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) { t.Run("rejects negative keep_last", func(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = &stubImageService{} }) req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`)) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusBadRequest, rec.Code) assert.Contains(t, rec.Body.String(), "keep_last must be") }) t.Run("returns service unavailable when image service missing", func(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/images", nil) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusServiceUnavailable, rec.Code) assert.Contains(t, rec.Body.String(), "image service not available") }) } func TestHandler_ImagesPrune_ScopeDefaults(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) tests := []struct { name string body string expectedPruneDangling bool expectedPruneRegistry bool expectedKeepLast int wantStatus int }{ { name: "missing scope fields defaults to both true", body: `{}`, expectedPruneDangling: true, expectedPruneRegistry: true, expectedKeepLast: domain.DefaultImagePruneKeepLast, wantStatus: http.StatusOK, }, { name: "explicit dangling only", body: `{"prune_dangling": true, "prune_registry": false}`, expectedPruneDangling: true, expectedPruneRegistry: false, expectedKeepLast: domain.DefaultImagePruneKeepLast, wantStatus: http.StatusOK, }, { name: "explicit registry only", body: `{"prune_dangling": false, "prune_registry": true, "keep_last": 5}`, expectedPruneDangling: false, expectedPruneRegistry: true, expectedKeepLast: 5, wantStatus: http.StatusOK, }, { name: "both scopes false returns 400", body: `{"prune_dangling": false, "prune_registry": false}`, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { called := false imageSvc := &stubImageService{ pruneFunc: func(_ context.Context, opts domain.ImagePruneOptions) (domain.ImagePruneReport, error) { called = true assert.Equal(t, tt.expectedPruneDangling, opts.PruneDangling) assert.Equal(t, tt.expectedPruneRegistry, opts.PruneRegistry) assert.Equal(t, tt.expectedKeepLast, opts.KeepLast) return domain.ImagePruneReport{}, nil }, } handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = imageSvc }) req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body)) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, tt.wantStatus, rec.Code) if tt.wantStatus == http.StatusOK { assert.True(t, called, "prune should have been called") } if tt.wantStatus == http.StatusBadRequest { assert.Contains(t, rec.Body.String(), "at least one prune scope") } }) } } func TestHandler_Images_Authorization(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc d.ImageSvc = &stubImageService{} }) t.Run("images list requires status:read", func(t *testing.T) { req := httptest.NewRequest("GET", "/admin/images", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read") }) t.Run("images prune requires config:write", func(t *testing.T) { req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`)) req = req.WithContext(ctxWithScopes("admin:status:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusForbidden, rec.Code) assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write") }) } func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) { handler := newTestHandler(t, func(d *HandlerDeps) { d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t) }) server := newScopedTestServer(t, handler, "admin:config:write") resp, err := http.Get(server.URL + "/admin/tls/status") require.NoError(t, err) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) assert.Contains(t, string(body), "insufficient permissions for status:read") } func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) { now := time.Now() tlsStatus := domain.PublicTLSStatus{ ACMEEnabled: true, ConfiguredMode: domain.ACMEChallengeAuto, EffectiveMode: domain.ACMEChallengeHTTP01, SelectionReason: "auto-selected", TokenSource: domain.ACMETokenSourcePass, Certificates: []domain.ManagedCertificate{ { ID: "cert-1", Names: []string{"example.com"}, Challenge: domain.ACMEChallengeHTTP01, Status: domain.TLSCertificateStatusValid, NotAfter: now.Add(60 * 24 * time.Hour), }, }, Routes: []domain.TLSRouteCoverage{ { Domain: "example.com", Covered: true, CoveredBy: "cert-1", RequiredACME: true, }, }, } handler := newTestHandler(t, func(d *HandlerDeps) { fakeSvc := inmocks.NewMockPublicTLSService(t) fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus) d.PublicTLSSvc = fakeSvc }) server := newScopedTestServer(t, handler, "admin:status:read") resp, err := http.Get(server.URL + "/admin/tls/status") require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) var body dto.TLSStatusResponse err = json.NewDecoder(resp.Body).Decode(&body) require.NoError(t, err) assert.True(t, body.ACMEEnabled) assert.Equal(t, "auto", body.ConfiguredMode) assert.Equal(t, "http-01", body.EffectiveMode) assert.Equal(t, "auto-selected", body.SelectionReason) assert.Equal(t, "pass", body.TokenSource) require.Len(t, body.Certificates, 1) assert.Equal(t, "cert-1", body.Certificates[0].ID) assert.Equal(t, "valid", body.Certificates[0].Status) assert.Equal(t, "http-01", body.Certificates[0].Challenge) require.Len(t, body.Routes, 1) assert.Equal(t, "example.com", body.Routes[0].Domain) assert.True(t, body.Routes[0].Covered) } func TestHandler_TLSStatus_POSTReturns405(t *testing.T) { handler := newTestHandler(t, func(d *HandlerDeps) { d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t) }) server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write") resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) } func TestHandler_AttachmentOrphans_ReturnsOrphanedAttachments(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) attachments := []domain.CleanupAttachment{{ContainerID: "attachment-1", Name: "postgres", Owner: "app.example.com"}} containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(attachments, nil).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var resp struct { Attachments []domain.CleanupAttachment `json:"attachments"` } require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp)) require.Len(t, resp.Attachments, 1) assert.Equal(t, "app.example.com", resp.Attachments[0].Owner) } func TestHandler_AttachmentPrune_StopsOnlyWhenRequested(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) report := &domain.CleanupReport{RemovedContainers: []domain.CleanupContainer{{ID: "attachment-1"}}} containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(report, nil).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) var got domain.CleanupReport require.NoError(t, json.NewDecoder(rec.Body).Decode(&got)) require.Len(t, got.RemovedContainers, 1) assert.Equal(t, "attachment-1", got.RemovedContainers[0].ID) } func TestHandler_AttachmentOrphans_ReturnsServerErrorOnListFailure(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) containerSvc.EXPECT().ListOrphanedAttachments(mock.Anything).Return(nil, errors.New("boom")).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("GET", "/admin/attachments/orphans", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) } func TestHandler_AttachmentOrphans_PostReturns405(t *testing.T) { handler := newTestHandler(t, func(d *HandlerDeps) { d.ContainerSvc = inmocks.NewMockContainerService(t) }) req := httptest.NewRequest("POST", "/admin/attachments/orphans", nil) req = req.WithContext(ctxWithScopes("admin:config:read")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) } func TestHandler_AttachmentPrune_ReturnsServerErrorOnCleanupFailure(t *testing.T) { configSvc := inmocks.NewMockConfigService(t) authSvc := inmocks.NewMockAuthService(t) containerSvc := inmocks.NewMockContainerService(t) secretSvc := inmocks.NewMockSecretService(t) containerSvc.EXPECT().CleanupOrphanedAttachments(mock.Anything, "", true).Return(nil, errors.New("boom")).Once() handler := newTestHandler(t, func(d *HandlerDeps) { d.ConfigSvc = configSvc d.AuthSvc = authSvc d.ContainerSvc = containerSvc d.SecretSvc = secretSvc }) req := httptest.NewRequest("POST", "/admin/attachments/prune?stop=true", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusInternalServerError, rec.Code) } func TestHandler_AttachmentPrune_GetReturns405(t *testing.T) { handler := newTestHandler(t, func(d *HandlerDeps) { d.ContainerSvc = inmocks.NewMockContainerService(t) }) req := httptest.NewRequest("GET", "/admin/attachments/prune", nil) req = req.WithContext(ctxWithScopes("admin:config:write")) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) }