tester-1.0.0/src/Drush/Commands/TesterCommands.php
src/Drush/Commands/TesterCommands.php
<?php
namespace Drupal\tester\Drush\Commands;
use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
use Drupal\Component\DependencyInjection\ContainerInterface;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Database\Connection;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ModuleInstallerInterface;
use Drupal\Core\Http\ClientFactory;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\tester\TesterPluginManager;
use Drush\Commands\DrushCommands;
use GuzzleHttp\Cookie\CookieJar;
/**
* Defines the class for our drush commands.
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
*/
class TesterCommands extends DrushCommands {
use StringTranslationTrait;
/**
* The tester plugin manager.
*
* @var \Drupal\tester\TesterPluginManager
*/
protected $pluginManager;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The module installer.
*
* @var \Drupal\Core\Extension\ModuleInstallerInterface
*/
protected $moduleInstaller;
/**
* The config factory service.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The default http client.
*
* @var \GuzzleHttp\Client
*/
protected $httpClient;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The database service.
*
* @var \Drupal\Core\Database\Connection
*/
protected $database;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The error count for the run.
*
* @var array
*/
protected $errorLog = [];
/**
* The admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* The time service.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs the class.
*
* @param \Drupal\tester\TesterPluginManager $plugin_manager
* The tester plugin manager.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Extension\ModuleInstallerInterface $module_installer
* The module installer.
* @param \Drupal\Core\Http\ClientFactory $http_client_factory
* The http client factory service.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\Core\Database\Connection $database
* The database connection.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(TesterPluginManager $plugin_manager, ModuleHandlerInterface $module_handler, ModuleInstallerInterface $module_installer, ClientFactory $http_client_factory, ConfigFactoryInterface $config_factory, StateInterface $state, Connection $database, EntityTypeManagerInterface $entity_type_manager, TimeInterface $time, LanguageManagerInterface $language_manager) {
parent::__construct();
$this->pluginManager = $plugin_manager;
$this->moduleHandler = $module_handler;
$this->moduleInstaller = $module_installer;
$this->configFactory = $config_factory;
$this->httpClient = $http_client_factory->fromOptions(['cookies' => TRUE, 'allow_redirects' => TRUE]);
$this->state = $state;
$this->database = $database;
$this->entityTypeManager = $entity_type_manager;
$this->time = $time;
$this->languageManager = $language_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
return new static(
$container->get('plugin.manager.tester'),
$container->get('module_handler'),
$container->get('module_installer'),
$container->get('http_client_factory'),
$container->get('config.factory'),
$container->get('state'),
$container->get('database'),
$container->get('entity_type.manager'),
$container->get('datetime.time'),
$container->get('language_manager')
);
}
/**
* Lists valid plugins.
*
* @command tester:list
* @aliases tcl
* @usage drush tester:list
*/
public function listPlugins() {
$list = $this->chooseOptions();
unset($list['all']);
unset($list['cancel']);
// @phpstan-ignore-next-line
$this->io()->title($this->t('Available plugins:'));
// @phpstan-ignore-next-line
return $this->io()->listing($list);
}
/**
* Crawls a site looking for errors.
*
* @param string $base_url
* The base URL to use when crawling the site. No trailing slash.
* If not provided, the global $base_url value will be used.
* @param array $options
* Command options array containing test configuration.
*
* @option test
* The test to run (optional). Pass `--test=all` to run all tests.
* @option limit
* The number of urls to crawl for _each_ plugin. Pass 0 to crawl all urls.
* Default value is 500.
* @option menus
* The menus to crawl as a comma-separated list. If blank, the tester will
* crawl the `admin`, `main` and `footer` menus (optional).
* @option admin
* Boolean flag to log in with user `admin` and password `admin` (optional).
* @option user
* The user name to log in with prior to the test (optional).
* @option password
* The user password to log in with prior to the test
* (required if --user is set).
* @option errors
* If set, only errors will be reported.
* @option verify
* If set, the site must have a valid SSL certificate.
*
* @command tester:crawl
* @aliases tester-crawl, tc
* @usage drush tester:crawl, drush tc
* @usage drush tester:crawl --test=all
* @usage drush tester:crawl --test=all --limit=10
* @usage drush tester:crawl --test=all --limit=10 --menus=main,header
* @usage drush tester:crawl --test=all --limit=10 --menus=admin,main,header --admin
* @usage drush tester:crawl --test=all --limit=10 --menus=admin,main,header --user=[USERNAME] --password=[PASSWORD]
* @usage drush tester:crawl example.com
* @usage drush tester:crawl example.com --test=node
* @usage drush tester:crawl example.com --test=node --errors
*
* @field-labels
* path: Path
* status: Status
* errors: Errors
* @default_fields path,status,errors
*
* @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields
* Table output.
*/
public function crawl(
$base_url = NULL,
array $options = [
'test' => NULL,
'limit' => 500,
'menus' => 'main,footer,admin',
'admin' => FALSE,
'user' => NULL,
'password' => NULL,
'errors' => FALSE,
'verify' => FALSE,
],
) {
$this->adminUser = $this->entityTypeManager->getStorage('user')->load(1);
$this->setUp();
// Handle test selection.
$test_selection = $this->handleTestSelection($options);
if ($test_selection !== NULL) {
return $test_selection;
}
// @phpstan-ignore-next-line
$this->io()->title("Crawling URLs");
// Prepare base URL and URLs to test.
$base_url = $this->prepareBaseUrl($base_url);
$urls = array_unique($this->getUrls($options));
if (count($urls) === 0) {
// @phpstan-ignore-next-line
return $this->io()->error($this->t('No valid plugins were found.'));
}
// Configure HTTP options.
$http_options = $this->configureHttpOptions($options);
// Perform authentication if requested.
$this->performAuthentication($base_url, $options, $http_options);
// Crawl URLs and collect results.
$results = $this->crawlUrls($base_url, $urls, $options, $http_options);
$this->tearDown();
// Display final results.
$this->displayResults($urls, $results['error_count']);
return new RowsOfFields($results['rows']);
}
/**
* Handles test selection logic.
*
* @param array $options
* Command options array.
*
* @return \Consolidation\OutputFormatters\StructuredData\RowsOfFields|null
* Returns early result if operation cancelled, NULL otherwise.
*/
protected function handleTestSelection(array &$options) {
if (is_null($options['test'])) {
$select = $this->chooseOptions();
// @phpstan-ignore-next-line
$options['test'] = $this->io()->choice($this->t('Select the tests to run'), $select);
}
if ($options['test'] === 'cancel') {
// @phpstan-ignore-next-line
return $this->io()->success($this->t('Operation cancelled.'));
}
return NULL;
}
/**
* Prepares the base URL for testing.
*
* @param string|null $base_url
* The provided base URL or NULL to use global.
*
* @return string
* The prepared base URL.
*/
protected function prepareBaseUrl($base_url) {
if (is_null($base_url)) {
global $base_url;
}
return $base_url;
}
/**
* Configures HTTP options for the crawler.
*
* @param array $options
* Command options array.
*
* @return array
* HTTP options for Guzzle client.
*/
protected function configureHttpOptions(array $options) {
// We want to test 403 and 404 pages, so allow them.
// See https://docs.guzzlephp.org/en/stable/request-options.html#http-errors
$cookie_jar = new CookieJar();
$http_options = [
'http_errors' => FALSE,
'cookies' => $cookie_jar,
];
// Set an explicit http version for the request.
if (!$options['version']) {
$http_options['version'] = 1.1;
}
return $http_options;
}
/**
* Performs authentication if requested.
*
* @param string $base_url
* The base URL for the site.
* @param array $options
* Command options array.
* @param array $http_options
* HTTP options for requests.
*/
protected function performAuthentication($base_url, array $options, array $http_options) {
if ($options['admin']) {
$this->loginAsAdmin($http_options);
}
elseif ($options['user'] && $options['password']) {
$this->loginWithCredentials($base_url, $options, $http_options);
}
}
/**
* Logs in as admin user using one-time login.
*
* @param array $http_options
* HTTP options for the request.
*/
protected function loginAsAdmin(array $http_options) {
$account = $this->adminUser;
$timestamp = $this->time->getRequestTime();
$link = Url::fromRoute(
'user.reset.login',
[
'uid' => $account->id(),
'timestamp' => $timestamp,
'hash' => user_pass_rehash($account, $timestamp),
],
[
'absolute' => TRUE,
'query' => [],
'language' => $this->languageManager->getLanguage($account->getPreferredLangcode()),
'https' => FALSE,
]
)->toString();
$this->httpClient->request('GET', $link, $http_options);
}
/**
* Logs in with provided credentials.
*
* @param string $base_url
* The base URL for the site.
* @param array $options
* Command options containing credentials.
* @param array $http_options
* HTTP options for the request.
*/
protected function loginWithCredentials($base_url, array $options, array &$http_options) {
// @phpstan-ignore-next-line
$this->io()->success($this->t("Logging in as '@user' using password...", ['@user' => $options['user']]));
$http_options['form_params'] = [
'name' => $options['user'],
'pass' => $options['password'],
'form_id' => 'user_login_form',
];
$this->httpClient->request('POST', $base_url . '/user/login', $http_options);
unset($http_options['form_params']);
}
/**
* Crawls URLs and collects results.
*
* @param string $base_url
* The base URL for the site.
* @param array $urls
* URLs to crawl.
* @param array $options
* Command options.
* @param array $http_options
* HTTP options for requests.
*
* @return array
* Array with 'rows' and 'error_count' keys.
*/
protected function crawlUrls($base_url, array $urls, array $options, array $http_options) {
$rows = [];
$error_count = 0;
// @phpstan-ignore-next-line
$this->io()->progressStart(count($urls));
foreach ($urls as $url) {
$path = $this->preparePath($base_url, $url, $options['verify']);
$this->setErrorStorage($path);
$response = $this->httpClient->request('GET', $path, $http_options);
// @phpstan-ignore-next-line
$this->io()->progressAdvance();
$this->setErrorLog($path, ['response' => $response->getStatusCode()]);
$this->captureErrors($path);
$result = $this->processUrlResult($path, $options['errors']);
if ($result !== NULL) {
$rows = array_merge($rows, $result['rows']);
$error_count += $result['error_count'];
}
}
// @phpstan-ignore-next-line
$this->io()->progressFinish();
return ['rows' => $rows, 'error_count' => $error_count];
}
/**
* Prepares a path for testing.
*
* @param string $base_url
* The base URL.
* @param string $url
* The relative URL.
* @param bool $verify_ssl
* Whether to verify SSL certificates.
*
* @return string
* The prepared full path.
*/
protected function preparePath($base_url, $url, $verify_ssl) {
$path = $base_url . $url;
if (!$verify_ssl) {
$path = str_replace('https:', 'http:', $path);
}
return $path;
}
/**
* Processes the result of a single URL test.
*
* @param string $path
* The tested path.
* @param bool $errors_only
* Whether to only show errors.
*
* @return array|null
* Array with rows and error count, or NULL if not included.
*/
protected function processUrlResult($path, $errors_only) {
$errors = $this->getErrorLog($path, 'count') ?? 0;
if (!$errors_only || $errors > 0) {
$row = [
'path' => $path,
'status' => $this->getErrorLog($path, 'response'),
'errors' => $errors,
];
$rows = [$row];
$error_count = 0;
if ($row['errors'] > 0) {
$rows[] = ['path' => ''];
foreach ($this->getErrorLog($path, 'errors') as $error) {
$error_count++;
$rows[] = [
'path' => ' • ' . trim(strip_tags($error), "."),
];
}
$rows[] = ['path' => ''];
}
return ['rows' => $rows, 'error_count' => $error_count];
}
return NULL;
}
/**
* Displays the final results of the crawl.
*
* @param array $urls
* The URLs that were tested.
* @param int $error_count
* The total number of errors found.
*/
protected function displayResults(array $urls, $error_count) {
$message = $this->t('Tested @count urls and found @error_count errors.', [
'@count' => count($urls),
'@error_count' => $error_count,
]);
if ($error_count > 0) {
// @phpstan-ignore-next-line
$this->io()->error($message);
}
else {
// @phpstan-ignore-next-line
$this->io()->success($message);
}
}
/**
* Chooses the plugins to run during a crawl.
*
* @return array
* An array of available plugin options for selection.
*/
public function chooseOptions() {
$options = [];
$options['all'] = $this->t('all');
$plugins = $this->pluginManager->getDefinitions();
foreach (array_keys($plugins) as $id) {
$instance = $this->pluginManager->createInstance($id);
$dependencies = $instance->dependencies();
if ($this->isAllowed($dependencies)) {
$id = $instance->getPluginId();
$options[$id] = $id;
}
}
$options['cancel'] = $this->t('cancel');
return $options;
}
/**
* Retrieves the list of URLs to test.
*
* @param array $options
* The options passed to the command.
*
* @return array
* An array of URLs.
*/
private function getUrls(array $options) {
$urls = [];
$plugins = $this->pluginManager->getDefinitions();
foreach (array_keys($plugins) as $id) {
if ($options['test'] !== 'all' && $id !== $options['test']) {
continue;
}
$instance = $this->pluginManager->createInstance($id);
$dependencies = $instance->dependencies();
if ($this->isAllowed($dependencies)) {
$urls = array_merge($urls, $instance->urls($options));
}
}
return $urls;
}
/**
* Determines if a plugin is valid, based on dependencies.
*
* @param array $dependencies
* The dependencies, as defined in TesterPluginInterface.
*
* @return bool
* TRUE if the plugin is valid.
*/
private function isAllowed(array $dependencies) {
$return = TRUE;
// @todo Right now we only handle modules.
// We would need to inject the theme handler service.
foreach ($dependencies as $type => $extensions) {
switch ($type) {
case "modules":
default:
foreach ($extensions as $extension) {
if (!$this->moduleHandler->moduleExists($extension)) {
$return = FALSE;
}
}
break;
}
}
return $return;
}
/**
* Captures the errors for a specific path for display.
*
* @param string $path
* The URL being tested.
*/
protected function captureErrors($path) {
$final = $this->getWatchdogCount();
$initial = $this->getErrorLog($path, 'initial');
if ($final > $initial) {
$count = $final - $initial;
$errors = $this->getErrors($count, $initial);
$this->setErrorLog($path, [
'final' => $final,
'count' => count($errors),
'errors' => $errors,
]);
}
}
/**
* Gets the errors from {watchdog} and returns them.
*
* @param int $count
* The number of errors to return.
* @param int $initial
* The record number to start with.
*/
protected function getErrors(int $count, int $initial) {
$errors = [];
// We cannot filter by type accurately?
$query = $this->database->select('watchdog', 'w')
->fields('w', ['wid', 'message', 'variables', 'type'])
->orderBy('wid', 'ASC')
->range($initial, $count);
$result = $query->execute();
foreach ($result as $dblog) {
if ($dblog->type === 'php') {
$errors[$dblog->wid] = $this->formatMessage($dblog);
}
}
return $errors;
}
/**
* Initializes error capture for a path request.
*
* @param string $path
* The URL being tested.
*/
protected function setErrorStorage($path) {
$data = [
'response' => NULL,
'initial' => $this->getWatchdogCount(),
'final' => 0,
'count' => 0,
'errors' => [],
];
$this->setErrorLog($path, $data);
}
/**
* Returns an error log value for a specific path.
*
* @param string $path
* The path being checked.
* @param string $value
* The value to retrieve.
*
* @return mixed|null
* The requested error log value or NULL if not found.
*/
public function getErrorLog($path, $value) {
$return = NULL;
// The default returns must match set types.
switch ($value) {
case 'response':
$return = $this->errorLog[$path][$value] ?? NULL;
break;
case 'errors':
$return = $this->errorLog[$path][$value] ?? [];
break;
default:
$return = $this->errorLog[$path][$value] ?? 0;
break;
}
return $return;
}
/**
* Sets the error log for a request.
*
* @param string $path
* The path being checked.
* @param array $values
* The values to set. Only pass what has changed.
*
* @return array
* The complete error log array.
*/
public function setErrorLog($path, array $values) {
foreach ($values as $key => $value) {
$this->errorLog[$path][$key] = $value;
}
return $this->errorLog;
}
/**
* Returns the highwater row in the {watchdog} table.
*
* We do this so we can query the errors specific to a path.
*
* @return int
* The count.
*/
protected function getWatchdogCount() {
$query = $this->database->select('watchdog', 'w')
->fields('w', ['wid'])
->condition('w.type', 'php')
->orderBy('wid', 'DESC')
->range(0, 1);
return $query->execute()->fetchField();
}
/**
* Sets up the crawler run by changing application state.
*
* We want dblog enabled and full error reporting. When finished, we will
* set those back.
*/
private function setUp() {
$state_change = [
'error_level' => NULL,
'dblog' => FALSE,
];
// Set up error logging to highest level.
$config = $this->configFactory->getEditable('system.logging');
$error_level = $config->get('error_level');
if ($error_level !== "all") {
$config->set('error_level', 'all')->save();
$state_change['error_level'] = $error_level;
}
// Ensure dblog is enabled.
if (!$this->moduleHandler->moduleExists("dblog")) {
$this->moduleInstaller->install(['dblog']);
$state_change['dblog'] = TRUE;
}
// Set values in state.
$this->state->set('tester', $state_change);
}
/**
* Tears down the crawler run by changing application state.
*/
private function tearDown() {
$state_change = $this->state->get('tester');
// Reset error reporting.
if (!is_null($state_change['error_level'])) {
$config = $this->configFactory->getEditable('system.logging');
$config->set('error_level', $state_change['error_level'])->save();
}
// Reset dblog.
if ($state_change['dblog'] === TRUE) {
$this->moduleInstaller->uninstall(['dblog']);
}
// Clear state.
$this->state->delete('tester');
}
/**
* Formats a database log message.
*
* @param object $row
* The record from the watchdog table. The object properties are: wid, uid,
* severity, type, timestamp, message, variables, link, name.
*
* @return string|\Drupal\Core\StringTranslation\TranslatableMarkup|false
* The formatted log message or FALSE if the message or variables properties
* are not set.
*/
public function formatMessage($row) {
// Check for required properties.
if (isset($row->message, $row->variables)) {
// Drupal's watchdog variables contain basic types from logging system.
// phpcs:ignore Drupal.Functions.DiscouragedFunctions.Discouraged
$variables = @unserialize($row->variables);
// Messages without variables or user specified text.
if ($variables === NULL) {
$message = Xss::filterAdmin($row->message);
}
elseif (!is_array($variables)) {
$message = $this->t('Log data is corrupted and cannot be unserialized: @message', ['@message' => Xss::filterAdmin($row->message)]);
}
// Message to translate with injected variables.
else {
// We deliberately suppress the backtrace.
$variables['@backtrace_string'] = "";
// Database log messages contain placeholders that need variable
// substitution.
// phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString
$message = $this->t($row->message, $variables);
}
}
else {
$message = FALSE;
}
return $message;
}
}
