Extract WebDAV PUT logic into a dedicated WebdavPutController and add comprehensive tests

- Moved `putDocument` method from `WebdavController` to the new `WebdavPutController`.
- Introduced stricter locking and permission checks during PUT operations.
- Added new test suite `WebdavPutControllerTest` to cover different scenarios, including lock validation and access control.
- Updated existing logic in `WebdavControllerTest` to reflect these changes.
This commit is contained in:
2026-04-03 18:01:23 +02:00
parent a1d72cefff
commit 4a224054e2
4 changed files with 338 additions and 45 deletions

View File

@@ -16,7 +16,6 @@ use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -43,7 +42,6 @@ final readonly class WebdavController
private \Twig\Environment $engine,
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private EntityManagerInterface $entityManager,
) {
$this->requestAnalyzer = new PropfindRequestAnalyzer();
}
@@ -194,20 +192,6 @@ final readonly class WebdavController
return $response;
}
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PUT'])]
public function putDocument(StoredObject $storedObject, Request $request): Response
{
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
$this->entityManager->flush();
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
/**
* @return array{0: array, 1: \DateTimeInterface, 2: string, 3: int} properties, lastModified, etag, length
*/

View File

@@ -0,0 +1,71 @@
<?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\Controller;
use Chill\DocStoreBundle\Dav\Response\DavResponse;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Security;
final readonly class WebdavPutController
{
public function __construct(
private StoredObjectManagerInterface $storedObjectManager,
private Security $security,
private EntityManagerInterface $entityManager,
private LockTokenParser $lockTokenParser,
private StoredObjectLockManager $lockManager,
) {}
#[Route(path: '/dav/{access_token}/get/{uuid}/d', methods: ['PUT'])]
public function putDocument(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();
}
}
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
throw new AccessDeniedHttpException();
}
$this->storedObjectManager->write($storedObject, $request->getContent());
$this->entityManager->flush();
return new DavResponse('', Response::HTTP_NO_CONTENT);
}
}

View File

@@ -47,15 +47,11 @@ class WebdavControllerTest extends KernelTestCase
$storedObjectManager = new MockedStoredObjectManager();
}
if (null === $entityManager) {
$entityManager = $this->createMock(EntityManagerInterface::class);
}
$security = $this->prophesize(Security::class);
$security->isGranted(Argument::in(['SEE_AND_EDIT', 'SEE']), Argument::type(StoredObject::class))
->willReturn(true);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal(), $entityManager);
return new WebdavController($this->engine, $storedObjectManager, $security->reveal());
}
private function buildDocument(): StoredObject
@@ -152,6 +148,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<d:resourcetype/>
<d:getcontenttype>application/vnd.oasis.opendocument.text</d:getcontenttype>
</d:prop>
@@ -241,6 +247,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<!-- the date scraped from a webserver is >Sun, 10 Sep 2023 14:10:23 GMT -->
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
</d:prop>
@@ -267,6 +283,16 @@ class WebdavControllerTest extends KernelTestCase
<d:href>/dav/1234/get/716e6688-4579-4938-acf3-c4ab5856803b/d</d:href>
<d:propstat>
<d:prop>
<d:supportedlock>
<d:lockentry>
<d:lockscope>
<d:exclusive/>
</d:lockscope>
<d:locktype>
<d:write/>
</d:locktype>
</d:lockentry>
</d:supportedlock>
<d:resourcetype/>
<d:creationdate/>
<d:getlastmodified>Wed, 13 Sep 2023 14:15:00 +0200</d:getlastmodified>
@@ -390,30 +416,6 @@ class WebdavControllerTest extends KernelTestCase
self::assertEquals('application/vnd.oasis.opendocument.text', $response->headers->get('content-type'));
self::assertEquals(5, $response->headers->get('content-length'));
}
public function testPutDocument(): void
{
$document = $this->buildDocument();
$entityManager = $this->createMock(EntityManagerInterface::class);
$storedObjectManager = $this->createMock(StoredObjectManagerInterface::class);
// entity manager must be flushed
$entityManager->expects($this->once())
->method('flush');
// object must be written by StoredObjectManager
$storedObjectManager->expects($this->once())
->method('write')
->with($this->identicalTo($document), $this->identicalTo('1234'));
$controller = $this->buildController($entityManager, $storedObjectManager);
$request = new Request(content: '1234');
$response = $controller->putDocument($document, $request);
self::assertEquals(204, $response->getStatusCode());
self::assertEquals('', $response->getContent());
}
}
class MockedStoredObjectManager implements StoredObjectManagerInterface

