e80108d7074131a56c8f4b28474af29b934bdf5d
[civicrm-core.git] / ext / authx / tests / phpunit / Civi / Authx / AllFlowsTest.php
1 <?php
2
3 namespace Civi\Authx;
4
5 use Civi\Test\HttpTestTrait;
6 use CRM_Authx_ExtensionUtil as E;
7 use Civi\Test\EndToEndInterface;
8 use GuzzleHttp\Cookie\CookieJar;
9 use GuzzleHttp\Psr7\AppendStream;
10 use GuzzleHttp\Psr7\Request;
11 use GuzzleHttp\Psr7\Uri;
12 use Psr\Http\Message\ResponseInterface;
13 use function GuzzleHttp\Psr7\stream_for;
14
15 /**
16 * This is a matrix-style test which assesses all supported permutations of
17 *
18 * @group e2e
19 */
20 class AllFlowsTest extends \PHPUnit\Framework\TestCase implements EndToEndInterface {
21
22 use HttpTestTrait;
23
24 /**
25 * Backup copy of the original settings.
26 *
27 * @var array
28 */
29 protected $settingsBackup;
30
31 /**
32 * List of CMS-dependent quirks that should be ignored during testing.
33 * @var array
34 */
35 protected $quirks = [];
36
37 public static function setUpBeforeClass(): void {
38 \Civi\Test::e2e()
39 ->installMe(__DIR__)
40 ->callback(
41 function() {
42 \CRM_Utils_System::synchronizeUsers();
43 },
44 'synchronizeUsers'
45 )
46 ->apply();
47 }
48
49 public function setUp(): void {
50 $quirks = [
51 'Joomla' => ['sendsExcessCookies', 'authErrorShowsForm'],
52 'WordPress' => ['sendsExcessCookies'],
53 ];
54 $this->quirks = $quirks[CIVICRM_UF] ?? [];
55
56 parent::setUp();
57 $this->settingsBackup = [];
58 foreach (\Civi\Authx\Meta::getFlowTypes() as $flowType) {
59 foreach (["authx_{$flowType}_cred", "authx_{$flowType}_user"] as $setting) {
60 $this->settingsBackup[$setting] = \Civi::settings()->get($setting);
61 }
62 }
63 }
64
65 public function tearDown(): void {
66 foreach ($this->settingsBackup as $setting => $value) {
67 \Civi::settings()->set($setting, $value);
68 }
69 parent::tearDown();
70 }
71
72 public function getStatelessExamples() {
73 $exs = [];
74 $exs[] = ['pass', 'param'];
75 $exs[] = ['pass', 'header'];
76 $exs[] = ['pass', 'xheader'];
77 $exs[] = ['api_key', 'param'];
78 $exs[] = ['api_key', 'header'];
79 $exs[] = ['api_key', 'xheader'];
80 $exs[] = ['jwt', 'param'];
81 $exs[] = ['jwt', 'header'];
82 $exs[] = ['jwt', 'xheader'];
83 return $exs;
84 }
85
86 public function getCredTypes() {
87 $exs = [];
88 $exs[] = ['pass'];
89 $exs[] = ['api_key'];
90 $exs[] = ['jwt'];
91 return $exs;
92 }
93
94 public function testAnonymous(): void {
95 $http = $this->createGuzzle(['http_errors' => FALSE]);
96
97 /** @var \Psr\Http\Message\RequestInterface $request */
98 $request = $this->requestMyContact();
99 $response = $http->send($request);
100 $this->assertAnonymousContact($response);
101 }
102
103 /**
104 * Send a request using a stateless protocol. Assert that identities are setup correctly.
105 *
106 * @param string $credType
107 * The type of credential to put in the `Authorization:` header.
108 * @param string $flowType
109 * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
110 * @throws \CiviCRM_API3_Exception
111 * @throws \GuzzleHttp\Exception\GuzzleException
112 * @dataProvider getStatelessExamples
113 */
114 public function testStatelessContactOnly($credType, $flowType): void {
115 if ($credType === 'pass') {
116 $this->assertTrue(TRUE, 'No need to test password credentials with non-user contacts');
117 return;
118 }
119 $http = $this->createGuzzle(['http_errors' => FALSE]);
120
121 /** @var \Psr\Http\Message\RequestInterface $request */
122 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getLebowskiCID());
123
124 // Phase 1: Request fails if this credential type is not enabled
125 \Civi::settings()->set("authx_{$flowType}_cred", []);
126 $response = $http->send($request);
127 $this->assertFailedDueToProhibition($response);
128
129 // Phase 2: Request succeeds if this credential type is enabled
130 \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
131 $response = $http->send($request);
132 $this->assertMyContact($this->getLebowskiCID(), NULL, $response);
133 if (!in_array('sendsExcessCookies', $this->quirks)) {
134 $this->assertNoCookies($response);
135 }
136 }
137
138 /**
139 * Send a request using a stateless protocol. Assert that identities are setup correctly.
140 *
141 * @param string $credType
142 * The type of credential to put in the `Authorization:` header.
143 * @param string $flowType
144 * The "flow" determines how the credential is added on top of the base-request (e.g. adding a parameter or header).
145 * @throws \CiviCRM_API3_Exception
146 * @throws \GuzzleHttp\Exception\GuzzleException
147 * @dataProvider getStatelessExamples
148 */
149 public function testStatelessUserContact($credType, $flowType): void {
150 $http = $this->createGuzzle(['http_errors' => FALSE]);
151
152 /** @var \Psr\Http\Message\RequestInterface $request */
153 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
154
155 // Phase 1: Request fails if this credential type is not enabled
156 \Civi::settings()->set("authx_{$flowType}_cred", []);
157 $response = $http->send($request);
158 $this->assertFailedDueToProhibition($response);
159
160 // Phase 2: Request succeeds if this credential type is enabled
161 \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
162 $response = $http->send($request);
163 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
164 if (!in_array('sendsExcessCookies', $this->quirks)) {
165 $this->assertNoCookies($response);
166 }
167 }
168
169 /**
170 * The login flow allows you use 'civicrm/authx/login' and 'civicrm/authx/logout'
171 * to setup/teardown a session.
172 *
173 * @param string $credType
174 * The type of credential to put in the login request.
175 * @throws \CiviCRM_API3_Exception
176 * @throws \GuzzleHttp\Exception\GuzzleException
177 * @dataProvider getCredTypes
178 */
179 public function testStatefulLoginAllowed($credType): void {
180 $flowType = 'login';
181 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
182
183 // Phase 1: Some pages are not accessible.
184 $http = $this->createGuzzle(['http_errors' => FALSE]);
185 $http->get('civicrm/user');
186 $this->assertDashboardUnauthorized();
187
188 // Phase 2: Request succeeds if this credential type is enabled
189 $cookieJar = new CookieJar();
190 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
191 \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
192 $response = $http->post('civicrm/authx/login', [
193 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
194 ]);
195 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
196 $this->assertHasCookies($response);
197
198 // Phase 3: We can use cookies to request other pages
199 $response = $http->get('civicrm/authx/id');
200 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
201 $response = $http->get('civicrm/user');
202 $this->assertDashboardOk();
203
204 // Phase 4: After logout, requests should fail.
205 $oldCookies = clone $cookieJar;
206 $http->get('civicrm/authx/logout');
207 $this->assertStatusCode(200);
208 $http->get('civicrm/user');
209 $this->assertDashboardUnauthorized();
210
211 $httpHaxor = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $oldCookies]);
212 $httpHaxor->get('civicrm/user');
213 $this->assertDashboardUnauthorized();
214 }
215
216 /**
217 * The login flow 'civicrm/authx/login' may be prohibited by policy.
218 *
219 * @param string $credType
220 * The type of credential to put in the login request.
221 * @throws \CiviCRM_API3_Exception
222 * @throws \GuzzleHttp\Exception\GuzzleException
223 * @dataProvider getCredTypes
224 */
225 public function testStatefulLoginProhibited($credType): void {
226 $flowType = 'login';
227 $http = $this->createGuzzle(['http_errors' => FALSE]);
228 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
229
230 \Civi::settings()->set("authx_{$flowType}_cred", []);
231 $response = $http->post('civicrm/authx/login', [
232 'form_params' => ['_authx' => $this->$credFunc($this->getDemoCID())],
233 ]);
234 $this->assertFailedDueToProhibition($response);
235 }
236
237 /**
238 * The auto-login flow allows you to request a specific page with specific
239 * credentials. The new session is setup, and the page is displayed.
240 *
241 * @param string $credType
242 * The type of credential to put in the login request.
243 * @throws \CiviCRM_API3_Exception
244 * @throws \GuzzleHttp\Exception\GuzzleException
245 * @dataProvider getCredTypes
246 */
247 public function testStatefulAutoAllowed($credType): void {
248 $flowType = 'auto';
249 $cookieJar = new CookieJar();
250 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
251
252 /** @var \Psr\Http\Message\RequestInterface $request */
253 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
254
255 \Civi::settings()->set("authx_{$flowType}_cred", [$credType]);
256 $this->assertEquals(0, $cookieJar->count());
257 $response = $http->send($request);
258 $this->assertTrue($cookieJar->count() >= 1);
259 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
260
261 // FIXME: Assert that re-using cookies yields correct result.
262 }
263
264 /**
265 * The auto-login flow allows you to request a specific page with specific
266 * credentials. The new session is setup, and the page is displayed.
267 *
268 * @param string $credType
269 * The type of credential to put in the login request.
270 * @throws \CiviCRM_API3_Exception
271 * @throws \GuzzleHttp\Exception\GuzzleException
272 * @dataProvider getCredTypes
273 */
274 public function testStatefulAutoProhibited($credType): void {
275 $flowType = 'auto';
276 $cookieJar = new CookieJar();
277 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
278
279 /** @var \Psr\Http\Message\RequestInterface $request */
280 $request = $this->applyAuth($this->requestMyContact(), $credType, $flowType, $this->getDemoCID());
281
282 \Civi::settings()->set("authx_{$flowType}_cred", []);
283 $response = $http->send($request);
284 $this->assertFailedDueToProhibition($response);
285 }
286
287 /**
288 * Create a session for $demoCID. Within the session, make a single
289 * stateless request as $lebowskiCID.
290 *
291 * @throws \CiviCRM_API3_Exception
292 * @throws \GuzzleHttp\Exception\GuzzleException
293 */
294 public function testStatefulStatelessOverlap(): void {
295 \Civi::settings()->set("authx_login_cred", ['api_key']);
296 \Civi::settings()->set("authx_header_cred", ['api_key']);
297
298 $cookieJar = new CookieJar();
299 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
300
301 // Phase 1: Login, create a session.
302 $response = $http->post('civicrm/authx/login', [
303 'form_params' => ['_authx' => $this->credApikey($this->getDemoCID())],
304 ]);
305 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
306 $this->assertHasCookies($response);
307 $response = $http->get('civicrm/authx/id');
308 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
309
310 // Phase 2: Make a single, stateless request with different creds
311 /** @var \Psr\Http\Message\RequestInterface $request */
312 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
313 $response = $http->send($request);
314 $this->assertFailedDueToProhibition($response);
315 // The following assertion merely identifies current behavior. If you can get it working generally, then huzza.
316 $this->assertBodyRegexp(';Session already active;', $response);
317 // $this->assertMyContact($this->getLebowskiCID(), NULL, $response);
318 // $this->assertNoCookies($response);
319
320 // Phase 3: Original session is still valid
321 $response = $http->get('civicrm/authx/id');
322 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response);
323 }
324
325 /**
326 * This consumer intends to make stateless requests with a handful of different identities,
327 * but their browser happens to be cookie-enabled. Ensure that identities do not leak between requests.
328 *
329 * @throws \CiviCRM_API3_Exception
330 * @throws \GuzzleHttp\Exception\GuzzleException
331 */
332 public function testMultipleStateless(): void {
333 \Civi::settings()->set("authx_header_cred", ['api_key']);
334 $cookieJar = new CookieJar();
335 $http = $this->createGuzzle(['http_errors' => FALSE, 'cookies' => $cookieJar]);
336
337 /** @var \Psr\Http\Message\RequestInterface $request */
338
339 // Alternate calls among (A)nonymous, (D)emo, and (L)ebowski
340 $planSteps = 'LADA LDLD DDLLAA';
341 $actualSteps = '';
342
343 for ($i = 0; $i < strlen($planSteps); $i++) {
344 switch ($planSteps[$i]) {
345 case 'L':
346 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getLebowskiCID());
347 $response = $http->send($request);
348 $this->assertMyContact($this->getLebowskiCID(), NULL, $response, 'Expected Lebowski in step #' . $i);
349 $actualSteps .= 'L';
350 break;
351
352 case 'A':
353 $request = $this->requestMyContact();
354 $response = $http->send($request);
355 $this->assertAnonymousContact($response);
356 $actualSteps .= 'A';
357 break;
358
359 case 'D':
360 $request = $this->applyAuth($this->requestMyContact(), 'api_key', 'header', $this->getDemoCID());
361 $response = $http->send($request);
362 $this->assertMyContact($this->getDemoCID(), $this->getDemoUID(), $response, 'Expected demo in step #' . $i);
363 $actualSteps .= 'D';
364 break;
365
366 case ' ':
367 $actualSteps .= ' ';
368 break;
369
370 default:
371 $this->fail('Unrecognized step #' . $i);
372 }
373 }
374
375 $this->assertEquals($actualSteps, $planSteps);
376 }
377
378 /**
379 * Filter a request, applying the given authentication options
380 *
381 * @param \Psr\Http\Message\RequestInterface $request
382 * @param string $credType
383 * Ex: 'pass', 'jwt', 'api_key'
384 * @param string $flowType
385 * Ex: 'param', 'header', 'xheader'
386 * @param int $cid
387 * @return \Psr\Http\Message\RequestInterface
388 */
389 protected function applyAuth($request, $credType, $flowType, $cid) {
390 $credFunc = 'cred' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $credType));
391 $flowFunc = 'auth' . ucfirst(preg_replace(';[^a-zA-Z0-9];', '', $flowType));
392 return $this->$flowFunc($request, $this->$credFunc($cid));
393 }
394
395 // ------------------------------------------------
396 // Library: Base requests
397
398 /**
399 * Make an AJAX request with info about the current contact.
400 *
401 * @return \GuzzleHttp\Psr7\Request
402 */
403 public function requestMyContact() {
404 $p = (['where' => [['id', '=', 'user_contact_id']]]);
405 $uri = (new Uri('civicrm/authx/id'))
406 ->withQuery('params=' . urlencode(json_encode($p)));
407 $req = new Request('GET', $uri);
408 return $req;
409 }
410
411 /**
412 * Assert the AJAX request provided the expected contact.
413 *
414 * @param int $cid
415 * The expected contact ID
416 * @param int|null $uid
417 * The expected user ID
418 * @param \Psr\Http\Message\ResponseInterface $response
419 */
420 public function assertMyContact($cid, $uid, ResponseInterface $response): void {
421 $this->assertContentType('application/json', $response);
422 $this->assertStatusCode(200, $response);
423 $j = json_decode((string) $response->getBody(), 1);
424 $formattedFailure = $this->formatFailure($response);
425 $this->assertEquals($cid, $j['contact_id'], "Response did not give expected contact ID\n" . $formattedFailure);
426 $this->assertEquals($uid, $j['user_id'], "Response did not give expected user ID\n" . $formattedFailure);
427 }
428
429 /**
430 * Assert the AJAX request provided empty contact information
431 *
432 * @param \Psr\Http\Message\ResponseInterface $response
433 */
434 public function assertAnonymousContact(ResponseInterface $response): void {
435 $formattedFailure = $this->formatFailure($response);
436 $this->assertContentType('application/json', $response);
437 $this->assertStatusCode(200, $response);
438 $j = json_decode((string) $response->getBody(), 1);
439 if (json_last_error() !== JSON_ERROR_NONE || empty($j)) {
440 $this->fail('Malformed JSON' . $formattedFailure);
441 }
442 $this->assertTrue(array_key_exists('contact_id', $j) && $j['contact_id'] === NULL, 'contact_id should be null' . $formattedFailure);
443 $this->assertTrue(array_key_exists('user_id', $j) && $j['user_id'] === NULL, 'user_id should be null' . $formattedFailure);
444 }
445
446 /**
447 * Assert that the $response indicates the user cannot view the dashboard.
448 *
449 * @param \Psr\Http\Message\ResponseInterface $response
450 */
451 public function assertDashboardUnauthorized($response = NULL): void {
452 $response = $this->resolveResponse($response);
453 if (!in_array('authErrorShowsForm', $this->quirks)) {
454 $this->assertStatusCode(403, $response);
455 }
456 $this->assertFalse(
457 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
458 'Response should not contain a dashboard' . $this->formatFailure($response)
459 );
460 }
461
462 public function assertDashboardOk($response = NULL): void {
463 $response = $this->resolveResponse($response);
464 $this->assertStatusCode(200, $response);
465 $this->assertContentType('text/html', $response);
466 // If the first two assertions pass but the next fails, then... perhaps the
467 // local site permissions are wrong?
468 $this->assertTrue(
469 (bool) preg_match(';crm-dashboard-groups;', (string) $response->getBody()),
470 'Response should contain a dashboard' . $this->formatFailure($response)
471 );
472 }
473
474 // ------------------------------------------------
475 // Library: Flow functions
476
477 /**
478 * Add query parameter ("&_authx=<CRED>").
479 *
480 * @param \GuzzleHttp\Psr7\Request $request
481 * @param string $cred
482 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
483 * @return \GuzzleHttp\Psr7\Request
484 */
485 public function authParam(Request $request, $cred) {
486 $query = $request->getUri()->getQuery();
487 return $request->withUri(
488 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred))
489 );
490 }
491
492 /**
493 * Add query parameter ("&_authx=<CRED>&_authxSes=1").
494 *
495 * @param \GuzzleHttp\Psr7\Request $request
496 * @param string $cred
497 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
498 * @return \GuzzleHttp\Psr7\Request
499 */
500 public function authAuto(Request $request, $cred) {
501 $query = $request->getUri()->getQuery();
502 return $request->withUri(
503 $request->getUri()->withQuery($query . '&_authx=' . urlencode($cred) . '&_authxSes=1')
504 );
505 }
506
507 public function authLogin(Request $request, $cred) {
508 return $request->withMethod('POST')
509 ->withBody(new AppendStream([
510 stream_for('_authx=' . urlencode($cred) . '&'),
511 $request->getBody(),
512 ]));
513 }
514
515 public function authHeader(Request $request, $cred) {
516 return $request->withHeader('Authorization', $cred);
517 }
518
519 public function authXHeader(Request $request, $cred) {
520 return $request->withHeader('X-Civi-Auth', $cred);
521 }
522
523 public function authNone(Request $request, $cred) {
524 return $request;
525 }
526
527 // ------------------------------------------------
528 // Library: Credential functions
529
530 /**
531 * @param int $cid
532 * @return string
533 * The credential add to the request (e.g. "Basic ASDF==" or "Bearer FDSA").
534 */
535 public function credPass($cid) {
536 if ($cid === $this->getDemoCID()) {
537 return 'Basic ' . base64_encode($GLOBALS['_CV']['DEMO_USER'] . ':' . $GLOBALS['_CV']['DEMO_PASS']);
538 }
539 else {
540 $this->fail("This test does have the password the requested contact.");
541 }
542 }
543
544 public function credApikey($cid) {
545 $api_key = md5(\random_bytes(16));
546 \civicrm_api3('Contact', 'create', [
547 'id' => $cid,
548 'api_key' => $api_key,
549 ]);
550 return 'Bearer ' . $api_key;
551 }
552
553 public function credJwt($cid) {
554 if (empty(\Civi::service('crypto.registry')->findKeysByTag('SIGN'))) {
555 $this->markTestIncomplete('Cannot test JWT. No CIVICRM_SIGN_KEYS are defined.');
556 }
557 $token = \Civi::service('crypto.jwt')->encode([
558 'exp' => time() + 60 * 60,
559 'sub' => "cid:$cid",
560 'scope' => 'authx',
561 ]);
562 return 'Bearer ' . $token;
563 }
564
565 public function credNone($cid) {
566 return NULL;
567 }
568
569 /**
570 * @param \Psr\Http\Message\ResponseInterface $response
571 */
572 private function assertFailedDueToProhibition($response): void {
573 $this->assertBodyRegexp(';HTTP 401;', $response);
574 $this->assertContentType('text/plain', $response);
575 if (!in_array('sendsExcessCookies', $this->quirks)) {
576 $this->assertNoCookies($response);
577 }
578 $this->assertStatusCode(401, $response);
579
580 }
581
582 /**
583 * @param \Psr\Http\Message\ResponseInterface $response
584 */
585 private function assertNoCookies($response = NULL) {
586 $response = $this->resolveResponse($response);
587 $this->assertEmpty(
588 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
589 'Response should not have cookies' . $this->formatFailure($response)
590 );
591 return $this;
592 }
593
594 /**
595 * @param \Psr\Http\Message\ResponseInterface $response
596 */
597 private function assertHasCookies($response = NULL) {
598 $response = $this->resolveResponse($response);
599 $this->assertNotEmpty(
600 preg_grep('/Set-Cookie/i', array_keys($response->getHeaders())),
601 'Response should have cookies' . $this->formatFailure($response)
602 );
603 return $this;
604 }
605
606 /**
607 * @param $regexp
608 * @param \Psr\Http\Message\ResponseInterface $response
609 */
610 private function assertBodyRegexp($regexp, $response = NULL) {
611 $response = $this->resolveResponse($response);
612 $this->assertRegexp($regexp, (string) $response->getBody(),
613 'Response body does not match pattern' . $this->formatFailure($response));
614 return $this;
615 }
616
617 /**
618 * @return int
619 * @throws \CiviCRM_API3_Exception
620 */
621 private function getDemoCID(): int {
622 if (!isset(\Civi::$statics[__CLASS__]['demoId'])) {
623 \Civi::$statics[__CLASS__]['demoId'] = (int) \civicrm_api3('Contact', 'getvalue', [
624 'id' => '@user:' . $GLOBALS['_CV']['DEMO_USER'],
625 'return' => 'id',
626 ]);
627 }
628 return \Civi::$statics[__CLASS__]['demoId'];
629 }
630
631 private function getDemoUID(): int {
632 return \CRM_Core_Config::singleton()->userSystem->getUfId($GLOBALS['_CV']['DEMO_USER']);
633 }
634
635 public function getLebowskiCID() {
636 if (!isset(\Civi::$statics[__CLASS__]['lebowskiCID'])) {
637 $contact = \civicrm_api3('Contact', 'create', [
638 'contact_type' => 'Individual',
639 'first_name' => 'Jeffrey',
640 'last_name' => 'Lebowski',
641 'external_identifier' => __CLASS__,
642 'options' => [
643 'match' => 'external_identifier',
644 ],
645 ]);
646 \Civi::$statics[__CLASS__]['lebowskiCID'] = $contact['id'];
647 }
648 return \Civi::$statics[__CLASS__]['lebowskiCID'];
649 }
650
651 }