, * Michael Stilkerich * * This file is part of RCMCardDAV. * * RCMCardDAV is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 2 of the License, or * (at your option) any later version. * * RCMCardDAV is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with RCMCardDAV. If not, see . */ declare(strict_types=1); namespace MStilkerich\Tests\RCMCardDAV\Unit; use MStilkerich\RCMCardDAV\RoundcubeLogger; use MStilkerich\RCMCardDAV\Db\Database; use MStilkerich\RCMCardDAV\Frontend\AdminSettings; use MStilkerich\Tests\RCMCardDAV\TestInfrastructure; use PHPUnit\Framework\TestCase; final class AdminSettingsTest extends TestCase { public static function setUpBeforeClass(): void { // needed for URL placeholder replacements when admin settings are read $_SESSION['username'] = 'user@example.com'; } public function setUp(): void { $db = $this->createMock(Database::class); TestInfrastructure::init($db); } public function tearDown(): void { TestInfrastructure::logger()->reset(); self::cleanupTempConfigs(); } /** * @return array */ public static function configFileProvider(): array { $base = 'tests/Unit/data/adminSettingsTest'; return [ 'Non-existent config file' => [ "$base/notExistent" ], 'Valid config file with all settings' => [ "$base/fullconfig" ], ]; } /** * Tests that config.inc.php file is correctly parsed. * * @dataProvider configFileProvider */ public function testConfigFileParsedCorrectly(string $cfgFileBase): void { $expPrefs = TestInfrastructure::readJsonArray("$cfgFileBase.json"); $loggerMock = $this->createMock(RoundcubeLogger::class); $loggerHttpMock = $this->createMock(RoundcubeLogger::class); if (isset($expPrefs["loglevel"])) { $loggerMock->expects($this->once()) ->method("setLogLevel") ->with($this->equalTo($expPrefs['loglevel'])); } if (isset($expPrefs["loglevel_http"])) { $loggerHttpMock->expects($this->once()) ->method("setLogLevel") ->with($this->equalTo($expPrefs['loglevel_http'])); } $admPrefs = new AdminSettings("$cfgFileBase.inc.php", $loggerMock, $loggerHttpMock); $this->assertSame($expPrefs['pwStoreScheme'], $admPrefs->pwStoreScheme); $this->assertSame($expPrefs['forbidCustomAddressbooks'], $admPrefs->forbidCustomAddressbooks); $this->assertSame($expPrefs['hidePreferences'], $admPrefs->hidePreferences); $this->assertSame($expPrefs["presets"], TestInfrastructure::getPrivateProperty($admPrefs, 'presets')); $this->assertSame( $expPrefs["specialAbookMatchers"], TestInfrastructure::getPrivateProperty($admPrefs, 'specialAbookMatchers') ); } /** * Tests that getPreset() returns the expected preset data, merging account settings with those of extra * addressbooks. */ public function testGetPresetReturnsAddressbookSpecificConfig(): void { $cfgFileBase = 'tests/Unit/data/adminSettingsTest/fullconfig'; /** @var array> */ $expPrefs = TestInfrastructure::readJsonArray("$cfgFileBase-getPreset.json"); $loggerMock = $this->createMock(RoundcubeLogger::class); $admPrefs = new AdminSettings("$cfgFileBase.inc.php", $loggerMock, $loggerMock); foreach ($expPrefs as $presetName => $presetBooks) { foreach ($presetBooks as $url => $presetExp) { if (strlen($url) == 0) { $preset = $admPrefs->getPreset($presetName); $presetUnknown = $admPrefs->getPreset($presetName, "http://not.a.known.url/of/an/abook"); $this->assertSame($presetExp, $presetUnknown, "Unknown abook URL should return base properties"); } else { $preset = $admPrefs->getPreset($presetName, $url); } // PHP arrays are ordered, including string keys ksort($presetExp); ksort($preset); $this->assertSame($presetExp, $preset); } } } public function testGetPresetThrowsExceptionForNonexistingPreset(): void { $cfgFileBase = 'tests/Unit/data/adminSettingsTest/fullconfig'; $loggerMock = $this->createMock(RoundcubeLogger::class); $admPrefs = new AdminSettings("$cfgFileBase.inc.php", $loggerMock, $loggerMock); $this->expectException(\Exception::class); $this->expectExceptionMessage('Query for undefined preset Invalid Preset'); $admPrefs->getPreset('Invalid Preset'); } /** * @return array */ public static function errorsInAdminConfigProvider(): array { $ret = [ 'Non-array prefs' => [ function (array $_prefs): string { return 'not an array'; }, function (array $_expPrefs): array { return [ 'pwStoreScheme' => 'encrypted', 'forbidCustomAddressbooks' => false, 'hidePreferences' => false, 'presets' => [], ]; }, function (AdminSettings $_admPrefs, RoundcubeLogger $rcLogger, RoundcubeLogger $_rcLoggerHttp): void { TestCase::assertSame( 5, TestInfrastructure::getPrivateProperty($rcLogger, 'loglevel') ); }, 'prefs must be an array' ], 'Invalid loglevel value' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['loglevel'] = 'foo'; return $prefs; }, function (array $expPrefs): array { return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $rcLogger, RoundcubeLogger $_rcLoggerHttp): void { TestCase::assertSame( 5, TestInfrastructure::getPrivateProperty($rcLogger, 'loglevel') ); }, 'unknown loglevel' ], 'Invalid loglevel value (HTTP)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['loglevel_http'] = 'foo'; return $prefs; }, function (array $expPrefs): array { return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $rcLoggerHttp): void { TestCase::assertSame( 5, TestInfrastructure::getPrivateProperty($rcLoggerHttp, 'loglevel') ); }, 'unknown loglevel' ], 'Invalid loglevel type' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['loglevel'] = 5; return $prefs; }, function (array $expPrefs): array { return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $rcLogger, RoundcubeLogger $_rcLoggerHttp): void { TestCase::assertSame( 5, TestInfrastructure::getPrivateProperty($rcLogger, 'loglevel') ); }, 'unknown loglevel' ], 'Invalid loglevel type (HTTP)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['loglevel_http'] = 5; return $prefs; }, function (array $expPrefs): array { return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $rcLoggerHttp): void { TestCase::assertSame( 5, TestInfrastructure::getPrivateProperty($rcLoggerHttp, 'loglevel') ); }, 'unknown loglevel' ], 'Invalid pwstore scheme' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['pwstore_scheme'] = 'foo'; return $prefs; }, function (array $expPrefs): array { $expPrefs['pwStoreScheme'] = 'encrypted'; // default should be used return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "Invalid pwStoreScheme foo in config.inc.php - using default 'encrypted'" ], 'Invalid preset key (empty string)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs[''] = [ 'accountname' => 'Invalid' ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "A preset key must be a non-empty string - ignoring preset" ], 'Invalid preset key (integer)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs[0] = [ 'accountname' => 'Invalid' ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "A preset key must be a non-empty string - ignoring preset" ], 'Invalid preset key (not an array)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = false; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "preset definition must be an array" ], 'Invalid preset, mandatory attribute missing (extraabook.url)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Test', 'extra_addressbooks' => [['active' => true]] ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "required setting url is not set" ], 'Invalid preset, wrong type (extraabooks not an array)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Test', 'extra_addressbooks' => "example.com" ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "setting extra_addressbooks must be an array" ], 'Invalid preset, wrong type (extraabooks[x] not an array)' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Test', 'extra_addressbooks' => [ ['url' => 'foo.com'], "example.com" ] ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "setting extra_addressbooks\[1\] must be an array" ], 'Invalid preset referenced in collected senders' => [ function (array $prefs): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['_GLOBAL']['collected_senders'] = [ 'preset' => 'InvalidKey', ]; return $prefs; }, function (array $expPrefs): array { // invalid special addressbook matcher must be ignored if ( isset($expPrefs['specialAbookMatchers']) && is_array($expPrefs['specialAbookMatchers']) && isset($expPrefs['specialAbookMatchers']['collected_senders']) ) { unset($expPrefs['specialAbookMatchers']['collected_senders']); } return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "Setting for collected_senders must include a valid preset attribute" ], ]; foreach (['accountname', 'username', 'password', 'discovery_url', 'rediscover_time', 'refresh_time'] as $k) { $ret["Wrong type for string attribute ($k)"] = [ function (array $prefs) use ($k): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Invalid', $k => 1 ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "setting $k must be a string" ]; } foreach (['rediscover_time', 'refresh_time'] as $timeStrAttr) { $ret["Wrong type for timestring attribute ($timeStrAttr)"] = [ function (array $prefs) use ($timeStrAttr): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Invalid', $timeStrAttr => 'foo' ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function (AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp): void { }, "Time string foo could not be parsed" ]; } foreach (['fixed'] as $strArrayAttr) { foreach ([ true, 'foo', 1, [ 'foo', 1 ] ] as $idx => $errVal) { $ret["Wrong type for string array attribute ($strArrayAttr $idx)"] = [ function (array $prefs) use ($strArrayAttr, $errVal): array { TestCase::assertIsArray($prefs['_GLOBAL']); $prefs['Invalid'] = [ 'accountname' => 'Invalid', $strArrayAttr => $errVal ]; return $prefs; }, function (array $expPrefs): array { // invalid preset must be ignored return $expPrefs; }, function ( AdminSettings $_admPrefs, RoundcubeLogger $_rcLogger, RoundcubeLogger $_rcLoggerHttp ): void { }, is_array($errVal) ? "must be string" : "setting $strArrayAttr must be array" ]; } } return $ret; } /** * Tests that errors in the admin configuration are detected and, if possible, handled without a fatal error, i.e. * using roundcube should still be possible. * * If the error affects a single preset, the preset will be ignored, i.e. not present in the resulting preset list. * As a side effect, if the preset happened to work before, it may cause deletion of related addressbooks for users * that already had them added earlier. This is acceptable, since no data is lost (everything is on the CardDAV * server) and the preset will be added again. The only drawback is that some server traffic will be generated for * re-downloading the addressbook. * * The following errors are tested: * * - wrong data type for a global configuration setting - except bool, where we interpret different types according * to PHP's understanding of true/false * - wrong data type for a preset configuration setting - except bool, see above * - wrong value for a configuration setting (e.g. non-existent loglevel, invalid time string) * - wrong preset key referenced in a special addressbook matcher * * As basis, we use a valid configuration and inject one error at a time. * * @param callable(array):array|callable(array):string $modifyPrefsFunc * @param callable(array):array $modifyExpResultFunc * @param callable(AdminSettings, RoundcubeLogger, RoundcubeLogger):void $validateFunc * * @dataProvider errorsInAdminConfigProvider */ public function testErrorsInAdminConfigAreDetected( $modifyPrefsFunc, $modifyExpResultFunc, $validateFunc, string $expLogMsg ): void { $prefs = TestInfrastructure::readPhpPrefsArray('tests/Unit/data/adminSettingsTest/fullconfig.inc.php'); // modify prefs $prefs = $modifyPrefsFunc($prefs); // write modified prefs to temporary file $tmpfile = tempnam("testreports", "adminSettingsTest_"); $this->assertIsString($tmpfile); file_put_contents($tmpfile, "config; $rcubecfg->set('log_dir', __DIR__ . '/../../testreports/'); $this->assertNotFalse(file_put_contents('testreports/adminSettingsTest_log.log', '')); $logger = new RoundcubeLogger('adminSettingsTest_log'); $loggerHttp = new RoundcubeLogger('adminSettingsTest_logHttp'); $admPrefs = new AdminSettings($tmpfile, $logger, $loggerHttp); // compare to expected settings $expPrefs = TestInfrastructure::readJsonArray("tests/Unit/data/adminSettingsTest/fullconfig.json"); $expPrefs = $modifyExpResultFunc($expPrefs); $this->assertSame($expPrefs['pwStoreScheme'], $admPrefs->pwStoreScheme); $this->assertSame($expPrefs['forbidCustomAddressbooks'], $admPrefs->forbidCustomAddressbooks); $this->assertSame($expPrefs['hidePreferences'], $admPrefs->hidePreferences); $this->assertSame($expPrefs["presets"], TestInfrastructure::getPrivateProperty($admPrefs, 'presets')); // extra validation function $validateFunc($admPrefs, $logger, $loggerHttp); // check that expected error message was logged $logEntries = file_get_contents('testreports/adminSettingsTest_log.log'); $this->assertMatchesRegularExpression("/\[5 ERR\] .*$expLogMsg/", $logEntries, "expected error log not found"); } /** * Delete temporary files from testErrorsInAdminConfigAreDetected */ public static function cleanupTempConfigs(): void { $tmpfs = glob("testreports/adminSettingsTest_*"); if (is_array($tmpfs) && (count($tmpfs) > 0)) { foreach ($tmpfs as $tmpf) { unlink($tmpf); } } } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120