mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2026-04-16 10:59:31 +00:00
Compare commits
29 Commits
509-refact
...
508-lock-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
58509ae4da
|
|||
|
60b31a0b19
|
|||
|
d24842fc17
|
|||
|
fbb04eb783
|
|||
|
d17d211429
|
|||
|
06146f7909
|
|||
|
7b00006943
|
|||
|
a27173ee4a
|
|||
|
b171afba2f
|
|||
|
bb9da5dada
|
|||
|
012dc3c27d
|
|||
|
4c86dcb9ff
|
|||
|
3905b7c9a7
|
|||
|
003cccfdc4
|
|||
|
c60383b636
|
|||
|
678ec844e2
|
|||
|
4afdc9a7cc
|
|||
|
25962e0e39
|
|||
|
4a224054e2
|
|||
|
a1d72cefff
|
|||
|
ff9e4f2709
|
|||
|
a3b857253a
|
|||
|
c9a632f3a9
|
|||
|
ba2288de55
|
|||
|
9f8e349a85
|
|||
|
76d3612d33
|
|||
|
277e4fa490
|
|||
|
fe11780ad5
|
|||
|
c1e5346ef9
|
@@ -87,7 +87,8 @@
|
||||
"specs-create-dir": "mkdir -p templates/api",
|
||||
"specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate",
|
||||
"version": "node --version",
|
||||
"eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\""
|
||||
"eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\"",
|
||||
"tsc-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
||||
@@ -12,16 +12,17 @@ declare(strict_types=1);
|
||||
namespace Chill\DocStoreBundle\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class StoredObjectApiController extends ApiController
|
||||
class StoredObjectApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
@@ -53,4 +54,17 @@ class StoredObjectApiController extends ApiController
|
||||
json: true
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/api/1.0/doc-store/stored-object/{uuid}', methods: ['GET', 'HEAD'])]
|
||||
public function getStoredObject(StoredObject $storedObject, Request $request): JsonResponse
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException('No permission to see the stored object');
|
||||
}
|
||||
|
||||
return new JsonResponse(
|
||||
$this->serializer->serialize($storedObject, 'json', [AbstractNormalizer::GROUPS => ['read']]),
|
||||
json: true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class StoredObjectLockApiController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private StoredObjectLockManager $storedObjectLockManager,
|
||||
private ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/doc-store/stored-object/{uuid}/lock', name: 'chill_docstore_storedobjectlock_removelock', methods: ['DELETE'])]
|
||||
public function removeLock(StoredObject $storedObject): Response
|
||||
{
|
||||
if (!$this->security->isGranted(StoredObjectRoleEnum::EDIT->value, $storedObject)) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
if (!$this->storedObjectLockManager->hasLock($storedObject)) {
|
||||
throw new PreconditionFailedHttpException('No lock found for this stored object');
|
||||
}
|
||||
|
||||
$this->storedObjectLockManager->deleteLock($storedObject, $this->clock->now());
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -75,7 +73,7 @@ final readonly class WebdavController
|
||||
;
|
||||
|
||||
// $response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,PROPPATCH,COPY,MOVE,REPORT,PATCH,POST,TRACE']);
|
||||
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT']);
|
||||
$response->headers->add(['Allow' => 'OPTIONS,GET,HEAD,DELETE,PROPFIND,PUT,LOCK,UNLOCK']);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?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\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;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
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;
|
||||
|
||||
final readonly class WebdavLockController
|
||||
{
|
||||
public function __construct(
|
||||
private StoredObjectLockManager $lockManager,
|
||||
private Security $security,
|
||||
private Environment $twig,
|
||||
private LockTimeoutAnalyzer $lockTimeoutAnalyzer,
|
||||
private ClockInterface $clock,
|
||||
private LockTokenParser $lockTokenParser,
|
||||
) {}
|
||||
|
||||
#[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);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$users = $user instanceof User ? [$user] : [];
|
||||
|
||||
$lock = $this->lockManager->setLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WEBDAV,
|
||||
expiresAt: $this->clock->now()->add($timeoutInterval),
|
||||
users: $users
|
||||
);
|
||||
|
||||
$content = $this->twig->render('@ChillDocStore/Webdav/doc_lock.xml.twig', [
|
||||
'lock' => $lock,
|
||||
'timeout' => $timeout,
|
||||
]);
|
||||
|
||||
return (new DavResponse($content))->setLockToken($lock->getToken());
|
||||
}
|
||||
|
||||
#[Route(path: '/dav/{access_token}/get/{uuid}/d', name: 'chill_docstore_dav_document_unlock', methods: ['UNLOCK'])]
|
||||
public function unlockDocument(StoredObject $storedObject, Request $request): Response
|
||||
{
|
||||
$lockToken = $this->lockTokenParser->parseLockToken($request);
|
||||
|
||||
if (null === $lockToken) {
|
||||
throw new BadRequestHttpException('LockToken not found');
|
||||
}
|
||||
|
||||
$check = $this->lockManager->checkLock($storedObject, $lockToken, $this->security->getUser());
|
||||
|
||||
if (true === $check) {
|
||||
$this->lockManager->deleteLock($storedObject, $this->clock->now()->add(new \DateInterval('PT3S')));
|
||||
|
||||
return new DavResponse(null, status: Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
$e = match ($check) {
|
||||
LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH, LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_BELONG_TO_USER => new ConflictHttpException(),
|
||||
LockTokenCheckResultEnum::NO_LOCK_FOUND => new PreconditionFailedHttpException(),
|
||||
};
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ class DavResponse extends Response
|
||||
{
|
||||
parent::__construct($content, $status, $headers);
|
||||
|
||||
$this->headers->add(['DAV' => '1']);
|
||||
$this->headers->add(['DAV' => '1,2']);
|
||||
}
|
||||
|
||||
public function setLockToken(string $token): self
|
||||
{
|
||||
$this->headers->add(['Lock-Token' => $token]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?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\Dav\Utils;
|
||||
|
||||
use DateInterval;
|
||||
|
||||
class LockTimeoutAnalyzer
|
||||
{
|
||||
/**
|
||||
* Analyzes the timeout value from the provided content string, RFC2068 string
|
||||
* and return a DateInterval object representing the timeout duration.
|
||||
*
|
||||
* @param string $content The input string containing timeout information, as described by RFC2518, section 4.2
|
||||
*
|
||||
* @return \DateInterval the calculated timeout as a DateInterval object
|
||||
*
|
||||
* @throws \Exception if the DateInterval creation fails
|
||||
*/
|
||||
public function analyzeTimeout(string $content): \DateInterval
|
||||
{
|
||||
$types = explode(',', $content);
|
||||
$firstType = trim(reset($types));
|
||||
|
||||
if (str_starts_with($firstType, 'Second-')) {
|
||||
$seconds = (int) substr($firstType, 7);
|
||||
|
||||
return new \DateInterval(sprintf('PT%dS', $seconds));
|
||||
}
|
||||
|
||||
if ('Infinite' === $firstType) {
|
||||
return new \DateInterval('PT24H');
|
||||
}
|
||||
|
||||
return new \DateInterval('PT3600S');
|
||||
}
|
||||
}
|
||||
58
src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php
Normal file
58
src/Bundle/ChillDocStoreBundle/Dav/Utils/LockTokenParser.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?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\Dav\Utils;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final readonly class LockTokenParser
|
||||
{
|
||||
public function parseLockToken(Request $request): ?string
|
||||
{
|
||||
$token = $request->headers->get('lock-token');
|
||||
|
||||
if (null === $token) {
|
||||
return null;
|
||||
}
|
||||
if (str_starts_with($token, '"')) {
|
||||
$token = substr($token, 1, -1);
|
||||
}
|
||||
|
||||
if (str_starts_with($token, '<')) {
|
||||
$token = substr($token, 1);
|
||||
}
|
||||
|
||||
if (str_ends_with($token, '>')) {
|
||||
$token = substr($token, 0, -1);
|
||||
}
|
||||
|
||||
if (str_ends_with($token, '"')) {
|
||||
$token = substr($token, 1, -1);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function parseIfCondition(Request $request): ?string
|
||||
{
|
||||
$if = $request->headers->get('if');
|
||||
|
||||
if (null === $if) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\((?:not\s+)?<([^>]+)>/i', $if, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,12 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true, fetch: 'EAGER')]
|
||||
private Collection&Selectable $versions;
|
||||
|
||||
/**
|
||||
* @var Collection<int, StoredObjectLock>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectLock::class, cascade: ['persist', 'remove', 'refresh', 'merge'])]
|
||||
private Collection $locks;
|
||||
|
||||
/**
|
||||
* @param StoredObject::STATUS_* $status
|
||||
*/
|
||||
@@ -107,6 +113,41 @@ class StoredObject implements Document, TrackCreationInterface
|
||||
$this->uuid = Uuid::uuid4();
|
||||
$this->versions = new ArrayCollection();
|
||||
$this->prefix = self::generatePrefix();
|
||||
$this->locks = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal use @see{StoredObjectLock::__construct}
|
||||
*/
|
||||
public function addLock(StoredObjectLock $lock): void
|
||||
{
|
||||
if (!$this->locks->contains($lock)) {
|
||||
$this->locks->add($lock);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeLock(StoredObjectLock $lock): void
|
||||
{
|
||||
$this->locks->removeElement($lock);
|
||||
}
|
||||
|
||||
public function isLockedAt(\DateTimeImmutable $at): bool
|
||||
{
|
||||
foreach ($this->locks as $lock) {
|
||||
if ($lock->isActiveAt($at)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, StoredObjectLock>
|
||||
*/
|
||||
public function getLocks(): Collection
|
||||
{
|
||||
return $this->locks;
|
||||
}
|
||||
|
||||
public function addGenerationTrial(): self
|
||||
|
||||
142
src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php
Normal file
142
src/Bundle/ChillDocStoreBundle/Entity/StoredObjectLock.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?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\Entity;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'stored_object_lock', schema: 'chill_doc')]
|
||||
class StoredObjectLock
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'uuid', unique: true)]
|
||||
private UuidInterface $uuid;
|
||||
|
||||
/**
|
||||
* @var Collection<int, User>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||
#[ORM\JoinTable(name: 'stored_object_lock_user', schema: 'chill_doc')]
|
||||
#[ORM\JoinColumn(referencedColumnName: 'uuid', nullable: false)]
|
||||
private Collection $users;
|
||||
|
||||
/**
|
||||
* @param list<User> $users
|
||||
*/
|
||||
public function __construct(
|
||||
#[ORM\ManyToOne(targetEntity: StoredObject::class, inversedBy: 'locks')]
|
||||
private StoredObject $storedObject,
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 10, nullable: false, enumType: StoredObjectLockMethodEnum::class)]
|
||||
private StoredObjectLockMethodEnum $method,
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: false)]
|
||||
private \DateTimeImmutable $createdAt,
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
|
||||
private string $token = '',
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $expireAt = null,
|
||||
array $users = [],
|
||||
) {
|
||||
$this->uuid = Uuid::uuid7();
|
||||
$this->users = new ArrayCollection();
|
||||
$this->storedObject->addLock($this);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$this->addUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function addUser(User $user): void
|
||||
{
|
||||
if (!$this->users->contains($user)) {
|
||||
$this->users->add($user);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeUser(User $user): void
|
||||
{
|
||||
$this->users->removeElement($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
public function getUsers(): Collection
|
||||
{
|
||||
return $this->users;
|
||||
}
|
||||
|
||||
public function setToken(string $token): void
|
||||
{
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
public function setExpireAt(?\DateTimeImmutable $expireAt): void
|
||||
{
|
||||
$this->expireAt = $expireAt;
|
||||
}
|
||||
|
||||
public function getUuid(): UuidInterface
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getStoredObject(): StoredObject
|
||||
{
|
||||
return $this->storedObject;
|
||||
}
|
||||
|
||||
public function getMethod(): StoredObjectLockMethodEnum
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getExpireAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->expireAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the lock must be considered as active.
|
||||
*
|
||||
* A StoredObjectLock is active if there isn't any expiration date, or
|
||||
* if the expiration date and time is before the given time.
|
||||
*/
|
||||
public function isActiveAt(\DateTimeImmutable $at): bool
|
||||
{
|
||||
return null === $this->getExpireAt() || $at < $this->getExpireAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the lock token is exclusive.
|
||||
*
|
||||
* Currently, this is linked to the webdav method.
|
||||
*/
|
||||
public function isExclusive(): bool
|
||||
{
|
||||
return StoredObjectLockMethodEnum::WEBDAV === $this->getMethod();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?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\Entity;
|
||||
|
||||
enum StoredObjectLockMethodEnum: string
|
||||
{
|
||||
case WEBDAV = 'webdav';
|
||||
case WOPI = 'wopi';
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Repository;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<StoredObjectLock>
|
||||
*/
|
||||
class StoredObjectLockRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $manager)
|
||||
{
|
||||
parent::__construct($manager, StoredObjectLock::class);
|
||||
}
|
||||
|
||||
public function removeExpiredBefore(\DateTimeImmutable $dateTime): void
|
||||
{
|
||||
$qb = $this->createQueryBuilder('sol');
|
||||
|
||||
$qb->delete()
|
||||
->where($qb->expr()->lt('sol.expireAt', ':at'))
|
||||
->andWhere($qb->expr()->isNotNull('sol.expireAt'))
|
||||
->setParameter('at', $dateTime, Types::DATETIMETZ_IMMUTABLE)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ window.addEventListener("DOMContentLoaded", function () {
|
||||
};
|
||||
},
|
||||
template:
|
||||
'<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange"></document-action-buttons-group>',
|
||||
'<document-action-buttons-group :can-edit="canEdit" :filename="filename" :stored-object="storedObject" :small="small" :dav-link="davLink" :dav-link-expiration="davLinkExpiration" @on-stored-object-status-change="onStoredObjectStatusChange" @onStoredObjectRefresh="onStoredObjectRefresh"></document-action-buttons-group>',
|
||||
methods: {
|
||||
onStoredObjectStatusChange: function (
|
||||
newStatus: StoredObjectStatusChange,
|
||||
@@ -64,6 +64,9 @@ window.addEventListener("DOMContentLoaded", function () {
|
||||
el.remove();
|
||||
});
|
||||
},
|
||||
onStoredObjectRefresh: function (newStoredObject: StoredObject) {
|
||||
this.$data.storedObject = newStoredObject;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,16 @@ import { SignedUrlGet } from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpe
|
||||
|
||||
export type StoredObjectStatus = "empty" | "ready" | "failure" | "pending";
|
||||
|
||||
export type StoredObjectLockMethodEnum = "webdav" | "wopi";
|
||||
|
||||
export interface StoredObjectLock {
|
||||
uuid: string;
|
||||
method: StoredObjectLockMethodEnum;
|
||||
createdAt: DateTime;
|
||||
expiresAt: DateTime;
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export interface StoredObject {
|
||||
id: number;
|
||||
title: string | null;
|
||||
@@ -31,6 +41,11 @@ export interface StoredObject {
|
||||
};
|
||||
downloadLink?: SignedUrlGet;
|
||||
};
|
||||
lock: StoredObjectLock | null;
|
||||
_editor?: {
|
||||
webdav: boolean;
|
||||
wopi: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredObjectVersion {
|
||||
|
||||
@@ -14,8 +14,22 @@
|
||||
aria-expanded="false"
|
||||
>
|
||||
Actions
|
||||
<currently-editing-icon
|
||||
v-if="props.storedObject.lock !== null"
|
||||
></currently-editing-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li v-if="null !== props.storedObject.lock">
|
||||
<is-currently-edited
|
||||
:stored-object="
|
||||
props.storedObject as StoredObject & { lock: StoredObjectLock }
|
||||
"
|
||||
@require-refresh-stored-object="refreshStoredObject"
|
||||
></is-currently-edited>
|
||||
</li>
|
||||
<li v-if="null !== props.storedObject.lock">
|
||||
<hr class="dropdown-divider" />
|
||||
</li>
|
||||
<li v-if="isEditableOnline">
|
||||
<wopi-edit-button
|
||||
:stored-object="props.storedObject"
|
||||
@@ -28,6 +42,7 @@
|
||||
:classes="{ 'dropdown-item': true }"
|
||||
:edit-link="props.davLink ?? ''"
|
||||
:expiration-link="props.davLinkExpiration ?? 0"
|
||||
:stored-object="props.storedObject"
|
||||
></desktop-edit-button>
|
||||
</li>
|
||||
<li v-if="isConvertibleToPdf">
|
||||
@@ -49,7 +64,7 @@
|
||||
<li v-if="isHistoryViewable">
|
||||
<history-button
|
||||
:stored-object="props.storedObject"
|
||||
:can-edit="canEdit && props.storedObject._permissions.canEdit"
|
||||
:can-edit="props.storedObject._permissions.canEdit"
|
||||
></history-button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -68,17 +83,22 @@ import ConvertButton from "./StoredObjectButton/ConvertButton.vue";
|
||||
import DownloadButton from "./StoredObjectButton/DownloadButton.vue";
|
||||
import WopiEditButton from "./StoredObjectButton/WopiEditButton.vue";
|
||||
import {
|
||||
get_stored_object,
|
||||
is_extension_editable,
|
||||
is_extension_viewable,
|
||||
is_object_ready,
|
||||
} from "./StoredObjectButton/helpers";
|
||||
import {
|
||||
StoredObject,
|
||||
StoredObjectLock,
|
||||
StoredObjectStatusChange,
|
||||
WopiEditButtonExecutableBeforeLeaveFunction,
|
||||
} from "../types";
|
||||
import DesktopEditButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DesktopEditButton.vue";
|
||||
import HistoryButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton.vue";
|
||||
import CurrentlyEditingIcon from "ChillDocStoreAssets/vuejs/StoredObjectButton/CurrentlyEditingIcon.vue";
|
||||
import IsCurrentlyEdited from "ChillDocStoreAssets/vuejs/StoredObjectButton/IsCurrentlyEdited.vue";
|
||||
import { useIntervalFn } from "@vueuse/core";
|
||||
|
||||
interface DocumentActionButtonsGroupConfig {
|
||||
storedObject: StoredObject;
|
||||
@@ -110,15 +130,27 @@ interface DocumentActionButtonsGroupConfig {
|
||||
* the expiration date of the download, as a unix timestamp
|
||||
*/
|
||||
davLinkExpiration?: number;
|
||||
|
||||
/**
|
||||
* This module will trigger a refresh of the stored object on a given interval, if true.
|
||||
*/
|
||||
refreshStoredObjectAuto?: boolean;
|
||||
|
||||
/**
|
||||
* When enabled, the refresh interval, in milliseconds.
|
||||
*/
|
||||
refreshStoredObjectInterval?: number;
|
||||
|
||||
/**
|
||||
* When enable, the refresh will stop after a given number of executions.
|
||||
*/
|
||||
refreshStoredObjectMaxTimes?: number;
|
||||
}
|
||||
|
||||
const emit =
|
||||
defineEmits<
|
||||
(
|
||||
e: "onStoredObjectStatusChange",
|
||||
newStatus: StoredObjectStatusChange,
|
||||
) => void
|
||||
>();
|
||||
const emit = defineEmits<{
|
||||
(e: "onStoredObjectStatusChange", newStatus: StoredObjectStatusChange): void;
|
||||
(e: "onStoredObjectRefresh", newStoredObject: StoredObject): void;
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
|
||||
small: false,
|
||||
@@ -127,6 +159,9 @@ const props = withDefaults(defineProps<DocumentActionButtonsGroupConfig>(), {
|
||||
canConvertPdf: true,
|
||||
returnPath:
|
||||
window.location.pathname + window.location.search + window.location.hash,
|
||||
refreshStoredObjectAuto: true,
|
||||
refreshStoredObjectInterval: 60000,
|
||||
refreshStoredObjectMaxTimes: 20,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -221,8 +256,33 @@ const onObjectNewStatusCallback = async function (): Promise<void> {
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
let numberOfRefresh = 0;
|
||||
|
||||
const refreshStoredObject = async () => {
|
||||
if (!props.refreshStoredObjectAuto) {
|
||||
interval.pause();
|
||||
return;
|
||||
}
|
||||
numberOfRefresh++;
|
||||
const n = await get_stored_object(props.storedObject.uuid);
|
||||
emit("onStoredObjectRefresh", n);
|
||||
|
||||
if (numberOfRefresh >= props.refreshStoredObjectMaxTimes) {
|
||||
interval.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const interval = useIntervalFn(
|
||||
refreshStoredObject,
|
||||
props.refreshStoredObjectInterval,
|
||||
{ immediate: false, immediateCallback: false },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
checkForReady();
|
||||
if (props.refreshStoredObjectAuto) {
|
||||
interval.resume();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
:can-download="true"
|
||||
:dav-link="dav_link_href ?? ''"
|
||||
:dav-link-expiration="dav_link_expiration ?? 0"
|
||||
:refresh-stored-object-auto="false"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
class="icon-pen-fill"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="pen-grad"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="-16"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stop-color="#334D5C" />
|
||||
<stop offset="40%" stop-color="#334D5C" />
|
||||
<stop offset="40%" stop-color="#43B29D" />
|
||||
<stop offset="50%" stop-color="#43B29D" />
|
||||
<stop offset="50%" stop-color="#328474" />
|
||||
<stop offset="60%" stop-color="#328474" />
|
||||
<stop offset="60%" stop-color="#EEC84A" />
|
||||
<stop offset="70%" stop-color="#EEC84A" />
|
||||
<stop offset="70%" stop-color="#E2793D" />
|
||||
<stop offset="80%" stop-color="#E2793D" />
|
||||
<stop offset="80%" stop-color="#DF4949" />
|
||||
<stop offset="90%" stop-color="#DF4949" />
|
||||
<stop offset="90%" stop-color="#cabb9f" />
|
||||
<stop offset="100%" stop-color="#cabb9f" />
|
||||
<stop offset="100%" stop-color="#334D5C" />
|
||||
<animateTransform
|
||||
attributeName="gradientTransform"
|
||||
type="translate"
|
||||
from="0 0"
|
||||
to="32 32"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path
|
||||
fill="url(#pen-grad)"
|
||||
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -37,7 +37,7 @@
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
<a :class="props.classes" @click="state.modalOpened = true">
|
||||
<a :class="classesComputed()" @click="state.modalOpened = true">
|
||||
<i class="fa fa-desktop"></i>
|
||||
Éditer sur le bureau
|
||||
</a>
|
||||
@@ -55,11 +55,13 @@ i.fa::before {
|
||||
<script setup lang="ts">
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { StoredObject } from "ChillDocStoreAssets/types";
|
||||
|
||||
export interface DesktopEditButtonConfig {
|
||||
editLink: string;
|
||||
classes: Record<string, boolean>;
|
||||
expirationLink: number | Date;
|
||||
storedObject: StoredObject;
|
||||
}
|
||||
|
||||
interface DesktopEditButtonState {
|
||||
@@ -74,6 +76,19 @@ const buildCommand = computed<string>(
|
||||
() => "vnd.libreoffice.command:ofe|u|" + props.editLink,
|
||||
);
|
||||
|
||||
function classesComputed(): Record<string, boolean> {
|
||||
const cl = props.classes;
|
||||
cl["btn"] = true;
|
||||
|
||||
if (false === props.storedObject._editor?.webdav) {
|
||||
cl["disabled"] = true;
|
||||
} else {
|
||||
cl["disabled"] = false;
|
||||
}
|
||||
|
||||
return cl;
|
||||
}
|
||||
|
||||
const editionUntilFormatted = computed<string>(() => {
|
||||
let d;
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { StoredObject, StoredObjectLock } from "ChillDocStoreAssets/types";
|
||||
import CurrentlyEditingIcon from "ChillDocStoreAssets/vuejs/StoredObjectButton/CurrentlyEditingIcon.vue";
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import { computed, reactive } from "vue";
|
||||
import { localizeList } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
import { User } from "ChillMainAssets/types";
|
||||
import {
|
||||
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED,
|
||||
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS,
|
||||
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_SHORT,
|
||||
STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS_SHORT,
|
||||
STORED_OBJECT_LOCK_EDITING_SINCE,
|
||||
STORED_OBJECT_LOCK_LIST_OF_USERS_MAY_BE_INCOMPLETE,
|
||||
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK,
|
||||
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_POSSIBLE_EXPLAIN,
|
||||
STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_WARNING,
|
||||
STORED_OBJECT_LOCK_REMOVE_LOCK_FAILURE,
|
||||
STORED_OBJECT_LOCK_REMOVE_LOCK_SUCCESS,
|
||||
trans,
|
||||
} from "translator";
|
||||
import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
|
||||
import { remove_lock } from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpers";
|
||||
import { useToast } from "vue-toast-notification";
|
||||
|
||||
interface IsCurrentlyEditedConfig {
|
||||
storedObject: StoredObject & { lock: StoredObjectLock };
|
||||
}
|
||||
|
||||
const emit = defineEmits<(e: "requireRefreshStoredObject") => void>();
|
||||
|
||||
const props = defineProps<IsCurrentlyEditedConfig>();
|
||||
|
||||
const state = reactive({
|
||||
modalOpened: false,
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const sinceDate = computed<Date | null>(() =>
|
||||
ISOToDatetime(props.storedObject.lock.createdAt.datetime),
|
||||
);
|
||||
|
||||
const onModalClose = function (): void {
|
||||
state.modalOpened = false;
|
||||
};
|
||||
|
||||
const openModal = function (): void {
|
||||
state.modalOpened = true;
|
||||
};
|
||||
|
||||
const removeLock = async function (): Promise<void> {
|
||||
if (
|
||||
window.confirm(
|
||||
trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_WARNING, {
|
||||
nb: props.storedObject.lock.users.length,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await remove_lock(props.storedObject);
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
trans(STORED_OBJECT_LOCK_REMOVE_LOCK_FAILURE, {
|
||||
status: (e as Error).toString(),
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.reject(e);
|
||||
}
|
||||
emit("requireRefreshStoredObject");
|
||||
toast.success(trans(STORED_OBJECT_LOCK_REMOVE_LOCK_SUCCESS));
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
console.log("User did not confirmed edit");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<modal v-if="state.modalOpened" @close="onModalClose">
|
||||
<template v-slot:body>
|
||||
<div class="modal-currently-edit-content">
|
||||
<p v-if="props.storedObject.lock.users.length > 0">
|
||||
{{
|
||||
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED, {
|
||||
byUsers: localizeList(
|
||||
props.storedObject.lock.users.map((u: User) => u.label),
|
||||
),
|
||||
})
|
||||
}}.<template v-if="props.storedObject.lock.method === 'wopi'">
|
||||
{{
|
||||
trans(STORED_OBJECT_LOCK_LIST_OF_USERS_MAY_BE_INCOMPLETE)
|
||||
}}</template
|
||||
>
|
||||
</p>
|
||||
<p v-else>
|
||||
{{ trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS) }}
|
||||
</p>
|
||||
<p v-if="sinceDate !== null">
|
||||
{{ trans(STORED_OBJECT_LOCK_EDITING_SINCE, { since: sinceDate }) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
props.storedObject._permissions.canEdit &&
|
||||
props.storedObject.lock.method === 'webdav'
|
||||
"
|
||||
class="alert alert-danger"
|
||||
>
|
||||
<p>
|
||||
<i class="bi bi-exclamation-lg"></i
|
||||
>{{
|
||||
trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK_POSSIBLE_EXPLAIN)
|
||||
}}
|
||||
</p>
|
||||
<button class="btn btn-delete" @click="removeLock">
|
||||
{{ trans(STORED_OBJECT_LOCK_FORCE_REMOVE_LOCK) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</modal>
|
||||
</teleport>
|
||||
<button class="dropdown-item" type="button" @click="openModal()">
|
||||
<span class="currently-edited"
|
||||
><currently-editing-icon></currently-editing-icon
|
||||
></span>
|
||||
<span v-if="props.storedObject.lock.users.length > 0"
|
||||
>{{
|
||||
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_SHORT, {
|
||||
byUsers: localizeList([props.storedObject.lock.users[0].label]),
|
||||
})
|
||||
}}<span v-if="props.storedObject.lock.users.length > 1">
|
||||
(+{{ props.storedObject.lock.users.length - 1 }})</span
|
||||
></span
|
||||
>
|
||||
<span v-else>{{
|
||||
trans(STORED_OBJECT_LOCK_IS_CURRENTLY_EDITED_WITHOUT_USERS_SHORT)
|
||||
}}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
span.currently-edited {
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<a
|
||||
:class="Object.assign(props.classes, { btn: true })"
|
||||
:class="classesComputed()"
|
||||
@click="beforeLeave($event)"
|
||||
:href="build_wopi_editor_link(props.storedObject.uuid, props.returnPath)"
|
||||
:aria-disabled="!props.storedObject._editor?.wopi"
|
||||
>
|
||||
<i class="fa fa-paragraph"></i>
|
||||
Editer en ligne
|
||||
@@ -10,7 +11,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import WopiEditButton from "./WopiEditButton.vue";
|
||||
import { build_wopi_editor_link } from "./helpers";
|
||||
import {
|
||||
StoredObject,
|
||||
@@ -28,6 +28,19 @@ const props = defineProps<WopiEditButtonConfig>();
|
||||
|
||||
let executed = false;
|
||||
|
||||
function classesComputed(): Record<string, boolean> {
|
||||
const cl = props.classes;
|
||||
cl["btn"] = true;
|
||||
|
||||
if (false === props.storedObject._editor?.wopi) {
|
||||
cl["disabled"] = true;
|
||||
} else {
|
||||
cl["disabled"] = false;
|
||||
}
|
||||
|
||||
return cl;
|
||||
}
|
||||
|
||||
async function beforeLeave(event: Event): Promise<true> {
|
||||
if (props.executeBeforeLeave === undefined || executed === true) {
|
||||
return Promise.resolve(true);
|
||||
|
||||
@@ -264,6 +264,41 @@ async function is_object_ready(
|
||||
return await new_status_response.json();
|
||||
}
|
||||
|
||||
async function remove_lock(storedObject: StoredObject): Promise<void> {
|
||||
const remove_lock_response = await window.fetch(
|
||||
`/api/1.0/doc-store/stored-object/${storedObject.uuid}/lock`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
|
||||
if (remove_lock_response.ok) {
|
||||
return Promise.resolve();
|
||||
} else if (remove_lock_response.status === 412) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
throw new Error(
|
||||
"Could not remove lock: status code: " +
|
||||
remove_lock_response.status +
|
||||
" message: " +
|
||||
remove_lock_response.statusText,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a stored object by its unique identifier.
|
||||
*
|
||||
* @param {string} storedObjectUuid - The unique identifier of the stored object to retrieve.
|
||||
* @return {Promise<StoredObject>} A promise that resolves to the retrieved stored object.
|
||||
*/
|
||||
async function get_stored_object(
|
||||
storedObjectUuid: string,
|
||||
): Promise<StoredObject> {
|
||||
return await makeFetch<null, StoredObject>(
|
||||
"GET",
|
||||
`/api/1.0/doc-store/stored-object/${storedObjectUuid}`,
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
build_convert_link,
|
||||
build_wopi_editor_link,
|
||||
@@ -274,4 +309,6 @@ export {
|
||||
is_extension_editable,
|
||||
is_extension_viewable,
|
||||
is_object_ready,
|
||||
remove_lock,
|
||||
get_stored_object,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:prop xmlns:D="DAV:">
|
||||
<D:lockdiscovery>
|
||||
<D:activelock>
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:depth>infinity</D:depth>
|
||||
{% set user = lock.users.first %}
|
||||
{% if user is not same as null %}
|
||||
<D:owner><D:href>{{ lock.users.first }}</D:href></D:owner>
|
||||
{% endif %}
|
||||
<D:timeout>{{ timeout }}</D:timeout>
|
||||
<D:locktoken>
|
||||
<D:href>{{ lock.token }}</D:href>
|
||||
</D:locktoken>
|
||||
</D:activelock>
|
||||
</D:lockdiscovery>
|
||||
</D:prop>
|
||||
@@ -5,6 +5,12 @@
|
||||
{% if properties.lastModified or properties.contentLength or properties.resourceType or properties.etag or properties.contentType or properties.creationDate %}
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<d:supportedlock>
|
||||
<d:lockentry>
|
||||
<d:lockscope><d:exclusive/></d:lockscope>
|
||||
<d:locktype><d:write/></d:locktype>
|
||||
</d:lockentry>
|
||||
</d:supportedlock>
|
||||
{% if properties.resourceType %}
|
||||
<d:resourcetype/>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
final class StoredObjectLockNormalizer implements NormalizerInterface, NormalizerAwareInterface
|
||||
{
|
||||
use NormalizerAwareTrait;
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = []): array
|
||||
{
|
||||
assert($object instanceof StoredObjectLock);
|
||||
|
||||
return [
|
||||
'uuid' => $object->getUuid(),
|
||||
'method' => $object->getMethod(),
|
||||
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
|
||||
'expiresAt' => $this->normalizer->normalize($object->getExpireAt(), $format, $context),
|
||||
'users' => $this->normalizer->normalize($object->getUsers()->getValues(), $format, $context),
|
||||
];
|
||||
}
|
||||
|
||||
public function supportsNormalization($data, ?string $format = null): bool
|
||||
{
|
||||
return 'json' === $format && $data instanceof StoredObjectLock;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,11 @@ namespace Chill\DocStoreBundle\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectEditorDecisionManagerInterface;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
|
||||
@@ -42,6 +45,8 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly Security $security,
|
||||
private readonly TempUrlGeneratorInterface $tempUrlGenerator,
|
||||
private readonly StoredObjectEditorDecisionManagerInterface $storedObjectEditorDecisionManager,
|
||||
private readonly StoredObjectLockManager $storedObjectLockManager,
|
||||
) {}
|
||||
|
||||
public function normalize($object, ?string $format = null, array $context = [])
|
||||
@@ -58,6 +63,7 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),
|
||||
'currentVersion' => $this->normalizer->normalize($object->getCurrentVersion(), $format, [...$context, [AbstractNormalizer::GROUPS => 'read']]),
|
||||
'totalVersions' => $object->getVersions()->count(),
|
||||
'lock' => $this->storedObjectLockManager->hasLock($object) ? $this->normalizer->normalize($this->storedObjectLockManager->getLock($object), $format, $context) : null,
|
||||
];
|
||||
|
||||
// deprecated property
|
||||
@@ -110,6 +116,11 @@ final class StoredObjectNormalizer implements NormalizerInterface, NormalizerAwa
|
||||
];
|
||||
}
|
||||
|
||||
$datas['_editor'] = [
|
||||
'webdav' => $canEdit && $this->storedObjectEditorDecisionManager->canEdit($object, StoredObjectLockMethodEnum::WEBDAV),
|
||||
'wopi' => $canEdit && $this->storedObjectEditorDecisionManager->canEdit($object, StoredObjectLockMethodEnum::WOPI),
|
||||
];
|
||||
|
||||
return $datas;
|
||||
}
|
||||
|
||||
|
||||
33
src/Bundle/ChillDocStoreBundle/Service/Lock/CleanOldLock.php
Normal file
33
src/Bundle/ChillDocStoreBundle/Service/Lock/CleanOldLock.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectLockRepository;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
|
||||
class CleanOldLock
|
||||
{
|
||||
private readonly \DateInterval $cleanBefore;
|
||||
|
||||
public function __construct(
|
||||
private readonly StoredObjectLockRepository $storedObjectLockRepository,
|
||||
private readonly ClockInterface $clock,
|
||||
) {
|
||||
$this->cleanBefore = new \DateInterval('PT24H');
|
||||
}
|
||||
|
||||
public function __invoke(): void
|
||||
{
|
||||
$before = $this->clock->now()->sub($this->cleanBefore);
|
||||
$this->storedObjectLockRepository->removeExpiredBefore($before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
|
||||
final readonly class CleanOldLockCronJob implements CronJobInterface
|
||||
{
|
||||
private const KEY = 'clean-old-stored-object-lock';
|
||||
|
||||
public function __construct(private CleanOldLock $cleanOldLock, private ClockInterface $clock) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
if (null === $cronJobExecution) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->clock->now() > $cronJobExecution->getLastStart()->add(new \DateInterval('PT12H'));
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return self::KEY;
|
||||
}
|
||||
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
($this->cleanOldLock)();
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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\Service\Lock\Exception;
|
||||
|
||||
class NoLockFoundException extends \RuntimeException
|
||||
{
|
||||
public function __construct(?string $message = null, int $code = 0, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct($message ?? 'No lock found', $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
enum LockTokenCheckResultEnum
|
||||
{
|
||||
case NO_LOCK_FOUND;
|
||||
case LOCK_TOKEN_DO_NOT_MATCH;
|
||||
|
||||
case LOCK_TOKEN_DO_NOT_BELONG_TO_USER;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
|
||||
final readonly class StoredObjectEditorDecisionManager implements StoredObjectEditorDecisionManagerInterface
|
||||
{
|
||||
public function __construct(private StoredObjectLockManager $lockManager) {}
|
||||
|
||||
public function canEdit(StoredObject $storedObject, StoredObjectLockMethodEnum $lockMethodEnum): bool
|
||||
{
|
||||
if (!$this->lockManager->hasLock($storedObject)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$lock = $this->lockManager->getLock($storedObject);
|
||||
|
||||
return StoredObjectLockMethodEnum::WOPI === $lockMethodEnum && StoredObjectLockMethodEnum::WOPI === $lock->getMethod();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
|
||||
/**
|
||||
* Responsible for managing decisions related to editing stored objects based on their lock status and lock methods.
|
||||
*
|
||||
* This class focuses solely on determining editability concerning existing locks on the stored object.
|
||||
* It does not evaluate permissions.
|
||||
*/
|
||||
interface StoredObjectEditorDecisionManagerInterface
|
||||
{
|
||||
/**
|
||||
* Determines if a stored object can be edited based on its lock status and the lock method.
|
||||
*
|
||||
* This method does not take into account the permissions to edit the stored object. Its purpose is to
|
||||
* check that a future edition (and lock) will be allowed.
|
||||
*
|
||||
* @param StoredObject $storedObject the stored object to check for edit permissions
|
||||
* @param StoredObjectLockMethodEnum $lockMethodEnum the lock method to verify against the stored object's lock
|
||||
*
|
||||
* @return bool returns true if the stored object can be edited, false otherwise
|
||||
*/
|
||||
public function canEdit(StoredObject $storedObject, StoredObjectLockMethodEnum $lockMethodEnum): bool;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\Exception\NoLockFoundException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class StoredObjectLockManager implements EventSubscriberInterface
|
||||
{
|
||||
private bool $mustFlush = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClockInterface $clock,
|
||||
) {}
|
||||
|
||||
public function deleteLock(StoredObject $document, \DateTimeImmutable $expiresAt): bool
|
||||
{
|
||||
foreach ($document->getLocks() as $lock) {
|
||||
if ($lock->isActiveAt($this->clock->now())) {
|
||||
$lock->setExpireAt($expiresAt);
|
||||
$this->mustFlush = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getLock(StoredObject $document): StoredObjectLock
|
||||
{
|
||||
foreach ($document->getLocks() as $lock) {
|
||||
if ($lock->isActiveAt($this->clock->now())) {
|
||||
return $lock;
|
||||
}
|
||||
}
|
||||
|
||||
throw new NoLockFoundException();
|
||||
}
|
||||
|
||||
public function hasLock(StoredObject $document): bool
|
||||
{
|
||||
foreach ($document->getLocks() as $lock) {
|
||||
if ($lock->isActiveAt($this->clock->now())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function setLock(
|
||||
StoredObject $document,
|
||||
StoredObjectLockMethodEnum $method,
|
||||
?string $lockId = null,
|
||||
?\DateTimeImmutable $expiresAt = null,
|
||||
array $users = [],
|
||||
): StoredObjectLock {
|
||||
if (null === $expiresAt) {
|
||||
$expiresAt = $this->clock->now()->add(new \DateInterval('PT60M'));
|
||||
}
|
||||
|
||||
if ($document->isLockedAt($this->clock->now())) {
|
||||
foreach ($document->getLocks() as $lock) {
|
||||
if ($lock->isActiveAt($this->clock->now())) {
|
||||
if (null !== $lockId) {
|
||||
$lock->setToken($lockId);
|
||||
}
|
||||
$lock->setExpireAt($expiresAt);
|
||||
foreach ($users as $user) {
|
||||
$lock->addUser($user);
|
||||
}
|
||||
|
||||
$this->mustFlush = true;
|
||||
|
||||
return $lock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $lockId) {
|
||||
$lockId = 'opaquelocktoken:'.Uuid::uuid4();
|
||||
}
|
||||
|
||||
// there is no lock yet, we must create one
|
||||
$lock = new StoredObjectLock(
|
||||
$document,
|
||||
method: $method,
|
||||
createdAt: $this->clock->now(),
|
||||
token: $lockId,
|
||||
expireAt: $expiresAt,
|
||||
);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$lock->addUser($user);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($lock);
|
||||
$this->mustFlush = true;
|
||||
|
||||
return $lock;
|
||||
}
|
||||
|
||||
public function checkLock(StoredObject $storedObject, string $lockId, ?UserInterface $byUser = null): true|LockTokenCheckResultEnum
|
||||
{
|
||||
if (!$this->hasLock($storedObject)) {
|
||||
return LockTokenCheckResultEnum::NO_LOCK_FOUND;
|
||||
}
|
||||
|
||||
$lock = $this->getLock($storedObject);
|
||||
|
||||
if ($lockId !== $lock->getToken()) {
|
||||
return LockTokenCheckResultEnum::LOCK_TOKEN_DO_NOT_MATCH;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [TerminateEvent::class => 'onKernelTerminate'];
|
||||
}
|
||||
|
||||
public function onKernelTerminate(TerminateEvent $event): void
|
||||
{
|
||||
if ($this->mustFlush) {
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,18 @@ namespace Chill\DocStoreBundle\Tests\Controller;
|
||||
|
||||
use Chill\DocStoreBundle\Controller\StoredObjectApiController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
* @covers \Chill\DocStoreBundle\Controller\StoredObjectApiController
|
||||
*/
|
||||
class StoredObjectApiControllerTest extends TestCase
|
||||
{
|
||||
@@ -52,4 +54,32 @@ class StoredObjectApiControllerTest extends TestCase
|
||||
self::assertInstanceOf(JsonResponse::class, $actual);
|
||||
self::assertEquals($r, $actual->getContent());
|
||||
}
|
||||
|
||||
public function testGet(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$request = new Request();
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->once())->method('isGranted')
|
||||
->with($this->identicalTo(StoredObjectRoleEnum::SEE->value), $this->identicalTo($storedObject))
|
||||
->willReturn(true)
|
||||
;
|
||||
|
||||
$entityManager = $this->createMock(EntityManagerInterface::class);
|
||||
|
||||
$serializer = $this->createMock(SerializerInterface::class);
|
||||
$serializer->expects($this->once())->method('serialize')
|
||||
->with($this->identicalTo($storedObject), 'json', $this->anything())
|
||||
->willReturn($r = <<<'JSON'
|
||||
{"type": "stored-object", "id": 1}
|
||||
JSON);
|
||||
|
||||
$controller = new StoredObjectApiController($security, $serializer, $entityManager);
|
||||
|
||||
$actual = $controller->getStoredObject($storedObject, $request);
|
||||
|
||||
self::assertInstanceOf(JsonResponse::class, $actual);
|
||||
self::assertEquals($r, $actual->getContent());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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\StoredObjectLockApiController;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectLockApiControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testRemoveLockAccessDenied(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$lockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$clock = new MockClock();
|
||||
$storedObject = new StoredObject();
|
||||
|
||||
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(false);
|
||||
|
||||
$controller = new StoredObjectLockApiController(
|
||||
$security->reveal(),
|
||||
$lockManager->reveal(),
|
||||
$clock
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->removeLock($storedObject);
|
||||
}
|
||||
|
||||
public function testRemoveLockNoLockFound(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$lockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$clock = new MockClock();
|
||||
$storedObject = new StoredObject();
|
||||
|
||||
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(true);
|
||||
$lockManager->hasLock($storedObject)->willReturn(false);
|
||||
|
||||
$controller = new StoredObjectLockApiController(
|
||||
$security->reveal(),
|
||||
$lockManager->reveal(),
|
||||
$clock
|
||||
);
|
||||
|
||||
$this->expectException(PreconditionFailedHttpException::class);
|
||||
$this->expectExceptionMessage('No lock found for this stored object');
|
||||
|
||||
$controller->removeLock($storedObject);
|
||||
}
|
||||
|
||||
public function testRemoveLockSuccess(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$lockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$clock = new MockClock('2024-01-01 12:00:00');
|
||||
$storedObject = new StoredObject();
|
||||
|
||||
$security->isGranted(StoredObjectRoleEnum::EDIT, $storedObject)->willReturn(true);
|
||||
$lockManager->hasLock($storedObject)->willReturn(true);
|
||||
|
||||
$lockManager->deleteLock($storedObject, $clock->now())->shouldBeCalled();
|
||||
|
||||
$controller = new StoredObjectLockApiController(
|
||||
$security->reveal(),
|
||||
$lockManager->reveal(),
|
||||
$clock
|
||||
);
|
||||
|
||||
$response = $controller->removeLock($storedObject);
|
||||
|
||||
self::assertSame(Response::HTTP_ACCEPTED, $response->getStatusCode());
|
||||
self::assertNull($response->getContent() ?: null);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?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\Dav\Utils;
|
||||
|
||||
use Chill\DocStoreBundle\Dav\Utils\LockTimeoutAnalyzer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class LockTimeoutAnalyzerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideTimeoutData
|
||||
*/
|
||||
public function testAnalyzeTimeout(string $content, \DateInterval $expected): void
|
||||
{
|
||||
$analyzer = new LockTimeoutAnalyzer();
|
||||
$result = $analyzer->analyzeTimeout($content);
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public static function provideTimeoutData(): iterable
|
||||
{
|
||||
yield 'Second-1800' => [
|
||||
'Second-1800',
|
||||
new \DateInterval('PT1800S'),
|
||||
];
|
||||
|
||||
yield 'Second-3600' => [
|
||||
'Second-3600',
|
||||
new \DateInterval('PT3600S'),
|
||||
];
|
||||
|
||||
yield 'Infinite' => [
|
||||
'Infinite',
|
||||
new \DateInterval('PT24H'), // Typically "Infinite" is represented by a long duration or special handling; RFC says it should be long. Let's assume a long one for now or check common practices.
|
||||
];
|
||||
|
||||
yield 'Multiple types, first is Second' => [
|
||||
'Second-1800, Infinite',
|
||||
new \DateInterval('PT1800S'),
|
||||
];
|
||||
|
||||
yield 'Multiple types, first is Infinite' => [
|
||||
'Infinite, Second-1800',
|
||||
new \DateInterval('PT24H'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?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\Dav\Utils;
|
||||
|
||||
use Chill\DocStoreBundle\Dav\Utils\LockTokenParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class LockTokenParserTest extends TestCase
|
||||
{
|
||||
private LockTokenParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new LockTokenParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider provideIfConditions
|
||||
*/
|
||||
public function testParseIfCondition(string $ifHeader, ?string $expectedToken): void
|
||||
{
|
||||
$request = new Request();
|
||||
$request->headers->set('if', $ifHeader);
|
||||
|
||||
$this->assertSame($expectedToken, $this->parser->parseIfCondition($request));
|
||||
}
|
||||
|
||||
public static function provideIfConditions(): array
|
||||
{
|
||||
return [
|
||||
'standard lock token' => [
|
||||
'(<opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
|
||||
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
|
||||
],
|
||||
'with resource uri' => [
|
||||
'<http://www.ics.uci.edu/users/f/fielding/index.html> (<opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
|
||||
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
|
||||
],
|
||||
'no match' => [
|
||||
'some other value',
|
||||
null,
|
||||
],
|
||||
'with NOT' => [
|
||||
'(Not <opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6>)',
|
||||
'opaquelocktoken:f81d4fae-7dec-11d0-a765-00a0c91e6bf6',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testParseIfConditionWithNoHeader(): void
|
||||
{
|
||||
$request = new Request();
|
||||
$this->assertNull($this->parser->parseIfCondition($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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\Entity;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\DocStoreBundle\Entity\StoredObjectLock
|
||||
*/
|
||||
class StoredObjectLockTest extends TestCase
|
||||
{
|
||||
public function testIsActiveAtWithNoExpiration(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$now = new \DateTimeImmutable();
|
||||
$lock = new StoredObjectLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WOPI,
|
||||
$now,
|
||||
'token',
|
||||
null
|
||||
);
|
||||
|
||||
self::assertTrue($lock->isActiveAt($now));
|
||||
self::assertTrue($lock->isActiveAt($now->modify('+1 year')));
|
||||
}
|
||||
|
||||
public function testIsActiveAtWithFutureExpiration(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$now = new \DateTimeImmutable();
|
||||
$expireAt = $now->modify('+1 hour');
|
||||
$lock = new StoredObjectLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WOPI,
|
||||
$now,
|
||||
'token',
|
||||
$expireAt
|
||||
);
|
||||
|
||||
self::assertTrue($lock->isActiveAt($now));
|
||||
self::assertTrue($lock->isActiveAt($now->modify('+30 minutes')));
|
||||
}
|
||||
|
||||
public function testIsActiveAtWithPastExpiration(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$now = new \DateTimeImmutable();
|
||||
$expireAt = $now->modify('-1 hour');
|
||||
$lock = new StoredObjectLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WOPI,
|
||||
$now->modify('-2 hours'),
|
||||
'token',
|
||||
$expireAt
|
||||
);
|
||||
|
||||
self::assertFalse($lock->isActiveAt($now));
|
||||
self::assertFalse($lock->isActiveAt($expireAt));
|
||||
self::assertFalse($lock->isActiveAt($expireAt->modify('+1 second')));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?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\Serializer\Normalizer;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectLockNormalizer;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectLockNormalizer
|
||||
*/
|
||||
class StoredObjectLockNormalizerTest extends TestCase
|
||||
{
|
||||
private StoredObjectLockNormalizer $normalizer;
|
||||
private NormalizerInterface $childNormalizer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->normalizer = new StoredObjectLockNormalizer();
|
||||
$this->childNormalizer = $this->createMock(NormalizerInterface::class);
|
||||
$this->normalizer->setNormalizer($this->childNormalizer);
|
||||
}
|
||||
|
||||
public function testSupportsNormalization(): void
|
||||
{
|
||||
$storedObject = new StoredObject('pending');
|
||||
$lock = new StoredObjectLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WEBDAV,
|
||||
new \DateTimeImmutable()
|
||||
);
|
||||
|
||||
$this->assertTrue($this->normalizer->supportsNormalization($lock, 'json'));
|
||||
$this->assertFalse($this->normalizer->supportsNormalization($lock, 'xml'));
|
||||
$this->assertFalse($this->normalizer->supportsNormalization($storedObject, 'json'));
|
||||
}
|
||||
|
||||
public function testNormalize(): void
|
||||
{
|
||||
$storedObject = new StoredObject('pending');
|
||||
$user = $this->createMock(User::class);
|
||||
$expireAt = new \DateTimeImmutable('2026-04-08 23:00:00');
|
||||
$createdAt = new \DateTimeImmutable('2026-04-08 22:00:00');
|
||||
|
||||
$lock = new StoredObjectLock(
|
||||
$storedObject,
|
||||
StoredObjectLockMethodEnum::WEBDAV,
|
||||
$createdAt,
|
||||
'some-token',
|
||||
$expireAt,
|
||||
[$user]
|
||||
);
|
||||
|
||||
$this->childNormalizer->expects($this->exactly(3))
|
||||
->method('normalize')
|
||||
->withAnyParameters()
|
||||
->willReturnCallback(function ($data, $format, $context) use ($createdAt, $expireAt, $user) {
|
||||
if ($data === $createdAt) {
|
||||
return '2026-04-08T22:00:00+00:00';
|
||||
}
|
||||
if ($data === $expireAt) {
|
||||
return '2026-04-08T23:00:00+00:00';
|
||||
}
|
||||
if ($data === [$user]) {
|
||||
return [['username' => 'testuser']];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
$result = $this->normalizer->normalize($lock, 'json');
|
||||
|
||||
$this->assertEquals([
|
||||
'uuid' => $lock->getUuid(),
|
||||
'method' => StoredObjectLockMethodEnum::WEBDAV,
|
||||
'createdAt' => '2026-04-08T22:00:00+00:00',
|
||||
'expiresAt' => '2026-04-08T23:00:00+00:00',
|
||||
'users' => [['username' => 'testuser']],
|
||||
], $result);
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,15 @@ namespace Chill\DocStoreBundle\Tests\Serializer\Normalizer;
|
||||
use Chill\DocStoreBundle\AsyncUpload\SignedUrl;
|
||||
use Chill\DocStoreBundle\AsyncUpload\TempUrlGeneratorInterface;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Security\Guard\JWTDavTokenProviderInterface;
|
||||
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectEditorDecisionManagerInterface;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
@@ -29,6 +34,8 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
*/
|
||||
class StoredObjectNormalizerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testNormalize(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
@@ -37,45 +44,51 @@ class StoredObjectNormalizerTest extends TestCase
|
||||
$idProperty = $reflection->getProperty('id');
|
||||
$idProperty->setValue($storedObject, 1);
|
||||
|
||||
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->expects($this->once())->method('createToken')->withAnyParameters()->willReturn('token');
|
||||
$jwtProvider->expects($this->once())->method('getTokenExpiration')->with('token')->willReturn($d = new \DateTimeImmutable());
|
||||
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->createToken(Argument::cetera())->willReturn('token');
|
||||
$jwtProvider->getTokenExpiration('token')->willReturn($d = new \DateTimeImmutable());
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->once())->method('generate')
|
||||
->with(
|
||||
'chill_docstore_dav_document_get',
|
||||
[
|
||||
'uuid' => $storedObject->getUuid(),
|
||||
'access_token' => 'token',
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
)
|
||||
->willReturn($davLink = 'http://localhost/dav/token');
|
||||
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||
$urlGenerator->generate(
|
||||
'chill_docstore_dav_document_get',
|
||||
[
|
||||
'uuid' => $storedObject->getUuid(),
|
||||
'access_token' => 'token',
|
||||
],
|
||||
UrlGeneratorInterface::ABSOLUTE_URL,
|
||||
)->willReturn($davLink = 'http://localhost/dav/token');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->exactly(2))->method('isGranted')
|
||||
->with(
|
||||
$this->logicalOr(StoredObjectRoleEnum::EDIT->value, StoredObjectRoleEnum::SEE->value),
|
||||
$storedObject
|
||||
)
|
||||
->willReturn(true);
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::type('string'), $storedObject)->willReturn(true);
|
||||
|
||||
$globalNormalizer = $this->createMock(NormalizerInterface::class);
|
||||
$globalNormalizer->expects($this->exactly(3))->method('normalize')
|
||||
->withAnyParameters()
|
||||
->willReturnCallback(function (?object $object, string $format, array $context) {
|
||||
if (null === $object) {
|
||||
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
|
||||
$globalNormalizer->normalize(Argument::cetera())
|
||||
->will(function ($args) {
|
||||
if (null === $args[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['sub' => 'sub'];
|
||||
});
|
||||
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
|
||||
|
||||
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
|
||||
$normalizer->setNormalizer($globalNormalizer);
|
||||
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
|
||||
$storedObjectEditorDecisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV)->willReturn(true);
|
||||
$storedObjectEditorDecisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI)->willReturn(true);
|
||||
|
||||
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$storedObjectLockManager->hasLock($storedObject)->willReturn(false);
|
||||
|
||||
$normalizer = new StoredObjectNormalizer(
|
||||
$jwtProvider->reveal(),
|
||||
$urlGenerator->reveal(),
|
||||
$security->reveal(),
|
||||
$tempUrlGenerator->reveal(),
|
||||
$storedObjectEditorDecisionManager->reveal(),
|
||||
$storedObjectLockManager->reveal()
|
||||
);
|
||||
$normalizer->setNormalizer($globalNormalizer->reveal());
|
||||
|
||||
$actual = $normalizer->normalize($storedObject, 'json');
|
||||
|
||||
@@ -93,11 +106,67 @@ class StoredObjectNormalizerTest extends TestCase
|
||||
self::assertArrayHasKey('datas', $actual);
|
||||
self::assertArrayHasKey('createdAt', $actual);
|
||||
self::assertArrayHasKey('createdBy', $actual);
|
||||
self::assertArrayHasKey('lock', $actual);
|
||||
self::assertNull($actual['lock']);
|
||||
self::assertArrayHasKey('_permissions', $actual);
|
||||
self::assertEqualsCanonicalizing(['canEdit' => true, 'canSee' => true], $actual['_permissions']);
|
||||
self::assertArrayHaskey('_links', $actual);
|
||||
self::assertArrayHasKey('dav_link', $actual['_links']);
|
||||
self::assertEqualsCanonicalizing(['href' => $davLink, 'expiration' => $d->getTimestamp()], $actual['_links']['dav_link']);
|
||||
self::assertArrayHasKey('_editor', $actual);
|
||||
self::assertArrayHasKey('webdav', $actual['_editor']);
|
||||
self::assertArrayHasKey('wopi', $actual['_editor']);
|
||||
self::assertTrue($actual['_editor']['webdav']);
|
||||
self::assertTrue($actual['_editor']['wopi']);
|
||||
}
|
||||
|
||||
public function testNormalizeWithLock(): void
|
||||
{
|
||||
$storedObject = new StoredObject();
|
||||
$storedObject->setTitle('test');
|
||||
$reflection = new \ReflectionClass(StoredObject::class);
|
||||
$idProperty = $reflection->getProperty('id');
|
||||
$idProperty->setValue($storedObject, 1);
|
||||
|
||||
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->createToken(Argument::cetera())->willReturn('token');
|
||||
$jwtProvider->getTokenExpiration('token')->willReturn(new \DateTimeImmutable());
|
||||
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||
$urlGenerator->generate(Argument::cetera())->willReturn('http://localhost');
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::cetera())->willReturn(true);
|
||||
|
||||
$lock = $this->prophesize(StoredObjectLock::class);
|
||||
|
||||
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
|
||||
$globalNormalizer->normalize(Argument::cetera())->willReturn(['sub' => 'sub']);
|
||||
|
||||
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
|
||||
|
||||
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
|
||||
$storedObjectEditorDecisionManager->canEdit(Argument::cetera())->willReturn(false);
|
||||
|
||||
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$storedObjectLockManager->hasLock($storedObject)->willReturn(true);
|
||||
$storedObjectLockManager->getLock($storedObject)->willReturn($lock->reveal());
|
||||
|
||||
$normalizer = new StoredObjectNormalizer(
|
||||
$jwtProvider->reveal(),
|
||||
$urlGenerator->reveal(),
|
||||
$security->reveal(),
|
||||
$tempUrlGenerator->reveal(),
|
||||
$storedObjectEditorDecisionManager->reveal(),
|
||||
$storedObjectLockManager->reveal()
|
||||
);
|
||||
$normalizer->setNormalizer($globalNormalizer->reveal());
|
||||
|
||||
$actual = $normalizer->normalize($storedObject, 'json');
|
||||
|
||||
self::assertArrayHasKey('lock', $actual);
|
||||
self::assertEquals(['sub' => 'sub'], $actual['lock']);
|
||||
self::assertArrayHasKey('_editor', $actual);
|
||||
self::assertFalse($actual['_editor']['webdav']);
|
||||
self::assertFalse($actual['_editor']['wopi']);
|
||||
}
|
||||
|
||||
public function testWithDownloadLinkOnly(): void
|
||||
@@ -109,32 +178,42 @@ class StoredObjectNormalizerTest extends TestCase
|
||||
$idProperty = $reflection->getProperty('id');
|
||||
$idProperty->setValue($storedObject, 1);
|
||||
|
||||
$jwtProvider = $this->createMock(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->expects($this->never())->method('createToken')->withAnyParameters();
|
||||
$jwtProvider = $this->prophesize(JWTDavTokenProviderInterface::class);
|
||||
$jwtProvider->createToken(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$urlGenerator = $this->createMock(UrlGeneratorInterface::class);
|
||||
$urlGenerator->expects($this->never())->method('generate');
|
||||
$urlGenerator = $this->prophesize(UrlGeneratorInterface::class);
|
||||
$urlGenerator->generate(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->never())->method('isGranted');
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$globalNormalizer = $this->createMock(NormalizerInterface::class);
|
||||
$globalNormalizer->expects($this->exactly(4))->method('normalize')
|
||||
->withAnyParameters()
|
||||
->willReturnCallback(function (?object $object, string $format, array $context) {
|
||||
if (null === $object) {
|
||||
$globalNormalizer = $this->prophesize(NormalizerInterface::class);
|
||||
$globalNormalizer->normalize(Argument::cetera())
|
||||
->will(function ($args) {
|
||||
if (null === $args[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['sub' => 'sub'];
|
||||
});
|
||||
|
||||
$tempUrlGenerator = $this->createMock(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator->expects($this->once())->method('generate')->with('GET', $storedObject->getCurrentVersion()->getFilename(), $this->isType('int'))
|
||||
$tempUrlGenerator = $this->prophesize(TempUrlGeneratorInterface::class);
|
||||
$tempUrlGenerator->generate('GET', $storedObject->getCurrentVersion()->getFilename(), Argument::type('int'))
|
||||
->willReturn(new SignedUrl('GET', 'https://some-link/test', new \DateTimeImmutable('300 seconds'), $storedObject->getCurrentVersion()->getFilename()));
|
||||
|
||||
$normalizer = new StoredObjectNormalizer($jwtProvider, $urlGenerator, $security, $tempUrlGenerator);
|
||||
$normalizer->setNormalizer($globalNormalizer);
|
||||
$storedObjectEditorDecisionManager = $this->prophesize(StoredObjectEditorDecisionManagerInterface::class);
|
||||
$storedObjectLockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$storedObjectLockManager->hasLock($storedObject)->willReturn(false);
|
||||
|
||||
$normalizer = new StoredObjectNormalizer(
|
||||
$jwtProvider->reveal(),
|
||||
$urlGenerator->reveal(),
|
||||
$security->reveal(),
|
||||
$tempUrlGenerator->reveal(),
|
||||
$storedObjectEditorDecisionManager->reveal(),
|
||||
$storedObjectLockManager->reveal()
|
||||
);
|
||||
$normalizer->setNormalizer($globalNormalizer->reveal());
|
||||
|
||||
$actual = $normalizer->normalize($storedObject, 'json', ['groups' => ['read', 'read:download-link-only']]);
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Service\Lock\CleanOldLock;
|
||||
use Chill\DocStoreBundle\Service\Lock\CleanOldLockCronJob;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
|
||||
/**
|
||||
* @covers \Chill\DocStoreBundle\Service\Lock\CleanOldLockCronJob
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CleanOldLockCronJobTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private MockClock $clock;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy<CleanOldLock>
|
||||
*/
|
||||
private ObjectProphecy $cleanOldLock;
|
||||
|
||||
private CleanOldLockCronJob $cronJob;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new MockClock('2024-01-02 12:00:00');
|
||||
$this->cleanOldLock = $this->prophesize(CleanOldLock::class);
|
||||
$this->cronJob = new CleanOldLockCronJob(
|
||||
$this->cleanOldLock->reveal(),
|
||||
$this->clock
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetKey(): void
|
||||
{
|
||||
self::assertSame('clean-old-stored-object-lock', $this->cronJob->getKey());
|
||||
}
|
||||
|
||||
public function testRun(): void
|
||||
{
|
||||
$this->cleanOldLock->__invoke()->shouldBeCalled();
|
||||
|
||||
$result = $this->cronJob->run([]);
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
public function testCanRunWhenNullExecution(): void
|
||||
{
|
||||
self::assertTrue($this->cronJob->canRun(null));
|
||||
}
|
||||
|
||||
public function testCanRunWhenLastStartIsWithin12Hours(): void
|
||||
{
|
||||
$execution = new CronJobExecution('key');
|
||||
// lastStart is "now" (2024-01-02 12:00:00)
|
||||
// clock is "now"
|
||||
// now > lastStart + 12h is FALSE
|
||||
|
||||
self::assertFalse($this->cronJob->canRun($execution));
|
||||
}
|
||||
|
||||
public function testCanRunWhenLastStartIsOlderThan12Hours(): void
|
||||
{
|
||||
$execution = new CronJobExecution('key');
|
||||
$execution->setLastStart(new \DateTimeImmutable('2024-01-01 12:00:00'));
|
||||
// clock is 2024-01-02 12:00:00
|
||||
// lastStart + 12h is 2024-01-02 00:00:00
|
||||
// 2024-01-02 12:00:00 > 2024-01-02 00:00:00 is TRUE
|
||||
|
||||
self::assertTrue($this->cronJob->canRun($execution));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Repository\StoredObjectLockRepository;
|
||||
use Chill\DocStoreBundle\Service\Lock\CleanOldLock;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
|
||||
/**
|
||||
* @covers \Chill\DocStoreBundle\Service\Lock\CleanOldLock
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class CleanOldLockTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private MockClock $clock;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy<StoredObjectLockRepository>
|
||||
*/
|
||||
private ObjectProphecy $repository;
|
||||
|
||||
private CleanOldLock $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new MockClock('2024-01-02 12:00:00');
|
||||
$this->repository = $this->prophesize(StoredObjectLockRepository::class);
|
||||
$this->service = new CleanOldLock(
|
||||
$this->repository->reveal(),
|
||||
$this->clock
|
||||
);
|
||||
}
|
||||
|
||||
public function testInvoke(): void
|
||||
{
|
||||
$expectedBefore = $this->clock->now()->sub(new \DateInterval('PT24H'));
|
||||
|
||||
$this->repository->removeExpiredBefore($expectedBefore)->shouldBeCalled();
|
||||
|
||||
($this->service)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectEditorDecisionManager;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectEditorDecisionManagerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private ObjectProphecy|StoredObjectLockManager $lockManager;
|
||||
private StoredObjectEditorDecisionManager $decisionManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->lockManager = $this->prophesize(StoredObjectLockManager::class);
|
||||
$this->decisionManager = new StoredObjectEditorDecisionManager($this->lockManager->reveal());
|
||||
}
|
||||
|
||||
public function testCanEditReturnsTrueIfNoLock(): void
|
||||
{
|
||||
$storedObject = $this->prophesize(StoredObject::class)->reveal();
|
||||
$this->lockManager->hasLock($storedObject)->willReturn(false);
|
||||
|
||||
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
|
||||
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
|
||||
}
|
||||
|
||||
public function testCanEditReturnsFalseIfExistingLockIsWebdav(): void
|
||||
{
|
||||
$storedObject = $this->prophesize(StoredObject::class)->reveal();
|
||||
$lock = $this->prophesize(StoredObjectLock::class);
|
||||
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WEBDAV);
|
||||
|
||||
$this->lockManager->hasLock($storedObject)->willReturn(true);
|
||||
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
|
||||
|
||||
// Always false if existing lock is Webdav
|
||||
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
|
||||
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
|
||||
}
|
||||
|
||||
public function testCanEditReturnsTrueIfBothAreWopi(): void
|
||||
{
|
||||
$storedObject = $this->prophesize(StoredObject::class)->reveal();
|
||||
$lock = $this->prophesize(StoredObjectLock::class);
|
||||
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WOPI);
|
||||
|
||||
$this->lockManager->hasLock($storedObject)->willReturn(true);
|
||||
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
|
||||
|
||||
$this->assertTrue($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WOPI));
|
||||
}
|
||||
|
||||
public function testCanEditReturnsFalseIfExistingLockIsWopiButRequestedIsWebdav(): void
|
||||
{
|
||||
$storedObject = $this->prophesize(StoredObject::class)->reveal();
|
||||
$lock = $this->prophesize(StoredObjectLock::class);
|
||||
$lock->getMethod()->willReturn(StoredObjectLockMethodEnum::WOPI);
|
||||
|
||||
$this->lockManager->hasLock($storedObject)->willReturn(true);
|
||||
$this->lockManager->getLock($storedObject)->willReturn($lock->reveal());
|
||||
|
||||
$this->assertFalse($this->decisionManager->canEdit($storedObject, StoredObjectLockMethodEnum::WEBDAV));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
<?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\Service\Lock;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLock;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\Exception\NoLockFoundException;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\HttpKernel\Event\TerminateEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class StoredObjectLockManagerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private MockClock $clock;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy<EntityManagerInterface>
|
||||
*/
|
||||
private ObjectProphecy $entityManager;
|
||||
private StoredObjectLockManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = new MockClock();
|
||||
$this->entityManager = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->manager = new StoredObjectLockManager(
|
||||
$this->entityManager->reveal(),
|
||||
$this->clock
|
||||
);
|
||||
}
|
||||
|
||||
public function testHasLockNoLock(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$this->assertFalse($this->manager->hasLock($document));
|
||||
}
|
||||
|
||||
public function testSetLockNew(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$method = StoredObjectLockMethodEnum::WEBDAV;
|
||||
$lockId = 'test-lock-id';
|
||||
$expiresAt = $this->clock->now()->add(new \DateInterval('PT30M'));
|
||||
$user = new User();
|
||||
|
||||
$this->entityManager->persist(Argument::type(StoredObjectLock::class))->shouldBeCalled();
|
||||
|
||||
$result = $this->manager->setLock($document, $method, $lockId, $expiresAt, [$user]);
|
||||
|
||||
$this->assertInstanceOf(StoredObjectLock::class, $result);
|
||||
$this->assertTrue($this->manager->hasLock($document));
|
||||
|
||||
$lock = $this->manager->getLock($document);
|
||||
$this->assertSame($document, $lock->getStoredObject());
|
||||
$this->assertSame($method, $lock->getMethod());
|
||||
$this->assertSame($lockId, $lock->getToken());
|
||||
$this->assertSame($expiresAt, $lock->getExpireAt());
|
||||
$this->assertContains($user, $lock->getUsers());
|
||||
}
|
||||
|
||||
public function testSetLockDefaultValues(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$method = StoredObjectLockMethodEnum::WOPI;
|
||||
|
||||
$this->entityManager->persist(Argument::type(StoredObjectLock::class))->shouldBeCalled();
|
||||
|
||||
$result = $this->manager->setLock($document, $method);
|
||||
|
||||
$this->assertInstanceOf(StoredObjectLock::class, $result);
|
||||
$lock = $this->manager->getLock($document);
|
||||
$this->assertNotEmpty($lock->getToken());
|
||||
$this->assertEquals($this->clock->now()->add(new \DateInterval('PT60M')), $lock->getExpireAt());
|
||||
}
|
||||
|
||||
public function testHasLockActive(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'token', $this->clock->now()->add(new \DateInterval('PT1M')));
|
||||
|
||||
$this->assertTrue($this->manager->hasLock($document));
|
||||
}
|
||||
|
||||
public function testHasLockExpired(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now()->sub(new \DateInterval('PT2M')), 'token', $this->clock->now()->sub(new \DateInterval('PT1M')));
|
||||
|
||||
$this->assertFalse($this->manager->hasLock($document));
|
||||
}
|
||||
|
||||
public function testGetLockThrowsExceptionWhenNoLock(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$this->expectException(NoLockFoundException::class);
|
||||
$this->manager->getLock($document);
|
||||
}
|
||||
|
||||
public function testSetLockExistingUpdatesLock(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$initialExpire = $this->clock->now()->add(new \DateInterval('PT10M'));
|
||||
$lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'initial-token', $initialExpire);
|
||||
|
||||
$newLockId = 'new-token';
|
||||
$newExpire = $this->clock->now()->add(new \DateInterval('PT20M'));
|
||||
$user = new User();
|
||||
|
||||
// Should NOT call persist again
|
||||
$this->entityManager->persist(Argument::any())->shouldNotBeCalled();
|
||||
|
||||
$result = $this->manager->setLock($document, StoredObjectLockMethodEnum::WOPI, $newLockId, $newExpire, [$user]);
|
||||
|
||||
$this->assertInstanceOf(StoredObjectLock::class, $result);
|
||||
$this->assertCount(1, $document->getLocks());
|
||||
$this->assertSame($lock, $document->getLocks()->first());
|
||||
$this->assertSame($newLockId, $lock->getToken());
|
||||
$this->assertSame($newExpire, $lock->getExpireAt());
|
||||
$this->assertContains($user, $lock->getUsers());
|
||||
}
|
||||
|
||||
public function testSetLockExistingUpdatesLockWithoutNewLockId(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$initialExpire = $this->clock->now()->add(new \DateInterval('PT10M'));
|
||||
$lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'initial-token', $initialExpire);
|
||||
|
||||
$newExpire = $this->clock->now()->add(new \DateInterval('PT20M'));
|
||||
$user = new User();
|
||||
|
||||
// Should NOT call persist again
|
||||
$this->entityManager->persist(Argument::any())->shouldNotBeCalled();
|
||||
|
||||
$result = $this->manager->setLock($document, StoredObjectLockMethodEnum::WOPI, null, $newExpire, [$user]);
|
||||
|
||||
$this->assertInstanceOf(StoredObjectLock::class, $result);
|
||||
$this->assertCount(1, $document->getLocks());
|
||||
$this->assertSame($lock, $document->getLocks()->first());
|
||||
$this->assertSame('initial-token', $lock->getToken());
|
||||
$this->assertSame($newExpire, $lock->getExpireAt());
|
||||
$this->assertContains($user, $lock->getUsers());
|
||||
}
|
||||
|
||||
public function testDeleteLock(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$expiresAt = $this->clock->now()->add(new \DateInterval('PT10M'));
|
||||
$lock = new StoredObjectLock($document, StoredObjectLockMethodEnum::WEBDAV, $this->clock->now(), 'token', $expiresAt);
|
||||
|
||||
$this->assertTrue($this->manager->hasLock($document));
|
||||
|
||||
$newExpire = $this->clock->now();
|
||||
$result = $this->manager->deleteLock($document, $newExpire);
|
||||
|
||||
$this->assertTrue($result);
|
||||
$this->assertSame($newExpire, $lock->getExpireAt());
|
||||
// Since isActiveAt uses $at < $expireAt, and we passed $this->clock->now(), it should be inactive
|
||||
$this->assertFalse($this->manager->hasLock($document));
|
||||
}
|
||||
|
||||
public function testOnKernelTerminateFlushesWhenMustFlushIsTrue(): void
|
||||
{
|
||||
$document = new StoredObject();
|
||||
$this->entityManager->persist(Argument::any())->shouldBeCalled();
|
||||
$this->manager->setLock($document, StoredObjectLockMethodEnum::WEBDAV);
|
||||
|
||||
$this->entityManager->flush()->shouldBeCalledOnce();
|
||||
|
||||
$event = new TerminateEvent(
|
||||
$this->prophesize(HttpKernelInterface::class)->reveal(),
|
||||
new Request(),
|
||||
new \Symfony\Component\HttpFoundation\Response()
|
||||
);
|
||||
|
||||
$this->manager->onKernelTerminate($event);
|
||||
}
|
||||
|
||||
public function testOnKernelTerminateDoesNotFlushWhenMustFlushIsFalse(): void
|
||||
{
|
||||
$this->entityManager->flush()->shouldNotBeCalled();
|
||||
|
||||
$event = new TerminateEvent(
|
||||
$this->prophesize(HttpKernelInterface::class)->reveal(),
|
||||
new Request(),
|
||||
new \Symfony\Component\HttpFoundation\Response()
|
||||
);
|
||||
|
||||
$this->manager->onKernelTerminate($event);
|
||||
}
|
||||
|
||||
public function testGetSubscribedEvents(): void
|
||||
{
|
||||
$events = StoredObjectLockManager::getSubscribedEvents();
|
||||
$this->assertArrayHasKey(TerminateEvent::class, $events);
|
||||
$this->assertSame('onKernelTerminate', $events[TerminateEvent::class]);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,32 @@ paths:
|
||||
404:
|
||||
description: "Not found"
|
||||
|
||||
/1.0/doc-store/stored-object/{uuid}:
|
||||
get:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Get a stored object
|
||||
parameters:
|
||||
- in: path
|
||||
name: uuid
|
||||
required: true
|
||||
allowEmptyValue: false
|
||||
description: The UUID of the storedObject
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
200:
|
||||
description: "OK"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StoredObject"
|
||||
403:
|
||||
description: "Unauthorized"
|
||||
404:
|
||||
description: "Not found"
|
||||
|
||||
/1.0/doc-store/stored-object/{uuid}/versions:
|
||||
get:
|
||||
tags:
|
||||
@@ -196,3 +222,26 @@ paths:
|
||||
$ref: '#/components/schemas/GenericDoc'
|
||||
|
||||
type: object
|
||||
/1.0/doc-store/stored-object/{uuid}/lock:
|
||||
delete:
|
||||
tags:
|
||||
- storedobject
|
||||
summary: Force removing a lock on a stored object
|
||||
parameters:
|
||||
- in: path
|
||||
name: uuid
|
||||
required: true
|
||||
allowEmptyValue: false
|
||||
description: The UUID of the storedObjeect
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
204:
|
||||
description: "No content"
|
||||
403:
|
||||
description: "Forbidden"
|
||||
404:
|
||||
description: "Not found"
|
||||
412:
|
||||
description: "No lock found for this stored object"
|
||||
|
||||
@@ -26,6 +26,8 @@ services:
|
||||
|
||||
Chill\DocStoreBundle\Service\:
|
||||
resource: '../Service/'
|
||||
exclude:
|
||||
'../Service/Lock/Exception/*'
|
||||
|
||||
Chill\DocStoreBundle\GenericDoc\Manager:
|
||||
arguments:
|
||||
@@ -63,3 +65,10 @@ services:
|
||||
|
||||
Chill\DocStoreBundle\AsyncUpload\Templating\:
|
||||
resource: '../AsyncUpload/Templating/'
|
||||
|
||||
Chill\DocStoreBundle\Dav\:
|
||||
resource: '../Dav/'
|
||||
exclude:
|
||||
- '../Dav/Exception/*'
|
||||
- '../Dav/Request/*'
|
||||
- '../Dav/Response/*'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?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\Migrations\DocStore;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260331122339 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add locks for stored objects';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE chill_doc.stored_object_lock (
|
||||
uuid UUID NOT NULL, method VARCHAR(10) NOT NULL,
|
||||
token TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
expireAt TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL, storedObject_id INT DEFAULT NULL,
|
||||
PRIMARY KEY(uuid))');
|
||||
$this->addSql('CREATE INDEX IDX_E66CB6516C99C13A ON chill_doc.stored_object_lock (storedObject_id)');
|
||||
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.uuid IS \'(DC2Type:uuid)\'');
|
||||
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.createdAt IS \'(DC2Type:datetimetz_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock.expireAt IS \'(DC2Type:datetimetz_immutable)\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock ADD CONSTRAINT FK_E66CB6516C99C13A FOREIGN KEY (storedObject_id) REFERENCES chill_doc.stored_object (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('CREATE TABLE chill_doc.stored_object_lock_user (storedobjectlock_uuid UUID NOT NULL, user_id INT NOT NULL, PRIMARY KEY(storedobjectlock_uuid, user_id))');
|
||||
$this->addSql('CREATE INDEX IDX_A4353741F52905D0 ON chill_doc.stored_object_lock_user (storedobjectlock_uuid)');
|
||||
$this->addSql('CREATE INDEX IDX_A4353741A76ED395 ON chill_doc.stored_object_lock_user (user_id)');
|
||||
$this->addSql('COMMENT ON COLUMN chill_doc.stored_object_lock_user.storedobjectlock_uuid IS \'(DC2Type:uuid)\'');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user ADD CONSTRAINT FK_A4353741F52905D0 FOREIGN KEY (storedobjectlock_uuid) REFERENCES chill_doc.stored_object_lock (uuid) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user ADD CONSTRAINT FK_A4353741A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user DROP CONSTRAINT FK_A4353741F52905D0');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock_user DROP CONSTRAINT FK_A4353741A76ED395');
|
||||
$this->addSql('DROP TABLE chill_doc.stored_object_lock_user');
|
||||
$this->addSql('ALTER TABLE chill_doc.stored_object_lock DROP CONSTRAINT FK_E66CB6516C99C13A');
|
||||
$this->addSql('DROP TABLE chill_doc.stored_object_lock');
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,23 @@ workflow:
|
||||
doc_shared_automatically_at_explanation: >-
|
||||
Le document a été partagé avec vous le {at, date, long} à {at, time, short}
|
||||
|
||||
stored_object:
|
||||
lock:
|
||||
is_currently_edited: Le document est en cours d'édition par {byUsers}, un verrou est placé sur le document.
|
||||
is_currently_edited_short: Le document est en cours d'édition par {byUsers}
|
||||
is_currently_edited_without_users: Le document est en cours d'édition, un verrou est placé sur le document.
|
||||
is_currently_edited_without_users_short: Le document est en cours d'édition
|
||||
list_of_users_may_be_incomplete: La liste des utilisateurs mentionné peut être incomplète.
|
||||
editing_since: >
|
||||
L'édition a débuté le {since, date, long} à {since, time, short}
|
||||
force_remove_lock_possible_explain: Vous pouvez supprimer le verrou à l'édition. Les modifications apportées par l'utilisateur pourront être perdues. Veuillez le contacter au préalable.
|
||||
force_remove_lock: Supprimer le verrou à l'édition
|
||||
force_remove_lock_warning: >-
|
||||
J'ai pris contact avec {nb, plural,
|
||||
=0 {l'utilisateur}
|
||||
one {l'utilisateur}
|
||||
other {les utilisateurs}
|
||||
}, et je confirme vouloir supprimer le verrou à l'édition.
|
||||
remove_lock_failure: 'Le verrou n''a pas pu être supprimé: {status}'
|
||||
remove_lock_success: Le verrou a été supprimé
|
||||
|
||||
|
||||
@@ -102,4 +102,17 @@ export function localizeDateTimeFormat(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list into the locale, using the Intl.ListFormat.
|
||||
*
|
||||
* This method is a stub while PHP supports the formatting of list using ICU.
|
||||
*/
|
||||
export function localizeList(
|
||||
list: Iterable<string>,
|
||||
options?: Intl.ListFormatOptions,
|
||||
): string {
|
||||
const formatter = new Intl.ListFormat(getLocale(), options);
|
||||
return formatter.format(list);
|
||||
}
|
||||
|
||||
export default datetimeFormats;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<document-action-buttons-group
|
||||
:stored-object="storedObject"
|
||||
:filename="filename"
|
||||
:refresh-stored-object-auto="false"
|
||||
></document-action-buttons-group>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -53,6 +53,7 @@ const canRemove = computed<boolean>((): boolean => {
|
||||
<li>
|
||||
<document-action-buttons-group
|
||||
:stored-object="a.genericDoc.storedObject"
|
||||
:refresh-stored-object-auto="false"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<li v-if="canRemove">
|
||||
|
||||
@@ -38,6 +38,7 @@ const clickOnAddButton = () => {
|
||||
<li v-if="props.genericDoc.storedObject !== null">
|
||||
<document-action-buttons-group
|
||||
:stored-object="props.genericDoc.storedObject"
|
||||
:refresh-stored-object-auto="false"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
30
src/Bundle/ChillMainBundle/Test/RandomUserTrait.php
Normal file
30
src/Bundle/ChillMainBundle/Test/RandomUserTrait.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?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\MainBundle\Test;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
trait RandomUserTrait
|
||||
{
|
||||
public function getRandomUser(EntityManagerInterface $em): User
|
||||
{
|
||||
$userRepository = $em->getRepository(User::class);
|
||||
$count = $userRepository->count([]);
|
||||
$random = mt_rand(0, $count - 1);
|
||||
|
||||
return $em->createQuery('SELECT u FROM '.User::class.' u ')
|
||||
->setMaxResults(1)
|
||||
->setFirstResult($random)
|
||||
->getSingleResult();
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,14 @@
|
||||
@on-stored-object-status-change="
|
||||
$emit('statusDocumentChanged', $event)
|
||||
"
|
||||
@on-stored-object-refresh="
|
||||
(newStoredObject) =>
|
||||
replaceDocument(
|
||||
d,
|
||||
newStoredObject,
|
||||
newStoredObject.currentVersion,
|
||||
)
|
||||
"
|
||||
></document-action-buttons-group>
|
||||
</li>
|
||||
<!--replace document-->
|
||||
@@ -318,6 +326,7 @@ async function goToGenerateWorkflowEvaluationDocument({
|
||||
* @return {void}
|
||||
*/
|
||||
async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
|
||||
console.log("replaceDocument", storedObject, storedObjectVersion);
|
||||
let document = {
|
||||
type: "accompanying_period_work_evaluation_document",
|
||||
storedObject: storedObject,
|
||||
@@ -328,6 +337,7 @@ async function replaceDocument(oldDocument, storedObject, storedObjectVersion) {
|
||||
key: props.evaluation.key,
|
||||
document,
|
||||
oldDocument: oldDocument,
|
||||
storedObject: storedObject,
|
||||
stored_object_version: storedObjectVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ const store = createStore({
|
||||
* Replaces a document in the state with a new document.
|
||||
*
|
||||
* @param {object} state - The current state of the application.
|
||||
* @param {{key: number, oldDocument: {key: number}, stored_object_version: StoredObjectVersion}} payload - The object containing the information about the document to be replaced.
|
||||
* @param {{key: number, oldDocument: {key: number}, stored_object_version: StoredObjectVersion, storedObject: StoredObject}} payload - The object containing the information about the document to be replaced.
|
||||
* @return {void} - returns nothing.
|
||||
*/
|
||||
replaceDocument(state, payload) {
|
||||
@@ -364,6 +364,7 @@ const store = createStore({
|
||||
return;
|
||||
}
|
||||
|
||||
doc.storedObject = payload.storedObject;
|
||||
doc.storedObject.currentVersion = payload.stored_object_version;
|
||||
return;
|
||||
let newDocument = Object.assign(payload.document, {
|
||||
|
||||
@@ -13,10 +13,16 @@ namespace Chill\WopiBundle\Service\Wopi;
|
||||
|
||||
use ChampsLibres\WopiLib\Contract\Entity\Document;
|
||||
use ChampsLibres\WopiLib\Contract\Service\DocumentLockManagerInterface;
|
||||
use Chill\MainBundle\Redis\ChillRedis;
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\DocStoreBundle\Entity\StoredObjectLockMethodEnum;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class ChillDocumentLockManager implements DocumentLockManagerInterface
|
||||
final readonly class ChillDocumentLockManager implements DocumentLockManagerInterface
|
||||
{
|
||||
private const LOCK_DURATION = 60 * 30;
|
||||
|
||||
@@ -26,54 +32,49 @@ class ChillDocumentLockManager implements DocumentLockManagerInterface
|
||||
private const LOCK_GRACEFUL_DURATION_TIME = 3;
|
||||
|
||||
public function __construct(
|
||||
private readonly ChillRedis $redis,
|
||||
private readonly int $ttlAfterDeleteSeconds = self::LOCK_GRACEFUL_DURATION_TIME,
|
||||
private Security $security,
|
||||
private ClockInterface $clock,
|
||||
private StoredObjectLockManager $storedObjectLockManager,
|
||||
private int $ttlAfterDeleteSeconds = self::LOCK_GRACEFUL_DURATION_TIME,
|
||||
) {}
|
||||
|
||||
public function deleteLock(Document $document, RequestInterface $request): bool
|
||||
{
|
||||
if (0 === $this->redis->exists($this->getCacheId($document))) {
|
||||
return true;
|
||||
}
|
||||
Assert::isInstanceOf($document, StoredObject::class);
|
||||
|
||||
// some queries (ex.: putFile) may be executed on the same time than the unlock, so
|
||||
// we add a delay before unlocking the file, instead of deleting it immediatly
|
||||
return $this->redis->expire($this->getCacheId($document), $this->ttlAfterDeleteSeconds);
|
||||
return $this->storedObjectLockManager->deleteLock(
|
||||
$document,
|
||||
$this->clock->now()->add(new \DateInterval('PT'.$this->ttlAfterDeleteSeconds.'S'))
|
||||
);
|
||||
}
|
||||
|
||||
public function getLock(Document $document, RequestInterface $request): string
|
||||
{
|
||||
if (false !== $value = $this->redis->get($this->getCacheId($document))) {
|
||||
return $value;
|
||||
}
|
||||
Assert::isInstanceOf($document, StoredObject::class);
|
||||
|
||||
throw new \RuntimeException('wopi key does not exists');
|
||||
return $this->storedObjectLockManager->getLock($document)->getToken();
|
||||
}
|
||||
|
||||
public function hasLock(Document $document, RequestInterface $request): bool
|
||||
{
|
||||
$r = $this->redis->exists($this->getCacheId($document));
|
||||
Assert::isInstanceOf($document, StoredObject::class);
|
||||
|
||||
if (is_bool($r)) {
|
||||
return $r;
|
||||
}
|
||||
if (is_int($r)) {
|
||||
return $r > 0;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('data type not supported');
|
||||
return $this->storedObjectLockManager->hasLock($document);
|
||||
}
|
||||
|
||||
public function setLock(Document $document, string $lockId, RequestInterface $request): bool
|
||||
{
|
||||
$key = $this->getCacheId($document);
|
||||
$this->redis->setex($key, self::LOCK_DURATION, $lockId);
|
||||
Assert::isInstanceOf($document, StoredObject::class);
|
||||
$user = $this->security->getUser();
|
||||
|
||||
$this->storedObjectLockManager->setLock(
|
||||
$document,
|
||||
StoredObjectLockMethodEnum::WOPI,
|
||||
$lockId,
|
||||
$this->clock->now()->add(new \DateInterval('PT'.self::LOCK_DURATION.'S')),
|
||||
$user instanceof User ? [$user] : []
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function getCacheId(Document $document): string
|
||||
{
|
||||
return sprintf('wopi_lib_lock_%s', $document->getWopiDocId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,16 @@ declare(strict_types=1);
|
||||
namespace Chill\WopiBundle\Tests\Service\Wopi;
|
||||
|
||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||
use Chill\MainBundle\Redis\ChillRedis;
|
||||
use Chill\DocStoreBundle\Service\Lock\StoredObjectLockManager;
|
||||
use Chill\MainBundle\Test\RandomUserTrait;
|
||||
use Chill\WopiBundle\Service\Wopi\ChillDocumentLockManager;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\Clock\MockClock;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -27,14 +32,31 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
use RandomUserTrait;
|
||||
|
||||
private MockClock $clock;
|
||||
|
||||
/**
|
||||
* @var ObjectProphecy<Security>
|
||||
*/
|
||||
private ObjectProphecy $security;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get('doctrine.orm.entity_manager');
|
||||
$this->security = $this->prophesize(Security::class);
|
||||
$this->clock = new MockClock();
|
||||
}
|
||||
|
||||
public function testRelock()
|
||||
{
|
||||
$user = $this->getRandomUser($this->em);
|
||||
$this->security->getUser()->willReturn($user);
|
||||
$manager = $this->makeManager(1);
|
||||
|
||||
$document = new StoredObject();
|
||||
$request = $this->prophesize(RequestInterface::class);
|
||||
|
||||
@@ -50,15 +72,22 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
|
||||
|
||||
$this->assertTrue($manager->deleteLock($document, $request->reveal()));
|
||||
|
||||
sleep(3); // wait for redis to remove the key
|
||||
$this->clock->sleep(10);
|
||||
|
||||
$this->assertFalse($manager->hasLock($document, $request->reveal()));
|
||||
$this->em->remove($document);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
public function testSingleLock()
|
||||
{
|
||||
$user = $this->getRandomUser($this->em);
|
||||
$this->security->getUser()->willReturn($user);
|
||||
$manager = $this->makeManager(1);
|
||||
$document = new StoredObject();
|
||||
$this->em->persist($document);
|
||||
$this->em->flush();
|
||||
|
||||
$request = $this->prophesize(RequestInterface::class);
|
||||
|
||||
$this->assertFalse($manager->hasLock($document, $request->reveal()));
|
||||
@@ -69,15 +98,22 @@ final class ChillDocumentLockManagerTest extends KernelTestCase
|
||||
|
||||
$this->assertTrue($manager->deleteLock($document, $request->reveal()));
|
||||
|
||||
sleep(3); // wait for redis to remove the key
|
||||
$this->clock->sleep(10);
|
||||
|
||||
$this->assertFalse($manager->hasLock($document, $request->reveal()));
|
||||
|
||||
$this->em->remove($document);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function makeManager(int $ttlAfterDeleteSeconds = -1): ChillDocumentLockManager
|
||||
{
|
||||
$redis = self::getContainer()->get(ChillRedis::class);
|
||||
$storedObjectLockManager = new StoredObjectLockManager($this->em, $this->clock);
|
||||
|
||||
return new ChillDocumentLockManager($redis, $ttlAfterDeleteSeconds);
|
||||
if (-1 !== $ttlAfterDeleteSeconds) {
|
||||
return new ChillDocumentLockManager($this->security->reveal(), $this->clock, $storedObjectLockManager, $ttlAfterDeleteSeconds);
|
||||
}
|
||||
|
||||
return new ChillDocumentLockManager($this->security->reveal(), $this->clock, $storedObjectLockManager);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user