From 95afc5a5305c90a582d73793044765b4b4c182c9 Mon Sep 17 00:00:00 2001 From: Tim Otten Date: Wed, 15 Nov 2023 15:24:28 -0800 Subject: [PATCH] (REF) Extract base class `AbstractFlowsTest` --- .../phpunit/Civi/Authx/AbstractFlowsTest.php | 376 ++++++++++++++++++ .../tests/phpunit/Civi/Authx/AllFlowsTest.php | 371 +---------------- 2 files changed, 377 insertions(+), 370 deletions(-) create mode 100644 ext/authx/tests/phpunit/Civi/Authx/AbstractFlowsTest.php diff --git a/ext/authx/tests/phpunit/Civi/Authx/AbstractFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AbstractFlowsTest.php new file mode 100644 index 0000000000..d46b462407 --- /dev/null +++ b/ext/authx/tests/phpunit/Civi/Authx/AbstractFlowsTest.php @@ -0,0 +1,376 @@ +installMe(__DIR__) + ->callback( + function() { + \CRM_Utils_System::synchronizeUsers(); + }, + 'synchronizeUsers' + ) + ->apply(); + } + + public function setUp(): void { + $quirks = [ + 'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'], + 'WordPress' => ['sendsExcessCookies'], + ]; + $this->quirks = $quirks[CIVICRM_UF] ?? []; + + parent::setUp(); + $this->settingsBackup = []; + foreach (\Civi\Authx\Meta::getFlowTypes() as $flowType) { + 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(): void { + foreach ($this->settingsBackup as $setting => $value) { + \Civi::settings()->set($setting, $value); + } + parent::tearDown(); + } + + public function getCredTypes() { + $exs = []; + $exs[] = ['pass']; + $exs[] = ['api_key']; + $exs[] = ['jwt']; + return $exs; + } + + public function getFlowTypes() { + $exs = []; + $exs[] = ['param']; + $exs[] = ['header']; + $exs[] = ['xheader']; + return $exs; + } + + /** + * Filter a request, applying the given authentication options + * + * @param \Psr\Http\Message\RequestInterface $request + * @param string $credType + * Ex: 'pass', 'jwt', 'api_key' + * @param string $flowType + * Ex: 'param', 'header', 'xheader' + * @param int $cid + * @return \Psr\Http\Message\RequestInterface + */ + protected function applyAuth($request, $credType, $flowType, $cid) { + $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType)); + $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType)); + return $this->$flowFunc($request, $this->$credFunc($cid)); + } + + // ------------------------------------------------ + // Library: Base requests + + /** + * Make an AJAX request with info about the current contact. + * + * @return \GuzzleHttp\Psr7\Request + */ + public function requestMyContact() { + $p = (['where' => [['id', '=', 'user_contact_id']]]); + $uri = (new Uri('civicrm/authx/id')) + ->withQuery('params=' . urlencode(json_encode($p))); + $req = new Request('GET', $uri); + return $req; + } + + /** + * 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, $uid, $credType, $flow, ResponseInterface $response): void { + $this->assertContentType('application/json', $response); + $this->assertStatusCode(200, $response); + $j = json_decode((string) $response->getBody(), 1); + $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 \Psr\Http\Message\ResponseInterface $response + * @param string $additionalMessage + */ + public function assertAnonymousContact(ResponseInterface $response, $additionalMessage = ''): 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' . $formattedFailure); + } + $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL, 'contact_id should be null' . $formattedFailure . ' ' . $additionalMessage); + $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL, 'user_id should be null' . $formattedFailure . ' ' . $additionalMessage); + } + + /** + * Assert that the $response indicates the user cannot view the dashboard. + * + * @param \Psr\Http\Message\ResponseInterface $response + */ + public function assertDashboardUnauthorized($response = NULL): void { + $response = $this->resolveResponse($response); + if (!in_array('authErrorShowsForm', $this->quirks)) { + $this->assertStatusCode(403, $response); + } + $this->assertFalse( + (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()), + 'Response should not contain a dashboard' . $this->formatFailure($response) + ); + } + + public function assertDashboardOk($response = NULL): void { + $response = $this->resolveResponse($response); + $this->assertStatusCode(200, $response); + $this->assertContentType('text/html', $response); + // If the first two assertions pass but the next fails, then... perhaps the + // local site permissions are wrong? + $this->assertTrue( + (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()), + 'Response should contain a dashboard' . $this->formatFailure($response) + ); + } + + // ------------------------------------------------ + // Library: Flow functions + + /** + * Add query parameter ("&_authx="). + * + * @param \GuzzleHttp\Psr7\Request $request + * @param string $cred + * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). + * @return \GuzzleHttp\Psr7\Request + */ + public function authParam(Request $request, $cred) { + $query = $request->getUri()->getQuery(); + return $request->withUri( + $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred)) + ); + } + + /** + * Add query parameter ("&_authx=&_authxSes=1"). + * + * @param \GuzzleHttp\Psr7\Request $request + * @param string $cred + * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). + * @return \GuzzleHttp\Psr7\Request + */ + public function authAuto(Request $request, $cred) { + $query = $request->getUri()->getQuery(); + return $request->withUri( + $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred) . '&_authxSes=1') + ); + } + + public function authLogin(Request $request, $cred) { + return $request->withMethod('POST') + ->withBody(new AppendStream([ + stream_for('_authx=' . urlencode($cred) . '&'), + $request->getBody(), + ])); + } + + public function authHeader(Request $request, $cred) { + return $request->withHeader('Authorization', $cred); + } + + public function authXHeader(Request $request, $cred) { + return $request->withHeader('X-Civi-Auth', $cred); + } + + public function authNone(Request $request, $cred) { + return $request; + } + + // ------------------------------------------------ + // Library: Credential functions + + /** + * @param int $cid + * @return string + * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). + */ + public function credPass($cid) { + if ($cid === $this->getDemoCID()) { + return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']); + } + else { + $this->fail("This test does have the password the requested contact."); + } + } + + public function credApikey($cid) { + $api_key = md5(\random_bytes(16)); + \civicrm_api3('Contact', 'create', [ + 'id' => $cid, + 'api_key' => $api_key, + ]); + return 'Bearer ' . $api_key; + } + + public function credJwt($cid, $expired = FALSE) { + if (empty(\Civi::service('crypto.registry')->findKeysByTag('SIGN'))) { + $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.'); + } + $token = \Civi::service('crypto.jwt')->encode([ + 'exp' => $expired ? time() - 60 * 60 : time() + 60 * 60, + 'sub' => "cid:$cid", + 'scope' => 'authx', + ]); + return 'Bearer ' . $token; + } + + public function credNone($cid) { + return NULL; + } + + /** + * Assert that a request was not authenticated. + * + * @param string $mode + * Expect that the 'prohibited' or 'anon' + * @param \Psr\Http\Message\ResponseInterface $response + */ + protected function assertNotAuthenticated(string $mode, $response) { + switch ($mode) { + case 'anon': + $this->assertAnonymousContact($response); + break; + + case 'prohibit': + $this->assertFailedDueToProhibition($response); + break; + + default: + throw new \RuntimeException("Invalid option: mode=$mode"); + } + } + + /** + * @param \Psr\Http\Message\ResponseInterface $response + */ + protected function assertFailedDueToProhibition($response): void { + $this->assertBodyRegexp(';HTTP 401;', $response); + $this->assertContentType('text/plain', $response); + if (!in_array('sendsExcessCookies', $this->quirks)) { + $this->assertNoCookies($response); + } + $this->assertStatusCode(401, $response); + + } + + /** + * @param \Psr\Http\Message\ResponseInterface $response + */ + protected function assertNoCookies($response = NULL) { + $response = $this->resolveResponse($response); + $this->assertEmpty( + preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())), + 'Response should not have cookies' . $this->formatFailure($response) + ); + return $this; + } + + /** + * @param \Psr\Http\Message\ResponseInterface $response + */ + protected function assertHasCookies($response = NULL) { + $response = $this->resolveResponse($response); + $this->assertNotEmpty( + preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())), + 'Response should have cookies' . $this->formatFailure($response) + ); + return $this; + } + + /** + * @return int + * @throws \CRM_Core_Exception + */ + protected function getDemoCID(): int { + if (!isset(\Civi::$statics[__CLASS__]['demoId'])) { + \Civi::$statics[__CLASS__]['demoId'] = (int) \civicrm_api3('Contact', 'getvalue', [ + 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'], + 'return' => 'id', + ]); + } + return \Civi::$statics[__CLASS__]['demoId']; + } + + protected 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']; + } + +} diff --git a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php index f02d987211..87e52771ae 100644 --- a/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php +++ b/ext/authx/tests/phpunit/Civi/Authx/AllFlowsTest.php @@ -4,74 +4,14 @@ namespace Civi\Authx; use Civi\Pipe\BasicPipeClient; use Civi\Pipe\JsonRpcMethodException; -use Civi\Test\HttpTestTrait; -use CRM_Authx_ExtensionUtil as E; -use Civi\Test\EndToEndInterface; use GuzzleHttp\Cookie\CookieJar; -use GuzzleHttp\Psr7\AppendStream; -use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Uri; -use Psr\Http\Message\ResponseInterface; -use function GuzzleHttp\Psr7\stream_for; /** * This is a matrix-style test which assesses all supported permutations of * * @group e2e */ -class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterface { - - use HttpTestTrait; - - /** - * Backup copy of the original settings. - * - * @var array - */ - protected $settingsBackup; - - /** - * List of CMS-dependent quirks that should be ignored during testing. - * @var array - */ - protected $quirks = []; - - 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'], - ]; - $this->quirks = $quirks[CIVICRM_UF] ?? []; - - parent::setUp(); - $this->settingsBackup = []; - foreach (\Civi\Authx\Meta::getFlowTypes() as $flowType) { - 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(): void { - foreach ($this->settingsBackup as $setting => $value) { - \Civi::settings()->set($setting, $value); - } - parent::tearDown(); - } +class AllFlowsTest extends AbstractFlowsTest { public function getStatelessExamples() { $exs = []; @@ -87,22 +27,6 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf return $exs; } - public function getCredTypes() { - $exs = []; - $exs[] = ['pass']; - $exs[] = ['api_key']; - $exs[] = ['jwt']; - return $exs; - } - - public function getFlowTypes() { - $exs = []; - $exs[] = ['param']; - $exs[] = ['header']; - $exs[] = ['xheader']; - return $exs; - } - public function testAnonymous(): void { $http = $this->createGuzzle(['http_errors' => FALSE]); @@ -647,297 +571,4 @@ class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterf $this->assertEquals($this->getDemoCID(), $me['values'][0]['contact_id']); } - /** - * Filter a request, applying the given authentication options - * - * @param \Psr\Http\Message\RequestInterface $request - * @param string $credType - * Ex: 'pass', 'jwt', 'api_key' - * @param string $flowType - * Ex: 'param', 'header', 'xheader' - * @param int $cid - * @return \Psr\Http\Message\RequestInterface - */ - protected function applyAuth($request, $credType, $flowType, $cid) { - $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType)); - $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType)); - return $this->$flowFunc($request, $this->$credFunc($cid)); - } - - // ------------------------------------------------ - // Library: Base requests - - /** - * Make an AJAX request with info about the current contact. - * - * @return \GuzzleHttp\Psr7\Request - */ - public function requestMyContact() { - $p = (['where' => [['id', '=', 'user_contact_id']]]); - $uri = (new Uri('civicrm/authx/id')) - ->withQuery('params=' . urlencode(json_encode($p))); - $req = new Request('GET', $uri); - return $req; - } - - /** - * 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, $uid, $credType, $flow, ResponseInterface $response): void { - $this->assertContentType('application/json', $response); - $this->assertStatusCode(200, $response); - $j = json_decode((string) $response->getBody(), 1); - $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 \Psr\Http\Message\ResponseInterface $response - * @param string $additionalMessage - */ - public function assertAnonymousContact(ResponseInterface $response, $additionalMessage = ''): 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' . $formattedFailure); - } - $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL, 'contact_id should be null' . $formattedFailure . ' ' . $additionalMessage); - $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL, 'user_id should be null' . $formattedFailure . ' ' . $additionalMessage); - } - - /** - * Assert that the $response indicates the user cannot view the dashboard. - * - * @param \Psr\Http\Message\ResponseInterface $response - */ - public function assertDashboardUnauthorized($response = NULL): void { - $response = $this->resolveResponse($response); - if (!in_array('authErrorShowsForm', $this->quirks)) { - $this->assertStatusCode(403, $response); - } - $this->assertFalse( - (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()), - 'Response should not contain a dashboard' . $this->formatFailure($response) - ); - } - - public function assertDashboardOk($response = NULL): void { - $response = $this->resolveResponse($response); - $this->assertStatusCode(200, $response); - $this->assertContentType('text/html', $response); - // If the first two assertions pass but the next fails, then... perhaps the - // local site permissions are wrong? - $this->assertTrue( - (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()), - 'Response should contain a dashboard' . $this->formatFailure($response) - ); - } - - // ------------------------------------------------ - // Library: Flow functions - - /** - * Add query parameter ("&_authx="). - * - * @param \GuzzleHttp\Psr7\Request $request - * @param string $cred - * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). - * @return \GuzzleHttp\Psr7\Request - */ - public function authParam(Request $request, $cred) { - $query = $request->getUri()->getQuery(); - return $request->withUri( - $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred)) - ); - } - - /** - * Add query parameter ("&_authx=&_authxSes=1"). - * - * @param \GuzzleHttp\Psr7\Request $request - * @param string $cred - * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). - * @return \GuzzleHttp\Psr7\Request - */ - public function authAuto(Request $request, $cred) { - $query = $request->getUri()->getQuery(); - return $request->withUri( - $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred) . '&_authxSes=1') - ); - } - - public function authLogin(Request $request, $cred) { - return $request->withMethod('POST') - ->withBody(new AppendStream([ - stream_for('_authx=' . urlencode($cred) . '&'), - $request->getBody(), - ])); - } - - public function authHeader(Request $request, $cred) { - return $request->withHeader('Authorization', $cred); - } - - public function authXHeader(Request $request, $cred) { - return $request->withHeader('X-Civi-Auth', $cred); - } - - public function authNone(Request $request, $cred) { - return $request; - } - - // ------------------------------------------------ - // Library: Credential functions - - /** - * @param int $cid - * @return string - * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA"). - */ - public function credPass($cid) { - if ($cid === $this->getDemoCID()) { - return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']); - } - else { - $this->fail("This test does have the password the requested contact."); - } - } - - public function credApikey($cid) { - $api_key = md5(\random_bytes(16)); - \civicrm_api3('Contact', 'create', [ - 'id' => $cid, - 'api_key' => $api_key, - ]); - return 'Bearer ' . $api_key; - } - - public function credJwt($cid, $expired = FALSE) { - if (empty(\Civi::service('crypto.registry')->findKeysByTag('SIGN'))) { - $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.'); - } - $token = \Civi::service('crypto.jwt')->encode([ - 'exp' => $expired ? time() - 60 * 60 : time() + 60 * 60, - 'sub' => "cid:$cid", - 'scope' => 'authx', - ]); - return 'Bearer ' . $token; - } - - public function credNone($cid) { - return NULL; - } - - /** - * Assert that a request was not authenticated. - * - * @param string $mode - * Expect that the 'prohibited' or 'anon' - * @param \Psr\Http\Message\ResponseInterface $response - */ - private function assertNotAuthenticated(string $mode, $response) { - switch ($mode) { - case 'anon': - $this->assertAnonymousContact($response); - break; - - case 'prohibit': - $this->assertFailedDueToProhibition($response); - break; - - default: - throw new \RuntimeException("Invalid option: mode=$mode"); - } - } - - /** - * @param \Psr\Http\Message\ResponseInterface $response - */ - private function assertFailedDueToProhibition($response): void { - $this->assertBodyRegexp(';HTTP 401;', $response); - $this->assertContentType('text/plain', $response); - if (!in_array('sendsExcessCookies', $this->quirks)) { - $this->assertNoCookies($response); - } - $this->assertStatusCode(401, $response); - - } - - /** - * @param \Psr\Http\Message\ResponseInterface $response - */ - private function assertNoCookies($response = NULL) { - $response = $this->resolveResponse($response); - $this->assertEmpty( - preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())), - 'Response should not have cookies' . $this->formatFailure($response) - ); - return $this; - } - - /** - * @param \Psr\Http\Message\ResponseInterface $response - */ - private function assertHasCookies($response = NULL) { - $response = $this->resolveResponse($response); - $this->assertNotEmpty( - preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())), - 'Response should have cookies' . $this->formatFailure($response) - ); - return $this; - } - - /** - * @return int - * @throws \CRM_Core_Exception - */ - private function getDemoCID(): int { - if (!isset(\Civi::$statics[__CLASS__]['demoId'])) { - \Civi::$statics[__CLASS__]['demoId'] = (int) \civicrm_api3('Contact', 'getvalue', [ - 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'], - 'return' => 'id', - ]); - } - 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']; - } - } -- 2.25.1