<?php namespace Codeception\Module; use Codeception\TestInterface; /** * * Works with SFTP/FTP servers. * * In order to test the contents of a specific file stored on any remote FTP/SFTP system * this module downloads a temporary file to the local system. The temporary directory is * defined by default as ```tests/_data``` to specify a different directory set the tmp config * option to your chosen path. * * Don't forget to create the folder and ensure its writable. * * Supported and tested FTP types are: * * * FTP * * SFTP * * Connection uses php build in FTP client for FTP, * connection to SFTP uses [phpseclib](http://phpseclib.sourceforge.net/) pulled in using composer. * * For SFTP, add [phpseclib](http://phpseclib.sourceforge.net/) to require list. * ``` * "require": { * "phpseclib/phpseclib": "0.3.6" * } * ``` * * ## Status * * * Maintainer: **nathanmac** * * Stability: * - FTP: **stable** * - SFTP: **stable** * * Contact: nathan.macnamara@outlook.com * * ## Config * * * type: ftp - type of connection ftp/sftp (defaults to ftp). * * host *required* - hostname/ip address of the ftp server. * * port: 21 - port number for the ftp server * * timeout: 90 - timeout settings for connecting the ftp server. * * user: anonymous - user to access ftp server, defaults to anonymous authentication. * * password - password, defaults to empty for anonymous. * * key - path to RSA key for sftp. * * tmp - path to local directory for storing tmp files. * * passive: true - Turns on or off passive mode (FTP only) * * cleanup: true - remove tmp files from local directory on completion. * * ### Example * #### Example (FTP) * * modules: * enabled: [FTP] * config: * FTP: * type: ftp * host: '127.0.0.1' * port: 21 * timeout: 120 * user: 'root' * password: 'root' * key: ~/.ssh/id_rsa * tmp: 'tests/_data/ftp' * passive: true * cleanup: false * * #### Example (SFTP) * * modules: * enabled: [FTP] * config: * FTP: * type: sftp * host: '127.0.0.1' * port: 22 * timeout: 120 * user: 'root' * password: 'root' * key: '' * tmp: 'tests/_data/ftp' * cleanup: false * * * This module extends the Filesystem module, file contents methods are inherited from this module. */ class FTP extends Filesystem { /** * FTP/SFTP connection handler */ protected $ftp = null; /** * Configuration options and default settings * * @var array */ protected $config = [ 'type' => 'ftp', 'port' => 21, 'timeout' => 90, 'user' => 'anonymous', 'password' => '', 'key' => '', 'tmp' => 'tests/_data', 'passive' => false, 'cleanup' => true ]; /** * Required configuration fields * * @var array */ protected $requiredFields = ['host']; // ----------- SETUP METHODS BELOW HERE -------------------------// /** * Setup connection and login with config settings * * @param \Codeception\TestInterface $test */ public function _before(TestInterface $test) { // Login using config settings $this->loginAs($this->config['user'], $this->config['password']); } /** * Close the FTP connection & Clear up */ public function _after(TestInterface $test) { $this->_closeConnection(); // Clean up temp files if ($this->config['cleanup']) { if (file_exists($this->config['tmp'] . '/ftp_data_file.tmp')) { unlink($this->config['tmp'] . '/ftp_data_file.tmp'); } } } /** * Change the logged in user mid-way through your test, this closes the * current connection to the server and initialises and new connection. * * On initiation of this modules you are automatically logged into * the server using the specified config options or defaulted * to anonymous user if not provided. * * ``` php * <?php * $I->loginAs('user','password'); * ?> * ``` * * @param String $user * @param String $password */ public function loginAs($user = 'anonymous', $password = '') { $this->_openConnection($user, $password); // Create new connection and login. } /** * Enters a directory on the ftp system - FTP root directory is used by default * * @param $path */ public function amInPath($path) { $this->_changeDirectory($this->path = $this->absolutizePath($path) . ($path == '/' ? '' : DIRECTORY_SEPARATOR)); $this->debug('Moved to ' . $this->path); } /** * Resolve path * * @param $path * @return string */ protected function absolutizePath($path) { if (strpos($path, '/') === 0) { return $path; } return $this->path . $path; } // ----------- SEARCH METHODS BELOW HERE ------------------------// /** * Checks if file exists in path on the remote FTP/SFTP system. * DOES NOT OPEN the file when it's exists * * ``` php * <?php * $I->seeFileFound('UserModel.php','app/models'); * ?> * ``` * * @param $filename * @param string $path */ public function seeFileFound($filename, $path = '') { $files = $this->grabFileList($path); $this->debug("see file: {$filename}"); $this->assertContains($filename, $files, "file {$filename} not found in {$path}"); } /** * Checks if file exists in path on the remote FTP/SFTP system, using regular expression as filename. * DOES NOT OPEN the file when it's exists * * ``` php * <?php * $I->seeFileFoundMatches('/^UserModel_([0-9]{6}).php$/','app/models'); * ?> * ``` * * @param $regex * @param string $path */ public function seeFileFoundMatches($regex, $path = '') { foreach ($this->grabFileList($path) as $filename) { preg_match($regex, $filename, $matches); if (!empty($matches)) { $this->debug("file '{$filename}' matches '{$regex}'"); return; } } $this->fail("no file matches found for '{$regex}'"); } /** * Checks if file does not exist in path on the remote FTP/SFTP system * * @param $filename * @param string $path */ public function dontSeeFileFound($filename, $path = '') { $files = $this->grabFileList($path); $this->debug("don't see file: {$filename}"); $this->assertNotContains($filename, $files); } /** * Checks if file does not exist in path on the remote FTP/SFTP system, using regular expression as filename. * DOES NOT OPEN the file when it's exists * * @param $regex * @param string $path */ public function dontSeeFileFoundMatches($regex, $path = '') { foreach ($this->grabFileList($path) as $filename) { preg_match($regex, $filename, $matches); if (!empty($matches)) { $this->fail("file matches found for {$regex}"); } } $this->assertTrue(true); $this->debug("no files match '{$regex}'"); } // ----------- UTILITY METHODS BELOW HERE -------------------------// /** * Opens a file (downloads from the remote FTP/SFTP system to a tmp directory for processing) * and stores it's content. * * Usage: * * ``` php * <?php * $I->openFile('composer.json'); * $I->seeInThisFile('codeception/codeception'); * ?> * ``` * * @param $filename */ public function openFile($filename) { $this->_openFile($this->absolutizePath($filename)); } /** * Saves contents to tmp file and uploads the FTP/SFTP system. * Overwrites current file on server if exists. * * ``` php * <?php * $I->writeToFile('composer.json', 'some data here'); * ?> * ``` * * @param $filename * @param $contents */ public function writeToFile($filename, $contents) { $this->_writeToFile($this->absolutizePath($filename), $contents); } /** * Create a directory on the server * * ``` php * <?php * $I->makeDir('vendor'); * ?> * ``` * * @param $dirname */ public function makeDir($dirname) { $this->makeDirectory($this->absolutizePath($dirname)); } /** * Currently not supported in this module, overwrite inherited method * * @param $src * @param $dst */ public function copyDir($src, $dst) { $this->fail('copyDir() currently unsupported by FTP module'); } /** * Rename/Move file on the FTP/SFTP server * * ``` php * <?php * $I->renameFile('composer.lock', 'composer_old.lock'); * ?> * ``` * * @param $filename * @param $rename */ public function renameFile($filename, $rename) { $this->renameDirectory($this->absolutizePath($filename), $this->absolutizePath($rename)); } /** * Rename/Move directory on the FTP/SFTP server * * ``` php * <?php * $I->renameDir('vendor', 'vendor_old'); * ?> * ``` * * @param $dirname * @param $rename */ public function renameDir($dirname, $rename) { $this->renameDirectory($this->absolutizePath($dirname), $this->absolutizePath($rename)); } /** * Deletes a file on the remote FTP/SFTP system * * ``` php * <?php * $I->deleteFile('composer.lock'); * ?> * ``` * * @param $filename */ public function deleteFile($filename) { $this->delete($this->absolutizePath($filename)); } /** * Deletes directory with all subdirectories on the remote FTP/SFTP server * * ``` php * <?php * $I->deleteDir('vendor'); * ?> * ``` * * @param $dirname */ public function deleteDir($dirname) { $this->delete($this->absolutizePath($dirname)); } /** * Erases directory contents on the FTP/SFTP server * * ``` php * <?php * $I->cleanDir('logs'); * ?> * ``` * * @param $dirname */ public function cleanDir($dirname) { $this->clearDirectory($this->absolutizePath($dirname)); } // ----------- GRABBER METHODS BELOW HERE -----------------------// /** * Grabber method for returning file/folders listing in an array * * ```php * <?php * $files = $I->grabFileList(); * $count = $I->grabFileList('TEST', false); // Include . .. .thumbs.db * ?> * ``` * * @param string $path * @param bool $ignore - suppress '.', '..' and '.thumbs.db' * @return array */ public function grabFileList($path = '', $ignore = true) { $absolutize_path = $this->absolutizePath($path) . ($path != '' && substr($path, -1) != '/' ? DIRECTORY_SEPARATOR : ''); $files = $this->_listFiles($absolutize_path); $display_files = []; if (is_array($files) && !empty($files)) { $this->debug('File List:'); foreach ($files as &$file) { if (strtolower($file) != '.' && strtolower($file) != '..' && strtolower($file) != 'thumbs.db' ) { // Ignore '.', '..' and 'thumbs.db' // Replace full path from file listings if returned in listing $file = str_replace( $absolutize_path, '', $file ); $display_files[] = $file; $this->debug(' - ' . $file); } } return $ignore ? $display_files : $files; } $this->debug("File List: <empty>"); return []; } /** * Grabber method for returning file/folders count in directory * * ```php * <?php * $count = $I->grabFileCount(); * $count = $I->grabFileCount('TEST', false); // Include . .. .thumbs.db * ?> * ``` * * @param string $path * @param bool $ignore - suppress '.', '..' and '.thumbs.db' * @return int */ public function grabFileCount($path = '', $ignore = true) { $count = count($this->grabFileList($path, $ignore)); $this->debug("File Count: {$count}"); return $count; } /** * Grabber method to return file size * * ```php * <?php * $size = $I->grabFileSize('test.txt'); * ?> * ``` * * @param $filename * @return bool */ public function grabFileSize($filename) { $fileSize = $this->size($filename); $this->debug("{$filename} has a file size of {$fileSize}"); return $fileSize; } /** * Grabber method to return last modified timestamp * * ```php * <?php * $time = $I->grabFileModified('test.txt'); * ?> * ``` * * @param $filename * @return bool */ public function grabFileModified($filename) { $time = $this->modified($filename); $this->debug("{$filename} was last modified at {$time}"); return $time; } /** * Grabber method to return current working directory * * ```php * <?php * $pwd = $I->grabDirectory(); * ?> * ``` * * @return string */ public function grabDirectory() { $pwd = $this->_directory(); $this->debug("PWD: {$pwd}"); return $pwd; } // ----------- SERVER CONNECTION METHODS BELOW HERE -------------// /** * Open a new FTP/SFTP connection and authenticate user. * * @param string $user * @param string $password */ private function _openConnection($user = 'anonymous', $password = '') { $this->_closeConnection(); // Close connection if already open if ($this->isSFTP()) { $this->sftpConnect($user, $password); } else { $this->ftpConnect($user, $password); } $pwd = $this->grabDirectory(); $this->path = $pwd . ($pwd == '/' ? '' : DIRECTORY_SEPARATOR); } /** * Close open FTP/SFTP connection */ private function _closeConnection() { if (!$this->ftp) { return; } if (!$this->isSFTP()) { ftp_close($this->ftp); $this->ftp = null; } } /** * Get the file listing for FTP/SFTP connection * * @param String $path * @return array */ private function _listFiles($path) { if ($this->isSFTP()) { $files = @$this->ftp->nlist($path); } else { $files = @ftp_nlist($this->ftp, $path); } if ($files === false) { $this->fail("couldn't list files"); } return $files; } /** * Get the current directory for the FTP/SFTP connection * * @return string */ private function _directory() { if ($this->isSFTP()) { // == DIRECTORY_SEPARATOR ? '' : $pwd; $pwd = @$this->ftp->pwd(); } else { $pwd = @ftp_pwd($this->ftp); } if (!$pwd) { $this->fail("couldn't get current directory"); } } /** * Change the working directory on the FTP/SFTP server * * @param $path */ private function _changeDirectory($path) { if ($this->isSFTP()) { $changed = @$this->ftp->chdir($path); } else { $changed = @ftp_chdir($this->ftp, $path); } if (!$changed) { $this->fail("couldn't change directory {$path}"); } } /** * Download remote file to local tmp directory and open contents. * * @param $filename */ private function _openFile($filename) { // Check local tmp directory if (!is_dir($this->config['tmp']) || !is_writeable($this->config['tmp'])) { $this->fail('tmp directory not found or is not writable'); } // Download file to local tmp directory $tmp_file = $this->config['tmp'] . "/ftp_data_file.tmp"; if ($this->isSFTP()) { $downloaded = @$this->ftp->get($filename, $tmp_file); } else { $downloaded = @ftp_get($this->ftp, $tmp_file, $filename, FTP_BINARY); } if (!$downloaded) { $this->fail('failed to download file to tmp directory'); } // Open file content to variable if ($this->file = file_get_contents($tmp_file)) { $this->filepath = $filename; } else { $this->fail('failed to open tmp file'); } } /** * Write data to local tmp file and upload to server * * @param $filename * @param $contents */ private function _writeToFile($filename, $contents) { // Check local tmp directory if (!is_dir($this->config['tmp']) || !is_writeable($this->config['tmp'])) { $this->fail('tmp directory not found or is not writable'); } // Build temp file $tmp_file = $this->config['tmp'] . "/ftp_data_file.tmp"; file_put_contents($tmp_file, $contents); // Update variables $this->filepath = $tmp_file; $this->file = $contents; // Upload the file to server if ($this->isSFTP()) { $uploaded = @$this->ftp->put($filename, $tmp_file, NET_SFTP_LOCAL_FILE); } else { $uploaded = ftp_put($this->ftp, $filename, $tmp_file, FTP_BINARY); } if (!$uploaded) { $this->fail('failed to upload file to server'); } } /** * Make new directory on server * * @param $path */ private function makeDirectory($path) { if ($this->isSFTP()) { $created = @$this->ftp->mkdir($path, true); } else { $created = @ftp_mkdir($this->ftp, $path); } if (!$created) { $this->fail("couldn't make directory {$path}"); } $this->debug("Make directory: {$path}"); } /** * Rename/Move directory/file on server * * @param $path * @param $rename */ private function renameDirectory($path, $rename) { if ($this->isSFTP()) { $renamed = @$this->ftp->rename($path, $rename); } else { $renamed = @ftp_rename($this->ftp, $path, $rename); } if (!$renamed) { $this->fail("couldn't rename directory {$path} to {$rename}"); } $this->debug("Renamed directory: {$path} to {$rename}"); } /** * Delete file on server * * @param $filename */ private function delete($filename, $isDir = false) { if ($this->isSFTP()) { $deleted = @$this->ftp->delete($filename, $isDir); } else { $deleted = @$this->ftpDelete($filename); } if (!$deleted) { $this->fail("couldn't delete {$filename}"); } $this->debug("Deleted: {$filename}"); } /** * Function to recursively delete folder, used for PHP FTP build in client. * * @param $directory * @return bool */ private function ftpDelete($directory) { // here we attempt to delete the file/directory if (!(@ftp_rmdir($this->ftp, $directory) || @ftp_delete($this->ftp, $directory))) { // if the attempt to delete fails, get the file listing $filelist = @ftp_nlist($this->ftp, $directory); // loop through the file list and recursively delete the FILE in the list foreach ($filelist as $file) { $this->ftpDelete($file); } // if the file list is empty, delete the DIRECTORY we passed $this->ftpDelete($directory); } return true; } /** * Clear directory on server of all content * * @param $path */ private function clearDirectory($path) { $this->debug("Clear directory: {$path}"); $this->delete($path); $this->makeDirectory($path); } /** * Return the size of a given file * * @param $filename * @return bool */ private function size($filename) { if ($this->isSFTP()) { $size = (int)@$this->ftp->size($filename); } else { $size = @ftp_size($this->ftp, $filename); } if ($size > 0) { return $size; } $this->fail("couldn't get the file size for {$filename}"); } /** * Return the last modified time of a given file * * @param $filename * @return bool */ private function modified($filename) { if ($this->isSFTP()) { $info = @$this->ftp->lstat($filename); if ($info) { return $info['mtime']; } } else { if ($time = @ftp_mdtm($this->ftp, $filename)) { return $time; } } $this->fail("couldn't get the file size for {$filename}"); } /** * @param $user * @param $password */ protected function sftpConnect($user, $password) { $this->ftp = new \Net_SFTP($this->config['host'], $this->config['port'], $this->config['timeout']); if ($this->ftp === false) { $this->ftp = null; $this->fail('failed to connect to ftp server'); } if (isset($this->config['key'])) { $keyFile = file_get_contents($this->config['key']); $password = new \Crypt_RSA(); $password->loadKey($keyFile); } if (!$this->ftp->login($user, $password)) { $this->fail('failed to authenticate user'); } } /** * @param $user * @param $password */ protected function ftpConnect($user, $password) { $this->ftp = ftp_connect($this->config['host'], $this->config['port'], $this->config['timeout']); if ($this->ftp === false) { $this->ftp = null; $this->fail('failed to connect to ftp server'); } // Login using given access details if (!@ftp_login($this->ftp, $user, $password)) { $this->fail('failed to authenticate user'); } // Set passive mode option (ftp only option) if (isset($this->config['passive'])) { ftp_pasv($this->ftp, $this->config['passive']); } } protected function isSFTP() { return strtolower($this->config['type']) == 'sftp'; } }