package services import ( "context" "errors" "fmt" "net" "net/url" "os" "path/filepath" "strings" "syscall" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDockerService_New(t *testing.T) { // NewDockerService now always returns a service (never nil) // If Docker is unavailable, the service will have initErr set svc := NewDockerService() assert.NotNil(t, svc, "NewDockerService should always return a non-nil service") // If Docker is unavailable, the service should have an initErr if svc.initErr != nil { t.Logf("Docker service initialized but Docker is unavailable: %v", svc.initErr) } } func TestDockerService_ListContainers(t *testing.T) { svc := NewDockerService() assert.NotNil(t, svc) // Test local listing containers, err := svc.ListContainers(context.Background(), "") // If service has initErr, it should return DockerUnavailableError if svc.initErr != nil { var unavailableErr *DockerUnavailableError assert.ErrorAs(t, err, &unavailableErr, "Should return DockerUnavailableError when Docker is not available") t.Logf("Docker unavailable (expected in some environments): %v", err) return } // If we can connect to docker daemon, this should succeed if err == nil { assert.IsType(t, []DockerContainer{}, containers) } } func TestDockerUnavailableError_ErrorMethods(t *testing.T) { // Test NewDockerUnavailableError with base error baseErr := errors.New("socket not found") err := NewDockerUnavailableError(baseErr) // Test Error() method assert.Contains(t, err.Error(), "docker unavailable") assert.Contains(t, err.Error(), "socket not found") // Test Unwrap() unwrapped := err.Unwrap() assert.Equal(t, baseErr, unwrapped) // Test Details() errWithDetails := NewDockerUnavailableError(baseErr, "socket permission mismatch") assert.Equal(t, "socket permission mismatch", errWithDetails.Details()) // Test nil receiver cases var nilErr *DockerUnavailableError assert.Equal(t, "docker unavailable", nilErr.Error()) assert.Nil(t, nilErr.Unwrap()) // Test nil base error nilBaseErr := NewDockerUnavailableError(nil) assert.Equal(t, "docker unavailable", nilBaseErr.Error()) assert.Nil(t, nilBaseErr.Unwrap()) assert.Equal(t, "", nilBaseErr.Details()) } func TestIsDockerConnectivityError(t *testing.T) { tests := []struct { name string err error expected bool }{ {"nil error", nil, false}, {"daemon not running", errors.New("cannot connect to the docker daemon"), true}, {"daemon running check", errors.New("is the docker daemon running"), true}, {"error during connect", errors.New("error during connect: test"), true}, {"connection refused", syscall.ECONNREFUSED, true}, {"no such file", os.ErrNotExist, true}, {"context timeout", context.DeadlineExceeded, true}, {"permission denied - EACCES", syscall.EACCES, true}, {"permission denied - EPERM", syscall.EPERM, true}, {"no entry - ENOENT", syscall.ENOENT, true}, {"random error", errors.New("random error"), false}, {"empty error", errors.New(""), false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := isDockerConnectivityError(tt.err) assert.Equal(t, tt.expected, result, "isDockerConnectivityError(%v) = %v, want %v", tt.err, result, tt.expected) }) } } // ============== Phase 3.1: Additional Docker Service Tests ============== func TestIsDockerConnectivityError_URLError(t *testing.T) { // Test wrapped url.Error innerErr := errors.New("connection refused") urlErr := &url.Error{ Op: "Get", URL: "https://discord.com/api/webhooks/123/abc", Err: innerErr, } result := isDockerConnectivityError(urlErr) // Should unwrap and process the inner error assert.False(t, result, "url.Error wrapping non-connectivity error should return false") // Test url.Error wrapping ECONNREFUSED urlErrWithSyscall := &url.Error{ Op: "dial", URL: "unix:///var/run/docker.sock", Err: syscall.ECONNREFUSED, } result = isDockerConnectivityError(urlErrWithSyscall) assert.True(t, result, "url.Error wrapping ECONNREFUSED should return true") } func TestIsDockerConnectivityError_OpError(t *testing.T) { // Test wrapped net.OpError opErr := &net.OpError{ Op: "dial", Net: "unix", Err: syscall.ENOENT, } result := isDockerConnectivityError(opErr) assert.True(t, result, "net.OpError wrapping ENOENT should return true") } func TestIsDockerConnectivityError_SyscallError(t *testing.T) { // Test wrapped os.SyscallError syscallErr := &os.SyscallError{ Syscall: "connect", Err: syscall.ECONNREFUSED, } result := isDockerConnectivityError(syscallErr) assert.True(t, result, "os.SyscallError wrapping ECONNREFUSED should return true") } // Implement net.Error interface for timeoutError type timeoutError struct { timeout bool temporary bool } func (e *timeoutError) Error() string { return "timeout" } func (e *timeoutError) Timeout() bool { return e.timeout } func (e *timeoutError) Temporary() bool { return e.temporary } func TestIsDockerConnectivityError_NetErrorTimeout(t *testing.T) { // Create a mock net.Error with Timeout() err := &timeoutError{timeout: true, temporary: true} // Wrap it to ensure it implements net.Error var netErr net.Error = err result := isDockerConnectivityError(netErr) assert.True(t, result, "net.Error with Timeout() should return true") } func TestResolveLocalDockerHost_IgnoresRemoteTCPEnv(t *testing.T) { t.Setenv("DOCKER_HOST", "tcp://docker-proxy:2375") host := resolveLocalDockerHost() assert.Equal(t, "unix:///var/run/docker.sock", host) } func TestResolveLocalDockerHost_UsesExistingUnixSocketFromEnv(t *testing.T) { tmpDir := t.TempDir() socketFile := filepath.Join(tmpDir, "docker.sock") require.NoError(t, os.WriteFile(socketFile, []byte(""), 0o600)) t.Setenv("DOCKER_HOST", "unix://"+socketFile) host := resolveLocalDockerHost() assert.Equal(t, "unix://"+socketFile, host) } func TestBuildLocalDockerUnavailableDetails_PermissionDeniedIncludesGroupHint(t *testing.T) { err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES} details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") assert.Contains(t, details, "not accessible") assert.Contains(t, details, "uid=") assert.Contains(t, details, "gid=") assert.NotContains(t, strings.ToLower(details), "token") // When docker socket exists with a GID not in process groups, verify both // CLI and compose supplemental-group guidance are present. if strings.Contains(details, "--group-add") { assert.Contains(t, details, "group_add", "when supplemental group hint is present, it should include compose group_add syntax") } } func TestBuildLocalDockerUnavailableDetails_MissingSocket(t *testing.T) { err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT} host := "unix:///tmp/nonexistent-docker.sock" details := buildLocalDockerUnavailableDetails(err, host) assert.Contains(t, details, "not found") assert.Contains(t, details, "/tmp/nonexistent-docker.sock") assert.Contains(t, details, host) assert.Contains(t, details, "Mount", "ENOENT path should include mount guidance") } func TestBuildLocalDockerUnavailableDetails_PermissionDeniedSocketGIDInGroups(t *testing.T) { // Temp file GID = our primary GID (already in process groups) → no group hint tmpDir := t.TempDir() socketFile := filepath.Join(tmpDir, "docker.sock") require.NoError(t, os.WriteFile(socketFile, []byte(""), 0o660)) host := "unix://" + socketFile err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES} details := buildLocalDockerUnavailableDetails(err, host) assert.Contains(t, details, "not accessible") assert.Contains(t, details, "uid=") assert.NotContains(t, details, "--group-add", "group-add hint should not appear when socket GID is already in process groups") } func TestBuildLocalDockerUnavailableDetails_PermissionDeniedStatFails(t *testing.T) { // EACCES with a socket path that doesn't exist → stat fails err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EACCES} details := buildLocalDockerUnavailableDetails(err, "unix:///tmp/nonexistent-stat-fail.sock") assert.Contains(t, details, "not accessible") assert.Contains(t, details, "could not be stat") } func TestBuildLocalDockerUnavailableDetails_ConnectionRefused(t *testing.T) { err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.ECONNREFUSED} details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") assert.Contains(t, details, "not accepting connections") } func TestBuildLocalDockerUnavailableDetails_GenericError(t *testing.T) { err := errors.New("some unknown docker error") details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") assert.Contains(t, details, "Cannot connect") assert.Contains(t, details, "uid=") assert.Contains(t, details, "gid=") } // ===== Additional coverage for uncovered paths ===== func TestDockerUnavailableError_NilDetails(t *testing.T) { var nilErr *DockerUnavailableError assert.Equal(t, "", nilErr.Details()) } func TestExtractErrno_UrlErrorWrapping(t *testing.T) { urlErr := &url.Error{Op: "dial", URL: "unix:///var/run/docker.sock", Err: syscall.EACCES} errno, ok := extractErrno(urlErr) assert.True(t, ok) assert.Equal(t, syscall.EACCES, errno) } func TestExtractErrno_SyscallError(t *testing.T) { scErr := &os.SyscallError{Syscall: "connect", Err: syscall.ECONNREFUSED} errno, ok := extractErrno(scErr) assert.True(t, ok) assert.Equal(t, syscall.ECONNREFUSED, errno) } func TestExtractErrno_NilError(t *testing.T) { _, ok := extractErrno(nil) assert.False(t, ok) } func TestExtractErrno_NonSyscallError(t *testing.T) { _, ok := extractErrno(errors.New("some generic error")) assert.False(t, ok) } func TestExtractErrno_OpErrorWrapping(t *testing.T) { opErr := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} errno, ok := extractErrno(opErr) assert.True(t, ok) assert.Equal(t, syscall.EPERM, errno) } func TestExtractErrno_NestedUrlSyscallOpError(t *testing.T) { innerErr := &net.OpError{ Op: "dial", Net: "unix", Err: &os.SyscallError{Syscall: "connect", Err: syscall.EACCES}, } urlErr := &url.Error{Op: "Get", URL: "unix:///var/run/docker.sock", Err: innerErr} errno, ok := extractErrno(urlErr) assert.True(t, ok) assert.Equal(t, syscall.EACCES, errno) } func TestSocketPathFromDockerHost(t *testing.T) { tests := []struct { name string host string expected string }{ {"unix socket", "unix:///var/run/docker.sock", "/var/run/docker.sock"}, {"tcp host", "tcp://192.168.1.1:2375", ""}, {"empty", "", ""}, {"whitespace unix", " unix:///tmp/docker.sock ", "/tmp/docker.sock"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := socketPathFromDockerHost(tt.host) assert.Equal(t, tt.expected, result) }) } } func TestBuildLocalDockerUnavailableDetails_OsErrNotExist(t *testing.T) { err := fmt.Errorf("wrapped: %w", os.ErrNotExist) details := buildLocalDockerUnavailableDetails(err, "unix:///var/run/docker.sock") assert.Contains(t, details, "not found") assert.Contains(t, details, "/var/run/docker.sock") } func TestBuildLocalDockerUnavailableDetails_NonUnixHost(t *testing.T) { err := errors.New("cannot connect") details := buildLocalDockerUnavailableDetails(err, "tcp://192.168.1.1:2375") assert.Contains(t, details, "Cannot connect") assert.Contains(t, details, "tcp://192.168.1.1:2375") } func TestBuildLocalDockerUnavailableDetails_EPERMWithStatFail(t *testing.T) { err := &net.OpError{Op: "dial", Net: "unix", Err: syscall.EPERM} details := buildLocalDockerUnavailableDetails(err, "unix:///tmp/nonexistent-eperm.sock") assert.Contains(t, details, "not accessible") assert.Contains(t, details, "could not be stat") }