+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class output_http_provider extends output_provider {
+
+ /**
+ * Prints help on the script usage.
+ */
+ public function help() {
+ // No help available via HTTP
+ }
+
+ /**
+ * Display the information about uncaught exception
+ *
+ * @param Exception $e uncaught exception
+ */
+ public function exception(Exception $e) {
+ $this->start_output();
+ echo('Oops! It did it again
');
+ echo('Moodle deployment utility had a trouble with your request. See the debugging information for more details.
');
+ echo('');
+ echo exception_handlers::format_exception_info($e);
+ echo('');
+ $this->end_output();
+ }
+
+ // End of external API
+
+ /**
+ * Produce the HTML page header
+ */
+ protected function start_output() {
+ echo '
+
+
+
+
+
+
+';
+ }
+
+ /**
+ * Produce the HTML page footer
+ */
+ protected function end_output() {
+ echo '
';
+ }
+}
+
+// The main class providing all the functionality //////////////////////////////
+
+/**
+ * The actual worker class implementing the main functionality of the script.
+ *
+ * @copyright 2012 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class worker extends singleton_pattern {
+
+ const EXIT_OK = 0; // Success exit code.
+ const EXIT_HELP = 1; // Explicit help required.
+ const EXIT_UNKNOWN_ACTION = 127; // Neither -i nor -u provided.
+
+ /** @var input_manager */
+ protected $input = null;
+
+ /** @var output_manager */
+ protected $output = null;
+
+ /** @var int the most recent cURL error number, zero for no error */
+ private $curlerrno = null;
+
+ /** @var string the most recent cURL error message, empty string for no error */
+ private $curlerror = null;
+
+ /** @var array|false the most recent cURL request info, if it was successful */
+ private $curlinfo = null;
+
+ /** @var string the full path to the log file */
+ private $logfile = null;
+
+ /**
+ * Main - the one that actually does something
+ */
+ public function execute() {
+
+ $this->log('=== MDEPLOY EXECUTION START ===');
+
+ // Authorize access. None in CLI. Passphrase in HTTP.
+ $this->authorize();
+
+ // Asking for help in the CLI mode.
+ if ($this->input->get_option('help')) {
+ $this->output->help();
+ $this->done(self::EXIT_HELP);
+ }
+
+ if ($this->input->get_option('upgrade')) {
+ $this->log('Plugin upgrade requested');
+
+ // Fetch the ZIP file into a temporary location.
+ $source = $this->input->get_option('package');
+ $target = $this->target_location($source);
+ $this->log('Downloading package '.$source);
+
+ if ($this->download_file($source, $target)) {
+ $this->log('Package downloaded into '.$target);
+ } else {
+ $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
+ $this->log('Unable to download the file');
+ throw new download_file_exception('Unable to download the package');
+ }
+
+ // Compare MD5 checksum of the ZIP file
+ $md5remote = $this->input->get_option('md5');
+ $md5local = md5_file($target);
+
+ if ($md5local !== $md5remote) {
+ $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
+ throw new checksum_exception('MD5 checksum failed');
+ }
+ $this->log('MD5 checksum ok');
+
+ // Backup the current version of the plugin
+ $plugintyperoot = $this->input->get_option('typeroot');
+ $pluginname = $this->input->get_option('name');
+ $sourcelocation = $plugintyperoot.'/'.$pluginname;
+ $backuplocation = $this->backup_location($sourcelocation);
+
+ $this->log('Current plugin code location: '.$sourcelocation);
+ $this->log('Moving the current code into archive: '.$backuplocation);
+
+ // We don't want to touch files unless we are pretty sure it would be all ok.
+ if (!$this->move_directory_source_precheck($sourcelocation)) {
+ throw new backup_folder_exception('Unable to backup the current version of the plugin (source precheck failed)');
+ }
+ if (!$this->move_directory_target_precheck($backuplocation)) {
+ throw new backup_folder_exception('Unable to backup the current version of the plugin (backup precheck failed)');
+ }
+
+ // Looking good, let's try it.
+ if (!$this->move_directory($sourcelocation, $backuplocation)) {
+ throw new backup_folder_exception('Unable to backup the current version of the plugin (moving failed)');
+ }
+
+ // Unzip the plugin package file into the target location.
+ $this->unzip_plugin($target, $plugintyperoot, $sourcelocation, $backuplocation);
+ $this->log('Package successfully extracted');
+
+ // Redirect to the given URL (in HTTP) or exit (in CLI).
+ $this->done();
+
+ } else if ($this->input->get_option('install')) {
+ // Installing a new plugin not implemented yet.
+ }
+
+ // Print help in CLI by default.
+ $this->output->help();
+ $this->done(self::EXIT_UNKNOWN_ACTION);
+ }
+
+ /**
+ * Attempts to log a thrown exception
+ *
+ * @param Exception $e uncaught exception
+ */
+ public function log_exception(Exception $e) {
+ $this->log($e->__toString());
+ }
+
+ /**
+ * Initialize the worker class.
+ */
+ protected function initialize() {
+ $this->input = input_manager::instance();
+ $this->output = output_manager::instance();
+ }
+
+ // End of external API
+
+ /**
+ * Finish this script execution.
+ *
+ * @param int $exitcode
+ */
+ protected function done($exitcode = self::EXIT_OK) {
+
+ if (PHP_SAPI === 'cli') {
+ exit($exitcode);
+
+ } else {
+ $returnurl = $this->input->get_option('returnurl');
+ $this->redirect($returnurl);
+ exit($exitcode);
+ }
+ }
+
+ /**
+ * Authorize access to the script.
+ *
+ * In CLI mode, the access is automatically authorized. In HTTP mode, the
+ * passphrase submitted via the request params must match the contents of the
+ * file, the name of which is passed in another parameter.
+ *
+ * @throws unauthorized_access_exception
+ */
+ protected function authorize() {
+
+ if (PHP_SAPI === 'cli') {
+ $this->log('Successfully authorized using the CLI SAPI');
+ return;
+ }
+
+ $dataroot = $this->input->get_option('dataroot');
+ $passfile = $this->input->get_option('passfile');
+ $password = $this->input->get_option('password');
+
+ $passpath = $dataroot.'/mdeploy/auth/'.$passfile;
+
+ if (!is_readable($passpath)) {
+ throw new unauthorized_access_exception('Unable to read the passphrase file.');
+ }
+
+ $stored = file($passpath, FILE_IGNORE_NEW_LINES);
+
+ // "This message will self-destruct in five seconds." -- Mission Commander Swanbeck, Mission: Impossible II
+ unlink($passpath);
+
+ if (is_readable($passpath)) {
+ throw new unauthorized_access_exception('Unable to remove the passphrase file.');
+ }
+
+ if (count($stored) < 2) {
+ throw new unauthorized_access_exception('Invalid format of the passphrase file.');
+ }
+
+ if (time() - (int)$stored[1] > 30 * 60) {
+ throw new unauthorized_access_exception('Passphrase timeout.');
+ }
+
+ if (strlen($stored[0]) < 24) {
+ throw new unauthorized_access_exception('Session passphrase not long enough.');
+ }
+
+ if ($password !== $stored[0]) {
+ throw new unauthorized_access_exception('Session passphrase does not match the stored one.');
+ }
+
+ $this->log('Successfully authorized using the passphrase file');
+ }
+
+ /**
+ * Returns the full path to the log file.
+ *
+ * @return string
+ */
+ protected function log_location() {
+
+ if (!is_null($this->logfile)) {
+ return $this->logfile;
+ }
+
+ $dataroot = $this->input->get_option('dataroot', '');
+
+ if (empty($dataroot)) {
+ $this->logfile = false;
+ return $this->logfile;
+ }
+
+ $myroot = $dataroot.'/mdeploy';
+
+ if (!is_dir($myroot)) {
+ mkdir($myroot, 02777, true);
+ }
+
+ $this->logfile = $myroot.'/mdeploy.log';
+ return $this->logfile;
+ }
+
+ /**
+ * Choose the target location for the given ZIP's URL.
+ *
+ * @param string $source URL
+ * @return string
+ */
+ protected function target_location($source) {
+
+ $dataroot = $this->input->get_option('dataroot');
+ $pool = $dataroot.'/mdeploy/var';
+
+ if (!is_dir($pool)) {
+ mkdir($pool, 02777, true);
+ }
+
+ $target = $pool.'/'.md5($source);
+
+ $suffix = 0;
+ while (file_exists($target.'.'.$suffix.'.zip')) {
+ $suffix++;
+ }
+
+ return $target.'.'.$suffix.'.zip';
+ }
+
+ /**
+ * Choose the location of the current plugin folder backup
+ *
+ * @param string $path full path to the current folder
+ * @return string
+ */
+ protected function backup_location($path) {
+
+ $dataroot = $this->input->get_option('dataroot');
+ $pool = $dataroot.'/mdeploy/archive';
+
+ if (!is_dir($pool)) {
+ mkdir($pool, 02777, true);
+ }
+
+ $target = $pool.'/'.basename($path).'_'.time();
+
+ $suffix = 0;
+ while (file_exists($target.'.'.$suffix)) {
+ $suffix++;
+ }
+
+ return $target.'.'.$suffix;
+ }
+
+ /**
+ * Downloads the given file into the given destination.
+ *
+ * This is basically a simplified version of {@link download_file_content()} from
+ * Moodle itself, tuned for fetching files from moodle.org servers.
+ *
+ * @param string $source file url starting with http(s)://
+ * @param string $target store the downloaded content to this file (full path)
+ * @return bool true on success, false otherwise
+ * @throws download_file_exception
+ */
+ protected function download_file($source, $target) {
+
+ $newlines = array("\r", "\n");
+ $source = str_replace($newlines, '', $source);
+ if (!preg_match('|^https?://|i', $source)) {
+ throw new download_file_exception('Unsupported transport protocol.');
+ }
+ if (!$ch = curl_init($source)) {
+ $this->log('Unable to init cURL.');
+ return false;
+ }
+
+ curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // verify the peer's certificate
+ curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // check the existence of a common name and also verify that it matches the hostname provided
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // return the transfer as a string
+ curl_setopt($ch, CURLOPT_HEADER, false); // don't include the header in the output
+ curl_setopt($ch, CURLOPT_TIMEOUT, 3600);
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20); // nah, moodle.org is never unavailable! :-p
+ curl_setopt($ch, CURLOPT_URL, $source);
+
+ $targetfile = fopen($target, 'w');
+
+ if (!$targetfile) {
+ throw new download_file_exception('Unable to create local file '.$target);
+ }
+
+ curl_setopt($ch, CURLOPT_FILE, $targetfile);
+
+ $result = curl_exec($ch);
+
+ // try to detect encoding problems
+ if ((curl_errno($ch) == 23 or curl_errno($ch) == 61) and defined('CURLOPT_ENCODING')) {
+ curl_setopt($ch, CURLOPT_ENCODING, 'none');
+ $result = curl_exec($ch);
+ }
+
+ fclose($targetfile);
+
+ $this->curlerrno = curl_errno($ch);
+ $this->curlerror = curl_error($ch);
+ $this->curlinfo = curl_getinfo($ch);
+
+ if (!$result or $this->curlerrno) {
+ return false;
+
+ } else if (is_array($this->curlinfo) and (empty($this->curlinfo['http_code']) or $this->curlinfo['http_code'] != 200)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Log a message
+ *
+ * @param string $message
+ */
+ protected function log($message) {
+
+ $logpath = $this->log_location();
+
+ if (empty($logpath)) {
+ // no logging available
+ return;
+ }
+
+ $f = fopen($logpath, 'ab');
+
+ if ($f === false) {
+ throw new filesystem_exception('Unable to open the log file for appending');
+ }
+
+ $message = $this->format_log_message($message);
+
+ fwrite($f, $message);
+
+ fclose($f);
+ }
+
+ /**
+ * Prepares the log message for writing into the file
+ *
+ * @param string $msg
+ * @return string
+ */
+ protected function format_log_message($msg) {
+
+ $msg = trim($msg);
+ $timestamp = date("Y-m-d H:i:s");
+
+ return $timestamp . ' '. $msg . PHP_EOL;
+ }
+
+ /**
+ * Checks to see if the given source could be safely moved into a new location
+ *
+ * @param string $source full path to the existing directory
+ * @return bool
+ */
+ protected function move_directory_source_precheck($source) {
+
+ if (!is_writable($source)) {
+ return false;
+ }
+
+ if (is_dir($source)) {
+ $handle = opendir($source);
+ } else {
+ return false;
+ }
+
+ $result = true;
+
+ while ($filename = readdir($handle)) {
+ $sourcepath = $source.'/'.$filename;
+
+ if ($filename === '.' or $filename === '..') {
+ continue;
+ }
+
+ if (is_dir($sourcepath)) {
+ $result = $result && $this->move_directory_source_precheck($sourcepath);
+
+ } else {
+ $result = $result && is_writable($sourcepath);
+ }
+ }
+
+ closedir($handle);
+
+ return $result;
+ }
+
+ /**
+ * Checks to see if a source foldr could be safely moved into the given new location
+ *
+ * @param string $destination full path to the new expected location of a folder
+ * @return bool
+ */
+ protected function move_directory_target_precheck($target) {
+
+ if (file_exists($target)) {
+ return false;
+ }
+
+ $result = mkdir($target, 02777) && rmdir($target);
+
+ return $result;
+ }
+
+ /**
+ * Moves the given source into a new location recursively
+ *
+ * @param string $source full path to the existing directory
+ * @param string $destination full path to the new location of the folder
+ * @return bool
+ */
+ protected function move_directory($source, $target) {
+
+ if (file_exists($target)) {
+ throw new filesystem_exception('Unable to move the directory - target location already exists');
+ }
+
+ if (is_dir($source)) {
+ $handle = opendir($source);
+ } else {
+ throw new filesystem_exception('Source location is not a directory');
+ }
+
+ mkdir($target, 02777);
+
+ while ($filename = readdir($handle)) {
+ $sourcepath = $source.'/'.$filename;
+ $targetpath = $target.'/'.$filename;
+
+ if ($filename === '.' or $filename === '..') {
+ continue;
+ }
+
+ if (is_dir($sourcepath)) {
+ $this->move_directory($sourcepath, $targetpath);
+
+ } else {
+ rename($sourcepath, $targetpath);
+ }
+ }
+
+ closedir($handle);
+ return rmdir($source);
+ }
+
+ /**
+ * Deletes the given directory recursively
+ *
+ * @param string $path full path to the directory
+ */
+ protected function remove_directory($path) {
+
+ if (!file_exists($path)) {
+ return;
+ }
+
+ if (is_dir($path)) {
+ $handle = opendir($path);
+ } else {
+ throw new filesystem_exception('Given path is not a directory');
+ }
+
+ while ($filename = readdir($handle)) {
+ $filepath = $path.'/'.$filename;
+
+ if ($filename === '.' or $filename === '..') {
+ continue;
+ }
+
+ if (is_dir($filepath)) {
+ $this->remove_directory($filepath);
+
+ } else {
+ unlink($filepath);
+ }
+ }
+
+ closedir($handle);
+ return rmdir($path);
+ }
+
+ /**
+ * Unzip the file obtained from the Plugins directory to this site
+ *
+ * @param string $ziplocation full path to the ZIP file
+ * @param string $plugintyperoot full path to the plugin's type location
+ * @param string $expectedlocation expected full path to the plugin after it is extracted
+ * @param string $backuplocation location of the previous version of the plugin
+ */
+ protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
+
+ $zip = new ZipArchive();
+ $result = $zip->open($ziplocation);
+
+ if ($result !== true) {
+ $this->move_directory($backuplocation, $expectedlocation);
+ throw new zip_exception('Unable to open the zip package');
+ }
+
+ // Make sure that the ZIP has expected structure
+ $pluginname = basename($expectedlocation);
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $stat = $zip->statIndex($i);
+ $filename = $stat['name'];
+ $filename = explode('/', $filename);
+ if ($filename[0] !== $pluginname) {
+ $zip->close();
+ throw new zip_exception('Invalid structure of the zip package');
+ }
+ }
+
+ if (!$zip->extractTo($plugintyperoot)) {
+ $zip->close();
+ $this->remove_directory($expectedlocation); // just in case something was created
+ $this->move_directory($backuplocation, $expectedlocation);
+ throw new zip_exception('Unable to extract the zip package');
+ }
+
+ $zip->close();
+ unlink($ziplocation);
+ }
+
+ /**
+ * Redirect the browser
+ *
+ * @todo check if there has been some output yet
+ * @param string $url
+ */
+ protected function redirect($url) {
+ header('Location: '.$url);
+ }
+}
+
+
+/**
+ * Provides exception handlers for this script
+ */
+class exception_handlers {
+
+ /**
+ * Sets the exception handler
+ *
+ *
+ * @param string $handler name
+ */
+ public static function set_handler($handler) {
+
+ if (PHP_SAPI === 'cli') {
+ // No custom handler available for CLI mode.
+ set_exception_handler(null);
+ return;
+ }
+
+ set_exception_handler('exception_handlers::'.$handler.'_exception_handler');
+ }
+
+ /**
+ * Returns the text describing the thrown exception
+ *
+ * By default, PHP displays full path to scripts when the exception is thrown. In order to prevent
+ * sensitive information leak (and yes, the path to scripts at a web server _is_ sensitive information)
+ * the path to scripts is removed from the message.
+ *
+ * @param Exception $e thrown exception
+ * @return string
+ */
+ public static function format_exception_info(Exception $e) {
+
+ $mydir = dirname(__FILE__).'/';
+ $text = $e->__toString();
+ $text = str_replace($mydir, '', $text);
+ return $text;
+ }
+
+ /**
+ * Very basic exception handler
+ *
+ * @param Exception $e uncaught exception
+ */
+ public static function bootstrap_exception_handler(Exception $e) {
+ echo('Oops! It did it again
');
+ echo('Moodle deployment utility had a trouble with your request. See the debugging information for more details.
');
+ echo('');
+ echo self::format_exception_info($e);
+ echo('');
+ }
+
+ /**
+ * Default exception handler
+ *
+ * When this handler is used, input_manager and output_manager singleton instances already
+ * exist in the memory and can be used.
+ *
+ * @param Exception $e uncaught exception
+ */
+ public static function default_exception_handler(Exception $e) {
+
+ $worker = worker::instance();
+ $worker->log_exception($e);
+
+ $output = output_manager::instance();
+ $output->exception($e);
+ }
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+// Check if the script is actually executed or if it was just included by someone
+// else - typically by the PHPUnit. This is a PHP alternative to the Python's
+// if __name__ == '__main__'
+if (!debug_backtrace()) {
+ // We are executed by the SAPI.
+ exception_handlers::set_handler('bootstrap');
+ // Initialize the worker class to actually make the job.
+ $worker = worker::instance();
+ exception_handlers::set_handler('default');
+
+ // Lights, Camera, Action!
+ $worker->execute();
+
+} else {
+ // We are included - probably by some unit testing framework. Do nothing.
+}
diff --git a/mdeploytest.php b/mdeploytest.php
new file mode 100644
index 00000000000..471a9d33eae
--- /dev/null
+++ b/mdeploytest.php
@@ -0,0 +1,215 @@
+.
+
+/**
+ * PHPUnit tests for the mdeploy.php utility
+ *
+ * Because the mdeploy.php can't be part of the Moodle code itself, this tests must be
+ * executed using something like:
+ *
+ * $ phpunit --no-configuration mdeploytest
+ *
+ * @package core
+ * @copyright 2012 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(__DIR__.'/mdeploy.php');
+
+/**
+ * Provides testable input options.
+ *
+ * @copyright 2012 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class input_fake_provider extends input_provider {
+
+ /** @var array */
+ protected $fakeoptions = array();
+
+ /**
+ * Sets fake raw options.
+ *
+ * @param array $options
+ */
+ public function set_fake_options(array $options) {
+ $this->fakeoptions = $options;
+ $this->populate_options();
+ }
+
+ /**
+ * Returns the explicitly set fake options.
+ *
+ * @return array
+ */
+ protected function parse_raw_options() {
+ return $this->fakeoptions;
+ }
+}
+
+/**
+ * Testable subclass.
+ *
+ * @copyright 2012 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_input_manager extends input_manager {
+
+ /**
+ * Provides access to the protected method so we can test it explicitly.
+ */
+ public function cast_value($raw, $type) {
+ return parent::cast_value($raw, $type);
+ }
+
+ /**
+ * Sets the fake input provider.
+ */
+ protected function initialize() {
+ $this->inputprovider = input_fake_provider::instance();
+ }
+}
+
+
+/**
+ * Test cases for the mdeploy utility
+ *
+ * @copyright 2012 David Mudrak
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mdeploytest extends PHPUnit_Framework_TestCase {
+
+ public function test_same_singletons() {
+ $a = input_manager::instance();
+ $b = input_manager::instance();
+ $this->assertSame($a, $b);
+ }
+
+ /**
+ * @dataProvider data_for_cast_value
+ */
+ public function test_cast_value($raw, $type, $result) {
+ $input = testable_input_manager::instance();
+ $this->assertSame($input->cast_value($raw, $type), $result);
+ }
+
+ public function data_for_cast_value() {
+ return array(
+ array('3', input_manager::TYPE_INT, 3),
+ array(4, input_manager::TYPE_INT, 4),
+ array('', input_manager::TYPE_INT, 0),
+
+ array(true, input_manager::TYPE_FLAG, true),
+ array(false, input_manager::TYPE_FLAG, true),
+ array(0, input_manager::TYPE_FLAG, true),
+ array('1', input_manager::TYPE_FLAG, true),
+ array('0', input_manager::TYPE_FLAG, true),
+ array('muhehe', input_manager::TYPE_FLAG, true),
+
+ array('C:\\WINDOWS\\user.dat', input_manager::TYPE_PATH, 'C/WINDOWS/user.dat'),
+ array('../../../etc/passwd', input_manager::TYPE_PATH, '/etc/passwd'),
+ array('///////.././public_html/test.php', input_manager::TYPE_PATH, '/public_html/test.php'),
+
+ array("!@#$%|/etc/qwerty\n\n\t\n\r", input_manager::TYPE_RAW, "!@#$%|/etc/qwerty\n\n\t\n\r"),
+
+ array("\nrock'n'roll.mp3\t.exe", input_manager::TYPE_FILE, 'rocknroll.mp3.exe'),
+
+ array('http://localhost/moodle/dev/plugin.zip', input_manager::TYPE_URL, 'http://localhost/moodle/dev/plugin.zip'),
+ array(
+ 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip',
+ input_manager::TYPE_URL,
+ 'https://moodle.org/plugins/download.php/1292/mod_stampcoll_moodle23_2012062201.zip'
+ ),
+
+ array('5e8d2ea4f50d154730100b1645fbad67', input_manager::TYPE_MD5, '5e8d2ea4f50d154730100b1645fbad67'),
+ );
+ }
+
+ /**
+ * @expectedException invalid_coding_exception
+ */
+ public function test_cast_array_argument() {
+ $input = testable_input_manager::instance();
+ $input->cast_value(array(1, 2, 3), input_manager::TYPE_INT); // must throw exception
+ }
+
+ /**
+ * @expectedException invalid_coding_exception
+ */
+ public function test_cast_object_argument() {
+ $input = testable_input_manager::instance();
+ $o = new stdClass();
+ $input->cast_value($o, input_manager::TYPE_INT); // must throw exception
+ }
+
+ /**
+ * @expectedException invalid_option_exception
+ */
+ public function test_cast_invalid_url_value() {
+ $input = testable_input_manager::instance();
+ $invalid = 'file:///etc/passwd';
+ $input->cast_value($invalid, input_manager::TYPE_URL); // must throw exception
+ }
+
+ /**
+ * @expectedException invalid_option_exception
+ */
+ public function test_cast_invalid_md5_value() {
+ $input = testable_input_manager::instance();
+ $invalid = 'this is not a valid md5 hash';
+ $input->cast_value($invalid, input_manager::TYPE_MD5); // must throw exception
+ }
+
+ /**
+ * @expectedException invalid_option_exception
+ */
+ public function test_cast_tilde_in_path() {
+ $input = testable_input_manager::instance();
+ $invalid = '~/public_html/moodle_dev';
+ $input->cast_value($invalid, input_manager::TYPE_PATH); // must throw exception
+ }
+
+ public function test_has_option() {
+ $provider = input_fake_provider::instance();
+
+ $provider->set_fake_options(array());
+ $this->assertFalse($provider->has_option('foo')); // foo not passed
+
+ $provider->set_fake_options(array('foo' => 1));
+ $this->assertFalse($provider->has_option('foo')); // foo passed but not a known option
+
+ $provider->set_fake_options(array('foo' => 1, 'help' => false));
+ $this->assertTrue($provider->has_option('help')); // help passed and it is a flag (value ignored)
+ $this->assertTrue($provider->has_option('h')); // 'h' is a shortname for 'help'
+ }
+
+ public function test_get_option() {
+ $input = testable_input_manager::instance();
+ $provider = input_fake_provider::instance();
+
+ $provider->set_fake_options(array('help' => false, 'passfile' => '_mdeploy.123456'));
+ $this->assertTrue($input->get_option('h'));
+ $this->assertEquals($input->get_option('passfile'), '_mdeploy.123456');
+ $this->assertEquals($input->get_option('password', 'admin123'), 'admin123');
+ try {
+ $this->assertEquals($input->get_option('password'), 'admin123'); // must throw exception (not passed but required)
+ $this->assertTrue(false);
+ } catch (missing_option_exception $e) {
+ $this->assertTrue(true);
+ }
+ }
+}
diff --git a/theme/base/style/admin.css b/theme/base/style/admin.css
index 1d71a50a5d7..3abdbc3b10b 100644
--- a/theme/base/style/admin.css
+++ b/theme/base/style/admin.css
@@ -102,6 +102,11 @@
#page-admin-index .adminwarning.availableupdatesinfo .moodleupdateinfo a {padding-right:1em;}
#page-admin-index .adminwarning.availableupdatesinfo .moodleupdateinfo .separator {border-left:1px dotted #333;}
+#page-admin-index .updateplugin div,
+#page-admin-plugins .updateplugin div {margin-bottom:0.5em;}
+#page-admin-index .updateplugin .updatepluginconfirmexternal,
+#page-admin-plugins .updateplugin .updatepluginconfirmexternal {padding:1em;background-color:#ffd3d9;border:1px solid #EEAAAA}
+
#page-admin-user-user_bulk #users .fgroup {white-space: nowrap;}
#page-admin-report-stats-index .graph {text-align: center;margin-bottom: 1em;}
#page-admin-report-courseoverview-index .graph {text-align: center;margin-bottom: 1em;}
@@ -248,6 +253,11 @@
#page-admin-plugins #plugins-overview-panel .info {padding:5px 10px;}
#page-admin-plugins #plugins-overview-panel .separator {border-left:1px dotted #999;}
#page-admin-plugins #plugins-overview-panel .info.updatable {margin-left:10px;background-color:#d2ebff;font-weight:bold;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;}
+#page-admin-plugins #plugins-overview-filter {margin:1em auto;text-align:center;}
+#page-admin-plugins #plugins-overview-filter .filter-item {padding:5px 10px;}
+#page-admin-plugins #plugins-overview-filter .filter-item.active {font-weight:bold;}
+#page-admin-plugins #plugins-overview-filter .separator {border-left:1px dotted #999;}
+#page-admin-plugins #plugins-control-panel .actions .uninstall {color:#900;}
#page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo {padding:5px 10px;margin:10px;background-color:#d2ebff;-moz-border-radius: 10px;-webkit-border-radius: 10px;border-radius: 10px;}
#page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity50 {background-color:#ffd3d9;}
#page-admin-plugins #plugins-control-panel .notes .pluginupdateinfo.maturity100,