E2E_Core_ErrorTest - Demonstrate whether error-pages are well-formed
authorTim Otten <totten@civicrm.org>
Tue, 19 Apr 2022 22:06:28 +0000 (15:06 -0700)
committerTim Otten <totten@civicrm.org>
Tue, 19 Apr 2022 22:50:51 +0000 (15:50 -0700)
CRM/Core/Page/FakeError.php [new file with mode: 0644]
CRM/Core/xml/Menu/Misc.xml
tests/phpunit/E2E/Core/ErrorTest.php [new file with mode: 0644]

diff --git a/CRM/Core/Page/FakeError.php b/CRM/Core/Page/FakeError.php
new file mode 100644 (file)
index 0000000..a98dda7
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved.                        |
+ |                                                                    |
+ | This work is published under the GNU AGPLv3 license with some      |
+ | permitted exceptions and without any warranty. For full license    |
+ | and copyright information, see https://civicrm.org/licensing       |
+ +--------------------------------------------------------------------+
+ */
+
+/**
+ * The "civicrm/dev/fake-error" page is a mock to facilitate E2E testing of the error-reporting mechanism.
+ * Use this page to provoke common/representative errors.
+ *
+ * Of course, we don't want to permit arbitrary users to provoke arbitrary errors -- that could
+ * lead to noisy/confusing logs.
+ *
+ * This has two main modes:
+ *
+ * - If you give no parameters (or unsigned parameters), it simply says "Hello world".
+ * - If you give an authentic JWT with the claim `civi.fake-error`, then it will report
+ *   one of the pre-canned error messages.
+ */
+class CRM_Core_Page_FakeError extends CRM_Core_Page {
+
+  public function run() {
+    try {
+      /** @var \Civi\Crypto\CryptoJwt $jwt */
+      $jwt = Civi::service('crypto.jwt');
+      $claims = $jwt->decode(CRM_Utils_Request::retrieve('token', 'String'));
+    }
+    catch (\Exception $e) {
+      $claims = [];
+    }
+
+    if (empty($claims['civi.fake-error'])) {
+      echo 'Hello world';
+      return;
+    }
+
+    switch ($claims['civi.fake-error']) {
+      case 'exception':
+        throw new \CRM_Core_Exception("This is a fake problem (exception).");
+
+      case 'fatal':
+        CRM_Core_Error::fatal('This is a fake problem (fatal).');
+        break;
+
+      case 'permission':
+        CRM_Utils_System::permissionDenied();
+        break;
+
+      default:
+        return 'Unrecognized error type.';
+    }
+  }
+
+}
index 5be55e832293de7f53b2cd840824a04ae6a28e17..89b5f35e4f93ddf56ab93bb1691d55f0d33fe1d5 100644 (file)
     <title>QUnit</title>
     <access_arguments>administer CiviCRM</access_arguments>
   </item>
+  <item>
+    <path>civicrm/dev/fake-error</path>
+    <page_callback>CRM_Core_Page_FakeError</page_callback>
+    <title>Fake Error</title>
+    <is_public>true</is_public>
+    <access_arguments>*always allow*</access_arguments>
+  </item>
   <item>
     <path>civicrm/profile-editor/schema</path>
     <page_callback>CRM_UF_Page_ProfileEditor::getSchemaJSON</page_callback>
