(REF) Split `AllFlowsTest` into smaller, thematic tests
authorTim Otten <totten@civicrm.org>
Thu, 16 Nov 2023 00:04:24 +0000 (16:04 -0800)
committerTim Otten <totten@civicrm.org>
Thu, 16 Nov 2023 00:20:21 +0000 (16:20 -0800)
ext/authx/tests/phpunit/Civi/Authx/AbstractFlowsTest.php
ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php [deleted file]
ext/authx/tests/phpunit/Civi/Authx/CustomFlowsTest.php [new file with mode: 0644]
ext/authx/tests/phpunit/Civi/Authx/JwtCredsTest.php [new file with mode: 0644]
ext/authx/tests/phpunit/Civi/Authx/MixedFlowsTest.php [new file with mode: 0644]
ext/authx/tests/phpunit/Civi/Authx/StatefulFlowsTest.php [new file with mode: 0644]
ext/authx/tests/phpunit/Civi/Authx/StatelessFlowsTest.php [new file with mode: 0644]

index d46b462407cf1175cca3fceea42eae9fea4d784b..8286d3d75f4162ad3dc27199227d6fe5d8836e6d 100644 (file)
@@ -40,11 +40,7 @@ class AbstractFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndI
   }
 
   public function setUp(): void {
-    $quirks = [
-      'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
-      'WordPress' => ['sendsExcessCookies'],
-    ];
-    $this->quirks = $quirks[CIVICRM_UF] ?? [];
+    $this->quirks = $this->findQuirks();
 
     parent::setUp();
     $this->settingsBackup = [];
@@ -81,15 +77,18 @@ class AbstractFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndI
   }
 
   /**
-   * Filter a request, applying the given authentication options
+   * Apply authentication options to a prepared HTTP request.
    *
    * @param \Psr\Http\Message\RequestInterface $request
+   *   The original HTTP request (without any authentication options).
    * @param string $credType
    *   Ex: 'pass', 'jwt', 'api_key'
    * @param string $flowType
    *   Ex: 'param', 'header', 'xheader'
    * @param int $cid
+   *   Authenticate as a specific contact (contact ID#).
    * @return \Psr\Http\Message\RequestInterface
+   *   The new HTTP request (with authentication options).
    */
   protected function applyAuth($request, $credType, $flowType, $cid) {
     $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
@@ -114,7 +113,7 @@ class AbstractFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndI
   }
 
   /**
-   * Assert the AJAX request provided the expected contact.
+   * Assert the AJAX response provided the expected contact.
    *
    * @param int $cid
    *   The expected contact ID
@@ -373,4 +372,15 @@ class AbstractFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndI
     return \Civi::$statics[__CLASS__]['lebowskiCID'];
   }
 
+  /**
+   * @return array|string[]
+   */
+  protected function findQuirks(): array {
+    $quirks = [
+      'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
+      'WordPress' => ['sendsExcessCookies'],
+    ];
+    return $quirks[CIVICRM_UF] ?? [];
+  }
+
 }
diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php
deleted file mode 100644 (file)
index 87e5277..0000000
+++ /dev/null
@@ -1,574 +0,0 @@
-<?php
-
-namespace Civi\Authx;
-
-use Civi\Pipe\BasicPipeClient;
-use Civi\Pipe\JsonRpcMethodException;
-use GuzzleHttp\Cookie\CookieJar;
-
-/**
- * This is a matrix-style test which assesses all supported permutations of
- *
- * @group e2e
- */
-class AllFlowsTest extends AbstractFlowsTest {
-
-  public function getStatelessExamples() {
-    $exs = [];
-    $exs[] = ['pass', 'param'];
-    $exs[] = ['pass', 'header'];
-    $exs[] = ['pass', 'xheader'];
-    $exs[] = ['api_key', 'param'];
-    $exs[] = ['api_key', 'header'];
-    $exs[] = ['api_key', 'xheader'];
-    $exs[] = ['jwt', 'param'];
-    $exs[] = ['jwt', 'header'];
-    $exs[] = ['jwt', 'xheader'];
-    return $exs;
-  }
-
-  public function testAnonymous(): void {
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $request = $this->requestMyContact();
-    $response = $http->send($request);
-    $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 \CRM_Core_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->assertNotAuthenticated($flowType === 'header' ? 'anon' : 'prohibit', $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);
-    }
-  }
-
-  /**
-   * 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 \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   * @dataProvider getStatelessExamples
-   */
-  public function testStatelessUserContact($credType, $flowType): void {
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
-
-    // Phase 1: Request fails if this credential type is not enabled
-    \Civi::settings()->set("authx_{$flowType}_cred", []);
-    $response = $http->send($request);
-    $this->assertNotAuthenticated($flowType === 'header' ? 'anon' : 'prohibit', $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->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
-    if (!in_array('sendsExcessCookies', $this->quirks)) {
-      $this->assertNoCookies($response);
-    }
-  }
-
-  /**
-   * Send a request using a jwt that can't be decoded at all. Assert that it fails
-   *
-   * @param string $flowType
-   *   The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
-   *
-   * @dataProvider getFlowTypes
-   */
-  public function testInvalidJwt($flowType): void {
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    $cred = $this->credJwt('Bearer thisisnotavalidjwt');
-
-    $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $request = $this->$flowFunc($this->requestMyContact(), $cred);
-
-    \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
-    $response = $http->send($request);
-    $this->assertNotAuthenticated('prohibit', $response);
-  }
-
-  /**
-   * Send a request using a jwt that has expired. Assert that it fails
-   *
-   * @param string $flowType
-   *   The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
-   *
-   * @dataProvider getFlowTypes
-   */
-  public function testExpiredJwt($flowType): void {
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    $cred = $this->credJwt($this->getDemoCID(), TRUE);
-    $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $request = $this->$flowFunc($this->requestMyContact(), $cred);
-
-    \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
-    $response = $http->send($request);
-    $this->assertNotAuthenticated('prohibit', $response);
-  }
-
-  /**
-   * The setting "authx_guard" may be used to require (or not require) the site_key.
-   *
-   * @throws \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   */
-  public function testStatelessGuardSiteKey() {
-    if (!defined('CIVICRM_SITE_KEY')) {
-      $this->markTestIncomplete("Cannot run test without CIVICRM_SITE_KEY");
-    }
-
-    [$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.
-   *
-   * @param string $credType
-   *   The type of credential to put in the login request.
-   * @throws \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   * @dataProvider getCredTypes
-   */
-  public function testStatefulLoginAllowed($credType): void {
-    $flowType = 'login';
-    $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
-
-    // Phase 1: Some pages are not accessible.
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-    $http->get('civicrm/user');
-    $this->assertDashboardUnauthorized();
-
-    // Phase 2: Request succeeds if this credential type is enabled
-    $cookieJar = new CookieJar();
-    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
-    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
-    $response = $http->post('civicrm/authx/login', [
-      'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
-    ]);
-    $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(), $this->getDemoUID(), $credType, $flowType, $response);
-    $response = $http->get('civicrm/user');
-    $this->assertDashboardOk();
-
-    // Phase 4: After logout, requests should fail.
-    $oldCookies = clone $cookieJar;
-    $http->get('civicrm/authx/logout');
-    $this->assertStatusCode(200);
-    $http->get('civicrm/user');
-    $this->assertDashboardUnauthorized();
-
-    $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
-    $httpHaxor->get('civicrm/user');
-    $this->assertDashboardUnauthorized();
-  }
-
-  /**
-   * The login flow 'civicrm/authx/login' may be prohibited by policy.
-   *
-   * @param string $credType
-   *   The type of credential to put in the login request.
-   * @throws \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   * @dataProvider getCredTypes
-   */
-  public function testStatefulLoginProhibited($credType): void {
-    $flowType = 'login';
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-    $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
-
-    \Civi::settings()->set("authx_{$flowType}_cred", []);
-    $response = $http->post('civicrm/authx/login', [
-      'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
-    ]);
-    $this->assertFailedDueToProhibition($response);
-  }
-
-  /**
-   * The auto-login flow allows you to request a specific page with specific
-   * credentials. The new session is setup, and the page is displayed.
-   *
-   * @param string $credType
-   *   The type of credential to put in the login request.
-   * @throws \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   * @dataProvider getCredTypes
-   */
-  public function testStatefulAutoAllowed($credType): void {
-    $flowType = 'auto';
-    $cookieJar = new CookieJar();
-    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
-
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $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->assertTrue($cookieJar->count() >= 1);
-    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
-
-    // FIXME: Assert that re-using cookies yields correct result.
-  }
-
-  /**
-   * The auto-login flow allows you to request a specific page with specific
-   * credentials. The new session is setup, and the page is displayed.
-   *
-   * @param string $credType
-   *   The type of credential to put in the login request.
-   * @throws \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   * @dataProvider getCredTypes
-   */
-  public function testStatefulAutoProhibited($credType): void {
-    $flowType = 'auto';
-    $cookieJar = new CookieJar();
-    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
-
-    /** @var \Psr\Http\Message\RequestInterface $request */
-    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
-
-    \Civi::settings()->set("authx_{$flowType}_cred", []);
-    $response = $http->send($request);
-    $this->assertFailedDueToProhibition($response);
-  }
-
-  /**
-   * Create a session for $demoCID. Within the session, make a single
-   * stateless request as $lebowskiCID.
-   *
-   * @throws \CRM_Core_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);
-  }
-
-  /**
-   * Suppose a deployment has two layers of authorization:
-   *
-   * (1) a generic/site-wide HTTP restriction (perhaps enforced by a reverse proxy)
-   * (2) anything/everything else (CMS/login-form/parameter/X-Civi-Auth stuff).
-   *
-   * Layer (1) has an `Authorization:` header that should be ignored by `authx`.
-   *
-   * This test submits both layer (1) and layer (2) credentials and ensures that authx respects
-   * the layer (2).
-   */
-  public function testIgnoredHeaderAuthorization() {
-    // We may submit some other credential - it will be used.
-    $flowType = 'param';
-    $credType = 'api_key';
-
-    \Civi::settings()->set("authx_header_cred", []);
-    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
-
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    // We submit both the irrelevant `Authorization:` and the relevant `?_authx=...` (DemoCID).
-    $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
-    $request = $this->applyAuth($request, $credType, $flowType, $this->getDemoCID());
-    // $request = $request->withAddedHeader('Authorization', $irrelevantAuthorization);
-    $response = $http->send($request);
-    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
-    if (!in_array('sendsExcessCookies', $this->quirks)) {
-      $this->assertNoCookies($response);
-    }
-  }
-
-  /**
-   * Similar to testIgnoredHeaderAuthorization(), but the Civi/CMS user is anonymous.
-   */
-  public function testIgnoredHeaderAuthorization_anon() {
-    $http = $this->createGuzzle(['http_errors' => FALSE]);
-
-    /** @var \Psr\Http\Message\RequestInterface $request */
-
-    // Variant 1: The `Authorization:` header is ignored (even if the content is totally fake/inauthentic).
-    \Civi::settings()->set("authx_header_cred", []);
-    $request = $this->requestMyContact()->withAddedHeader('Authorization', 'Basic ' . base64_encode("not:real"));
-    $response = $http->send($request);
-    $this->assertAnonymousContact($response);
-
-    // Variant 2: The `Authorization:` header is ignored (even if the content is sorta-real-ish for LebowskiCID).
-    \Civi::settings()->set("authx_header_cred", []);
-    $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
-    $response = $http->send($request);
-    $this->assertAnonymousContact($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 \CRM_Core_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, 'Expected Anonymous Contact in step #' . $i);
-          $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 \CRM_Core_Exception
-   * @throws \GuzzleHttp\Exception\GuzzleException
-   */
-  public function testJwtMiddleware() {
-    \Civi::settings()->revert("authx_param_cred");
-
-    // 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);
-    }
-  }
-
-  /**
-   * The internal API `authx_login()` should be used by background services to set the active user.
-   *
-   * To test this, we call `cv ev 'authx_login(...);'` and check the resulting identity.
-   *
-   * @throws \CRM_Core_Exception
-   */
-  public function testCliServiceLogin() {
-    $withCv = function($phpStmt) {
-      $cmd = strtr('cv ev -v @PHP', ['@PHP' => escapeshellarg($phpStmt)]);
-      exec($cmd, $output, $val);
-      $fullOutput = implode("\n", $output);
-      $this->assertEquals(0, $val, "Command returned error ($cmd) ($val):\n\"$fullOutput\"");
-      return json_decode($fullOutput, TRUE);
-    };
-
-    $principals = [
-      'contactId' => $this->getDemoCID(),
-      'userId' => $this->getDemoUID(),
-      'user' => $GLOBALS['_CV']['DEMO_USER'],
-    ];
-    foreach ($principals as $principalField => $principalValue) {
-      $msg = "Logged in with $principalField=$principalValue. We should see this user as authenticated.";
-
-      $loginArgs = ['principal' => [$principalField => $principalValue]];
-      $report = $withCv(sprintf('return authx_login(%s);', var_export($loginArgs, 1)));
-      $this->assertEquals($this->getDemoCID(), $report['contactId'], $msg);
-      $this->assertEquals($this->getDemoUID(), $report['userId'], $msg);
-      $this->assertEquals('script', $report['flow'], $msg);
-      $this->assertEquals('assigned', $report['credType'], $msg);
-      $this->assertEquals(FALSE, $report['useSession'], $msg);
-    }
-
-    $invalidPrincipals = [
-      ['contactId', 999999, AuthxException::CLASS, ';Contact ID 999999 is invalid;'],
-      ['userId', 999999, AuthxException::CLASS, ';Cannot login. Failed to determine contact ID.;'],
-      ['user', 'randuser' . mt_rand(0, 32767), AuthxException::CLASS, ';Must specify principal with valid user, userId, or contactId;'],
-    ];
-    foreach ($invalidPrincipals as $invalidPrincipal) {
-      [$principalField, $principalValue, $expectExceptionClass, $expectExceptionMessage] = $invalidPrincipal;
-
-      $loginArgs = ['principal' => [$principalField => $principalValue]];
-      $report = $withCv(sprintf('try { return authx_login(%s); } catch (Exception $e) { return [get_class($e), $e->getMessage()]; }', var_export($loginArgs, 1)));
-      $this->assertTrue(isset($report[0], $report[1]), "authx_login() should fail with invalid credentials ($principalField=>$principalValue). Received array: " . json_encode($report));
-      $this->assertMatchesRegularExpression($expectExceptionMessage, $report[1], "Invalid principal ($principalField=>$principalValue) should generate exception.");
-      $this->assertEquals($expectExceptionClass, $report[0], "Invalid principal ($principalField=>$principalValue) should generate exception.");
-    }
-  }
-
-  public function testCliPipeTrustedLogin() {
-    $rpc = new BasicPipeClient('cv ev \'Civi::pipe("tl");\'');
-    $this->assertEquals('trusted', $rpc->getWelcome()['t']);
-    $this->assertEquals(['login'], $rpc->getWelcome()['l']);
-
-    $login = $rpc->call('login', ['userId' => $this->getDemoUID()]);
-    $this->assertEquals($this->getDemoCID(), $login['contactId']);
-    $this->assertEquals($this->getDemoUID(), $login['userId']);
-
-    $me = $rpc->call('api3', ['Contact', 'get', ['id' => 'user_contact_id', 'sequential' => TRUE]]);
-    $this->assertEquals($this->getDemoCID(), $me['values'][0]['contact_id']);
-  }
-
-  public function testCliPipeUntrustedLogin() {
-    $rpc = new BasicPipeClient('cv ev \'Civi::pipe("ul");\'');
-    $this->assertEquals('untrusted', $rpc->getWelcome()['u']);
-    $this->assertEquals(['login'], $rpc->getWelcome()['l']);
-
-    try {
-      $rpc->call('login', ['userId' => $this->getDemoUID()]);
-      $this->fail('Untrusted sessions should require authentication credentials');
-    }
-    catch (JsonRpcMethodException $e) {
-      $this->assertMatchesRegularExpression(';not trusted;', $e->getMessage());
-    }
-
-    $login = $rpc->call('login', ['cred' => $this->credJwt($this->getDemoCID())]);
-    $this->assertEquals($this->getDemoCID(), $login['contactId']);
-    $this->assertEquals($this->getDemoUID(), $login['userId']);
-
-    $me = $rpc->call('api3', ['Contact', 'get', ['id' => 'user_contact_id', 'sequential' => TRUE]]);
-    $this->assertEquals($this->getDemoCID(), $me['values'][0]['contact_id']);
-  }
-
-}
diff --git a/ext/authx/tests/phpunit/Civi/Authx/CustomFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/CustomFlowsTest.php
new file mode 100644 (file)
index 0000000..2521d65
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+
+namespace Civi\Authx;
+
+use Civi\Pipe\BasicPipeClient;
+use Civi\Pipe\JsonRpcMethodException;
+
+/**
+ * Send requests using customizable flows. These don't use standard HTTP requests.
+ * Instead, they may involve authentication by external/third-party agents.
+ *
+ * The APIs `authx_login()` and `Civi::pipe()` should be focal points for customized
+ * flows. To test them, we run them in separate subprocesses (`cv ev ...`)
+ *
+ * @group e2e
+ */
+class CustomFlowsTest extends AbstractFlowsTest {
+
+  /**
+   * The internal API `authx_login()` should be used by background services to set the active user.
+   *
+   * To test this, we call `cv ev 'authx_login(...);'` and check the resulting identity.
+   *
+   * @throws \CRM_Core_Exception
+   */
+  public function testCliServiceLogin() {
+    $withCv = function($phpStmt) {
+      $cmd = strtr('cv ev -v @PHP', ['@PHP' => escapeshellarg($phpStmt)]);
+      exec($cmd, $output, $val);
+      $fullOutput = implode("\n", $output);
+      $this->assertEquals(0, $val, "Command returned error ($cmd) ($val):\n\"$fullOutput\"");
+      return json_decode($fullOutput, TRUE);
+    };
+
+    $principals = [
+      'contactId' => $this->getDemoCID(),
+      'userId' => $this->getDemoUID(),
+      'user' => $GLOBALS['_CV']['DEMO_USER'],
+    ];
+    foreach ($principals as $principalField => $principalValue) {
+      $msg = "Logged in with $principalField=$principalValue. We should see this user as authenticated.";
+
+      $loginArgs = ['principal' => [$principalField => $principalValue]];
+      $report = $withCv(sprintf('return authx_login(%s);', var_export($loginArgs, 1)));
+      $this->assertEquals($this->getDemoCID(), $report['contactId'], $msg);
+      $this->assertEquals($this->getDemoUID(), $report['userId'], $msg);
+      $this->assertEquals('script', $report['flow'], $msg);
+      $this->assertEquals('assigned', $report['credType'], $msg);
+      $this->assertEquals(FALSE, $report['useSession'], $msg);
+    }
+
+    $invalidPrincipals = [
+      ['contactId', 999999, AuthxException::CLASS, ';Contact ID 999999 is invalid;'],
+      ['userId', 999999, AuthxException::CLASS, ';Cannot login. Failed to determine contact ID.;'],
+      ['user', 'randuser' . mt_rand(0, 32767), AuthxException::CLASS, ';Must specify principal with valid user, userId, or contactId;'],
+    ];
+    foreach ($invalidPrincipals as $invalidPrincipal) {
+      [$principalField, $principalValue, $expectExceptionClass, $expectExceptionMessage] = $invalidPrincipal;
+
+      $loginArgs = ['principal' => [$principalField => $principalValue]];
+      $report = $withCv(sprintf('try { return authx_login(%s); } catch (Exception $e) { return [get_class($e), $e->getMessage()]; }', var_export($loginArgs, 1)));
+      $this->assertTrue(isset($report[0], $report[1]), "authx_login() should fail with invalid credentials ($principalField=>$principalValue). Received array: " . json_encode($report));
+      $this->assertMatchesRegularExpression($expectExceptionMessage, $report[1], "Invalid principal ($principalField=>$principalValue) should generate exception.");
+      $this->assertEquals($expectExceptionClass, $report[0], "Invalid principal ($principalField=>$principalValue) should generate exception.");
+    }
+  }
+
+  public function testCliPipeTrustedLogin() {
+    $rpc = new BasicPipeClient('cv ev \'Civi::pipe("tl");\'');
+    $this->assertEquals('trusted', $rpc->getWelcome()['t']);
+    $this->assertEquals(['login'], $rpc->getWelcome()['l']);
+
+    $login = $rpc->call('login', ['userId' => $this->getDemoUID()]);
+    $this->assertEquals($this->getDemoCID(), $login['contactId']);
+    $this->assertEquals($this->getDemoUID(), $login['userId']);
+
+    $me = $rpc->call('api3', ['Contact', 'get', ['id' => 'user_contact_id', 'sequential' => TRUE]]);
+    $this->assertEquals($this->getDemoCID(), $me['values'][0]['contact_id']);
+  }
+
+  public function testCliPipeUntrustedLogin() {
+    $rpc = new BasicPipeClient('cv ev \'Civi::pipe("ul");\'');
+    $this->assertEquals('untrusted', $rpc->getWelcome()['u']);
+    $this->assertEquals(['login'], $rpc->getWelcome()['l']);
+
+    try {
+      $rpc->call('login', ['userId' => $this->getDemoUID()]);
+      $this->fail('Untrusted sessions should require authentication credentials');
+    }
+    catch (JsonRpcMethodException $e) {
+      $this->assertMatchesRegularExpression(';not trusted;', $e->getMessage());
+    }
+
+    $login = $rpc->call('login', ['cred' => $this->credJwt($this->getDemoCID())]);
+    $this->assertEquals($this->getDemoCID(), $login['contactId']);
+    $this->assertEquals($this->getDemoUID(), $login['userId']);
+
+    $me = $rpc->call('api3', ['Contact', 'get', ['id' => 'user_contact_id', 'sequential' => TRUE]]);
+    $this->assertEquals($this->getDemoCID(), $me['values'][0]['contact_id']);
+  }
+
+}
diff --git a/ext/authx/tests/phpunit/Civi/Authx/JwtCredsTest.php b/ext/authx/tests/phpunit/Civi/Authx/JwtCredsTest.php
new file mode 100644 (file)
index 0000000..701f9c6
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+namespace Civi\Authx;
+
+/**
+ * Check that JWT credentials work in the expected ways.
+ *
+ * @group e2e
+ */
+class JwtCredsTest extends AbstractFlowsTest {
+
+  /**
+   * Send a request using a jwt that can't be decoded at all. Assert that it fails
+   *
+   * @param string $flowType
+   *   The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
+   *
+   * @dataProvider getFlowTypes
+   */
+  public function testInvalidJwt($flowType): void {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    $cred = $this->credJwt('Bearer thisisnotavalidjwt');
+
+    $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->$flowFunc($this->requestMyContact(), $cred);
+
+    \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
+    $response = $http->send($request);
+    $this->assertNotAuthenticated('prohibit', $response);
+  }
+
+  /**
+   * Send a request using a jwt that has expired. Assert that it fails
+   *
+   * @param string $flowType
+   *   The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
+   *
+   * @dataProvider getFlowTypes
+   */
+  public function testExpiredJwt($flowType): void {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    $cred = $this->credJwt($this->getDemoCID(), TRUE);
+    $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->$flowFunc($this->requestMyContact(), $cred);
+
+    \Civi::settings()->set("authx_{$flowType}_cred", ['jwt']);
+    $response = $http->send($request);
+    $this->assertNotAuthenticated('prohibit', $response);
+  }
+
+  /**
+   * 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 \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testJwtMiddleware() {
+    \Civi::settings()->revert("authx_param_cred");
+
+    // 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);
+    }
+  }
+
+}
diff --git a/ext/authx/tests/phpunit/Civi/Authx/MixedFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/MixedFlowsTest.php
new file mode 100644 (file)
index 0000000..5df227d
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+
+namespace Civi\Authx;
+
+use GuzzleHttp\Cookie\CookieJar;
+
+/**
+ * In the MixedFlowsTest, we assume that the basic flows work -- then step out to another level.
+ * What happens when different authentication behaviors get mixed-up/criss-crossed?
+ * For example:
+ *
+ * - What happens if you send several stateless requests as different users -- without realizing
+ *   that your HTTP client is actually tracking cookies? Are they truly stateless?
+ * - What happens if you send a mix of stateless and stateful requests for different users?
+ * - What happens if you mix `Authorization:` headers for authx with `Authorization:`
+ *   headers for another layer (HTTPD/CMS/proxy)?
+ *
+ * @group e2e
+ */
+class MixedFlowsTest extends AbstractFlowsTest {
+
+  /**
+   * Create a session for $demoCID. Within the session, make a single
+   * stateless request as $lebowskiCID.
+   *
+   * @throws \CRM_Core_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 \CRM_Core_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, 'Expected Anonymous Contact in step #' . $i);
+          $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);
+  }
+
+  /**
+   * Suppose a deployment has two layers of authorization:
+   *
+   * (1) a generic/site-wide HTTP restriction (perhaps enforced by a reverse proxy)
+   * (2) anything/everything else (CMS/login-form/parameter/X-Civi-Auth stuff).
+   *
+   * Layer (1) has an `Authorization:` header that should be ignored by `authx`.
+   *
+   * This test submits both layer (1) and layer (2) credentials and ensures that authx respects
+   * the layer (2).
+   */
+  public function testIgnoredHeaderAuthorization() {
+    // We may submit some other credential - it will be used.
+    $flowType = 'param';
+    $credType = 'api_key';
+
+    \Civi::settings()->set("authx_header_cred", []);
+    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
+
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    // We submit both the irrelevant `Authorization:` and the relevant `?_authx=...` (DemoCID).
+    $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
+    $request = $this->applyAuth($request, $credType, $flowType, $this->getDemoCID());
+    // $request = $request->withAddedHeader('Authorization', $irrelevantAuthorization);
+    $response = $http->send($request);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
+    if (!in_array('sendsExcessCookies', $this->quirks)) {
+      $this->assertNoCookies($response);
+    }
+  }
+
+  /**
+   * Similar to testIgnoredHeaderAuthorization(), but the Civi/CMS user is anonymous.
+   */
+  public function testIgnoredHeaderAuthorization_anon() {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+
+    // Variant 1: The `Authorization:` header is ignored (even if the content is totally fake/inauthentic).
+    \Civi::settings()->set("authx_header_cred", []);
+    $request = $this->requestMyContact()->withAddedHeader('Authorization', 'Basic ' . base64_encode("not:real"));
+    $response = $http->send($request);
+    $this->assertAnonymousContact($response);
+
+    // Variant 2: The `Authorization:` header is ignored (even if the content is sorta-real-ish for LebowskiCID).
+    \Civi::settings()->set("authx_header_cred", []);
+    $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
+    $response = $http->send($request);
+    $this->assertAnonymousContact($response);
+  }
+
+}
diff --git a/ext/authx/tests/phpunit/Civi/Authx/StatefulFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/StatefulFlowsTest.php
new file mode 100644 (file)
index 0000000..ec00197
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+
+namespace Civi\Authx;
+
+use GuzzleHttp\Cookie\CookieJar;
+
+/**
+ * Send requests using stateful authentication mechanisms (such as `login`).
+ *
+ * @group e2e
+ */
+class StatefulFlowsTest extends AbstractFlowsTest {
+
+  /**
+   * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
+   * to setup/teardown a session.
+   *
+   * @param string $credType
+   *   The type of credential to put in the login request.
+   * @throws \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getCredTypes
+   */
+  public function testStatefulLoginAllowed($credType): void {
+    $flowType = 'login';
+    $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
+
+    // Phase 1: Some pages are not accessible.
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+    $http->get('civicrm/user');
+    $this->assertDashboardUnauthorized();
+
+    // Phase 2: Request succeeds if this credential type is enabled
+    $cookieJar = new CookieJar();
+    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
+    \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
+    $response = $http->post('civicrm/authx/login', [
+      'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
+    ]);
+    $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(), $this->getDemoUID(), $credType, $flowType, $response);
+    $response = $http->get('civicrm/user');
+    $this->assertDashboardOk();
+
+    // Phase 4: After logout, requests should fail.
+    $oldCookies = clone $cookieJar;
+    $http->get('civicrm/authx/logout');
+    $this->assertStatusCode(200);
+    $http->get('civicrm/user');
+    $this->assertDashboardUnauthorized();
+
+    $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
+    $httpHaxor->get('civicrm/user');
+    $this->assertDashboardUnauthorized();
+  }
+
+  /**
+   * The login flow 'civicrm/authx/login' may be prohibited by policy.
+   *
+   * @param string $credType
+   *   The type of credential to put in the login request.
+   * @throws \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getCredTypes
+   */
+  public function testStatefulLoginProhibited($credType): void {
+    $flowType = 'login';
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+    $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
+
+    \Civi::settings()->set("authx_{$flowType}_cred", []);
+    $response = $http->post('civicrm/authx/login', [
+      'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
+    ]);
+    $this->assertFailedDueToProhibition($response);
+  }
+
+  /**
+   * The auto-login flow allows you to request a specific page with specific
+   * credentials. The new session is setup, and the page is displayed.
+   *
+   * @param string $credType
+   *   The type of credential to put in the login request.
+   * @throws \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getCredTypes
+   */
+  public function testStatefulAutoAllowed($credType): void {
+    $flowType = 'auto';
+    $cookieJar = new CookieJar();
+    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $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->assertTrue($cookieJar->count() >= 1);
+    $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $credType, $flowType, $response);
+
+    // FIXME: Assert that re-using cookies yields correct result.
+  }
+
+  /**
+   * The auto-login flow allows you to request a specific page with specific
+   * credentials. The new session is setup, and the page is displayed.
+   *
+   * @param string $credType
+   *   The type of credential to put in the login request.
+   * @throws \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getCredTypes
+   */
+  public function testStatefulAutoProhibited($credType): void {
+    $flowType = 'auto';
+    $cookieJar = new CookieJar();
+    $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
+
+    \Civi::settings()->set("authx_{$flowType}_cred", []);
+    $response = $http->send($request);
+    $this->assertFailedDueToProhibition($response);
+  }
+
+}
diff --git a/ext/authx/tests/phpunit/Civi/Authx/StatelessFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/StatelessFlowsTest.php
new file mode 100644 (file)
index 0000000..0e3bf8b
--- /dev/null
@@ -0,0 +1,141 @@
+<?php
+
+namespace Civi\Authx;
+
+/**
+ * Send requests using stateless authentication mechanisms (such as `header`, `xheader`, and
+ * `param`).
+ *
+ * @group e2e
+ */
+class StatelessFlowsTest extends AbstractFlowsTest {
+
+  public function getStatelessExamples() {
+    $exs = [];
+    $exs[] = ['pass', 'param'];
+    $exs[] = ['pass', 'header'];
+    $exs[] = ['pass', 'xheader'];
+    $exs[] = ['api_key', 'param'];
+    $exs[] = ['api_key', 'header'];
+    $exs[] = ['api_key', 'xheader'];
+    $exs[] = ['jwt', 'param'];
+    $exs[] = ['jwt', 'header'];
+    $exs[] = ['jwt', 'xheader'];
+    return $exs;
+  }
+
+  public function testAnonymous(): void {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->requestMyContact();
+    $response = $http->send($request);
+    $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 \CRM_Core_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->assertNotAuthenticated($flowType === 'header' ? 'anon' : 'prohibit', $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);
+    }
+  }
+
+  /**
+   * 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 \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   * @dataProvider getStatelessExamples
+   */
+  public function testStatelessUserContact($credType, $flowType): void {
+    $http = $this->createGuzzle(['http_errors' => FALSE]);
+
+    /** @var \Psr\Http\Message\RequestInterface $request */
+    $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
+
+    // Phase 1: Request fails if this credential type is not enabled
+    \Civi::settings()->set("authx_{$flowType}_cred", []);
+    $response = $http->send($request);
+    $this->assertNotAuthenticated($flowType === 'header' ? 'anon' : 'prohibit', $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->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 \CRM_Core_Exception
+   * @throws \GuzzleHttp\Exception\GuzzleException
+   */
+  public function testStatelessGuardSiteKey() {
+    if (!defined('CIVICRM_SITE_KEY')) {
+      $this->markTestIncomplete("Cannot run test without CIVICRM_SITE_KEY");
+    }
+
+    [$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);
+  }
+
+}