View File

@@ -0,0 +1,236 @@
<?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\WebdavPutController;
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
use Chill\DocStoreBundle\Service\StoredObjectManagerInterface;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Prophecy\PhpUnit\ProphecyTrait;
use Ramsey\Uuid\Uuid;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
/**
* @internal
*
* @coversNothing
*/
class WebdavPutControllerTest extends KernelTestCase
{
use ProphecyTrait;
private function buildDocument(): StoredObject
{
$object = (new StoredObject())
->registerVersion(type: 'application/vnd.oasis.opendocument.text')
->getStoredObject();
$reflectionObject = new \ReflectionClass($object);
$reflectionObjectUuid = $reflectionObject->getProperty('uuid');
$reflectionObjectUuid->setValue($object, Uuid::fromString('716e6688-4579-4938-acf3-c4ab5856803b'));
return $object;
}
private function buildController(EntityManagerInterface $entityManager, StoredObjectManagerInterface $storedObjectManager, LockTokenParser $lockTokenParser, StoredObjectLockManager $storedObjectLockManager, Security $security): WebdavPutController
{
return new WebdavPutController($storedObjectManager, $security, $entityManager, $lockTokenParser, $storedObjectLockManager);
}
public function testPutDocumentHappyScenario(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
// entity manager must be flushed
$entityManager->flush()->shouldBeCalled();
// object must be written by StoredObjectManager
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$storedObjectManager->write($document, '1234')->shouldBeCalled();
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(StoredObjectRoleEnum::EDIT->value, $document)
->willReturn(true)->shouldBeCalled();
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'opaquelocktoken:88f391da-2f72-11f1-bf99-67e47d3105b8', $user)
->willReturn(true)->shouldBeCalled();
$storedObjectLockManager->hasLock($document)->willReturn(true);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request(
content: '1234',
);
$request->headers->set('If', '"(<'.$token.'>)"');
$response = $controller->putDocument($document, $request);
self::assertEquals(204, $response->getStatusCode());
self::assertEquals('', $response->getContent());
}
public function testPutDocumentNoLockFound(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::NO_LOCK_FOUND);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentLockTokenDoNotMatch(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentLockTokenDoNotBelongToUser(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->checkLock($document, $token = 'token', $user)
->willReturn(\Chill\DocStoreBundle\Service\Lock\LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$request->headers->set('If', '"(<'.$token.'>)"');
$this->expectException(\Symfony\Component\HttpKernel\Exception\ConflictHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentNoLockGivenButTokenRequired(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($document)->willReturn(true);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$this->expectException(\Symfony\Component\HttpKernel\Exception\ConflictHttpException::class);
$controller->putDocument($document, $request);
}
public function testPutDocumentNotGranted(): void
{
$user = new User();
$document = $this->buildDocument();
$entityManager = $this->prophesize(EntityManagerInterface::class);
$storedObjectManager = $this->prophesize(StoredObjectManagerInterface::class);
$security = $this->prophesize(Security::class);
$security->getUser()->willReturn($user);
$security->isGranted(StoredObjectRoleEnum::EDIT->value, $document)
->willReturn(false);
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
$storedObjectLockManager->hasLock($document)->willReturn(false);
$controller = $this->buildController(
$entityManager->reveal(),
$storedObjectManager->reveal(),
new LockTokenParser(),
$storedObjectLockManager->reveal(),
$security->reveal()
);
$request = new Request();
$this->expectException(\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException::class);
$controller->putDocument($document, $request);
}
}