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.
This commit is contained in:
2026-04-08 21:33:00 +02:00
parent 25962e0e39
commit 4afdc9a7cc
2 changed files with 204 additions and 2 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\DocStoreBundle\Tests\Controller;
use Chill\DocStoreBundle\Controller\WebdavLockController;
use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectLock;
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\MainBundle\Entity\User;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Security;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
class WebdavLockControllerTest extends KernelTestCase
{
use ProphecyTrait;
private Environment $twig;
private MockClock $clock;
protected function setUp(): void
{
self::bootKernel();
$this->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'
<?xml version="1.0" encoding="UTF-8"?>
<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>LibreOffice - Julien Fastré</owner></lockinfo>
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);
}
}