diff --git a/tests/phpunit/E2E/Core/ErrorTest.php b/tests/phpunit/E2E/Core/ErrorTest.php
new file mode 100644 (file)
index 0000000..90f7c4e
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+
+namespace E2E\Core;
+
+use Civi\Test\HttpTestTrait;
+
+/**
+ * Class ErrorTest
+ * @package E2E\Core
+ * @group e2e
+ *
+ * Check that errors are reported in a sensible way. In this context, we speak of a few common error types, eg
+ *
+ * - `fatal` -- ie `CRM_Core_Error::fatal("Some message")'
+ * - `exception` -- ie `throw new \Exception("Some message")'
+ * - `permission` -- ie `CRM_Utils_System::permissionDenied()`
+ */
+class ErrorTest extends \CiviEndToEndTestCase {
+
+  use HttpTestTrait;
+
+  /**
+   * FIXME: These represent pre-existing bugs.
+   *
+   * By default, these test scenarios do not run in CI.
+   * However, you can run them manually by setting env-var `FORCE_ALL=1`.
+   *
+   * @var string[]
+   */
+  protected $nonCompliant = [
+    // Format: "{$uf}_{$testFunc}_{$errorType}"
+    '/WordPress_testErrorStatus_(fatal|exception)/',
+    '/Drupal_testErrorChrome_(fatal|exception)/',
+  ];
+
+  public function getErrorTypes() {
+    return [
+      'frontend_fatal' => ['frontend://civicrm/dev/fake-error', 'fatal'],
+      'frontend_exception' => ['frontend://civicrm/dev/fake-error', 'exception'],
+      'frontend_permission' => ['frontend://civicrm/dev/fake-error', 'permission'],
+      'backend_fatal' => ['backend://civicrm/dev/fake-error', 'fatal'],
+      'backend_exception' => ['backend://civicrm/dev/fake-error', 'exception'],
+      'backend_permission' => ['backend://civicrm/dev/fake-error', 'permission'],
+    ];
+  }
+
+  /**
+   * When showing an error screen, does the basic message come through?
+   *
+   * @param string $url
+   *   Ex: 'frontend://civicrm/dev/fake-error'
+   * @param string $errorType
+   *   Ex: 'fatal' or 'exception'
+   * @dataProvider getErrorTypes
+   */
+  public function testErrorMessage(string $url, string $errorType) {
+    $this->skipIfNonCompliant(__FUNCTION__, $errorType);
+    $messages = [
+      'fatal' => '/This is a fake problem \(fatal\)/',
+      'exception' => '/This is a fake problem \(exception\)/',
+      'permission' => '/(You do not have permission|You are not authorized to access)/',
+    ];
+    $response = $this->provokeError($url, $errorType);
+    $this->assertBodyRegexp($messages[$errorType] ?? 'Test error: Invalid error type', $response);
+  }
+
+  /**
+   * When showing an error screen, does the HTTP status indicate an error?
+   *
+   * @param string $url
+   *   Ex: 'frontend://civicrm/dev/fake-error'
+   * @param string $errorType
+   *   Ex: 'fatal' or 'exception'
+   * @dataProvider getErrorTypes
+   */
+  public function testErrorStatus(string $url, string $errorType) {
+    $this->skipIfNonCompliant(__FUNCTION__, $errorType);
+    $httpCodes = [
+      'fatal' => 500,
+      'exception' => 500,
+      'permission' => 403,
+    ];
+    $response = $this->provokeError($url, $errorType);
+    $this->assertStatusCode($httpCodes[$errorType] ?? 'Test error: Invalid error type', $response);
+  }
+
+  /**
+   * @param string $url
+   *   Ex: 'frontend://civicrm/dev/fake-error'
+   * @param string $errorType
+   *   Ex: 'fatal' or 'exception'
+   * @dataProvider getErrorTypes
+   */
+  public function testErrorChrome(string $url, string $errorType) {
+    $this->skipIfNonCompliant(__FUNCTION__, $errorType);
+    $patterns = [
+      'Backdrop' => '/body class=\".*not-logged-in/',
+      'Drupal' => '/body class=\".*not-logged-in/',
+      'Drupal8' => '/body class=\".*not-logged-in/',
+      'WordPress' => '/ role=.navigation./',
+    ];
+    if (!isset($patterns[CIVICRM_UF])) {
+      $this->markTestIncomplete('testErrorChrome() cannot check for chrome on ' . CIVICRM_UF);
+    }
+
+    $response = $this->provokeError($url, $errorType);
+    $this->assertContentType('text/html', $response);
+    $this->assertBodyRegexp($patterns[CIVICRM_UF], $response, 'Body should have some chrome/decoration');
+  }
+
+  /**
+   * @param string $url
+   * @param string $errorType
+   * @return \Psr\Http\Message\ResponseInterface
+   */
+  protected function provokeError(string $url, string $errorType) {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+    $jwt = \Civi::service('crypto.jwt')->encode([
+      'exp' => \CRM_Utils_Time::time() + 3600,
+      'civi.fake-error' => $errorType,
+    ]);
+    return $http->get("$url?token=$jwt");
+  }
+
+  protected function skipIfNonCompliant($func, $errorType) {
+    if (getenv('FORCE_ALL')) {
+      return;
+    }
+    $sig = implode('_', [CIVICRM_UF, $func, $errorType]);
+    foreach ($this->nonCompliant as $nonCompliant) {
+      if (preg_match($nonCompliant, $sig)) {
+        $this->markTestIncomplete("Skipping non-compliant scenario ($sig matches $nonCompliant)");
+      }
+    }
+  }
+
+}