From e6cf6beba028452d9ca2fc9f305b3072ecace88a Mon Sep 17 00:00:00 2001 From: Pieter van der Meulen Date: Wed, 5 Jun 2024 16:05:42 +0200 Subject: [PATCH] Add healthcheck to UserStorage, UserSecretStorage and StateStorage classes --- CHANGELOG.md | 3 + library/tiqr/Tiqr/HealthCheck/Interface.php | 19 ++++++ library/tiqr/Tiqr/StateStorage/Abstract.php | 16 +++-- library/tiqr/Tiqr/StateStorage/File.php | 20 +++++- library/tiqr/Tiqr/StateStorage/Memcache.php | 21 ++++++- library/tiqr/Tiqr/StateStorage/Pdo.php | 16 +++++ ...serSecretStorageTrait.php => Abstract.php} | 61 +++++++++++-------- library/tiqr/Tiqr/UserSecretStorage/File.php | 12 ++-- .../UserSecretStorage/OathServiceClient.php | 2 +- library/tiqr/Tiqr/UserSecretStorage/Pdo.php | 31 +++++++--- library/tiqr/Tiqr/UserStorage/Abstract.php | 12 +++- library/tiqr/Tiqr/UserStorage/FileTrait.php | 18 ++++++ library/tiqr/Tiqr/UserStorage/Pdo.php | 18 ++++++ .../tiqr/tests/Tiqr_StateStoragePdoTest.php | 20 ++++++ library/tiqr/tests/Tiqr_StateStorageTest.php | 2 + .../tests/Tiqr_UserSecretStorage_FileTest.php | 15 +++++ .../tests/Tiqr_UserSecretStorage_PdoTest.php | 25 +++++++- library/tiqr/tests/Tiqr_UserStorageTest.php | 31 ++++++++++ .../tiqr/tests/Tiqr_UserStorage_FileTest.php | 12 ++++ 19 files changed, 299 insertions(+), 55 deletions(-) create mode 100644 library/tiqr/Tiqr/HealthCheck/Interface.php rename library/tiqr/Tiqr/UserSecretStorage/{UserSecretStorageTrait.php => Abstract.php} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f1472..2bf3e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 4.3.0 +* Add healthCheck() to the UserStorage, UserSecretStorage and StateSTorage classes (#). + ## 4.2.0 * Require PHP 8.2 * Use SQL "REPLACE INTO" syntax for the state storage. This requires a mysql or sqlite backend. diff --git a/library/tiqr/Tiqr/HealthCheck/Interface.php b/library/tiqr/Tiqr/HealthCheck/Interface.php new file mode 100644 index 0000000..393b4ee --- /dev/null +++ b/library/tiqr/Tiqr/HealthCheck/Interface.php @@ -0,0 +1,19 @@ +logger = $logger; $this->_options = $options; } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + return true; // Health check is always successful when not implemented + } } diff --git a/library/tiqr/Tiqr/StateStorage/File.php b/library/tiqr/Tiqr/StateStorage/File.php index 09c421a..8967e81 100644 --- a/library/tiqr/Tiqr/StateStorage/File.php +++ b/library/tiqr/Tiqr/StateStorage/File.php @@ -32,7 +32,7 @@ * @author ivo * */ -class Tiqr_StateStorage_File implements Tiqr_StateStorage_StateStorageInterface +class Tiqr_StateStorage_File implements Tiqr_StateStorage_StateStorageInterface, Tiqr_HealthCheck_Interface { private $logger; @@ -131,4 +131,22 @@ public function init(): void { # Nothing to do here } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + try { + // Generate a random key and use it to store a value + $key = bin2hex(random_bytes(16)); + $this->setValue($key, 'healthcheck', 10); + $this->unsetValue($key); // Cleanup + } catch (Exception $e) { + $statusMessage = 'Tiqr_StateStorage_File: error setting key: ' . $e->getMessage(); + return false; + } + + return true; + } } diff --git a/library/tiqr/Tiqr/StateStorage/Memcache.php b/library/tiqr/Tiqr/StateStorage/Memcache.php index cec73d8..8801c12 100644 --- a/library/tiqr/Tiqr/StateStorage/Memcache.php +++ b/library/tiqr/Tiqr/StateStorage/Memcache.php @@ -158,12 +158,29 @@ public function getValue(string $key) $result = $this->_memcache->get($key); if ($result === false) { - // Memcache interface does not provide error information, either the key does not exists or + // Memcache interface does not provide error information, either the key does not exist or // there was an error communicating with the memcache $this->logger->info( sprintf('Unable to get key "%s" from memcache StateStorage', $key) ); return null; } return $result; } - + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + try { + // Generate a random key and use it to store a value in the memcache + $key = bin2hex(random_bytes(16)); + $this->setValue($key, 'healthcheck', 10); + } catch (Exception $e) { + $statusMessage = 'Unable to store key in memcache: ' . $e->getMessage(); + return false; + } + + return true; + } + } diff --git a/library/tiqr/Tiqr/StateStorage/Pdo.php b/library/tiqr/Tiqr/StateStorage/Pdo.php index 7247582..5732786 100644 --- a/library/tiqr/Tiqr/StateStorage/Pdo.php +++ b/library/tiqr/Tiqr/StateStorage/Pdo.php @@ -230,4 +230,20 @@ public function getValue(string $key) return $result; } + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + try { + // Retrieve a random row from the table, this checks that the table exists and is readable + $sth = $this->handle->prepare('SELECT `value`, `key`, `expire` FROM ' . $this->tablename . ' LIMIT 1'); + $sth->execute(); + } + catch (Exception $e) { + $statusMessage = sprintf('Error performing health check on PDO StateStorage: %s', $e->getMessage()); + return false; + } + return true; + } } diff --git a/library/tiqr/Tiqr/UserSecretStorage/UserSecretStorageTrait.php b/library/tiqr/Tiqr/UserSecretStorage/Abstract.php similarity index 59% rename from library/tiqr/Tiqr/UserSecretStorage/UserSecretStorageTrait.php rename to library/tiqr/Tiqr/UserSecretStorage/Abstract.php index b970c18..a6e4fe6 100644 --- a/library/tiqr/Tiqr/UserSecretStorage/UserSecretStorageTrait.php +++ b/library/tiqr/Tiqr/UserSecretStorage/Abstract.php @@ -2,39 +2,40 @@ use Psr\Log\LoggerInterface; -/** - * Copyright 2022 SURF B.V. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -trait UserSecretStorageTrait +abstract class Tiqr_UserSecretStorage_Abstract implements Tiqr_UserSecretStorage_Interface, Tiqr_HealthCheck_Interface { - /** - * @var Tiqr_UserSecretStorage_Encryption_Interface - */ - private $encryption; + protected LoggerInterface $logger; + private Tiqr_UserSecretStorage_Encryption_Interface $encryption; /** * @var array() of type_id (prefix) => Tiqr_UserSecretStorage_Encryption_Interface */ + private array $decryption; + + public function __construct(LoggerInterface $logger, Tiqr_UserSecretStorage_Encryption_Interface $encryption, array $decryption = array()) + { + $this->logger = $logger; + $this->encryption = $encryption; + $this->decryption = $decryption; + } - private $decryption; + /** + * Get the user's secret + * @param String $userId + * @return String The user's secret + * @throws Exception + */ + abstract protected function getUserSecret(string $userId): string; /** - * @var LoggerInterface + * Set the user's secret + * + * @param String $userId + * @param String $secret The user's secret + * @throws Exception */ - private $logger; + abstract protected function setUserSecret(string $userId, string $secret): void; + /** * Get the user's secret @@ -48,7 +49,7 @@ public function getSecret(string $userId): string $pos = strpos($encryptedSecret, ':'); if ($pos === false) { // If the secret is not prefixed with the encryption type_id, it is assumed to be unencrypted. - $this->logger->info("Secret for user '$userId' is not prefixed with the encryption type, assuming that it is not unencrypted"); + $this->logger->info("Secret for user '$userId' is not prefixed with the encryption type, assuming that it is not encrypted"); return $encryptedSecret; } @@ -56,7 +57,7 @@ public function getSecret(string $userId): string if ($prefix === $this->encryption->get_type()) { // Decrypt the secret if it is prefixed with the current encryption type // Remove the encryption type prefix before decrypting - return $this->encryption->decrypt( substr($encryptedSecret, $pos+1) ); + return $this->encryption->decrypt( substr($encryptedSecret, $pos+1) ); } // Check the decryption array for the encryption type to see if there is an encryption @@ -81,4 +82,12 @@ public function setSecret(string $userId, string $secret): void // Prefix the user secret with the encryption type $this->setUserSecret($userId, $this->encryption->get_type() . ':' . $encryptedSecret); } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + return true; // Health check is always successful when not implemented + } } diff --git a/library/tiqr/Tiqr/UserSecretStorage/File.php b/library/tiqr/Tiqr/UserSecretStorage/File.php index 4e6df60..c174e3c 100644 --- a/library/tiqr/Tiqr/UserSecretStorage/File.php +++ b/library/tiqr/Tiqr/UserSecretStorage/File.php @@ -34,9 +34,8 @@ * */ -class Tiqr_UserSecretStorage_File implements Tiqr_UserSecretStorage_Interface +class Tiqr_UserSecretStorage_File extends Tiqr_UserSecretStorage_Abstract { - use UserSecretStorageTrait; use FileTrait; private $path; @@ -47,10 +46,7 @@ public function __construct( LoggerInterface $logger, array $decryption = array() ) { - // See UserSecretStorageTrait - $this->encryption = $encryption; - $this->decryption = $decryption; - $this->logger = $logger; + parent::__construct($logger, $encryption, $decryption); // See FileTrait $this->path = $path; @@ -64,7 +60,7 @@ public function __construct( * @return String The user's secret * @throws Exception */ - private function getUserSecret(string $userId): string + protected function getUserSecret(string $userId): string { if ($data = $this->_loadUser($userId)) { if (isset($data["secret"])) { @@ -82,7 +78,7 @@ private function getUserSecret(string $userId): string * @param String $secret * @throws Exception */ - private function setUserSecret(string $userId, string $secret): void + protected function setUserSecret(string $userId, string $secret): void { $data=array(); if ($this->_userExists($userId)) { diff --git a/library/tiqr/Tiqr/UserSecretStorage/OathServiceClient.php b/library/tiqr/Tiqr/UserSecretStorage/OathServiceClient.php index e884863..b4f7dc7 100644 --- a/library/tiqr/Tiqr/UserSecretStorage/OathServiceClient.php +++ b/library/tiqr/Tiqr/UserSecretStorage/OathServiceClient.php @@ -28,7 +28,7 @@ class Tiqr_UserSecretStorage_OathServiceClient implements Tiqr_UserSecretStorage /** * @var LoggerInterface */ - private $logger; + private LoggerInterface $logger; public function __construct(Tiqr_API_Client $client, LoggerInterface $logger) { diff --git a/library/tiqr/Tiqr/UserSecretStorage/Pdo.php b/library/tiqr/Tiqr/UserSecretStorage/Pdo.php index 8d38045..0e05a78 100644 --- a/library/tiqr/Tiqr/UserSecretStorage/Pdo.php +++ b/library/tiqr/Tiqr/UserSecretStorage/Pdo.php @@ -54,10 +54,8 @@ * */ -class Tiqr_UserSecretStorage_Pdo implements Tiqr_UserSecretStorage_Interface +class Tiqr_UserSecretStorage_Pdo extends Tiqr_UserSecretStorage_Abstract { - use UserSecretStorageTrait; - private $tableName; private $handle; @@ -74,10 +72,7 @@ public function __construct( string $tableName, array $decryption = array() ) { - // See UserSecretStorageTrait - $this->encryption = $encryption; - $this->logger = $logger; - $this->decryption = $decryption; + parent::__construct($logger, $encryption, $decryption); // Set our own properties $this->handle = $handle; @@ -109,7 +104,7 @@ public function userExists(string $userId): bool * @return string * @throws Exception */ - private function getUserSecret(string $userId): string + protected function getUserSecret(string $userId): string { try { $sth = $this->handle->prepare('SELECT secret FROM ' . $this->tableName . ' WHERE userid = ?'); @@ -137,7 +132,7 @@ private function getUserSecret(string $userId): string * * @throws Exception */ - private function setUserSecret(string $userId, string $secret): void + protected function setUserSecret(string $userId, string $secret): void { // UserSecretStorage can be used in a separate table. In this case the table has its own userid column // This means that when a user has been created using in the UserStorage, it does not exists in the @@ -164,4 +159,22 @@ private function setUserSecret(string $userId, string $secret): void throw ReadWriteException::fromOriginalException($e); } } + + /** + * @see Tiqr_UserSecretStorage_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + // Check whether the table exists by reading a random row + try { + $sth = $this->handle->prepare('SELECT secret FROM '.$this->tableName.' LIMIT 1'); + $sth->execute(); + } + catch (Exception $e) { + $statusMessage = "UserSecretStorage_PDO error: " . $e->getMessage(); + return false; + } + + return true; + } } diff --git a/library/tiqr/Tiqr/UserStorage/Abstract.php b/library/tiqr/Tiqr/UserStorage/Abstract.php index 8c5cb33..4667b4e 100644 --- a/library/tiqr/Tiqr/UserStorage/Abstract.php +++ b/library/tiqr/Tiqr/UserStorage/Abstract.php @@ -26,9 +26,9 @@ * * @author peter */ -abstract class Tiqr_UserStorage_Abstract implements Tiqr_UserStorage_Interface +abstract class Tiqr_UserStorage_Abstract implements Tiqr_UserStorage_Interface, Tiqr_HealthCheck_Interface { - protected $logger; + protected LoggerInterface $logger; public function __construct(array $config, LoggerInterface $logger) { @@ -45,4 +45,12 @@ public function getAdditionalAttributes(string $userId): array { return array(); } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + return true; // Health check is always successful when not implemented + } } diff --git a/library/tiqr/Tiqr/UserStorage/FileTrait.php b/library/tiqr/Tiqr/UserStorage/FileTrait.php index 33e97b6..20d00d3 100644 --- a/library/tiqr/Tiqr/UserStorage/FileTrait.php +++ b/library/tiqr/Tiqr/UserStorage/FileTrait.php @@ -77,4 +77,22 @@ public function getPath(): string if (substr($this->path, -1)!="/") return $this->path."/"; return $this->path; } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + if (!is_dir($this->path)) { + $statusMessage = "FileStorage: Path does not exist"; + return false; + } + // Check if the path is writable + if (!is_writable($this->path)) { + $statusMessage = "FileStorage: Path is not writable"; + return false; + } + + return true; + } } diff --git a/library/tiqr/Tiqr/UserStorage/Pdo.php b/library/tiqr/Tiqr/UserStorage/Pdo.php index 9227d9b..e7fe5bc 100644 --- a/library/tiqr/Tiqr/UserStorage/Pdo.php +++ b/library/tiqr/Tiqr/UserStorage/Pdo.php @@ -372,4 +372,22 @@ public function getTemporaryBlockTimestamp(string $userId): int { return $this->_getIntValue('tmpblocktimestamp', $userId); } + + /** + * @see Tiqr_HealthCheck_Interface::healthCheck() + */ + public function healthCheck(string &$statusMessage = ''): bool + { + // Check whether the table exists by reading a random row + try { + $sth = $this->handle->prepare('SELECT displayname, notificationtype, notificationaddress, loginattempts, tmpblockattempts, blocked, tmpblocktimestamp FROM '.$this->tablename.' LIMIT 1'); + $sth->execute(); + } + catch (Exception $e) { + $statusMessage = "Error reading from UserStorage_PDO: ". $e->getMessage(); + return false; + } + + return true; + } } diff --git a/library/tiqr/tests/Tiqr_StateStoragePdoTest.php b/library/tiqr/tests/Tiqr_StateStoragePdoTest.php index 6e8d2a6..7e8910d 100644 --- a/library/tiqr/tests/Tiqr_StateStoragePdoTest.php +++ b/library/tiqr/tests/Tiqr_StateStoragePdoTest.php @@ -100,4 +100,24 @@ public function provideIncorrectCleanupProbabilityValues() 'value too high' => [1.001], ]; } + + public function test_healthcheck_fails_when_PDO_execute_fails() + { + $this->pdoInstance + ->shouldReceive('prepare') + ->shouldReceive('execute') + ->andThrow(new PDOException('Database unreachable')); + $this->assertFalse($this->stateStorage->healthCheck()); + } + + + public function test_healthcheck_fails_when_table_does_not_exist() + { + $targetPath = $this->makeTempDir(); + $dsn = 'sqlite:' . $targetPath . '/state.sq3'; + + $pdoInstance = new PDO($dsn, null, null); + $ss=new Tiqr_StateStorage_Pdo( $pdoInstance, $this->logger, 'does_not_exists', 1 ); + $this->assertFalse( $ss->healthCheck() ); + } } diff --git a/library/tiqr/tests/Tiqr_StateStorageTest.php b/library/tiqr/tests/Tiqr_StateStorageTest.php index 4a5dc16..5d51ea9 100644 --- a/library/tiqr/tests/Tiqr_StateStorageTest.php +++ b/library/tiqr/tests/Tiqr_StateStorageTest.php @@ -94,6 +94,7 @@ public function provideInvalidPdoConfigurationOptions() private function stateTests(Tiqr_StateStorage_StateStorageInterface $ss) { + $this->assertTrue($ss->healthCheck() ) ; // Getting nonexistent value returns NULL $this->assertEquals(NULL, $ss->getValue("nonexistent_key")); @@ -158,4 +159,5 @@ private function createStateStorage(): Tiqr_StateStorage_Pdo ]; return Tiqr_StateStorage::getStorage("pdo", $options, $this->logger); } + } diff --git a/library/tiqr/tests/Tiqr_UserSecretStorage_FileTest.php b/library/tiqr/tests/Tiqr_UserSecretStorage_FileTest.php index 3ebfab2..2fbc38d 100644 --- a/library/tiqr/tests/Tiqr_UserSecretStorage_FileTest.php +++ b/library/tiqr/tests/Tiqr_UserSecretStorage_FileTest.php @@ -64,4 +64,19 @@ private function buildUserSecretStorage(): Tiqr_UserSecretStorage_File { return new Tiqr_UserSecretStorage_File(new Tiqr_UserSecretStorage_Encryption_Plain([]), $this->targetPath, $this->logger); } + + public function test_healcheck() + { + $store = $this->buildUserSecretStorage(); + $this->assertTrue($store->healthCheck()); + } + + public function test_healcheck_fails_when_storage_is_not_writable() + { + $this->targetPath = '/path/to/nowhere'; + $store = $this->buildUserSecretStorage(); + $status = ''; + $this->assertFalse($store->healthCheck($status)); + $this->assertStringContainsString('FileStorage', $status); + } } diff --git a/library/tiqr/tests/Tiqr_UserSecretStorage_PdoTest.php b/library/tiqr/tests/Tiqr_UserSecretStorage_PdoTest.php index 89e4d9d..6c750b9 100644 --- a/library/tiqr/tests/Tiqr_UserSecretStorage_PdoTest.php +++ b/library/tiqr/tests/Tiqr_UserSecretStorage_PdoTest.php @@ -96,7 +96,7 @@ public function test_deprecated_getSecret_method_is_not_available() { $store = $this->buildUserSecretStorage(); $this->expectException(Error::class); - $this->expectExceptionMessageMatches("/Call to private method Tiqr_UserSecretStorage_Pdo::getUserSecret()/"); + $this->expectExceptionMessageMatches("/Call to protected method Tiqr_UserSecretStorage_Pdo::getUserSecret()/"); $store->getUserSecret('UserId'); } @@ -104,10 +104,31 @@ public function test_deprecated_setSecret_method_is_not_available() { $store = $this->buildUserSecretStorage(); $this->expectException(Error::class); - $this->expectExceptionMessageMatches("/Call to private method Tiqr_UserSecretStorage_Pdo::setUserSecret()/"); + $this->expectExceptionMessageMatches("/Call to protected method Tiqr_UserSecretStorage_Pdo::setUserSecret()/"); $store->setUserSecret('UserId', 'My Secret'); } + public function test_healthcheck() + { + $store = $this->buildUserSecretStorage(); + $status = ''; + $this->assertTrue($store->healthCheck($status)); + } + + public function test_healthcheck_fails_when_table_does_not_exist() + { + $ss=new Tiqr_UserSecretStorage_Pdo( + new Tiqr_UserSecretStorage_Encryption_Plain([]), + $this->logger, + $this->pdoInstance, + 'table_does_not_exist' + ); + + $status=''; + $this->assertFalse($ss->healthCheck($status)); + $this->assertStringContainsString('UserSecretStorage_PDO error', $status); + } + private function buildUserSecretStorage(): Tiqr_UserSecretStorage_Pdo { return new Tiqr_UserSecretStorage_Pdo( diff --git a/library/tiqr/tests/Tiqr_UserStorageTest.php b/library/tiqr/tests/Tiqr_UserStorageTest.php index 03981ee..5d73cb3 100644 --- a/library/tiqr/tests/Tiqr_UserStorageTest.php +++ b/library/tiqr/tests/Tiqr_UserStorageTest.php @@ -16,6 +16,7 @@ private function makeTempDir() { // Used by Pdo and File function userStorageTests(Tiqr_UserStorage_Abstract $userStorage) { + $this->assertTrue( $userStorage->healthCheck() ); $this->assertFalse( $userStorage->userExists( 'user1' ) ); // Create a user in the storage @@ -235,4 +236,34 @@ function testUserStorage_Pdo_split() { // Run user storage tests $this->userStorageTests($userStorage); } + + function test_pdo_healthcheck_fails_when_table_does_not_exist() + { + $tmpDir = $this->makeTempDir(); + $dsn = 'sqlite:' . $tmpDir . '/user.sq3'; + // Create test database + $pdo = new PDO( + $dsn, + null, + null, + array(\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,) + ); + + $options=array( + 'table' => 'does_not_exists', // Optional + 'dsn' => $dsn, + 'username' => null, + 'password' => null, + ); + $logger = Mockery::mock(LoggerInterface::class)->shouldIgnoreMissing(); + $userStorage = Tiqr_UserStorage::getStorage( + 'pdo', + $options, + $logger + ); + + $status = ''; + $this->assertFalse( $userStorage->healthCheck($status) ); + $this->assertStringContainsString('Error reading from UserStorage_PDO', $status); + } } diff --git a/library/tiqr/tests/Tiqr_UserStorage_FileTest.php b/library/tiqr/tests/Tiqr_UserStorage_FileTest.php index e0a1f7f..9a7be6c 100644 --- a/library/tiqr/tests/Tiqr_UserStorage_FileTest.php +++ b/library/tiqr/tests/Tiqr_UserStorage_FileTest.php @@ -54,6 +54,18 @@ public function test_set_secret_is_not_part_of_user_storage() $userStorage->setSecret('UserId', 'Secret'); } + public function test_healthcheck_fails_when_path_does_not_exist() + { + $config = [ + 'type' => 'file', + 'path' => '/path/to/nowhere' + ]; + $userStorage = new Tiqr_UserStorage_File($config, $this->logger, $config); + $status=''; + $this->assertFalse($userStorage->healthCheck($status)); + $this->assertStringContainsString('FileStorage:', $status); + } + private function buildUserStorage() { $config = [