, * 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 Sabre\VObject; use Sabre\VObject\Component\VCard; use rcube_cache; use MStilkerich\Tests\RCMCardDAV\{TestInfrastructure,TestLogger}; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\MockObject\MockObject; use MStilkerich\CardDavClient\{Account,AddressbookCollection}; use MStilkerich\RCMCardDAV\{DataConversion,DelayedPhotoLoader,DelayedVCardExporter}; use MStilkerich\RCMCardDAV\Db\Database; /** * XXX temporary workaround for vimeo/psalm#8980 * @psalm-import-type SaveDataFromDC from DataConversion */ final class DataConversionTest extends TestCase { /** @var rcube_cache & MockObject */ private $cache; /** @var Database & MockObject */ private $db; /** @var AddressbookCollection */ private $abook; public static function setUpBeforeClass(): void { } public function setUp(): void { $_SESSION['user_id'] = 105; $abook = $this->createStub(AddressbookCollection::class); $abook->method('downloadResource')->willReturnCallback([Utils::class, 'downloadResource']); $this->abook = $abook; $this->db = $this->createMock(Database::class); $this->cache = $this->createMock(\rcube_cache::class); TestInfrastructure::init($this->db); TestInfrastructure::$infra->setCache($this->cache); } public function tearDown(): void { Utils::cleanupTempImages(); TestInfrastructure::logger()->reset(); } /** * @return array */ private static function vcardSamplesProvider(string $basedir): array { $vcfFiles = glob("$basedir/*.vcf"); $result = []; foreach ($vcfFiles as $vcfFile) { $comp = pathinfo($vcfFile); $jsonFile = "{$comp["dirname"]}/{$comp["filename"]}.json"; $result[$comp["basename"]] = [ $vcfFile, $jsonFile ]; } return $result; } /** * @return array */ public static function vcardImportSamplesProvider(): array { return self::vcardSamplesProvider('tests/Unit/data/vcardImport'); } /** * Tests the conversion of VCards to roundcube's internal address data representation. * * @dataProvider vcardImportSamplesProvider */ public function testCorrectConversionOfVcardToRoundcube(string $vcfFile, string $jsonFile): void { $logger = TestInfrastructure::logger(); $dc = new DataConversion("42"); $vcard = TestInfrastructure::readVCard($vcfFile); $saveDataExp = Utils::readSaveDataFromJson($jsonFile); $saveData = $dc->toRoundcube($vcard, $this->abook); Utils::compareSaveData($saveDataExp, $saveData, "Converted VCard does not result in expected roundcube data"); $this->assertPhotoDownloadWarning($logger, $vcfFile); } /** * Tests that a new custom label is inserted into the database. */ public function testNewCustomLabelIsInsertedToDatabase(): void { $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "Speciallabel"] ]); $db->expects($this->once()) ->method("insert") ->with( $this->equalTo("xsubtypes"), $this->equalTo(["typename", "subtype", "abook_id"]), $this->equalTo([["email", "SpecialLabel", "42"]]) ) ->willReturn("49"); $dc = new DataConversion("42"); $vcard = TestInfrastructure::readVCard("tests/Unit/data/vcardImport/XAbLabel.vcf"); $dc->toRoundcube($vcard, $this->abook); } /** * Tests that known custom labels are offered in coltypes. */ public function testKnownCustomLabelPresentedToRoundcube(): void { $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "SpecialLabel"] ]); $dc = new DataConversion("42"); $coltypes = $dc->getColtypes(); $this->assertTrue(isset($coltypes["email"]["subtypes"])); $this->assertContains("SpecialLabel", $coltypes["email"]["subtypes"], "SpecialLabel not contained in coltypes"); } /** * Tests that a known custom label is not inserted into the database again. */ public function testKnownCustomLabelIsNotInsertedToDatabase(): void { $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "SpecialLabel"] ]); $db->expects($this->never()) ->method("insert"); $dc = new DataConversion("42"); $vcard = TestInfrastructure::readVCard("tests/Unit/data/vcardImport/XAbLabel.vcf"); $dc->toRoundcube($vcard, $this->abook); } /** * @return array */ public static function vcardCreateSamplesProvider(): array { return self::vcardSamplesProvider('tests/Unit/data/vcardCreate'); } /** * Tests that a new VCard is created from Roundcube data properly. * * @dataProvider vcardCreateSamplesProvider */ public function testCorrectCreationOfVcardFromRoundcube(string $vcfFile, string $jsonFile): void { $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "SpecialLabel"], ["typename" => "phone", "subtype" => "0"], ["typename" => "website", "subtype" => "0"] ]); $dc = new DataConversion("42"); $vcardExpected = TestInfrastructure::readVCard($vcfFile); $saveData = Utils::readSaveDataFromJson($jsonFile); $result = $dc->fromRoundcube($saveData); $this->compareVCards($vcardExpected, $result, true); } /** * Tests that errors in the save data are properly reported and handled. * * The offending parts of the save data should be dropped and an error message logged. */ public function testErroneousAttributesInSaveDataAreIgnored(): void { $logger = TestInfrastructure::logger(); $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([]); $dc = new DataConversion("42"); $vcardExpected = TestInfrastructure::readVCard('tests/Unit/data/singleTest/Errors.vcf'); $saveData = Utils::readSaveDataFromJson('tests/Unit/data/singleTest/Errors.json'); $result = $dc->fromRoundcube($saveData); $this->compareVCards($vcardExpected, $result, true); // check emitted warnings $logger->expectMessage("error", "save data nickname must be string"); } /** * @return array */ public static function vcardUpdateSamplesProvider(): array { return self::vcardSamplesProvider('tests/Unit/data/vcardUpdate'); } /** * Tests that an existing VCard is updated from Roundcube data properly. * * @dataProvider vcardUpdateSamplesProvider */ public function testCorrectUpdateOfVcardFromRoundcube(string $vcfFile, string $jsonFile): void { $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "SpecialLabel"], ["typename" => "email", "subtype" => "SpecialLabel2"] ]); $dc = new DataConversion("42"); $vcardOriginal = TestInfrastructure::readVCard($vcfFile); $vcardExpected = TestInfrastructure::readVCard("$vcfFile.new"); $saveData = Utils::readSaveDataFromJson($jsonFile); $result = $dc->fromRoundcube($saveData, $vcardOriginal); $this->compareVCards($vcardExpected, $result, false); } /** * @return array */ public static function cachePhotosSamplesProvider(): array { return [ "InlinePhoto.vcf" => ["tests/Unit/data/vcardImport/InlinePhoto", false, false], "UriPhotoCrop.vcf" => ["tests/Unit/data/vcardImport/UriPhotoCrop", true, true], "InvalidUriPhoto.vcf" => ["tests/Unit/data/vcardImport/InvalidUriPhoto", true, false], "UriPhoto.vcf" => ["tests/Unit/data/vcardImport/UriPhoto", true, true], ]; } /** * Tests whether a PHOTO is stored/not stored to the roundcube cache as expected. * * @dataProvider cachePhotosSamplesProvider */ public function testNewPhotoIsStoredToCacheIfNeeded(string $basename, bool $getExp, bool $storeExp): void { $logger = TestInfrastructure::logger(); $cache = $this->cache; $vcard = TestInfrastructure::readVCard("$basename.vcf"); $this->assertInstanceOf(VObject\Property::class, $vcard->PHOTO); $key = "photo_105_" . md5((string) $vcard->UID); $saveDataExpected = Utils::readSaveDataFromJson("$basename.json"); // photo should be stored to cache if not already stored in vcard in the final form if ($getExp) { // simulate cache miss $cache->expects($this->once()) ->method("get") ->with($this->equalTo($key)) ->willReturn(null); } else { $cache->expects($this->never())->method("get"); } if ($storeExp) { $checkPhotoFn = function (array $cacheObj) use ($vcard, $saveDataExpected): bool { $this->assertNotNull($cacheObj['photoPropMd5']); $this->assertNotNull($cacheObj['photo']); $this->assertIsString($cacheObj['photo']); $this->assertSame(md5($vcard->PHOTO->serialize()), $cacheObj['photoPropMd5']); $this->assertTrue(isset($saveDataExpected["photo"])); $this->assertIsString($saveDataExpected["photo"]); Utils::comparePhoto($saveDataExpected["photo"], $cacheObj['photo']); return true; }; $cache->expects($this->once()) ->method("set") ->with( $this->equalTo($key), $this->callback($checkPhotoFn) ) ->willReturn(true); } else { $cache->expects($this->never())->method("set"); } $dc = new DataConversion("42"); $saveData = $dc->toRoundcube($vcard, $this->abook); $this->assertTrue(isset($saveData['photo'])); $this->assertTrue(isset($saveDataExpected["photo"])); $this->assertIsString($saveDataExpected["photo"]); Utils::comparePhoto($saveDataExpected["photo"], (string) $saveData["photo"]); $this->assertPhotoDownloadWarning($logger, $basename); } /** * Tests that a photo is retrieved from the roundcube cache if available, skipping processing. * * @dataProvider cachePhotosSamplesProvider */ public function testPhotoIsUsedFromCacheIfAvailable(string $basename, bool $getExp, bool $storeExp): void { $cache = $this->cache; // we use this file as some placeholder for cached data that is not used in any of the vcards photos $cachedPhotoData = file_get_contents("tests/Unit/data/srv/pixel.jpg"); $this->assertNotFalse($cachedPhotoData); $vcard = TestInfrastructure::readVCard("$basename.vcf"); $this->assertInstanceOf(VObject\Property::class, $vcard->PHOTO); $key = "photo_105_" . md5((string) $vcard->UID); $saveDataExpected = Utils::readSaveDataFromJson("$basename.json"); if ($getExp) { // simulate cache hit $cache->expects($this->once()) ->method("get") ->with($this->equalTo($key)) ->willReturn([ 'photoPropMd5' => md5($vcard->PHOTO->serialize()), 'photo' => $cachedPhotoData ]); } else { $cache->expects($this->never())->method("get"); } // no cache update expected $cache->expects($this->never())->method("set"); $dc = new DataConversion("42"); $saveData = $dc->toRoundcube($vcard, $this->abook); $this->assertTrue(isset($saveData['photo'])); if ($getExp) { Utils::comparePhoto($cachedPhotoData, (string) $saveData["photo"]); // If we fetch it a second time, we should hit the internal cache of the photo loader and no processing // occurs again $cache->expects($this->never())->method("get"); Utils::comparePhoto($cachedPhotoData, (string) $saveData["photo"]); } else { $this->assertTrue(isset($saveDataExpected["photo"])); $this->assertIsString($saveDataExpected["photo"]); Utils::comparePhoto($saveDataExpected["photo"], (string) $saveData["photo"]); } } /** * Tests that an outdated photo in the cache is replaced by a newly processed one. * * @dataProvider cachePhotosSamplesProvider */ public function testOutdatedPhotoIsReplacedInCache(string $basename, bool $getExp, bool $storeExp): void { $logger = TestInfrastructure::logger(); $cache = $this->cache; // we use this file as some placeholder for cached data that is not used in any of the vcards photos $cachedPhotoData = file_get_contents("tests/Unit/data/srv/pixel.jpg"); $this->assertNotFalse($cachedPhotoData); $vcard = TestInfrastructure::readVCard("$basename.vcf"); $this->assertInstanceOf(VObject\Property::class, $vcard->PHOTO); $key = "photo_105_" . md5((string) $vcard->UID); $saveDataExpected = Utils::readSaveDataFromJson("$basename.json"); if ($getExp) { // simulate cache hit with non-matching md5sum $cache->expects($this->once()) ->method("get") ->with($this->equalTo($key)) ->willReturn([ 'photoPropMd5' => md5("foo"), // will not match the current md5 'photo' => $cachedPhotoData ]); // expect that the old record is purged $cache->expects($this->once()) ->method("remove") ->with($this->equalTo($key)); } else { $cache->expects($this->never())->method("get"); } // a new record should be inserted if photo requires caching if ($storeExp) { $checkPhotoFn = function (array $cacheObj) use ($vcard, $saveDataExpected): bool { $this->assertNotNull($cacheObj['photoPropMd5']); $this->assertNotNull($cacheObj['photo']); $this->assertIsString($cacheObj['photo']); $this->assertSame(md5($vcard->PHOTO->serialize()), $cacheObj['photoPropMd5']); $this->assertTrue(isset($saveDataExpected["photo"])); $this->assertIsString($saveDataExpected["photo"]); Utils::comparePhoto($saveDataExpected["photo"], $cacheObj['photo']); return true; }; $cache->expects($this->once()) ->method("set") ->with( $this->equalTo($key), $this->callback($checkPhotoFn) ) ->willReturn(true); } else { $cache->expects($this->never())->method("set"); } $dc = new DataConversion("42"); $saveData = $dc->toRoundcube($vcard, $this->abook); $this->assertTrue(isset($saveData['photo'])); $this->assertTrue(isset($saveDataExpected["photo"])); $this->assertIsString($saveDataExpected["photo"]); Utils::comparePhoto($saveDataExpected["photo"], (string) $saveData["photo"]); $this->assertPhotoDownloadWarning($logger, $basename); } /** * Tests that a delayed photo loader handles vcards lacking a PHOTO property. */ public function testPhotoloaderHandlesVcardWithoutPhotoProperty(): void { $vcard = TestInfrastructure::readVCard("tests/Unit/data/vcardImport/AllAttr.vcf"); $this->assertNull($vcard->PHOTO); $proxy = new DelayedPhotoLoader($vcard, $this->abook); $this->assertEquals("", $proxy); } /** * Tests that DelayedPhotoLoader logs an error in case rcube cache usage is attempted without user logged on. */ public function testPhotoloaderHandlesUnauthenticatedUsageError(): void { $logger = TestInfrastructure::logger(); unset($_SESSION['user_id']); $vcard = TestInfrastructure::readVCard("tests/Unit/data/vcardImport/UriPhoto.vcf"); $this->assertNotNull($vcard->PHOTO); $proxy = new DelayedPhotoLoader($vcard, $this->abook); $this->assertEquals("", $proxy); $logger->expectMessage("error", "determineCacheKey: user must be logged on to use photo cache"); } /** * Tests that DelayedPhotoLoader logs a warning if it encounters an unsupported PHOTO URI scheme */ public function testPhotoloaderHandlesUnknownUriScheme(): void { $logger = TestInfrastructure::logger(); $vcard = TestInfrastructure::readVCard("tests/Unit/data/vcardImport/UriPhoto.vcf"); $this->assertNotNull($vcard->PHOTO); $vcard->PHOTO->setValue('ftp://localhost/raven.jpg'); $proxy = new DelayedPhotoLoader($vcard, $this->abook); $this->assertEquals("", $proxy); $logger->expectMessage("warning", "Unsupported URI scheme ftp for PHOTO property"); } /** * Tests that the function properly reports single-value attributes. */ public function testSinglevalueAttributesReportedAsSuch(): void { $dc = new DataConversion("42"); $knownSingle = ['name', 'firstname', 'surname', 'middlename', 'prefix', 'suffix', 'nickname', 'jobtitle', 'organization', 'department', 'assistant', 'manager', 'gender', 'maidenname', 'spouse', 'birthday', 'anniversary', 'notes', 'photo']; foreach ($knownSingle as $singleAttr) { $this->assertFalse($dc->isMultivalueProperty($singleAttr), "Attribute $singleAttr expected to be single"); } } /** * Tests that the data converter properly reports multi-value attributes. */ public function testMultivalueAttributesReportedAsSuch(): void { $dc = new DataConversion("42"); $knownMulti = ['email', 'phone', 'address', 'website', 'im']; foreach ($knownMulti as $multiAttr) { $this->assertTrue($dc->isMultivalueProperty($multiAttr), "Attribute $multiAttr expected to be multi"); } } /** * Tests that the data converter throws an exception when asked for the type of an unknown attribute. */ public function testExceptionWhenAskedForTypeOfUnknownAttribute(): void { $dc = new DataConversion("42"); $this->expectExceptionMessage('not a known roundcube contact property'); $dc->isMultivalueProperty("unknown"); } /** * @return array */ public static function vcardExportSamplesProvider(): array { return self::vcardSamplesProvider('tests/Unit/data/vcardExport'); } /** * Tests that a VCard converted to roundcube can be properly exported as vcard again. * * * @dataProvider vcardExportSamplesProvider */ public function testCorrectExportOfVcardFromRoundcube(string $vcfFile, string $_jsonFileNotExisting): void { $logger = TestInfrastructure::logger(); $db = $this->db; $db->expects($this->once()) ->method("get") ->with( $this->equalTo(["abook_id" => "42"]), $this->equalTo(['typename', 'subtype']), $this->equalTo('xsubtypes') ) ->willReturn([ ["typename" => "email", "subtype" => "SpecialLabel"], ]); $dc = new DataConversion("42"); $vcardOrig = TestInfrastructure::readVCard($vcfFile); $saveData = $dc->toRoundcube($vcardOrig, $this->abook); $this->assertIsString($saveData["vcard"] ?? null); $vcardOrig = $saveData["_carddav_vcard"] ?? null; $this->assertInstanceOf(VCard::class, $vcardOrig); /** * @psalm-var SaveDataFromDC $saveData XXX temporary workaround for vimeo/psalm#8980 */ $vcfExported = DataConversion::exportVCard($vcardOrig, $saveData); $this->assertPhotoDownloadWarning($logger, $vcfFile); $vcardExported = VObject\Reader::read($vcfExported); $this->assertInstanceOf(VCard::class, $vcardExported); $vcardExpected = TestInfrastructure::readVCard("$vcfFile.exported"); $this->compareVCards($vcardExpected, $vcardExported, false); } private function compareVCards(VCard $vcardExpected, VCard $vcardRoundcube, bool $isNew): void { // These attributes are dynamically created / updated and therefore cannot be statically compared $noCompare = [ 'REV', 'PRODID' ]; if ($isNew) { // new VCard will have UID assigned by carddavclient lib on store $noCompare[] = 'UID'; } foreach ($noCompare as $property) { unset($vcardExpected->{$property}); unset($vcardRoundcube->{$property}); } /** @var VObject\Property[] */ $propsExp = $vcardExpected->children(); $propsExp = self::groupNodesByName($propsExp); /** @var VObject\Property[] */ $propsRC = $vcardRoundcube->children(); $propsRC = self::groupNodesByName($propsRC); // compare foreach ($propsExp as $name => $props) { TestCase::assertArrayHasKey($name, $propsRC, "Expected property $name missing from test vcard"); self::compareNodeList("Property $name", $props, $propsRC[$name]); for ($i = 0; $i < count($props); ++$i) { TestCase::assertEqualsIgnoringCase( $props[$i]->group, $propsRC[$name][$i]->group, "Property group name differs" ); /** @psalm-var VObject\Parameter[] */ $paramExp = $props[$i]->parameters(); $paramExp = self::groupNodesByName($paramExp); /** @psalm-var VObject\Parameter[] */ $paramRC = $propsRC[$name][$i]->parameters(); $paramRC = self::groupNodesByName($paramRC); foreach ($paramExp as $pname => $params) { self::compareNodeList("Parameter $name/$pname", $params, $paramRC[$pname]); unset($paramRC[$pname]); } TestCase::assertEmpty($paramRC, "Prop $name has extra params: " . implode(", ", array_keys($paramRC))); } unset($propsRC[$name]); } TestCase::assertEmpty($propsRC, "VCard has extra properties: " . implode(", ", array_keys($propsRC))); } /** * Groups a list of VObject\Node by node name. * * @template T of VObject\Property|VObject\Parameter * * @param T[] $nodes * @return array> Array with node names as keys, and arrays of nodes by that name as values. */ private static function groupNodesByName(array $nodes): array { $res = []; foreach ($nodes as $n) { $res[$n->name][] = $n; } return $res; } /** * Compares to lists of VObject nodes with the same name. * * This can be two lists of property instances (e.g. EMAIL, TEL) or two lists of parameters (e.g. TYPE). * * @param string $dbgid Some string to identify property/parameter for error messages * @param VObject\Property[]|VObject\Parameter[] $exp Expected list of nodes * @param VObject\Property[]|VObject\Parameter[] $rc List of nodes in the VCard produces by rcmcarddav */ private static function compareNodeList(string $dbgid, array $exp, array $rc): void { TestCase::assertCount(count($exp), $rc, "Different amount of $dbgid"); for ($i = 0; $i < count($exp); ++$i) { if ($dbgid == "Property PHOTO") { Utils::comparePhoto((string) $exp[$i]->getValue(), (string) $rc[$i]->getValue()); } else { TestCase::assertSame($exp[$i]->getValue(), $rc[$i]->getValue(), "Nodes $dbgid differ"); } } } /** * Asserts that a warning message concerning failure to download the photo has been issued for test cases that use * the InvalidUriPhoto.vcf data set. * * @param string Name of the vcffile used by the test. Assertion is only done if it contains InvalidUriPhoto. */ private function assertPhotoDownloadWarning(TestLogger $logger, string $vcffile): void { if (strpos($vcffile, 'InvalidUriPhoto') !== false) { $logger->expectMessage( 'warning', 'downloadPhoto: Attempt to download photo from http://localhost/doesNotExist.jpg failed' ); } } } // vim: ts=4:sw=4:expandtab:fenc=utf8:ff=unix:tw=120