diff --git a/bootstrap.php b/bootstrap.php index 4ead6e13d3..913daaf313 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -4,6 +4,10 @@ * @copyright Copyright (C) Ibexa AS. All rights reserved. * @license For full copyright and license information view LICENSE file distributed with this source code. */ + +use Ibexa\Core\Persistence\Legacy\Content\Gateway\DoctrineDatabase; +use Ibexa\Core\Persistence\Legacy\Content\Gateway\DoctrineDatabase\QueryBuilder; +use Ibexa\Core\Repository\ContentService; use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\HttpFoundation\Request; @@ -11,4 +15,9 @@ // https://github.com/symfony/symfony/issues/28259 ClockMock::register(Request::class); +// Register ClockMock, as otherwise they are mocked until first method call. +ClockMock::register(DoctrineDatabase::class); +ClockMock::register(ContentService::class); +ClockMock::register(QueryBuilder::class); + require_once __DIR__ . '/vendor/autoload.php'; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 266fb33436..3745cbbf09 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -70662,12 +70662,6 @@ parameters: count: 1 path: tests/lib/Repository/Service/Mock/ContentTest.php - - - message: '#^Call to new Ibexa\\Core\\Repository\\ContentService\(\) on a separate line has no effect\.$#' - identifier: new.resultUnused - count: 1 - path: tests/lib/Repository/Service/Mock/ContentTest.php - - message: '#^Cannot access offset mixed on Ibexa\\Contracts\\Core\\Repository\\Values\\Content\\Field\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -70836,12 +70830,6 @@ parameters: count: 1 path: tests/lib/Repository/Service/Mock/ContentTest.php - - - message: '#^Method Ibexa\\Tests\\Core\\Repository\\Service\\Mock\\ContentTest\:\:getPartlyMockedContentService\(\) invoked with 2 parameters, 0\-1 required\.$#' - identifier: arguments.count - count: 1 - path: tests/lib/Repository/Service/Mock/ContentTest.php - - message: '#^Method Ibexa\\Tests\\Core\\Repository\\Service\\Mock\\ContentTest\:\:getPartlyMockedContentService\(\) should return Ibexa\\Core\\Repository\\ContentService&PHPUnit\\Framework\\MockObject\\MockObject but returns Ibexa\\Core\\Repository\\ContentService\.$#' identifier: return.type diff --git a/src/bundle/Core/ApiLoader/RepositoryFactory.php b/src/bundle/Core/ApiLoader/RepositoryFactory.php index 2d2ac159fd..119fac99f8 100644 --- a/src/bundle/Core/ApiLoader/RepositoryFactory.php +++ b/src/bundle/Core/ApiLoader/RepositoryFactory.php @@ -19,6 +19,7 @@ use Ibexa\Contracts\Core\Search\Handler as SearchHandler; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Mapper; use Ibexa\Core\Repository\Permission\LimitationService; @@ -93,7 +94,8 @@ public function buildRepository( LocationFilteringHandler $locationFilteringHandler, PasswordValidatorInterface $passwordValidator, ConfigResolverInterface $configResolver, - NameSchemaServiceInterface $nameSchemaService + NameSchemaServiceInterface $nameSchemaService, + ContentCollector $contentCollector ): Repository { $config = $this->container->get(RepositoryConfigurationProvider::class)->getRepositoryConfig(); @@ -119,6 +121,7 @@ public function buildRepository( $passwordValidator, $configResolver, $nameSchemaService, + $contentCollector, [ 'role' => [ 'policyMap' => $this->policyMap, @@ -127,6 +130,7 @@ public function buildRepository( 'content' => [ 'default_version_archive_limit' => $config['options']['default_version_archive_limit'], 'remove_archived_versions_on_publish' => $config['options']['remove_archived_versions_on_publish'], + 'grace_period_in_seconds' => $config['options']['grace_period_in_seconds'] ?? (int) ini_get('max_execution_time'), ], ], $this->logger diff --git a/src/bundle/Core/DependencyInjection/Configuration/Parser/Repository/Options.php b/src/bundle/Core/DependencyInjection/Configuration/Parser/Repository/Options.php index 19d26ace5b..6f388ab37a 100644 --- a/src/bundle/Core/DependencyInjection/Configuration/Parser/Repository/Options.php +++ b/src/bundle/Core/DependencyInjection/Configuration/Parser/Repository/Options.php @@ -27,6 +27,9 @@ public function addSemanticConfig(NodeBuilder $nodeBuilder): void ->defaultTrue() ->info('Enables automatic removal of archived versions when publishing, at the cost of performance. "ezplatform:content:cleanup-versions" command should be used to perform this task instead if this option is set to false.') ->end() + ->integerNode('grace_period_in_seconds') + ->info('Provide a value in seconds, when archived content is still accessible for users with access to current version. Prevents 500 error when accessed content is updated during request. Defaults to php max execution time.') + ->end() ->end() ->end(); } diff --git a/src/bundle/Core/EventSubscriber/ClearCollectedContentCacheSubscriber.php b/src/bundle/Core/EventSubscriber/ClearCollectedContentCacheSubscriber.php new file mode 100644 index 0000000000..a11e4cdd00 --- /dev/null +++ b/src/bundle/Core/EventSubscriber/ClearCollectedContentCacheSubscriber.php @@ -0,0 +1,51 @@ +cache = $cache; + $this->identifierGenerator = $identifierGenerator; + $this->contentCollector = $contentCollector; + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::TERMINATE => 'clearCache']; + } + + public function clearCache(TerminateEvent $event): void + { + foreach ($this->contentCollector->getCollectedContentIds() as $contentId) { + $this->cache->invalidateTags([ + $this->identifierGenerator->generateTag('content', [$contentId]), + ]); + } + + $this->contentCollector->reset(); + } +} diff --git a/src/bundle/Core/Resources/config/services.yml b/src/bundle/Core/Resources/config/services.yml index 03585c9438..8babb6f451 100644 --- a/src/bundle/Core/Resources/config/services.yml +++ b/src/bundle/Core/Resources/config/services.yml @@ -304,6 +304,12 @@ services: tags: - {name: kernel.event_subscriber} + Ibexa\Bundle\Core\EventSubscriber\ClearCollectedContentCacheSubscriber: + autowire: true + autoconfigure: true + arguments: + $cache: '@ibexa.cache_pool' + Ibexa\Bundle\Core\EventSubscriber\TrustedHeaderClientIpEventSubscriber: arguments: $trustedHeaderName: '%ibexa.trusted_header_client_ip_name%' diff --git a/src/contracts/Persistence/Content/Handler.php b/src/contracts/Persistence/Content/Handler.php index 837f12a4be..963f48ab4c 100644 --- a/src/contracts/Persistence/Content/Handler.php +++ b/src/contracts/Persistence/Content/Handler.php @@ -131,6 +131,13 @@ public function loadContentInfoByRemoteId($remoteId); */ public function loadVersionInfo($contentId, $versionNo = null); + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * + * @return int[] + */ + public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array; + /** * Returns the number of versions with draft status created by the given $userId. * diff --git a/src/lib/Base/Container/ApiLoader/RepositoryFactory.php b/src/lib/Base/Container/ApiLoader/RepositoryFactory.php index c4b1cbb17e..310382149e 100644 --- a/src/lib/Base/Container/ApiLoader/RepositoryFactory.php +++ b/src/lib/Base/Container/ApiLoader/RepositoryFactory.php @@ -20,6 +20,7 @@ use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\Base\Exceptions\InvalidArgumentException; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Mapper; use Ibexa\Core\Repository\Permission\LimitationService; @@ -85,6 +86,7 @@ public function buildRepository( PasswordValidatorInterface $passwordValidator, ConfigResolverInterface $configResolver, NameSchemaServiceInterface $nameSchemaService, + ContentCollector $contentCollector, array $languages ): Repository { return new $this->repositoryClass( @@ -109,6 +111,7 @@ public function buildRepository( $passwordValidator, $configResolver, $nameSchemaService, + $contentCollector, [ 'role' => [ 'policyMap' => $this->policyMap, diff --git a/src/lib/Persistence/Cache/ContentHandler.php b/src/lib/Persistence/Cache/ContentHandler.php index 68d8027f02..c4ee400aec 100644 --- a/src/lib/Persistence/Cache/ContentHandler.php +++ b/src/lib/Persistence/Cache/ContentHandler.php @@ -266,6 +266,14 @@ public function loadVersionInfo($contentId, $versionNo = null) return $versionInfo; } + /** + * @return int[] + */ + public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array + { + return $this->persistenceHandler->contentHandler()->loadVersionNoArchivedWithin($contentId, $seconds); + } + public function countDraftsForUser(int $userId): int { $this->logger->logCall(__METHOD__, ['user' => $userId]); diff --git a/src/lib/Persistence/Legacy/Content/Gateway.php b/src/lib/Persistence/Legacy/Content/Gateway.php index 95f12bc0b4..aec6549d8a 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway.php +++ b/src/lib/Persistence/Legacy/Content/Gateway.php @@ -212,6 +212,11 @@ abstract public function loadContentInfoList(array $contentIds): array; */ abstract public function loadVersionInfo(int $contentId, ?int $versionNo = null): array; + /** + * @return array + */ + abstract public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array; + /** * Return the number of all versions with given status created by the given $userId. */ diff --git a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php index 3931637596..943f4da0ec 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php +++ b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase.php @@ -910,6 +910,51 @@ public function loadVersionInfo(int $contentId, ?int $versionNo = null): array return $queryBuilder->execute()->fetchAll(FetchMode::ASSOCIATIVE); } + /** + * @return array> + */ + public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array + { + $cutoffTimestamp = time() - $seconds; + if ($cutoffTimestamp < 0) { + return []; + } + $queryBuilder = $this->queryBuilder->createVersionInfoFindQueryBuilder(); + $expr = $queryBuilder->expr(); + + $queryBuilder + ->andWhere( + $expr->eq( + 'v.contentobject_id', + $queryBuilder->createNamedParameter( + $contentId, + ParameterType::INTEGER, + ':content_id' + ) + ) + )->andWhere( + $expr->eq( + 'v.status', + $queryBuilder->createNamedParameter( + VersionInfo::STATUS_ARCHIVED, + ParameterType::INTEGER, + ':status' + ) + ) + )->andWhere( + $expr->gt( + 'v.modified', + $queryBuilder->createNamedParameter( + $cutoffTimestamp, + ParameterType::INTEGER, + ':modified' + ) + ) + )->orderBy('v.modified', 'DESC'); + + return $queryBuilder->execute()->fetchAllAssociative(); + } + public function countVersionsForUser(int $userId, int $status = VersionInfo::STATUS_DRAFT): int { $query = $this->connection->createQueryBuilder(); diff --git a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php index cdc28887e9..dee1000437 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php +++ b/src/lib/Persistence/Legacy/Content/Gateway/DoctrineDatabase/QueryBuilder.php @@ -10,7 +10,6 @@ use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Query\QueryBuilder as DoctrineQueryBuilder; use Ibexa\Core\Persistence\Legacy\Content\Gateway; -use function time; /** * @internal For internal use by the Content gateway. diff --git a/src/lib/Persistence/Legacy/Content/Gateway/ExceptionConversion.php b/src/lib/Persistence/Legacy/Content/Gateway/ExceptionConversion.php index f4e8f9dc32..784c600015 100644 --- a/src/lib/Persistence/Legacy/Content/Gateway/ExceptionConversion.php +++ b/src/lib/Persistence/Legacy/Content/Gateway/ExceptionConversion.php @@ -217,6 +217,18 @@ public function loadVersionInfo(int $contentId, ?int $versionNo = null): array } } + /** + * @return int[] + */ + public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array + { + try { + return $this->innerGateway->loadVersionNoArchivedWithin($contentId, $seconds); + } catch (DBALException | PDOException $e) { + throw DatabaseException::wrap($e); + } + } + public function countVersionsForUser(int $userId, int $status = VersionInfo::STATUS_DRAFT): int { try { diff --git a/src/lib/Persistence/Legacy/Content/Handler.php b/src/lib/Persistence/Legacy/Content/Handler.php index 0acf1ab1e0..5d2afeb605 100644 --- a/src/lib/Persistence/Legacy/Content/Handler.php +++ b/src/lib/Persistence/Legacy/Content/Handler.php @@ -483,6 +483,21 @@ public function loadVersionInfo($contentId, $versionNo = null) return reset($versionInfo); } + public function loadVersionNoArchivedWithin(int $contentId, int $seconds): array + { + $rows = $this->contentGateway->loadVersionNoArchivedWithin($contentId, $seconds); + if (empty($rows)) { + throw new NotFound('content', $contentId); + } + + $archivedVersionNos = []; + foreach ($rows as $row) { + $archivedVersionNos[] = (int) $row['ezcontentobject_version_version']; + } + + return $archivedVersionNos; + } + public function countDraftsForUser(int $userId): int { return $this->contentGateway->countVersionsForUser($userId, VersionInfo::STATUS_DRAFT); diff --git a/src/lib/Repository/Collector/ContentCollector.php b/src/lib/Repository/Collector/ContentCollector.php new file mode 100644 index 0000000000..816089c863 --- /dev/null +++ b/src/lib/Repository/Collector/ContentCollector.php @@ -0,0 +1,36 @@ + */ + private array $contentMap = []; + + public function collectContent(Content $content): void + { + $this->contentMap[$content->getId()] = false; + } + + /** + * @return int[] + */ + public function getCollectedContentIds(): array + { + return array_keys($this->contentMap); + } + + public function reset(): void + { + $this->contentMap = []; + } +} diff --git a/src/lib/Repository/ContentService.php b/src/lib/Repository/ContentService.php index 4aee3f990a..16cd434e61 100644 --- a/src/lib/Repository/ContentService.php +++ b/src/lib/Repository/ContentService.php @@ -59,6 +59,7 @@ use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\Base\Exceptions\UnauthorizedException; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\Mapper\ContentDomainMapper; use Ibexa\Core\Repository\Mapper\ContentMapper; use Ibexa\Core\Repository\Values\Content\Content; @@ -105,6 +106,8 @@ class ContentService implements ContentServiceInterface /** @var \Ibexa\Contracts\Core\Persistence\Filter\Content\Handler */ private $contentFilteringHandler; + private ContentCollector $contentCollector; + public function __construct( RepositoryInterface $repository, Handler $handler, @@ -116,6 +119,7 @@ public function __construct( ContentMapper $contentMapper, ContentValidator $contentValidator, ContentFilteringHandler $contentFilteringHandler, + ContentCollector $contentCollector, array $settings = [] ) { $this->repository = $repository; @@ -129,11 +133,13 @@ public function __construct( // Version archive limit (0-50), only enforced on publish, not on un-publish. 'default_version_archive_limit' => 5, 'remove_archived_versions_on_publish' => true, + 'grace_period_in_seconds' => (int) ini_get('max_execution_time'), ]; $this->contentFilteringHandler = $contentFilteringHandler; $this->permissionResolver = $permissionService; $this->contentMapper = $contentMapper; $this->contentValidator = $contentValidator; + $this->contentCollector = $contentCollector; } /** @@ -381,7 +387,6 @@ public function loadContentByVersionInfo(APIVersionInfo $versionInfo, array $lan public function loadContent(int $contentId, array $languages = null, ?int $versionNo = null, bool $useAlwaysAvailable = true): APIContent { $content = $this->internalLoadContentById($contentId, $languages, $versionNo, $useAlwaysAvailable); - if (!$this->permissionResolver->canUser('content', 'read', $content)) { throw new UnauthorizedException('content', 'read', ['contentId' => $contentId]); } @@ -389,12 +394,33 @@ public function loadContent(int $contentId, array $languages = null, ?int $versi !$content->getVersionInfo()->isPublished() && !$this->permissionResolver->canUser('content', 'versionread', $content) ) { - throw new UnauthorizedException('content', 'versionread', ['contentId' => $contentId, 'versionNo' => $versionNo]); + if (!$this->isInGracePeriod($content, $this->settings['grace_period_in_seconds'], $versionNo)) { + throw new UnauthorizedException('content', 'versionread', ['contentId' => $contentId, 'versionNo' => $versionNo]); + } + $this->contentCollector->collectContent($content); } return $content; } + private function isInGracePeriod(APIContent $content, int $graceSeconds, ?int $versionNo): bool + { + if ($graceSeconds <= 0 || $versionNo === null) { + return false; + } + + try { + $lastArchivedVersionNos = $this->persistenceHandler->contentHandler()->loadVersionNoArchivedWithin( + $content->getId(), + $graceSeconds + ); + } catch (APINotFoundException $e) { + return false; + } + + return in_array($versionNo, $lastArchivedVersionNos, true); + } + public function internalLoadContentById( int $id, ?array $languages = null, diff --git a/src/lib/Repository/Repository.php b/src/lib/Repository/Repository.php index d97c929160..fa165c2222 100644 --- a/src/lib/Repository/Repository.php +++ b/src/lib/Repository/Repository.php @@ -41,6 +41,7 @@ use Ibexa\Contracts\Core\Search\Handler as SearchHandler; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Permission\LimitationService; use Ibexa\Core\Repository\ProxyFactory\ProxyDomainMapperFactoryInterface; @@ -261,6 +262,8 @@ class Repository implements RepositoryInterface private ConfigResolverInterface $configResolver; + private ContentCollector $contentCollector; + public function __construct( PersistenceHandler $persistenceHandler, SearchHandler $searchHandler, @@ -283,6 +286,7 @@ public function __construct( PasswordValidatorInterface $passwordValidator, ConfigResolverInterface $configResolver, NameSchemaServiceInterface $nameSchemaService, + ContentCollector $contentCollector, array $serviceSettings = [], ?LoggerInterface $logger = null ) { @@ -334,6 +338,7 @@ public function __construct( $this->passwordValidator = $passwordValidator; $this->configResolver = $configResolver; $this->nameSchemaService = $nameSchemaService; + $this->contentCollector = $contentCollector; } public function sudo(callable $callback, ?RepositoryInterface $outerRepository = null) @@ -365,6 +370,7 @@ public function getContentService(): ContentServiceInterface $this->contentMapper, $this->contentValidator, $this->contentFilteringHandler, + $this->contentCollector, $this->serviceSettings['content'], ); diff --git a/src/lib/Resources/settings/repository/inner.yml b/src/lib/Resources/settings/repository/inner.yml index f36d0b9a7e..8b5e99afcf 100644 --- a/src/lib/Resources/settings/repository/inner.yml +++ b/src/lib/Resources/settings/repository/inner.yml @@ -38,6 +38,7 @@ services: - '@Ibexa\Core\Repository\User\PasswordValidatorInterface' - '@ibexa.config.resolver' - '@Ibexa\Contracts\Core\Repository\NameSchema\NameSchemaServiceInterface' + - '@Ibexa\Core\Repository\Collector\ContentCollector' - '%languages%' Ibexa\Core\Repository\ContentService: @@ -271,3 +272,7 @@ services: Ibexa\Core\Repository\Validator\TargetContentValidatorInterface: alias: Ibexa\Core\Repository\Validator\TargetContentValidator + + Ibexa\Core\Repository\Collector\ContentCollector: + tags: + - { name: 'kernel.reset', method: 'reset' } diff --git a/tests/integration/Core/Repository/ContentServiceTest.php b/tests/integration/Core/Repository/ContentServiceTest.php index 91f42c9438..b609acec3c 100644 --- a/tests/integration/Core/Repository/ContentServiceTest.php +++ b/tests/integration/Core/Repository/ContentServiceTest.php @@ -7,6 +7,7 @@ namespace Ibexa\Tests\Integration\Core\Repository; use Exception; +use Ibexa\Contracts\Core\Repository\ContentService; use Ibexa\Contracts\Core\Repository\Exceptions\BadStateException; use Ibexa\Contracts\Core\Repository\Exceptions\ContentFieldValidationException; use Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException as APIInvalidArgumentException; @@ -32,6 +33,8 @@ use Ibexa\Core\Base\Exceptions\UnauthorizedException as CoreUnauthorizedException; use Ibexa\Core\Repository\Values\Content\ContentUpdateStruct; use InvalidArgumentException; +use ReflectionClass; +use Symfony\Bridge\PhpUnit\ClockMock; /** * Test case for operations in the ContentService using in memory storage. @@ -6751,6 +6754,65 @@ private function createContentWithRelations(): Content return $draft; } + + public function testLoadContentWithinGracePeriod(): void + { + $repository = $this->getRepository(); + + $contentTypeService = $repository->getContentTypeService(); + $contentType = $contentTypeService->loadContentTypeByIdentifier('folder'); + + $contentCreate = $this->contentService->newContentCreateStruct($contentType, self::ENG_US); + $contentCreate->modificationDate = new \DateTime('2025-04-01 14:00:00'); + $contentCreate->setField('name', 'My awesome Folder'); + + ClockMock::withClockMock(strtotime('2025-04-01 14:00:01')); + $content = $this->contentService->createContent($contentCreate); + $unPublishedVersionOneContent = $this->contentService->publishVersion($content->getVersionInfo()); + + $this->contentService->publishVersion( + $this->updateFolder($content, [self::ENG_US => 'Updated Name'])->getVersionInfo() + ); + + $anonymousUserId = $this->generateId('user', 10); + $repository->getPermissionResolver()->setCurrentUserReference($repository->getUserService()->loadUser($anonymousUserId)); + + $this->setGracePeriod(10); + + //Reset clock, to make sure that upfront operations did not exceed grace period. + ClockMock::withClockMock(strtotime('2025-04-01 14:00:02')); + $this->contentService->loadContent($unPublishedVersionOneContent->getId(), null, $unPublishedVersionOneContent->getVersionInfo()->versionNo); + + ClockMock::sleep(20); + $this->expectException(CoreUnauthorizedException::class); + $this->contentService->loadContent($unPublishedVersionOneContent->getId(), null, $unPublishedVersionOneContent->getVersionInfo()->versionNo); + + ClockMock::withClockMock(false); + } + + private function setGracePeriod(int $value): void + { + $reflection = new ReflectionClass($this->contentService); + $serviceProperty = $reflection->getProperty('service'); + $serviceProperty->setAccessible(true); + + $service = $serviceProperty->getValue($this->contentService); + + $serviceReflection = new ReflectionClass($service); + $innerServiceProperty = $serviceReflection->getProperty('innerService'); + $innerServiceProperty->setAccessible(true); + + $innerService = $innerServiceProperty->getValue($service); + + $innerServiceReflection = new ReflectionClass($innerService); + $settingsProperty = $innerServiceReflection->getProperty('settings'); + $settingsProperty->setAccessible(true); + + $settings = $settingsProperty->getValue($innerService); + $settings['grace_period_in_seconds'] = $value; + + $settingsProperty->setValue($innerService, $settings); + } } class_alias(ContentServiceTest::class, 'eZ\Publish\API\Repository\Tests\ContentServiceTest'); diff --git a/tests/integration/Core/Repository/UserServiceTest.php b/tests/integration/Core/Repository/UserServiceTest.php index d1d02f8fdd..1e3095f774 100644 --- a/tests/integration/Core/Repository/UserServiceTest.php +++ b/tests/integration/Core/Repository/UserServiceTest.php @@ -34,6 +34,7 @@ use Ibexa\Core\Repository\Values\Content\Content; use Ibexa\Core\Repository\Values\Content\VersionInfo; use Ibexa\Core\Repository\Values\User\UserGroup; +use Symfony\Bridge\PhpUnit\ClockMock; /** * Test case for operations in the UserService using in memory storage. @@ -1688,6 +1689,10 @@ public function testNewUserUpdateStruct() */ public function testUpdateUser() { + // As \Ibexa\Tests\Integration\Core\Repository\UserServiceTest::testUpdateUserUpdatesExpectedProperties belongs on this test, + // and it is the only test that tracks real time passing with delta + // but actual password change is done here, therefore for _reasons_ we need to disable ClockMock here. + ClockMock::withClockMock(false); $repository = $this->getRepository(); $userService = $repository->getUserService(); diff --git a/tests/lib/Repository/ContentServiceTest.php b/tests/lib/Repository/ContentServiceTest.php index ce2e14a2aa..861ed4f1df 100644 --- a/tests/lib/Repository/ContentServiceTest.php +++ b/tests/lib/Repository/ContentServiceTest.php @@ -16,6 +16,7 @@ use Ibexa\Contracts\Core\Repository\Validator\ContentValidator; use Ibexa\Contracts\Core\Repository\Values\Filter\Filter; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\ContentService; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Mapper\ContentDomainMapper; @@ -42,7 +43,8 @@ protected function setUp(): void $this->createMock(PermissionService::class), $this->createMock(ContentMapper::class), $this->createMock(ContentValidator::class), - $this->createMock(ContentFilteringHandler::class) + $this->createMock(ContentFilteringHandler::class), + new ContentCollector() ); } diff --git a/tests/lib/Repository/Service/Mock/Base.php b/tests/lib/Repository/Service/Mock/Base.php index dfda628e4f..887fb89508 100644 --- a/tests/lib/Repository/Service/Mock/Base.php +++ b/tests/lib/Repository/Service/Mock/Base.php @@ -19,6 +19,7 @@ use Ibexa\Contracts\Core\Repository\Values\Content\ContentInfo; use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface; use Ibexa\Core\FieldType\FieldTypeRegistry; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\FieldTypeService; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Mapper\ContentDomainMapper; @@ -129,6 +130,7 @@ protected function getRepository(array $serviceSettings = []) $this->createMock(PasswordValidatorInterface::class), $this->createMock(ConfigResolverInterface::class), $this->createMock(NameSchemaServiceInterface::class), + new ContentCollector(), $serviceSettings, ); diff --git a/tests/lib/Repository/Service/Mock/ContentTest.php b/tests/lib/Repository/Service/Mock/ContentTest.php index b6d49d0d9e..09455d0888 100644 --- a/tests/lib/Repository/Service/Mock/ContentTest.php +++ b/tests/lib/Repository/Service/Mock/ContentTest.php @@ -45,6 +45,7 @@ use Ibexa\Core\Base\Exceptions\NotFoundException; use Ibexa\Core\FieldType\ValidationError; use Ibexa\Core\FieldType\Value; +use Ibexa\Core\Repository\Collector\ContentCollector; use Ibexa\Core\Repository\ContentService; use Ibexa\Core\Repository\Helper\RelationProcessor; use Ibexa\Core\Repository\Values\Content\Content; @@ -99,6 +100,7 @@ public function testConstructor(): void $contentMapper, $contentValidatorStrategy, $contentFilteringHandlerMock, + new ContentCollector(), $settings ); } @@ -493,8 +495,6 @@ public function testLoadContentUnauthorized() public function testLoadContentNotPublishedStatusUnauthorized() { - $this->expectException(UnauthorizedException::class); - $permissionResolver = $this->getPermissionResolverMock(); $contentService = $this->getPartlyMockedContentService(['internalLoadContentById']); $content = $this->createMock(APIContent::class); @@ -524,6 +524,7 @@ public function testLoadContentNotPublishedStatusUnauthorized() ) ); + $this->expectException(UnauthorizedException::class); $contentService->loadContent($contentId); } @@ -3317,8 +3318,7 @@ protected function assertForTestUpdateContentNonRedundantFieldSet( ->expects($this->once()) ->method('getCurrentUserReference') ->willReturn(new UserReference(169)); - $mockedService = $this->getPartlyMockedContentService(['internalLoadContentById'], $permissionResolverMock); - $permissionResolverMock = $this->getPermissionResolverMock(); + $mockedService = $this->getPartlyMockedContentService(['internalLoadContentById']); /** @var \PHPUnit\Framework\MockObject\MockObject $contentHandlerMock */ $contentHandlerMock = $this->getPersistenceMock()->contentHandler(); /** @var \PHPUnit\Framework\MockObject\MockObject $languageHandlerMock */ @@ -6271,7 +6271,7 @@ protected function getLocationServiceMock() * * @return \Ibexa\Core\Repository\ContentService|\PHPUnit\Framework\MockObject\MockObject */ - protected function getPartlyMockedContentService(array $methods = null) + protected function getPartlyMockedContentService(array $methods = null, int $gracePeriodInSeconds = 0) { if (!isset($this->partlyMockedContentService)) { $this->partlyMockedContentService = $this->getMockBuilder(ContentService::class) @@ -6288,7 +6288,10 @@ protected function getPartlyMockedContentService(array $methods = null) $this->getContentMapper(), $this->getContentValidatorStrategy(), $this->getContentFilteringHandlerMock(), - [], + new ContentCollector(), + [ + 'grace_period_in_seconds' => $gracePeriodInSeconds, + ], ] ) ->getMock();