diff --git a/eZ/Publish/API/Repository/Tests/ContentServiceAuthorizationTest.php b/eZ/Publish/API/Repository/Tests/ContentServiceAuthorizationTest.php index 75aa3d14b19..cba95625a8e 100644 --- a/eZ/Publish/API/Repository/Tests/ContentServiceAuthorizationTest.php +++ b/eZ/Publish/API/Repository/Tests/ContentServiceAuthorizationTest.php @@ -10,6 +10,7 @@ use eZ\Publish\API\Repository\Repository; use eZ\Publish\API\Repository\Values\Content\ContentInfo; use eZ\Publish\API\Repository\Values\Content\Location; +use eZ\Publish\API\Repository\Values\User\Limitation\LanguageLimitation; use eZ\Publish\API\Repository\Values\User\Limitation\LocationLimitation; use eZ\Publish\API\Repository\Values\User\Limitation\SubtreeLimitation; @@ -584,6 +585,56 @@ public function testDeleteContentThrowsUnauthorizedException() $this->contentService->deleteContent($contentInfo); } + /** + * @covers \eZ\Publish\API\Repository\ContentService::deleteContent() + */ + public function testDeleteContentThrowsUnauthorizedExceptionWithLanguageLimitation(): void + { + $contentVersion2 = $this->createMultipleLanguageContentVersion2(); + $contentInfo = $contentVersion2->contentInfo; + $limitations = [ + new LanguageLimitation(['limitationValues' => ['eng-US']]), + ]; + + $user = $this->createUserWithPolicies( + 'user', + [ + ['module' => 'content', 'function' => 'remove', 'limitations' => $limitations], + ] + ); + + $this->permissionResolver->setCurrentUserReference($user); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessageRegExp('/\'remove\' \'content\'/'); + + $this->contentService->deleteContent($contentInfo); + } + + /** + * @covers \eZ\Publish\API\Repository\ContentService::deleteContent() + */ + public function testDeleteContentWithLanguageLimitation(): void + { + $contentVersion2 = $this->createMultipleLanguageContentVersion2(); + $contentInfo = $contentVersion2->contentInfo; + + $limitations = [ + new LanguageLimitation(['limitationValues' => ['eng-US', 'eng-GB']]), + ]; + + $user = $this->createUserWithPolicies( + 'user', + [ + ['module' => 'content', 'function' => 'remove', 'limitations' => $limitations], + ] + ); + + $this->permissionResolver->setCurrentUserReference($user); + + self::assertSame([$contentInfo->mainLocationId], $this->contentService->deleteContent($contentInfo)); + } + /** * Test for the createContentDraft() method. * diff --git a/eZ/Publish/API/Repository/Tests/Limitation/PermissionResolver/LanguageLimitationIntegrationTest.php b/eZ/Publish/API/Repository/Tests/Limitation/PermissionResolver/LanguageLimitationIntegrationTest.php index f4b7d4bac47..1e7ca0e988b 100644 --- a/eZ/Publish/API/Repository/Tests/Limitation/PermissionResolver/LanguageLimitationIntegrationTest.php +++ b/eZ/Publish/API/Repository/Tests/Limitation/PermissionResolver/LanguageLimitationIntegrationTest.php @@ -9,6 +9,7 @@ namespace eZ\Publish\API\Repository\Tests\Limitation\PermissionResolver; use eZ\Publish\API\Repository\Values\User\Limitation\LanguageLimitation; +use eZ\Publish\SPI\Limitation\Target; /** * Integration test for chosen use cases of calls to PermissionResolver::canUser. @@ -160,4 +161,117 @@ public function testCanUserPublishContent(array $limitations, bool $expectedResu $this->assertCanUser($expectedResult, 'content', 'publish', $limitations, $content); } + + /** + * Data provider for testCanUserDeleteContent. + * + * @see testCanUserDeleteContent + */ + public function providerForCanUserDeleteContent(): array + { + $limitationForGerman = new LanguageLimitation(); + $limitationForGerman->limitationValues = [self::LANG_GER_DE]; + + $limitationForBritishEnglish = new LanguageLimitation(); + $limitationForBritishEnglish->limitationValues = [self::LANG_ENG_GB]; + + $multilingualLimitation = new LanguageLimitation(); + $multilingualLimitation->limitationValues = [self::LANG_ENG_GB, self::LANG_GER_DE]; + + return [ + [[$limitationForBritishEnglish], false], + [[$limitationForGerman], false], + // dealing with British and German content, so true only for multilingual Language Limitation + [[$multilingualLimitation], true], + ]; + } + + /** + * @dataProvider providerForCanUserDeleteContent + * + * @param \eZ\Publish\API\Repository\Values\User\Limitation[] $limitations + * @param bool $expectedResult + * + * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException + * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException + * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException + */ + public function testCanUserDeleteContent(array $limitations, bool $expectedResult): void + { + $content = $this->createFolder( + [ + self::LANG_ENG_GB => 'British Folder', + self::LANG_GER_DE => 'German Folder', + ], + 2 + ); + + $this->loginAsEditorUserWithLimitations('content', 'remove', $limitations); + + $target = (new Target\Version())->deleteTranslations($content->getVersionInfo()->languageCodes); + $this->assertCanUser($expectedResult, 'content', 'remove', $limitations, $content, [$target]); + } + + /** + * Data provider for testCanUserDeleteContentTranslation. + * + * @see testCanUserDeleteContentTranslation + */ + public function providerForCanUserDeleteContentTranslation(): iterable + { + $limitationForGerman = new LanguageLimitation(); + $limitationForGerman->limitationValues = [self::LANG_GER_DE]; + + $limitationForBritishEnglish = new LanguageLimitation(); + $limitationForBritishEnglish->limitationValues = [self::LANG_ENG_GB]; + + $multilingualLimitation = new LanguageLimitation(); + $multilingualLimitation->limitationValues = [self::LANG_ENG_US, self::LANG_GER_DE]; + + yield 'Limitation with eng-GB should return true for eng-GB translation' => [ + [$limitationForBritishEnglish], + self::LANG_ENG_GB, + true, + ]; + + yield 'Limitation with ger-de should return false for eng-GB translation' => [ + [$limitationForGerman], + self::LANG_ENG_GB, + false, + ]; + + yield 'Limitation with neg-US and ger-de should return true for eng-US translation' => [ + [$multilingualLimitation], + self::LANG_ENG_US, + true, + ]; + } + + /** + * @dataProvider providerForCanUserDeleteContentTranslation + * + * @param \eZ\Publish\API\Repository\Values\User\Limitation[] $limitations + * @param string $translation + * @param bool $expectedResult + * + * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException + * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException + * @throws \eZ\Publish\API\Repository\Exceptions\UnauthorizedException + */ + public function testCanUserDeleteContentTranslation(array $limitations, string $translation, bool $expectedResult): void + { + $content = $this->createFolder( + [ + self::LANG_ENG_GB => 'British Folder', + self::LANG_GER_DE => 'German Folder', + self::LANG_ENG_US => 'US Folder', + ], + 2 + ); + + $this->loginAsEditorUserWithLimitations('content', 'remove', $limitations); + + $target = (new Target\Builder\VersionBuilder())->translateToAnyLanguageOf([$translation])->build(); + $this->assertCanUser($expectedResult, 'content', 'remove', $limitations, $content, [$target]); + } } diff --git a/eZ/Publish/API/Repository/Tests/LocationServiceAuthorizationTest.php b/eZ/Publish/API/Repository/Tests/LocationServiceAuthorizationTest.php index 779549fdfcc..4773c2193ef 100644 --- a/eZ/Publish/API/Repository/Tests/LocationServiceAuthorizationTest.php +++ b/eZ/Publish/API/Repository/Tests/LocationServiceAuthorizationTest.php @@ -6,7 +6,9 @@ */ namespace eZ\Publish\API\Repository\Tests; +use eZ\Publish\API\Repository\Exceptions\UnauthorizedException; use eZ\Publish\API\Repository\Values\Content\Location; +use eZ\Publish\API\Repository\Values\User\Limitation\LanguageLimitation; use eZ\Publish\API\Repository\Values\User\Limitation\OwnerLimitation; use eZ\Publish\API\Repository\Values\User\Limitation\SubtreeLimitation; @@ -384,6 +386,39 @@ public function testDeleteLocationThrowsUnauthorizedException() /* END: Use Case */ } + /** + * @covers \eZ\Publish\API\Repository\LocationService::deleteLocation() + */ + public function testDeleteLocationThrowsUnauthorizedExceptionWithLanguageLimitation(): void + { + $repository = $this->getRepository(); + $mediaLocationId = $this->generateId('location', 43); + + $locationService = $repository->getLocationService(); + $location = $locationService->loadLocation($mediaLocationId); + + $limitations = [ + new LanguageLimitation(['limitationValues' => ['ger-DE']]), + ]; + + $user = $this->createUserWithPolicies( + 'user', + [ + ['module' => 'content', 'function' => 'remove', 'limitations' => $limitations], + ['module' => 'content', 'function' => 'read'], + ['module' => 'content', 'function' => 'manage_locations'], + ] + ); + + $permissionResolver = $repository->getPermissionResolver(); + $permissionResolver->setCurrentUserReference($user); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessageRegExp('/\'remove\' \'content\'/'); + + $locationService->deleteLocation($location); + } + /** * Test for the deleteLocation() method. * diff --git a/eZ/Publish/API/Repository/Tests/TrashServiceAuthorizationTest.php b/eZ/Publish/API/Repository/Tests/TrashServiceAuthorizationTest.php index f3a1f2e6fc5..cbe3ded99f6 100644 --- a/eZ/Publish/API/Repository/Tests/TrashServiceAuthorizationTest.php +++ b/eZ/Publish/API/Repository/Tests/TrashServiceAuthorizationTest.php @@ -8,6 +8,7 @@ use eZ\Publish\API\Repository\Exceptions\UnauthorizedException; use eZ\Publish\API\Repository\Values\Content\Location; +use eZ\Publish\API\Repository\Values\User\Limitation\LanguageLimitation; use eZ\Publish\API\Repository\Values\User\Limitation\ObjectStateLimitation; use eZ\Publish\Core\Repository\Repository; use eZ\Publish\Core\Repository\TrashService; @@ -76,6 +77,41 @@ public function testTrashThrowsUnauthorizedException() $trashService->trash($mediaLocation); } + /** + * Test for the trash() method without proper permissions. + * + * @covers \eZ\Publish\API\Repository\TrashService::trash + */ + public function testTrashThrowsUnauthorizedExceptionWithLanguageLimitation(): void + { + $repository = $this->getRepository(); + $trashService = $repository->getTrashService(); + $locationService = $repository->getLocationService(); + + // Load "Media" page location to be trashed + $mediaLocation = $locationService->loadLocationByRemoteId( + '75c715a51699d2d309a924eca6a95145' + ); + + $limitations = [ + new LanguageLimitation(['limitationValues' => ['ger-DE']]), + ]; + + $user = $this->createUserWithPolicies( + 'user', + [ + ['module' => 'content', 'function' => 'remove', 'limitations' => $limitations], + ] + ); + + $repository->getPermissionResolver()->setCurrentUserReference($user); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('User does not have access to \'remove\' \'content\''); + + $trashService->trash($mediaLocation); + } + /** * Test for the trash() method with proper minimal permission set. * diff --git a/eZ/Publish/Core/Limitation/LanguageLimitation/ContentDeleteEvaluator.php b/eZ/Publish/Core/Limitation/LanguageLimitation/ContentDeleteEvaluator.php new file mode 100644 index 00000000000..f7cfd3d3a6b --- /dev/null +++ b/eZ/Publish/Core/Limitation/LanguageLimitation/ContentDeleteEvaluator.php @@ -0,0 +1,40 @@ +getTranslationsToDelete()); + } + + /** + * Allow access if all of the given language codes for content matches limitation values. + */ + public function evaluate(Target\Version $targetVersion, Limitation $limitationValue): ?bool + { + $diff = array_diff( + $targetVersion->getTranslationsToDelete(), + $limitationValue->limitationValues + ); + $accessVote = empty($diff) + ? LanguageLimitationType::ACCESS_GRANTED + : LanguageLimitationType::ACCESS_DENIED; + + return $accessVote; + } +} diff --git a/eZ/Publish/Core/Limitation/Tests/LangugeLimitation/ContentDeleteEvaluatorTest.php b/eZ/Publish/Core/Limitation/Tests/LangugeLimitation/ContentDeleteEvaluatorTest.php new file mode 100644 index 00000000000..9b81bfd1b74 --- /dev/null +++ b/eZ/Publish/Core/Limitation/Tests/LangugeLimitation/ContentDeleteEvaluatorTest.php @@ -0,0 +1,94 @@ +accept($targetVersion) + ); + } + + public function dataProviderForAccept(): iterable + { + yield [ + $this->getTergetVersion(['eng-GB', 'ger-DE']), + true, + ]; + + yield [ + $this->getTergetVersion([]), + false, + ]; + + yield [ + new Target\Version(), + false, + ]; + } + + /** + * @dataProvider dataProviderForEvaluate + */ + public function testEvaluate(Target\Version $targetVersion, Limitation $limitationValue, bool $expected): void + { + self::assertSame( + $expected, + (new ContentDeleteEvaluator())->evaluate($targetVersion, $limitationValue) + ); + } + + public function dataProviderForEvaluate(): iterable + { + yield 'same_values' => [ + $this->getTergetVersion(['eng-GB', 'ger-DE']), + $this->getLanguageLimitation(['eng-GB', 'ger-DE']), + true, + ]; + + yield 'missing_fr_limitation' => [ + $this->getTergetVersion(['eng-GB', 'ger-DE', 'fre-FR']), + $this->getLanguageLimitation(['eng-GB', 'ger-DE']), + false, + ]; + + yield 'extra_fr_limitation' => [ + $this->getTergetVersion(['eng-GB', 'ger-DE']), + $this->getLanguageLimitation(['eng-GB', 'ger-DE', 'fre-FR']), + true, + ]; + + yield 'separable_values' => [ + $this->getTergetVersion(['eng-GB']), + $this->getLanguageLimitation(['fre-FR']), + false, + ]; + } + + private function getTergetVersion(array $languageCodes): Target\Version + { + return (new Target\Version())->deleteTranslations($languageCodes); + } + + private function getLanguageLimitation(array $languageCodes): Limitation\LanguageLimitation + { + return new Limitation\LanguageLimitation(['limitationValues' => $languageCodes]); + } +} diff --git a/eZ/Publish/Core/Repository/ContentService.php b/eZ/Publish/Core/Repository/ContentService.php index ce55aead1c8..7388e64abb5 100644 --- a/eZ/Publish/Core/Repository/ContentService.php +++ b/eZ/Publish/Core/Repository/ContentService.php @@ -1040,8 +1040,14 @@ protected function publishUrlAliasesForContent(APIContent $content, $updatePathI public function deleteContent(ContentInfo $contentInfo) { $contentInfo = $this->internalLoadContentInfo($contentInfo->id); + $versionInfo = $this->persistenceHandler->contentHandler()->loadVersionInfo( + $contentInfo->id, + $contentInfo->currentVersionNo + ); + $translations = $versionInfo->languageCodes; + $target = (new Target\Version())->deleteTranslations($translations); - if (!$this->repository->canUser('content', 'remove', $contentInfo)) { + if (!$this->repository->canUser('content', 'remove', $contentInfo, [$target])) { throw new UnauthorizedException('content', 'remove', ['contentId' => $contentInfo->id]); } diff --git a/eZ/Publish/Core/Repository/LocationService.php b/eZ/Publish/Core/Repository/LocationService.php index 04b3974e7d8..80b7bea8030 100644 --- a/eZ/Publish/Core/Repository/LocationService.php +++ b/eZ/Publish/Core/Repository/LocationService.php @@ -16,6 +16,7 @@ use eZ\Publish\API\Repository\Values\Content\Location as APILocation; use eZ\Publish\API\Repository\Values\Content\LocationList; use eZ\Publish\API\Repository\Values\Content\VersionInfo; +use eZ\Publish\SPI\Limitation\Target; use eZ\Publish\SPI\Persistence\Content\Location as SPILocation; use eZ\Publish\SPI\Persistence\Content\Location\UpdateStruct; use eZ\Publish\API\Repository\LocationService as LocationServiceInterface; @@ -757,11 +758,18 @@ public function moveSubtree(APILocation $location, APILocation $newParentLocatio public function deleteLocation(APILocation $location) { $location = $this->loadLocation($location->id); + $contentInfo = $location->contentInfo; + $versionInfo = $this->persistenceHandler->contentHandler()->loadVersionInfo( + $contentInfo->id, + $contentInfo->currentVersionNo + ); + $translations = $versionInfo->languageCodes; + $target = (new Target\Version())->deleteTranslations($translations); if (!$this->repository->canUser('content', 'manage_locations', $location->getContentInfo())) { throw new UnauthorizedException('content', 'manage_locations', ['locationId' => $location->id]); } - if (!$this->repository->canUser('content', 'remove', $location->getContentInfo(), $location)) { + if (!$this->repository->canUser('content', 'remove', $location->getContentInfo(), [$location, $target])) { throw new UnauthorizedException('content', 'remove', ['locationId' => $location->id]); } diff --git a/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php b/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php index b0d3b2bcd6d..23c8e03ca32 100644 --- a/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php +++ b/eZ/Publish/Core/Repository/Tests/Service/Mock/ContentTest.php @@ -734,8 +734,26 @@ public function testDeleteContentThrowsUnauthorizedException() $contentInfo->expects($this->any()) ->method('__get') - ->with('id') - ->will($this->returnValue(42)); + ->willReturnMap( + [ + ['id', 42], + ['currentVersionNo', 7], + ] + ); + + $persistenceHandlerMock = $this->getPersistenceMockHandler('Handler'); + /** @var \PHPUnit\Framework\MockObject\MockObject $contentHandler */ + $contentHandler = $this->getPersistenceMock()->contentHandler(); + + $contentHandler + ->expects($this->once()) + ->method('loadVersionInfo') + ->with( + $this->equalTo(42), + $this->equalTo(7) + )->will( + $this->returnValue(new SPIVersionInfo()) + ); $contentService->expects($this->once()) ->method('internalLoadContentInfo') @@ -782,8 +800,26 @@ public function testDeleteContent() $contentInfo->expects($this->any()) ->method('__get') - ->with('id') - ->will($this->returnValue(42)); + ->willReturnMap( + [ + ['id', 42], + ['currentVersionNo', 7], + ] + ); + + $persistenceHandlerMock = $this->getPersistenceMockHandler('Handler'); + /** @var \PHPUnit\Framework\MockObject\MockObject $contentHandler */ + $contentHandler = $this->getPersistenceMock()->contentHandler(); + + $contentHandler + ->expects($this->once()) + ->method('loadVersionInfo') + ->with( + $this->equalTo(42), + $this->equalTo(7) + )->will( + $this->returnValue(new SPIVersionInfo()) + ); $repository->expects($this->once())->method('beginTransaction'); @@ -840,8 +876,26 @@ public function testDeleteContentWithRollback() $contentInfo->expects($this->any()) ->method('__get') - ->with('id') - ->will($this->returnValue(42)); + ->willReturnMap( + [ + ['id', 42], + ['currentVersionNo', 7], + ] + ); + + $persistenceHandlerMock = $this->getPersistenceMockHandler('Handler'); + /** @var \PHPUnit\Framework\MockObject\MockObject $contentHandler */ + $contentHandler = $this->getPersistenceMock()->contentHandler(); + + $contentHandler + ->expects($this->once()) + ->method('loadVersionInfo') + ->with( + $this->equalTo(42), + $this->equalTo(7) + )->will( + $this->returnValue(new SPIVersionInfo()) + ); $repository->expects($this->once())->method('beginTransaction'); diff --git a/eZ/Publish/Core/Repository/TrashService.php b/eZ/Publish/Core/Repository/TrashService.php index 71765cf458c..e78c3429c35 100644 --- a/eZ/Publish/Core/Repository/TrashService.php +++ b/eZ/Publish/Core/Repository/TrashService.php @@ -10,6 +10,7 @@ use eZ\Publish\API\Repository\Repository as RepositoryInterface; use eZ\Publish\API\Repository\Values\Content\Content; use eZ\Publish\API\Repository\Exceptions\UnauthorizedException as APIUnauthorizedException; +use eZ\Publish\SPI\Limitation\Target; use eZ\Publish\SPI\Persistence\Handler; use eZ\Publish\API\Repository\Values\Content\Location; use eZ\Publish\Core\Repository\Values\Content\TrashItem; @@ -384,7 +385,13 @@ protected function getDateTime($timestamp) */ private function userHasPermissionsToRemove(ContentInfo $contentInfo, Location $location) { - if (!$this->repository->canUser('content', 'remove', $contentInfo, [$location])) { + $versionInfo = $this->persistenceHandler->contentHandler()->loadVersionInfo( + $contentInfo->id, + $contentInfo->currentVersionNo + ); + $target = (new Target\Version())->deleteTranslations($versionInfo->languageCodes); + + if (!$this->repository->canUser('content', 'remove', $contentInfo, [$location, $target])) { return false; } $contentRemoveCriterion = $this->permissionCriterionResolver->getPermissionsCriterion('content', 'remove'); diff --git a/eZ/Publish/SPI/Limitation/Target/Version.php b/eZ/Publish/SPI/Limitation/Target/Version.php index 793decab3f8..13b26ad07e9 100644 --- a/eZ/Publish/SPI/Limitation/Target/Version.php +++ b/eZ/Publish/SPI/Limitation/Target/Version.php @@ -68,4 +68,29 @@ final class Version extends ValueObject implements Target * @var int|null */ protected $newStatus; + + /** + * List of language codes of translations to delete. All must match Limitation values. + * + * @var string[] + */ + private $translationsToDelete = []; + + /** + * @param string[] $translationsToDelete List of language codes of translations to delete + */ + public function deleteTranslations(array $translationsToDelete): self + { + $this->translationsToDelete = $translationsToDelete; + + return $this; + } + + /** + * @return string[] + */ + public function getTranslationsToDelete(): array + { + return $this->translationsToDelete; + } }