From 4afdc9a7cc54656fd5dfa9ceafffbc687e579e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Wed, 8 Apr 2026 21:33:00 +0200 Subject: [PATCH] Add WebDAV lock and renew functionality with comprehensive tests - Implemented `WebdavLockController::lockDocument` to handle LOCK requests for creating and renewing locks. - Introduced access control checks using `StoredObjectRoleEnum::EDIT` to restrict lock operations to authorized users. - Added test suite `WebdavLockControllerTest` to validate locking and renewing scenarios, including edge cases. - Refactored lock logic into `lockOrRenewToken` for streamlined processing. --- .../Controller/WebdavLockController.php | 32 +++- .../Controller/WebdavLockControllerTest.php | 174 ++++++++++++++++++ 2 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavLockControllerTest.php diff --git a/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php b/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php index cc1e53cf0..7daa62f0d 100644 --- a/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php +++ b/src/Bundle/ChillDocStoreBundle/Controller/WebdavLockController.php @@ -16,6 +16,7 @@ use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer; use Chill\DocStoreBundle\Dav\Utils\LockTokenParser; use Chill\DocStoreBundle\Entity\StoredObject; use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; use Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum; use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager; use Chill\MainBundle\Entity\User; @@ -25,11 +26,12 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Security\Core\Security; use Twig\Environment; -class WebdavLockController +final readonly class WebdavLockController { public function __construct( private StoredObjectLockManager $lockManager, @@ -43,6 +45,33 @@ class WebdavLockController #[Route(path: '/dav/{access_token}/get/{uuid}/d', name: 'chill_docstore_dav_document_lock', methods: ['LOCK'])] public function lockDocument(StoredObject $storedObject, Request $request): Response { + $user = $this->security->getUser(); + + if (null !== $token = $this->lockTokenParser->parseIfCondition($request)) { + // this is a renew of a token. We first perform checks + if (true !== $error = $this->lockManager->checkLock($storedObject, $token, $user)) { + $e = match ($error) { + LockTokenCheckResultEnum::NO_LOCK_FOUND, LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH => new PreconditionFailedHttpException(), + LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER => new ConflictHttpException(), + }; + + throw $e; + } + } else { + if ($this->lockManager->hasLock($storedObject)) { + throw new ConflictHttpException(); + } + } + + return $this->lockOrRenewToken($storedObject, $request); + } + + private function lockOrRenewToken(StoredObject $storedObject, Request $request): Response + { + if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) { + throw new UnauthorizedHttpException('not allowed to edit this document'); + } + $timeout = $request->headers->get('Timeout', 'Second-3600'); $timeoutInterval = $this->lockTimeoutAnalyzer->analyzeTimeout($timeout); @@ -87,6 +116,5 @@ class WebdavLockController }; throw $e; - } } diff --git a/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavLockControllerTest.php b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavLockControllerTest.php new file mode 100644 index 000000000..9935dcb34 --- /dev/null +++ b/src/Bundle/ChillDocStoreBundle/Tests/Controller/WebdavLockControllerTest.php @@ -0,0 +1,174 @@ +twig = self::getContainer()->get(Environment::class); + $this->clock = new MockClock(); + } + + private function buildController( + StoredObjectLockManager $lockManager, + Security $security, + LockTimeoutAnalyzer $lockTimeoutAnalyzer, + LockTokenParser $lockTokenParser, + ): WebdavLockController { + return new WebdavLockController( + $lockManager, + $security, + $this->twig, + $lockTimeoutAnalyzer, + $this->clock, + $lockTokenParser + ); + } + + public function testLockAndRenewDocument(): void + { + $storedObject = new StoredObject(); + // Set a fixed UUID for testing + $reflection = new \ReflectionClass($storedObject); + $uuidProp = $reflection->getProperty('uuid'); + $uuidProp->setValue($storedObject, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b')); + + $user = new User(); + + $lockManager = $this->prophesize(StoredObjectLockManager::class); + $security = $this->prophesize(Security::class); + $lockTimeoutAnalyzer = new LockTimeoutAnalyzer(); + $lockTokenParser = new LockTokenParser(); + + $security->getUser()->willReturn($user); + $security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)->willReturn(true); + + $lockToken = 'opaquelocktoken:'.Uuid::uuid4()->toString(); + $lock = new StoredObjectLock( + $storedObject, + StoredObjectLockMethodEnum::WEBDAV, + $this->clock->now(), + $lockToken, + $this->clock->now()->add(new \DateInterval('PT180S')), + [$user] + ); + + // Initial LOCK request + $lockManager->hasLock($storedObject)->willReturn(false); + $lockManager->setLock( + $storedObject, + StoredObjectLockMethodEnum::WEBDAV, + null, // lockId + Argument::type(\DateTimeImmutable::class), + [$user] + )->willReturn($lock); + + $controller = $this->buildController( + $lockManager->reveal(), + $security->reveal(), + $lockTimeoutAnalyzer, + $lockTokenParser + ); + + $initialBody = <<<'XML' + +LibreOffice - Julien Fastré +XML; + + $request = new Request([], [], [], [], [], [ + 'HTTP_TIMEOUT' => 'Second-180', + ], $initialBody); + $request->setMethod('LOCK'); + + $response = $controller->lockDocument($storedObject, $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + $tokenFromHeader = $response->headers->get('Lock-Token'); + self::assertNotNull($tokenFromHeader); + + // Extract token from XML content + $content = $response->getContent(); + self::assertStringContainsString($lockToken, $content); + + $dom = new \DOMDocument(); + $dom->loadXML($content); + $xpath = new \DOMXPath($dom); + $xpath->registerNamespace('D', 'DAV:'); + $tokenFromXml = $xpath->query('//D:locktoken/D:href')->item(0)->nodeValue; + + self::assertSame($lockToken, $tokenFromHeader); + self::assertSame($lockToken, $tokenFromXml); + + // RENEW request + $lockManager->checkLock($storedObject, $lockToken, $user)->willReturn(true); + // During renew, setLock is called again to update expiration + $lockManager->setLock( + $storedObject, + StoredObjectLockMethodEnum::WEBDAV, + null, // lockId is null because in lockOrRenewToken it's not passed, and it uses default null + Argument::type(\DateTimeImmutable::class), + [$user] + )->willReturn($lock); + + $renewRequest = new Request([], [], [], [], [], [ + 'HTTP_IF' => '(<'.$lockToken.'>)', + 'HTTP_TIMEOUT' => 'Second-180', + ], ''); + $renewRequest->setMethod('LOCK'); + + $renewResponse = $controller->lockDocument($storedObject, $renewRequest); + + self::assertSame(Response::HTTP_OK, $renewResponse->getStatusCode()); + $renewTokenFromHeader = $renewResponse->headers->get('Lock-Token'); + + $renewContent = $renewResponse->getContent(); + $domRenew = new \DOMDocument(); + $domRenew->loadXML($renewContent); + $xpathRenew = new \DOMXPath($domRenew); + $xpathRenew->registerNamespace('D', 'DAV:'); + $renewTokenFromXml = $xpathRenew->query('//D:locktoken/D:href')->item(0)->nodeValue; + + self::assertSame($lockToken, $renewTokenFromHeader); + self::assertSame($lockToken, $renewTokenFromXml); + } +}