package handlers import ( "context" "errors" "net/http" "net/http/httptest" "testing" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type fakeDockerService struct { called bool host string ret []services.DockerContainer err error } func (f *fakeDockerService) ListContainers(_ context.Context, host string) ([]services.DockerContainer, error) { f.called = true f.host = host return f.ret, f.err } type fakeRemoteServerService struct { gotUUID string server *models.RemoteServer err error } func (f *fakeRemoteServerService) GetByUUID(uuidStr string) (*models.RemoteServer, error) { f.gotUUID = uuidStr return f.server, f.err } func TestDockerHandler_ListContainers_InvalidHostRejected(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{} remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=tcp://127.0.0.1:2375", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.False(t, dockerSvc.called, "docker service should not be called for invalid host") } func TestDockerHandler_ListContainers_DockerUnavailableMappedTo503(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{err: services.NewDockerUnavailableError(errors.New("no docker socket"))} remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusServiceUnavailable, w.Code) assert.Contains(t, w.Body.String(), "Docker daemon unavailable") // Verify the new details field is included in the response assert.Contains(t, w.Body.String(), "details") assert.Contains(t, w.Body.String(), "Docker is running") } func TestDockerHandler_ListContainers_ServerIDResolvesToTCPHost(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{ret: []services.DockerContainer{}} remoteSvc := &fakeRemoteServerService{server: &models.RemoteServer{Host: "example.internal", Port: 2375}} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=abc-123", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.True(t, dockerSvc.called) assert.Equal(t, "abc-123", remoteSvc.gotUUID) assert.Equal(t, "tcp://example.internal:2375", dockerSvc.host) assert.Equal(t, http.StatusOK, w.Code) } func TestDockerHandler_ListContainers_ServerIDNotFoundReturns404(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{} remoteSvc := &fakeRemoteServerService{err: errors.New("not found")} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=missing", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) assert.False(t, dockerSvc.called) } // Phase 4.1: Additional test cases for complete coverage func TestDockerHandler_ListContainers_Local(t *testing.T) { // Test local/default docker connection (empty host parameter) gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{ ret: []services.DockerContainer{ { ID: "abc123456789", Names: []string{"test-container"}, Image: "nginx:latest", State: "running", Status: "Up 2 hours", Network: "bridge", IP: "172.17.0.2", Ports: []services.DockerPort{ {PrivatePort: 80, PublicPort: 8080, Type: "tcp"}, }, }, }, } remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.True(t, dockerSvc.called) assert.Empty(t, dockerSvc.host, "local connection should have empty host") assert.Contains(t, w.Body.String(), "test-container") assert.Contains(t, w.Body.String(), "nginx:latest") } func TestDockerHandler_ListContainers_RemoteServerSuccess(t *testing.T) { // Test successful remote server connection via server_id gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{ ret: []services.DockerContainer{ { ID: "remote123", Names: []string{"remote-nginx"}, Image: "nginx:alpine", State: "running", Status: "Up 1 day", }, }, } remoteSvc := &fakeRemoteServerService{ server: &models.RemoteServer{ UUID: "server-uuid-123", Name: "Production Server", Host: "192.168.1.100", Port: 2376, }, } h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=server-uuid-123", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) require.True(t, dockerSvc.called) assert.Equal(t, "server-uuid-123", remoteSvc.gotUUID) assert.Equal(t, "tcp://192.168.1.100:2376", dockerSvc.host) assert.Contains(t, w.Body.String(), "remote-nginx") } func TestDockerHandler_ListContainers_RemoteServerNotFound(t *testing.T) { // Test server_id that doesn't exist in database gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{} remoteSvc := &fakeRemoteServerService{ err: errors.New("server not found"), } h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?server_id=nonexistent-uuid", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) assert.False(t, dockerSvc.called, "docker service should not be called when server not found") assert.Contains(t, w.Body.String(), "Remote server not found") } func TestDockerHandler_ListContainers_InvalidHost(t *testing.T) { // Test SSRF protection: reject arbitrary host values gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{} remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) tests := []struct { name string hostParam string }{ {"arbitrary IP", "host=10.0.0.1"}, {"tcp URL", "host=tcp://evil.com:2375"}, {"unix socket", "host=unix:///var/run/docker.sock"}, {"http URL", "host=http://attacker.com/"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?"+tt.hostParam, http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code, "should reject invalid host: %s", tt.hostParam) assert.Contains(t, w.Body.String(), "Invalid docker host selector") assert.False(t, dockerSvc.called, "docker service should not be called for invalid host") }) } } func TestDockerHandler_ListContainers_DockerUnavailable(t *testing.T) { // Test various Docker unavailability scenarios tests := []struct { name string err error wantCode int wantMsg string }{ { name: "daemon not running", err: services.NewDockerUnavailableError(errors.New("cannot connect to docker daemon")), wantCode: http.StatusServiceUnavailable, wantMsg: "Docker daemon unavailable", }, { name: "socket permission denied", err: services.NewDockerUnavailableError(errors.New("permission denied")), wantCode: http.StatusServiceUnavailable, wantMsg: "Docker daemon unavailable", }, { name: "socket not found", err: services.NewDockerUnavailableError(errors.New("no such file or directory")), wantCode: http.StatusServiceUnavailable, wantMsg: "Docker daemon unavailable", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{err: tt.err} remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers?host=local", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tt.wantCode, w.Code) assert.Contains(t, w.Body.String(), tt.wantMsg) assert.True(t, dockerSvc.called) }) } } func TestDockerHandler_ListContainers_GenericError(t *testing.T) { // Test non-connectivity errors (should return 500) tests := []struct { name string err error wantCode int wantMsg string }{ { name: "API error", err: errors.New("API error: invalid request"), wantCode: http.StatusInternalServerError, wantMsg: "Failed to list containers", }, { name: "context cancelled", err: context.Canceled, wantCode: http.StatusInternalServerError, wantMsg: "Failed to list containers", }, { name: "unknown error", err: errors.New("unexpected error occurred"), wantCode: http.StatusInternalServerError, wantMsg: "Failed to list containers", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gin.SetMode(gin.TestMode) router := gin.New() dockerSvc := &fakeDockerService{err: tt.err} remoteSvc := &fakeRemoteServerService{} h := NewDockerHandler(dockerSvc, remoteSvc) api := router.Group("/api/v1") h.RegisterRoutes(api) req := httptest.NewRequest(http.MethodGet, "/api/v1/docker/containers", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tt.wantCode, w.Code) assert.Contains(t, w.Body.String(), tt.wantMsg) assert.True(t, dockerSvc.called) }) } }