package infra import ( "encoding/binary" "encoding/json" "net" "net/http" "strings" "testing" "time" ) // newUnixDockerServer levanta un httptest-style server en un socket Unix temporal. // Retorna el server (ya iniciado) y el path al socket. func newUnixDockerServer(t *testing.T, handler http.Handler) (socketPath string) { t.Helper() socketPath = t.TempDir() + "/docker_exec_test.sock" ln, err := net.Listen("unix", socketPath) if err != nil { t.Fatalf("listen unix %s: %v", socketPath, err) } srv := &http.Server{Handler: handler} go srv.Serve(ln) //nolint:errcheck t.Cleanup(func() { srv.Close() ln.Close() }) return socketPath } // buildDockerExecHandler construye un http.Handler que simula el flujo completo del // Docker Engine API para exec: create → start (stream multiplexado) → inspect. func buildDockerExecHandler(t *testing.T, exitCode int, stdoutPayload, stderrPayload string, delayStart time.Duration) http.Handler { t.Helper() const fakeExecID = "deadbeefcafe" // Construir stream multiplexado de Docker (Tty=false). // Frame: [type(1)] [0 0 0(3)] [size big-endian uint32(4)] [payload] buildFrame := func(streamType byte, payload string) []byte { if payload == "" { return nil } b := make([]byte, 8+len(payload)) b[0] = streamType binary.BigEndian.PutUint32(b[4:8], uint32(len(payload))) copy(b[8:], payload) return b } var streamBody []byte streamBody = append(streamBody, buildFrame(1, stdoutPayload)...) streamBody = append(streamBody, buildFrame(2, stderrPayload)...) mux := http.NewServeMux() // POST /containers/{id}/exec — devuelve ExecID. mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/exec") { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"Id": fakeExecID}) //nolint:errcheck }) // POST /exec/{id}/start — transmite stream multiplexado. // GET /exec/{id}/json — devuelve ExitCode. mux.HandleFunc("/exec/", func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/start") && r.Method == http.MethodPost: if delayStart > 0 { select { case <-r.Context().Done(): http.Error(w, "cancelled", http.StatusGatewayTimeout) return case <-time.After(delayStart): } } w.WriteHeader(http.StatusOK) if len(streamBody) > 0 { w.Write(streamBody) //nolint:errcheck } case strings.HasSuffix(r.URL.Path, "/json") && r.Method == http.MethodGet: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]int{"ExitCode": exitCode}) //nolint:errcheck default: http.NotFound(w, r) } }) return mux } func TestDockerContainerExec(t *testing.T) { const containerID = "abc123container" t.Run("binario en whitelist exitcode 0 stdout stderr capturados", func(t *testing.T) { handler := buildDockerExecHandler(t, 0, "hello stdout\n", "hello stderr\n", 0) socketPath := newUnixDockerServer(t, handler) result, err := DockerContainerExec(DockerExecOpts{ ContainerID: containerID, Cmd: []string{"ls", "-la"}, BinariesAllowed: []string{"ls", "cat", "echo"}, DockerHost: "unix://" + socketPath, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.ExitCode != 0 { t.Errorf("ExitCode: got %d, want 0", result.ExitCode) } if result.Stdout != "hello stdout\n" { t.Errorf("Stdout: got %q, want %q", result.Stdout, "hello stdout\n") } if result.Stderr != "hello stderr\n" { t.Errorf("Stderr: got %q, want %q", result.Stderr, "hello stderr\n") } if result.Duration < 0 { t.Errorf("Duration should be >= 0, got %d", result.Duration) } }) t.Run("binario NO en whitelist error sin contactar engine", func(t *testing.T) { // No levantamos server: si se contacta el engine, falla con connection refused. // La validacion debe fallar ANTES de intentar conectar. _, err := DockerContainerExec(DockerExecOpts{ ContainerID: containerID, Cmd: []string{"bash", "-c", "rm -rf /"}, BinariesAllowed: []string{"ls", "cat"}, DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock", }) if err == nil { t.Fatal("expected error for binary not in whitelist, got nil") } if !strings.Contains(err.Error(), "not in whitelist") { t.Errorf("error should mention whitelist, got: %v", err) } }) t.Run("whitelist vacia rechaza todo", func(t *testing.T) { _, err := DockerContainerExec(DockerExecOpts{ ContainerID: containerID, Cmd: []string{"ls"}, BinariesAllowed: []string{}, DockerHost: "unix:///tmp/nonexistent-docker-for-test.sock", }) if err == nil { t.Fatal("expected error for empty whitelist, got nil") } if !strings.Contains(err.Error(), "no binaries whitelisted") { t.Errorf("error should mention empty whitelist, got: %v", err) } }) t.Run("timeout simulado", func(t *testing.T) { // El handler demora 2s en /start, el timeout de la funcion es 1s. handler := buildDockerExecHandler(t, 0, "should not arrive", "", 2*time.Second) socketPath := newUnixDockerServer(t, handler) done := make(chan error, 1) go func() { _, err := DockerContainerExec(DockerExecOpts{ ContainerID: containerID, Cmd: []string{"sleep"}, BinariesAllowed: []string{"sleep"}, DockerHost: "unix://" + socketPath, TimeoutSeconds: 1, }) done <- err }() select { case err := <-done: if err == nil { t.Fatal("expected timeout error, got nil") } // Cualquier error de contexto/red es valido (context deadline exceeded, connection reset, etc.) case <-time.After(5 * time.Second): t.Fatal("test itself timed out — DockerContainerExec did not respect TimeoutSeconds") } }) }