HttpTestTrait - Allow one to easily add Authx JWTs to each request
[civicrm-core.git] / ext / authx / tests / phpunit / Civi / Authx / AllFlowsTest.php
index 7641452bd5997e9e49709e31bf2a806d67bc750b..e7792914f41681b2bd970d9957796d114ad9127e 100644 (file)
@@ -34,11 +34,19 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    */
   protected $quirks = [];
 
-  public static function setUpBeforeClass() {
-    \Civi\Test::e2e()->installMe(__DIR__)->apply();
-  }
-
-  public function setUp() {
+  public static function setUpBeforeClass(): void {
+    \Civi\Test::e2e()
+      ->installMe(__DIR__)
+      ->callback(
+        function() {
+          \CRM_Utils_System::synchronizeUsers();
+        },
+        'synchronizeUsers'
+      )
+      ->apply();
+  }
+
+  public function setUp(): void {
     $quirks = [
       'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
       'WordPress' => ['sendsExcessCookies'],
@@ -48,13 +56,15 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     parent::setUp();
     $this->settingsBackup = [];
     foreach (\Civi\Authx\Meta::getFlowTypes() as $flowType) {
-      foreach (["authx_{$flowType}_cred", "authx_{$flowType}_user"] as $setting) {
+      foreach (["authx_{$flowType}_cred", "authx_{$flowType}_user", "authx_guards"] as $setting) {
         $this->settingsBackup[$setting] = \Civi::settings()->get($setting);
       }
     }
+
+    \Civi::settings()->set('authx_guards', []);
   }
 
-  public function tearDown() {
+  public function tearDown(): void {
     foreach ($this->settingsBackup as $setting => $value) {
       \Civi::settings()->set($setting, $value);
     }
@@ -83,13 +93,48 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     return $exs;
   }
 
-  public function testAnonymous() {
+  public function testAnonymous(): void {
     $http = $this->createGuzzle(['http_errors' => FALSE]);
 
     /** @var \Psr\Http\Message\RequestInterface $request */
     $request = $this->requestMyContact();
     $response = $http->send($request);
-    $this->assertNoContact(NULL, $response);
+    $this->assertAnonymousContact($response);
+  }
+
+  /**
+   * Send a request using a stateless protocol. Assert that identities are setup correctly.
+   *
+   * @param string $credType
+   *   The type of credential to put in the `Authorization:` header.
+   * @param string $flowType
+   *   The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getStatelessExamples
+   */
+  public function testStatelessContactOnly($credType, $flowType): void {
+    if ($credType === 'pass') {
+      $this->assertTrue(TRUE, 'No need to test password credentials with non-user contacts');
+      return;
+    }
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getLebowskiCID());
+
+    // Phase 1: Request fails if this credential type is not enabled
+    \Civi::settings()->set("authx_{$flowType}_cred", []);
+    $response = $http->send($request);
+    $this->assertFailedDueToProhibition($response);
+
+    // Phase 2: Request succeeds if this credential type is enabled
+    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
+    $response = $http->send($request);
+    $this->assertMyContact($this->getLebowskiCID(), NULL, $credType, $flowType, $response);
+    if (!in_array('sendsExcessCookies', $this->quirks)) {
+      $this->assertNoCookies($response);
+    }
   }
 
   /**
@@ -103,7 +148,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * @throws \GuzzleHttp\Exception\GuzzleException
    * @dataProvider getStatelessExamples
    */
-  public function testStateless($credType, $flowType) {
+  public function testStatelessUserContact($credType, $flowType): void {
     $http = $this->createGuzzle(['http_errors' => FALSE]);
 
     /** @var \Psr\Http\Message\RequestInterface $request */
@@ -117,12 +162,58 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     // Phase 2: Request succeeds if this credential type is enabled
     \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
     $response = $http->send($request);
-    $this->assertMyContact($this->getDemoCID(), $response);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
     if (!in_array('sendsExcessCookies', $this->quirks)) {
       $this->assertNoCookies($response);
     }
   }
 
+  /**
+   * The setting "authx_guard" may be used to require (or not require) the site_key.
+   *
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testStatelessGuardSiteKey() {
+    if (!defined('CIVICRM_SITE_KEY')) {
+      $this->markTestIncomplete("Cannot run test without CIVICRM_SITE_KEY");
+    }
+
+    $addParam = function($request, $key, $value) {
+      $query = $request->getUri()->getQuery();
+      return $request->withUri(
+        $request->getUri()->withQuery($query . '&' . urlencode($key) . '=' . urlencode($value))
+      );
+    };
+
+    [$credType, $flowType] = ['pass', 'header'];
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
+
+    // Request OK. Policy requires site_key, and we have one.
+    \Civi::settings()->set("authx_guards", ['site_key']);
+    $response = $http->send($request->withHeader('X-Civi-Key', CIVICRM_SITE_KEY));
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
+
+    // Request OK. Policy does not require site_key, and we do not have one
+    \Civi::settings()->set("authx_guards", []);
+    $response = $http->send($request);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
+
+    // Request fails. Policy requires site_key, but we don't have the wrong value.
+    \Civi::settings()->set("authx_guards", ['site_key']);
+    $response = $http->send($request->withHeader('X-Civi-Key', 'not-the-site-key'));
+    $this->assertFailedDueToProhibition($response);
+
+    // Request fails. Policy requires site_key, but we don't have one.
+    \Civi::settings()->set("authx_guards", ['site_key']);
+    $response = $http->send($request);
+    $this->assertFailedDueToProhibition($response);
+  }
+
   /**
    * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
    * to setup/teardown a session.
@@ -133,7 +224,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * @throws \GuzzleHttp\Exception\GuzzleException
    * @dataProvider getCredTypes
    */
-  public function testStatefulLoginAllowed($credType) {
+  public function testStatefulLoginAllowed($credType): void {
     $flowType = 'login';
     $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
 
@@ -149,12 +240,12 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     $response = $http->post('civicrm/authx/login', [
       'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
     ]);
-    $this->assertMyContact($this->getDemoCID(), $response);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
     $this->assertHasCookies($response);
 
     // Phase 3: We can use cookies to request other pages
     $response = $http->get('civicrm/authx/id');
-    $this->assertMyContact($this->getDemoCID(), $response);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
     $response = $http->get('civicrm/user');
     $this->assertDashboardOk();
 
@@ -179,7 +270,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * @throws \GuzzleHttp\Exception\GuzzleException
    * @dataProvider getCredTypes
    */
-  public function testStatefulLoginProhibited($credType) {
+  public function testStatefulLoginProhibited($credType): void {
     $flowType = 'login';
     $http = $this->createGuzzle(['http_errors' => FALSE]);
     $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
@@ -201,7 +292,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * @throws \GuzzleHttp\Exception\GuzzleException
    * @dataProvider getCredTypes
    */
-  public function testStatefulAutoAllowed($credType) {
+  public function testStatefulAutoAllowed($credType): void {
     $flowType = 'auto';
     $cookieJar = new CookieJar();
     $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
@@ -210,9 +301,10 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
 
     \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
+    $this->assertEquals(0, $cookieJar->count());
     $response = $http->send($request);
-    $this->assertHasCookies($response);
-    $this->assertMyContact($this->getDemoCID(), $response);
+    $this->assertTrue($cookieJar->count() >= 1);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
 
     // FIXME: Assert that re-using cookies yields correct result.
   }
@@ -227,7 +319,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * @throws \GuzzleHttp\Exception\GuzzleException
    * @dataProvider getCredTypes
    */
-  public function testStatefulAutoProhibited($credType) {
+  public function testStatefulAutoProhibited($credType): void {
     $flowType = 'auto';
     $cookieJar = new CookieJar();
     $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
@@ -240,6 +332,137 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     $this->assertFailedDueToProhibition($response);
   }
 
+  /**
+   * Create a session for $demoCID. Within the session, make a single
+   * stateless request as $lebowskiCID.
+   *
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testStatefulStatelessOverlap(): void {
+    \Civi::settings()->set("authx_login_cred", ['api_key']);
+    \Civi::settings()->set("authx_header_cred", ['api_key']);
+
+    $cookieJar = new CookieJar();
+    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
+
+    // Phase 1: Login, create a session.
+    $response = $http->post('civicrm/authx/login', [
+      'form_params' => ['_authx' => $this->credApikey($this->getDemoCID())],
+    ]);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'api_key', 'login', $response);
+    $this->assertHasCookies($response);
+    $response = $http->get('civicrm/authx/id');
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'api_key', 'login', $response);
+
+    // Phase 2: Make a single, stateless request with different creds
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
+    $response = $http->send($request);
+    $this->assertFailedDueToProhibition($response);
+    // The following assertion merely identifies current behavior. If you can get it working generally, then huzza.
+    $this->assertBodyRegexp(';Session already active;', $response);
+    // $this->assertMyContact($this->getLebowskiCID(), NULL, $response);
+    // $this->assertNoCookies($response);
+
+    // Phase 3: Original session is still valid
+    $response = $http->get('civicrm/authx/id');
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'api_key', 'login', $response);
+  }
+
+  /**
+   * This consumer intends to make stateless requests with a handful of different identities,
+   * but their browser happens to be cookie-enabled. Ensure that identities do not leak between requests.
+   *
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testMultipleStateless(): void {
+    \Civi::settings()->set("authx_header_cred", ['api_key']);
+    $cookieJar = new CookieJar();
+    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+
+    // Alternate calls among (A)nonymous, (D)emo, and (L)ebowski
+    $planSteps = 'LADA LDLD DDLLAA';
+    $actualSteps = '';
+
+    for ($i = 0; $i < strlen($planSteps); $i++) {
+      switch ($planSteps[$i]) {
+        case 'L':
+          $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
+          $response = $http->send($request);
+          $this->assertMyContact($this->getLebowskiCID(), NULL, 'api_key', 'header', $response, 'Expected Lebowski in step #' . $i);
+          $actualSteps .= 'L';
+          break;
+
+        case 'A':
+          $request = $this->requestMyContact();
+          $response = $http->send($request);
+          $this->assertAnonymousContact($response);
+          $actualSteps .= 'A';
+          break;
+
+        case 'D':
+          $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getDemoCID());
+          $response = $http->send($request);
+          $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'api_key', 'header', $response, 'Expected demo in step #' . $i);
+          $actualSteps .= 'D';
+          break;
+
+        case ' ':
+          $actualSteps .= ' ';
+          break;
+
+        default:
+          $this->fail('Unrecognized step #' . $i);
+      }
+    }
+
+    $this->assertEquals($actualSteps, $planSteps);
+  }
+
+  /**
+   * Civi's test suite includes middleware that will add JWT tokens to outgoing requests.
+   *
+   * This test tries a few permutations with different principals ("demo", "Lebowski"),
+   * different identifier fields (authx_user, authx_contact_id), and different
+   * flows (param/header/xheader).
+   *
+   * @throws \CiviCRM_API3_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testJwtMiddleware() {
+    // HTTP GET with a specific user. Choose flow automatically.
+    $response = $this->createGuzzle()->get('civicrm/authx/id', [
+      'authx_user' => $GLOBALS['_CV']['DEMO_USER'],
+    ]);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response);
+
+    // HTTP GET with a specific contact. Choose flow automatically.
+    $response = $this->createGuzzle()->get('civicrm/authx/id', [
+      'authx_contact_id' => $this->getDemoCID(),
+    ]);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', 'param', $response);
+
+    // HTTP POST with a specific contact. Per-client default.
+    $response = $this->createGuzzle([
+      'authx_contact_id' => $this->getLebowskiCID(),
+    ])->post('civicrm/authx/id');
+    $this->assertMyContact($this->getLebowskiCID(), NULL, 'jwt', 'param', $response);
+
+    // Using explicit flow options...
+    foreach (['param', 'xheader', 'header'] as $flowType) {
+      \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
+      $response = $this->createGuzzle()->get('civicrm/authx/id', [
+        'authx_contact_id' => $this->getDemoCID(),
+        'authx_flow' => $flowType,
+      ]);
+      $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), 'jwt', $flowType, $response);
+    }
+  }
+
   /**
    * Filter a request, applying the given authentication options
    *
@@ -277,30 +500,43 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    * Assert the AJAX request provided the expected contact.
    *
    * @param int $cid
+   *   The expected contact ID
+   * @param int|null $uid
+   *   The expected user ID
+   * @param string $credType
+   * @param string $flow
    * @param \Psr\Http\Message\ResponseInterface $response
    */
-  public function assertMyContact($cid, ResponseInterface $response) {
+  public function assertMyContact($cid, $uid, $credType, $flow, ResponseInterface $response): void {
     $this->assertContentType('application/json', $response);
     $this->assertStatusCode(200, $response);
     $j = json_decode((string) $response->getBody(), 1);
-    $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $this->formatFailure($response));
+    $formattedFailure = $this->formatFailure($response);
+    $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $formattedFailure);
+    $this->assertEquals($uid, $j['user_id'], "Response did not give expected user ID\n" . $formattedFailure);
+    if ($flow !== NULL) {
+      $this->assertEquals($flow, $j['flow'], "Response did not give expected flow type\n" . $formattedFailure);
+    }
+    if ($credType !== NULL) {
+      $this->assertEquals($credType, $j['cred'], "Response did not give expected cred type\n" . $formattedFailure);
+    }
   }
 
   /**
    * Assert the AJAX request provided empty contact information
    *
-   * @param int $cid
    * @param \Psr\Http\Message\ResponseInterface $response
    */
-  public function assertNoContact($cid, ResponseInterface $response) {
+  public function assertAnonymousContact(ResponseInterface $response): void {
+    $formattedFailure = $this->formatFailure($response);
     $this->assertContentType('application/json', $response);
     $this->assertStatusCode(200, $response);
     $j = json_decode((string) $response->getBody(), 1);
     if (json_last_error() !== JSON_ERROR_NONE || empty($j)) {
-      $this->fail('Malformed JSON' . $this->formatFailure());
+      $this->fail('Malformed JSON' . $formattedFailure);
     }
-    $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL);
-    $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL);
+    $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL, 'contact_id should be null' . $formattedFailure);
+    $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL, 'user_id should be null' . $formattedFailure);
   }
 
   /**
@@ -308,7 +544,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
    *
    * @param \Psr\Http\Message\ResponseInterface $response
    */
-  public function assertDashboardUnauthorized($response = NULL) {
+  public function assertDashboardUnauthorized($response = NULL): void {
     $response = $this->resolveResponse($response);
     if (!in_array('authErrorShowsForm', $this->quirks)) {
       $this->assertStatusCode(403, $response);
@@ -319,7 +555,7 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     );
   }
 
-  public function assertDashboardOk($response = NULL) {
+  public function assertDashboardOk($response = NULL): void {
     $response = $this->resolveResponse($response);
     $this->assertStatusCode(200, $response);
     $this->assertContentType('text/html', $response);
@@ -426,22 +662,10 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     return NULL;
   }
 
-  //  public function createBareJwtCred() {
-  //    $contact = \civicrm_api3('Contact', 'create', [
-  //      'contact_type' => 'Individual',
-  //      'first_name' => 'Jeffrey',
-  //      'last_name' => 'Lebowski',
-  //      'external_identifier' => __CLASS__,
-  //      'options' => [
-  //        'match' => 'external_identifier',
-  //      ],
-  //    ]);
-  //  }
-
   /**
    * @param \Psr\Http\Message\ResponseInterface $response
    */
-  private function assertFailedDueToProhibition($response) {
+  private function assertFailedDueToProhibition($response): void {
     $this->assertBodyRegexp(';HTTP 401;', $response);
     $this->assertContentType('text/plain', $response);
     if (!in_array('sendsExcessCookies', $this->quirks)) {
@@ -500,4 +724,24 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf
     return \Civi::$statics[__CLASS__]['demoId'];
   }
 
+  private function getDemoUID(): int {
+    return \CRM_Core_Config::singleton()->userSystem->getUfId($GLOBALS['_CV']['DEMO_USER']);
+  }
+
+  public function getLebowskiCID() {
+    if (!isset(\Civi::$statics[__CLASS__]['lebowskiCID'])) {
+      $contact = \civicrm_api3('Contact', 'create', [
+        'contact_type' => 'Individual',
+        'first_name' => 'Jeffrey',
+        'last_name' => 'Lebowski',
+        'external_identifier' => __CLASS__,
+        'options' => [
+          'match' => 'external_identifier',
+        ],
+      ]);
+      \Civi::$statics[__CLASS__]['lebowskiCID'] = $contact['id'];
+    }
+    return \Civi::$statics[__CLASS__]['lebowskiCID'];
+  }
+
 }