diff --git a/app/modules/web/Controllers/PublicLink/SaveCreateFromAccountController.php b/app/modules/web/Controllers/PublicLink/SaveCreateFromAccountController.php index 52158f30..c48faf83 100644 --- a/app/modules/web/Controllers/PublicLink/SaveCreateFromAccountController.php +++ b/app/modules/web/Controllers/PublicLink/SaveCreateFromAccountController.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -24,7 +24,6 @@ namespace SP\Modules\Web\Controllers\PublicLink; - use Exception; use SP\Core\Acl\ActionsInterface; use SP\Core\Events\Event; @@ -79,4 +78,4 @@ final class SaveCreateFromAccountController extends PublicLinkSaveBase return $this->returnJsonResponseException($e); } } -} \ No newline at end of file +} diff --git a/lib/SP/Core/Context/ContextBase.php b/lib/SP/Core/Context/ContextBase.php index 709f1e10..f122b26b 100644 --- a/lib/SP/Core/Context/ContextBase.php +++ b/lib/SP/Core/Context/ContextBase.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -53,7 +53,7 @@ abstract class ContextBase implements ContextInterface * * @throws ContextException */ - public function setTrasientKey(string $key, $value) + public function setTrasientKey(string $key, mixed $value) { // If the key starts with "_" it's a protected key, thus cannot be overwritten if (str_starts_with($key, '_') @@ -72,7 +72,7 @@ abstract class ContextBase implements ContextInterface * Gets an arbitrary key from the trasient collection. * This key is not bound to any known method or type */ - public function getTrasientKey(string $key, $default = null) + public function getTrasientKey(string $key, mixed $default = null): mixed { return is_numeric($default) ? (int)$this->trasient->get($key, $default) diff --git a/lib/SP/Core/Context/ContextInterface.php b/lib/SP/Core/Context/ContextInterface.php index 7e688a14..ecc5a942 100644 --- a/lib/SP/Core/Context/ContextInterface.php +++ b/lib/SP/Core/Context/ContextInterface.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -34,6 +34,8 @@ use SP\Domain\User\Services\UserLoginResponse; */ interface ContextInterface { + const MASTER_PASSWORD_KEY = '_masterpass'; + /** * @throws ContextException */ @@ -122,18 +124,18 @@ interface ContextInterface * * @throws ContextException */ - public function setTrasientKey(string $key, $value); + public function setTrasientKey(string $key, mixed $value); /** * Gets an arbitrary key from the trasient collection. * This key is not bound to any known method or type * * @param string $key - * @param mixed $default + * @param mixed|null $default * * @return mixed */ - public function getTrasientKey(string $key, $default = null); + public function getTrasientKey(string $key, mixed $default = null): mixed; /** * Sets a temporary master password @@ -145,7 +147,7 @@ interface ContextInterface * @param string $key * @param mixed $value */ - public function setPluginKey(string $pluginName, string $key, $value); + public function setPluginKey(string $pluginName, string $key, mixed $value); public function getPluginKey(string $pluginName, string $key): mixed; } diff --git a/lib/SP/Core/Context/SessionContext.php b/lib/SP/Core/Context/SessionContext.php index c45c3238..4a539081 100644 --- a/lib/SP/Core/Context/SessionContext.php +++ b/lib/SP/Core/Context/SessionContext.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -530,7 +530,7 @@ class SessionContext extends ContextBase * * @return mixed */ - public function setPluginKey(string $pluginName, string $key, $value) + public function setPluginKey(string $pluginName, string $key, mixed $value) { /** @var ContextCollection $ctxKey */ $ctxKey = $this->getContextKey($pluginName, new ContextCollection()); diff --git a/lib/SP/Core/Context/StatelessContext.php b/lib/SP/Core/Context/StatelessContext.php index cf6ca883..fb78a840 100644 --- a/lib/SP/Core/Context/StatelessContext.php +++ b/lib/SP/Core/Context/StatelessContext.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -227,7 +227,7 @@ class StatelessContext extends ContextBase * * @return mixed */ - public function setPluginKey(string $pluginName, string $key, $value) + public function setPluginKey(string $pluginName, string $key, mixed $value): mixed { $ctxKey = $this->getContextKey('plugins'); diff --git a/lib/SP/Core/Crypt/Vault.php b/lib/SP/Core/Crypt/Vault.php index ad435516..71662e51 100644 --- a/lib/SP/Core/Crypt/Vault.php +++ b/lib/SP/Core/Crypt/Vault.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2021, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -24,7 +24,6 @@ namespace SP\Core\Crypt; -use Defuse\Crypto\Exception\CryptoException; use RuntimeException; /** @@ -34,35 +33,46 @@ use RuntimeException; */ final class Vault { - private ?string $data = null; - private ?string $key = null; - private int $timeSet = 0; - private int $timeUpdated = 0; + private ?string $data = null; + private ?string $key = null; + private int $timeSet = 0; - public static function getInstance(): Vault + private function __construct(private CryptInterface $crypt) {} + + public static function factory(CryptInterface $crypt): Vault { - return new self(); + return new self($crypt); } /** - * Regenerar la clave de sesión + * Re-key this vault * - * @throws CryptoException + * @throws \SP\Core\Exceptions\CryptException */ public function reKey(string $newSeed, string $oldSeed): Vault { - $this->timeUpdated = time(); - $sessionMPass = $this->getData($oldSeed); - - $this->saveData($sessionMPass, $newSeed); - - return $this; + return $this->saveData($this->getData($oldSeed), $newSeed); } /** - * Devolver la clave maestra de la sesión + * Create a new vault with the saved data * - * @throws CryptoException + * @throws \SP\Core\Exceptions\CryptException + */ + public function saveData($data, string $key): Vault + { + $vault = new Vault($this->crypt); + $vault->timeSet = time(); + $vault->key = $this->crypt->makeSecuredKey($key); + $vault->data = $this->crypt->encrypt($data, $vault->key, $key); + + return $vault; + } + + /** + * Get the data decrypted + * + * @throws \SP\Core\Exceptions\CryptException */ public function getData(string $key): string { @@ -70,41 +80,24 @@ final class Vault throw new RuntimeException('Either data or key must be set'); } - return Crypt::decrypt($this->data, $this->key, $key); + return $this->crypt->decrypt($this->data, $this->key, $key); } /** - * Guardar la clave maestra en la sesión - * - * @throws CryptoException - */ - public function saveData($data, string $key): Vault - { - if ($this->timeSet === 0) { - $this->timeSet = time(); - } - - $this->key = Crypt::makeSecuredKey($key); - $this->data = Crypt::encrypt($data, $this->key, $key); - - return $this; - } - - public function getTimeSet(): int - { - return $this->timeSet; - } - - public function getTimeUpdated(): int - { - return $this->timeUpdated; - } - - /** - * Serializaes the current object + * Serialize the current vault */ public function getSerialized(): string { return serialize($this); } -} \ No newline at end of file + + /** + * Get the last time the key and data were set + * + * @return int + */ + public function getTimeSet(): int + { + return $this->timeSet; + } +} diff --git a/lib/SP/DataModel/PublicLinkListData.php b/lib/SP/DataModel/PublicLinkListData.php index 6c0ecedb..2d8de7d2 100644 --- a/lib/SP/DataModel/PublicLinkListData.php +++ b/lib/SP/DataModel/PublicLinkListData.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -31,9 +31,9 @@ namespace SP\DataModel; */ class PublicLinkListData extends PublicLinkData { - protected ?string $userName; - protected ?string $userLogin; - protected ?string $accountName; + protected ?string $userName = null; + protected ?string $userLogin = null; + protected ?string $accountName = null; public function getName(): ?string { diff --git a/lib/SP/Domain/Account/Services/PublicLinkService.php b/lib/SP/Domain/Account/Services/PublicLinkService.php index 1c054972..a6a3f1eb 100644 --- a/lib/SP/Domain/Account/Services/PublicLinkService.php +++ b/lib/SP/Domain/Account/Services/PublicLinkService.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -27,7 +27,7 @@ namespace SP\Domain\Account\Services; use Defuse\Crypto\Exception\CryptoException; use Defuse\Crypto\Exception\EnvironmentIsBrokenException; use SP\Core\Application; -use SP\Core\Crypt\Crypt; +use SP\Core\Crypt\CryptInterface; use SP\Core\Crypt\Vault; use SP\Core\Exceptions\ConstraintException; use SP\Core\Exceptions\QueryException; @@ -38,6 +38,7 @@ use SP\DataModel\PublicLinkListData; use SP\Domain\Account\Ports\AccountServiceInterface; use SP\Domain\Account\Ports\PublicLinkRepositoryInterface; use SP\Domain\Account\Ports\PublicLinkServiceInterface; +use SP\Domain\Common\Models\Simple; use SP\Domain\Common\Services\Service; use SP\Domain\Common\Services\ServiceException; use SP\Domain\Common\Services\ServiceItemTrait; @@ -62,21 +63,14 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf */ public const TYPE_ACCOUNT = 1; - private PublicLinkRepositoryInterface $publicLinkRepository; - private RequestInterface $request; - private AccountServiceInterface $accountService; - public function __construct( Application $application, - PublicLinkRepositoryInterface $publicLinkRepository, - RequestInterface $request, - AccountServiceInterface $accountService + private PublicLinkRepositoryInterface $publicLinkRepository, + private RequestInterface $request, + private AccountServiceInterface $accountService, + private CryptInterface $crypt ) { parent::__construct($application); - - $this->publicLinkRepository = $publicLinkRepository; - $this->request = $request; - $this->accountService = $accountService; } /** @@ -95,11 +89,6 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf return hash('sha256', uniqid('sysPassPublicLink', true)); } - public static function getKeyForHash(string $salt, PublicLinkData $publicLinkData): string - { - return sha1($salt.$publicLinkData->getHash()); - } - /** * @param \SP\DataModel\ItemSearchData $itemSearchData * @@ -127,22 +116,15 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf throw new NoSuchItemException(__u('Link not found')); } - $key = $this->getPublicLinkKey(); - - /** @var PublicLinkData $publicLinkData */ - $publicLinkData = $result->getData(); - $publicLinkData->setHash($key->getHash()); - $publicLinkData->setData($this->getSecuredLinkData($publicLinkData->getItemId(), $key)); - $publicLinkData->setDateExpire(self::calcDateExpire($this->config)); - $publicLinkData->setMaxCountViews($this->config->getConfigData()->getPublinksMaxViews()); - - return $this->publicLinkRepository->refresh($publicLinkData); + return $this->publicLinkRepository + ->refresh($this->buildPublicLink(PublicLinkData::buildFromSimpleModel($result->getData()))); } /** * @param int $id * * @return \SP\DataModel\PublicLinkListData + * @throws \SP\Core\Exceptions\SPException * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException */ public function getById(int $id): PublicLinkListData @@ -153,7 +135,36 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf throw new NoSuchItemException(__u('Link not found')); } - return $result->getData(); + return PublicLinkListData::buildFromSimpleModel($result->getData()); + } + + /** + * @param \SP\DataModel\PublicLinkData $publicLinkData + * + * @return \SP\DataModel\PublicLinkData + * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\CryptException + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Domain\Common\Services\ServiceException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + */ + private function buildPublicLink(PublicLinkData $publicLinkData): PublicLinkData + { + $key = $this->getPublicLinkKey(); + + $publicLinkDataClone = clone $publicLinkData; + + $publicLinkDataClone->setHash($key->getHash()); + $publicLinkDataClone->setData($this->getSecuredLinkData($publicLinkDataClone->getItemId(), $key)); + $publicLinkDataClone->setDateExpire(self::calcDateExpire($this->config)); + $publicLinkDataClone->setMaxCountViews($this->config->getConfigData()->getPublinksMaxViews()); + + if ($publicLinkDataClone->getUserId() === null) { + $publicLinkDataClone->setUserId($this->context->getUserData()->getId()); + } + + return $publicLinkDataClone; } /** @@ -170,28 +181,30 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf /** * Obtener los datos de una cuenta y encriptarlos para el enlace * - * @throws \Defuse\Crypto\Exception\CryptoException + * @param int $itemId + * @param \SP\Domain\Account\Services\PublicLinkKey $key + * + * @return string * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\CryptException * @throws \SP\Core\Exceptions\QueryException - * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException * @throws \SP\Domain\Common\Services\ServiceException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException */ private function getSecuredLinkData(int $itemId, PublicLinkKey $key): string { - // Obtener los datos de la cuenta $accountData = $this->accountService->getDataForLink($itemId); - // Desencriptar la clave de la cuenta - $accountData->setPass( - Crypt::decrypt( - $accountData->getPass(), - $accountData->getKey(), + $accountDataClone = $accountData->mutate([ + 'pass' => $this->crypt->decrypt( + $accountData['pass'], + $accountData['key'], $this->getMasterKeyFromContext() - ) - ); - $accountData->setKey(null); + ), + 'key' => null, + ]); - return (new Vault())->saveData(serialize($accountData), $key->getKey())->getSerialized(); + return Vault::factory($this->crypt)->saveData(serialize($accountDataClone), $key->getKey())->getSerialized(); } /** @@ -244,25 +257,15 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf */ public function create(PublicLinkData $itemData): int { - $key = $this->getPublicLinkKey(); - - $itemData->setHash($key->getHash()); - $itemData->setData($this->getSecuredLinkData($itemData->getItemId(), $key)); - $itemData->setDateExpire(self::calcDateExpire($this->config)); - $itemData->setMaxCountViews($this->config->getConfigData()->getPublinksMaxViews()); - $itemData->setUserId($this->context->getUserData()->getId()); - - return $this->publicLinkRepository->create($itemData)->getLastId(); + return $this->publicLinkRepository->create($this->buildPublicLink($itemData))->getLastId(); } /** - * Get all items from the service's repository - * - * @return PublicLinkListData[] + * @throws \SP\Core\Exceptions\SPException */ public function getAllBasic(): array { - return $this->publicLinkRepository->getAll()->getDataAsArray(); + throw new ServiceException(__u('Not implemented')); } /** @@ -272,15 +275,30 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf * * @throws \SP\Core\Exceptions\ConstraintException * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Domain\Common\Services\ServiceException */ public function addLinkView(PublicLinkData $publicLinkData): void { - /** @var array $useInfo */ - $useInfo = unserialize($publicLinkData->getUseInfo(), false); - $useInfo[] = self::getUseInfo($publicLinkData->getHash(), $this->request); - $publicLinkData->setUseInfo($useInfo); + $useInfo = array(); - $this->publicLinkRepository->addLinkView($publicLinkData); + if (empty($publicLinkData->getHash())) { + throw new ServiceException(__u('Public link hash not set')); + } + + if (!empty($publicLinkData->getUseInfo())) { + $publicLinkUseInfo = unserialize($publicLinkData->getUseInfo(), ['allowed_classes' => false]); + + if (is_array($publicLinkUseInfo)) { + $useInfo = $publicLinkUseInfo; + } + } + + $useInfo[] = self::getUseInfo($publicLinkData->getHash(), $this->request); + + $publicLinkDataClone = clone $publicLinkData; + $publicLinkDataClone->setUseInfo($useInfo); + + $this->publicLinkRepository->addLinkView($publicLinkDataClone); } /** @@ -308,7 +326,7 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf throw new NoSuchItemException(__u('Link not found')); } - return $result->getData(); + return PublicLinkData::buildFromSimpleModel($result->getData(Simple::class)); } /** @@ -317,6 +335,7 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf * @param int $itemId * * @return \SP\DataModel\PublicLinkData + * @throws \SP\Core\Exceptions\SPException * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException */ public function getHashForItem(int $itemId): PublicLinkData @@ -327,7 +346,7 @@ final class PublicLinkService extends Service implements PublicLinkServiceInterf throw new NoSuchItemException(__u('Link not found')); } - return $result->getData(); + return PublicLinkData::buildFromSimpleModel($result->getData(Simple::class)); } /** diff --git a/lib/SP/Domain/Common/Models/Model.php b/lib/SP/Domain/Common/Models/Model.php index d33ab0ca..925e89e7 100644 --- a/lib/SP/Domain/Common/Models/Model.php +++ b/lib/SP/Domain/Common/Models/Model.php @@ -29,11 +29,10 @@ use JsonSerializable; /** * Class DataModel */ -abstract class Model implements JsonSerializable +abstract class Model implements JsonSerializable, \ArrayAccess { - private ?array $fields = null; /** - * Dynamically declared properties must not be class' properties + * Dynamically declared properties. Must not be class' properties */ private array $properties = []; @@ -100,8 +99,6 @@ abstract class Model implements JsonSerializable $fields = array_diff_key($fields, array_flip($filter)); } - $this->fields = array_keys($fields); - return $fields; } @@ -168,24 +165,21 @@ abstract class Model implements JsonSerializable return $this->toArray(); } - public function getFields(): ?array - { - return $this->fields; - } - /** + * Get non-class properties + * * @param string $name * * @return void */ public function __get(string $name) { - if (array_key_exists($name, $this->properties)) { - return $this->properties[$name]; - } + $this->offsetGet($name); } /** + * Set non-class properties + * * @param string $name * @param $value * @@ -193,6 +187,62 @@ abstract class Model implements JsonSerializable */ public function __set(string $name, $value): void { - $this->properties[$name] = $value; + $this->offsetSet($name, $value); + } + + /** + * Get non-class properties + * + * @param mixed $offset + * + * @return mixed + */ + public function offsetGet(mixed $offset): mixed + { + return $this->properties[$offset]; + } + + /** + * Set non-class properties + * + * @param mixed $offset + * @param mixed $value + * + * @return void + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->properties[$offset] = $value; + } + + /** + * Whether an offset exists in non-class properties + * + * @link https://php.net/manual/en/arrayaccess.offsetexists.php + * + * @param mixed $offset

+ * An offset to check for. + *

+ * + * @return bool true on success or false on failure. + *

+ *

+ * The return value will be casted to boolean if non-boolean was returned. + */ + public function offsetExists(mixed $offset): bool + { + return array_key_exists($offset, $this->properties); + } + + /** + * Unset a non-class property + * + * @param mixed $offset + * + * @return void + */ + public function offsetUnset(mixed $offset): void + { + unset($this->properties[$offset]); } } diff --git a/lib/SP/Domain/Common/Services/Service.php b/lib/SP/Domain/Common/Services/Service.php index 5675918a..64c4226d 100644 --- a/lib/SP/Domain/Common/Services/Service.php +++ b/lib/SP/Domain/Common/Services/Service.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -64,7 +64,7 @@ abstract class Service if ($this->context instanceof SessionContext) { $key = Session::getSessionKey($this->context); } else { - $key = $this->context->getTrasientKey('_masterpass'); + $key = $this->context->getTrasientKey(ContextInterface::MASTER_PASSWORD_KEY); } if (empty($key)) { diff --git a/lib/SP/Infrastructure/Account/Repositories/PublicLinkRepository.php b/lib/SP/Infrastructure/Account/Repositories/PublicLinkRepository.php index c2c492ef..9ebf790a 100644 --- a/lib/SP/Infrastructure/Account/Repositories/PublicLinkRepository.php +++ b/lib/SP/Infrastructure/Account/Repositories/PublicLinkRepository.php @@ -29,6 +29,7 @@ use SP\Core\Exceptions\QueryException; use SP\Core\Exceptions\SPException; use SP\DataModel\ItemSearchData; use SP\DataModel\PublicLinkData; +use SP\Domain\Account\Ports\PublicLinkRepositoryInterface; use SP\Infrastructure\Common\Repositories\DuplicatedItemException; use SP\Infrastructure\Common\Repositories\Repository; use SP\Infrastructure\Common\Repositories\RepositoryItemTrait; @@ -41,7 +42,7 @@ use function SP\__u; * * @package SP\Infrastructure\Common\Repositories\PublicLink */ -final class PublicLinkRepository extends Repository implements \SP\Domain\Account\Ports\PublicLinkRepositoryInterface +final class PublicLinkRepository extends Repository implements PublicLinkRepositoryInterface { use RepositoryItemTrait; diff --git a/lib/SP/Infrastructure/Database/QueryResult.php b/lib/SP/Infrastructure/Database/QueryResult.php index 811a3368..6e5aeb79 100644 --- a/lib/SP/Infrastructure/Database/QueryResult.php +++ b/lib/SP/Infrastructure/Database/QueryResult.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -94,7 +94,7 @@ class QueryResult private function checkDataType(?string $dataType = null): void { if (null !== $dataType && $this->dataType !== null && $dataType !== $this->dataType) { - throw new SPException(sprintf(__u('Invalid data\'s type: %s - Expected: %s'), $dataType, $this->dataType)); + throw new SPException(sprintf(__u('Invalid data\'s type: %s - Current: %s'), $dataType, $this->dataType)); } } diff --git a/tests/SP/Domain/Account/Services/PublicLinkServiceTest.php b/tests/SP/Domain/Account/Services/PublicLinkServiceTest.php new file mode 100644 index 00000000..b98aa7c1 --- /dev/null +++ b/tests/SP/Domain/Account/Services/PublicLinkServiceTest.php @@ -0,0 +1,582 @@ +. + */ + +namespace SP\Tests\Domain\Account\Services; + +use PHPUnit\Framework\Constraint\Callback; +use PHPUnit\Framework\MockObject\MockObject; +use SP\Core\Context\ContextInterface; +use SP\Core\Crypt\CryptInterface; +use SP\DataModel\ItemSearchData; +use SP\DataModel\PublicLinkData; +use SP\Domain\Account\Ports\AccountServiceInterface; +use SP\Domain\Account\Ports\PublicLinkRepositoryInterface; +use SP\Domain\Account\Services\PublicLinkService; +use SP\Domain\Common\Models\Simple; +use SP\Domain\Common\Services\ServiceException; +use SP\Http\RequestInterface; +use SP\Infrastructure\Common\Repositories\NoSuchItemException; +use SP\Infrastructure\Database\QueryResult; +use SP\Tests\Generators\PublicLinkDataGenerator; +use SP\Tests\UnitaryTestCase; + +/** + * Class PublicLinkServiceTest + * + * @group unitary + */ +class PublicLinkServiceTest extends UnitaryTestCase +{ + + private PublicLinkRepositoryInterface|MockObject $publicLinkRepository; + private RequestInterface|MockObject $request; + private MockObject|PublicLinkService $publicLinkService; + private CryptInterface|MockObject $crypt; + private MockObject|AccountServiceInterface $accountService; + + /** + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Common\Services\ServiceException + */ + public function testAddLinkView() + { + $publicLinkData = new PublicLinkData(); + $publicLinkData->setHash(self::$faker->sha1); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('addLinkView') + ->with( + new Callback(function (PublicLinkData $publicLinkData) { + $useInfo = unserialize($publicLinkData->getUseInfo(), ['allowed_classes' => false]); + + return is_array($useInfo) && count($useInfo) === 1; + }) + ); + + $this->publicLinkService->addLinkView($publicLinkData); + } + + /** + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Common\Services\ServiceException + */ + public function testAddLinkViewWithoutHash() + { + $publicLinkData = new PublicLinkData(); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Public link hash not set'); + + $this->publicLinkService->addLinkView($publicLinkData); + } + + /** + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Common\Services\ServiceException + */ + public function testAddLinkViewWithUseInfo() + { + $publicLinkData = new PublicLinkData(); + $publicLinkData->setHash(self::$faker->sha1); + $publicLinkData->setUseInfo([ + [ + 'who' => self::$faker->ipv4, + 'time' => time(), + 'hash' => self::$faker->sha1, + 'agent' => self::$faker->userAgent, + 'https' => self::$faker->boolean, + ], + ]); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('addLinkView') + ->with( + new Callback(function (PublicLinkData $publicLinkData) { + $useInfo = unserialize($publicLinkData->getUseInfo(), ['allowed_classes' => false]); + + return is_array($useInfo) && count($useInfo) === 2; + }) + ); + + $this->publicLinkService->addLinkView($publicLinkData); + } + + /** + * @throws \SP\Core\Exceptions\SPException + */ + public function testGetByHash() + { + $hash = self::$faker->sha1; + $publicLink = PublicLinkDataGenerator::factory()->buildPublicLink(); + $result = new QueryResult([new Simple($publicLink->toArray())]); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getByHash') + ->with($hash) + ->willReturn($result); + + $actual = $this->publicLinkService->getByHash($hash); + + $this->assertEquals($publicLink, $actual); + } + + /** + * @throws \SP\Core\Exceptions\SPException + */ + public function testGetByHashNotFound() + { + $hash = self::$faker->sha1; + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getByHash') + ->with($hash) + ->willReturn(new QueryResult([])); + + $this->expectException(NoSuchItemException::class); + $this->expectExceptionMessage('Link not found'); + + $this->publicLinkService->getByHash($hash); + } + + /** + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Common\Services\ServiceException + */ + public function testDeleteByIdBatch() + { + $ids = array_map(fn() => self::$faker->randomNumber(), range(0, 9)); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('deleteByIdBatch') + ->with($ids) + ->willReturn(10); + + $actual = $this->publicLinkService->deleteByIdBatch($ids); + + $this->assertEquals(count($ids), $actual); + } + + /** + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Domain\Common\Services\ServiceException + */ + public function testDeleteByIdBatchWithCountMismatch() + { + $ids = array_map(fn() => self::$faker->randomNumber(), range(0, 9)); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('deleteByIdBatch') + ->with($ids) + ->willReturn(1); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Error while removing the links'); + + $this->publicLinkService->deleteByIdBatch($ids); + } + + public function testCreateLinkHash() + { + $this->assertNotEmpty(PublicLinkService::createLinkHash()); + } + + /** + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\SPException + */ + public function testUpdate() + { + $publicLinkList = PublicLinkDataGenerator::factory()->buildPublicLinkList(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('update') + ->with($publicLinkList); + + $this->publicLinkService->update($publicLinkList); + } + + /** + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\QueryException + */ + public function testDelete() + { + $id = self::$faker->randomNumber(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('delete') + ->with($id); + + $this->publicLinkService->delete($id); + } + + public function testSearch() + { + $itemSearchData = new ItemSearchData(self::$faker->colorName); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('search') + ->with($itemSearchData); + + $this->publicLinkService->search($itemSearchData); + } + + /** + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + * @throws \SP\Core\Exceptions\SPException + */ + public function testGetHashForItem() + { + $itemId = self::$faker->randomNumber(); + $publicLinkData = PublicLinkDataGenerator::factory()->buildPublicLink(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getHashForItem') + ->with($itemId) + ->willReturn(new QueryResult([new Simple($publicLinkData->toArray())])); + + $actual = $this->publicLinkService->getHashForItem($itemId); + + $this->assertEquals($publicLinkData, $actual); + } + + /** + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + * @throws \SP\Core\Exceptions\SPException + */ + public function testGetHashForItemNotFound() + { + $itemId = self::$faker->randomNumber(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getHashForItem') + ->with($itemId) + ->willReturn(new QueryResult([])); + + $this->expectException(NoSuchItemException::class); + $this->expectExceptionMessage('Link not found'); + + $this->publicLinkService->getHashForItem($itemId); + } + + /** + * @throws \Defuse\Crypto\Exception\CryptoException + * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Domain\Common\Services\ServiceException + * @throws \SP\Core\Exceptions\SPException + */ + public function testRefresh() + { + $id = self::$faker->randomNumber(); + $publicLinkData = PublicLinkDataGenerator::factory()->buildPublicLink(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getById') + ->with($id) + ->willReturn(new QueryResult([new Simple($publicLinkData->toArray())])); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('refresh') + ->with( + new Callback(function (PublicLinkData $actual) use ($publicLinkData) { + $filter = ['hash', 'dateExpire', 'maxCountViews', 'data']; + + return $actual->toArray(null, $filter) === $publicLinkData->toArray(null, $filter) + && !empty($actual->getHash()) + && !empty($actual->getDateExpire()) + && !empty($actual->getMaxCountViews()) + && !empty($actual->getData()); + }) + ) + ->willReturn(true); + + $passData = ['pass' => self::$faker->password, 'key' => self::$faker->sha1]; + + $this->accountService + ->expects(self::once()) + ->method('getDataForLink') + ->with($publicLinkData->getItemId()) + ->willReturn(new Simple($passData)); + + $this->crypt + ->expects(self::once()) + ->method('decrypt') + ->with( + $passData['pass'], + $passData['key'], + $this->context->getTrasientKey(ContextInterface::MASTER_PASSWORD_KEY) + ) + ->willReturn(self::$faker->password); + + $actual = $this->publicLinkService->refresh($id); + + $this->assertTrue($actual); + } + + /** + * @throws \Defuse\Crypto\Exception\CryptoException + * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Domain\Common\Services\ServiceException + * @throws \SP\Core\Exceptions\SPException + */ + public function testRefreshNotFound() + { + $id = self::$faker->randomNumber(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getById') + ->with($id) + ->willReturn(new QueryResult([])); + + $this->expectException(NoSuchItemException::class); + $this->expectExceptionMessage('Link not found'); + + $this->publicLinkService->refresh($id); + } + + /** + * @return void + * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException + */ + public function testGetPublicLinkKey() + { + $hash = self::$faker->sha1; + + $actual = $this->publicLinkService->getPublicLinkKey($hash); + + $this->assertEquals($hash, $actual->getHash()); + $this->assertNotEmpty($actual->getKey()); + } + + /** + * @return void + * @throws \Defuse\Crypto\Exception\EnvironmentIsBrokenException + */ + public function testGetPublicLinkKeyWithoutHash() + { + $actual = $this->publicLinkService->getPublicLinkKey(); + + $this->assertNotEmpty($actual->getHash()); + $this->assertNotEmpty($actual->getKey()); + } + + /** + * @return void + * @throws \SP\Core\Exceptions\SPException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + */ + public function testGetById() + { + $itemId = self::$faker->randomNumber(); + $builPublicLinkList = PublicLinkDataGenerator::factory()->buildPublicLinkList(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getById') + ->with($itemId) + ->willReturn(new QueryResult([new Simple($builPublicLinkList->toArray())])); + + $actual = $this->publicLinkService->getById($itemId); + + $this->assertEquals($builPublicLinkList->toArray(null, ['clientName']), $actual->toArray()); + } + + /** + * @return void + * @throws \SP\Core\Exceptions\SPException + * @throws \SP\Infrastructure\Common\Repositories\NoSuchItemException + */ + public function testGetByIdNotFound() + { + $itemId = self::$faker->randomNumber(); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('getById') + ->with($itemId) + ->willReturn(new QueryResult([])); + + $this->expectException(NoSuchItemException::class); + $this->expectExceptionMessage('Link not found'); + + $this->publicLinkService->getById($itemId); + } + + /** + * @throws \SP\Core\Exceptions\SPException + */ + public function testGetAllBasic() + { + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Not implemented'); + + $this->publicLinkService->getAllBasic(); + } + + public function testGetUseInfo() + { + $hash = self::$faker->sha1; + $who = self::$faker->ipv4; + $userAgent = self::$faker->userAgent; + + $request = $this->createMock(RequestInterface::class); + + $request->expects(self::once()) + ->method('getClientAddress') + ->with(true) + ->willReturn($who); + + $request->expects(self::once()) + ->method('getHeader') + ->with('User-Agent') + ->willReturn($userAgent); + + $request->expects(self::once()) + ->method('isHttps') + ->willReturn(true); + + $actual = PublicLinkService::getUseInfo($hash, $request); + + $this->assertArrayHasKey('who', $actual); + $this->assertArrayHasKey('time', $actual); + $this->assertArrayHasKey('hash', $actual); + $this->assertArrayHasKey('agent', $actual); + $this->assertArrayHasKey('https', $actual); + $this->assertEquals($who, $actual['who']); + $this->assertEquals($hash, $actual['hash']); + $this->assertEquals($userAgent, $actual['agent']); + $this->assertTrue($actual['https']); + } + + /** + * @return void + * @throws \Defuse\Crypto\Exception\CryptoException + * @throws \SP\Core\Exceptions\ConstraintException + * @throws \SP\Core\Exceptions\QueryException + * @throws \SP\Core\Exceptions\SPException + */ + public function testCreate() + { + $publicLinkData = PublicLinkDataGenerator::factory()->buildPublicLink(); + $result = new QueryResult(); + $result->setLastId(self::$faker->randomNumber()); + + $this->publicLinkRepository + ->expects(self::once()) + ->method('create') + ->with( + new Callback(function (PublicLinkData $actual) use ($publicLinkData) { + $filter = ['hash', 'dateExpire', 'maxCountViews', 'data']; + + return $actual->toArray(null, $filter) === $publicLinkData->toArray(null, $filter) + && !empty($actual->getHash()) + && !empty($actual->getDateExpire()) + && !empty($actual->getMaxCountViews()) + && !empty($actual->getData()); + }) + ) + ->willReturn($result); + + $passData = ['pass' => self::$faker->password, 'key' => self::$faker->sha1]; + + $this->accountService + ->expects(self::once()) + ->method('getDataForLink') + ->with($publicLinkData->getItemId()) + ->willReturn(new Simple($passData)); + + $this->crypt + ->expects(self::once()) + ->method('decrypt') + ->with( + $passData['pass'], + $passData['key'], + $this->context->getTrasientKey(ContextInterface::MASTER_PASSWORD_KEY) + ) + ->willReturn(self::$faker->password); + + $actual = $this->publicLinkService->create($publicLinkData); + + $this->assertEquals($result->getLastId(), $actual); + } + + public function testCalcDateExpire() + { + $expireDate = time() + $this->config->getConfigData()->getPublinksMaxTime(); + + $this->assertEqualsWithDelta($expireDate, PublicLinkService::calcDateExpire($this->config), 2); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->publicLinkRepository = $this->createMock(PublicLinkRepositoryInterface::class); + $this->request = $this->createMock(RequestInterface::class); + $this->request->method('getClientAddress') + ->willReturn(self::$faker->ipv4); + $this->request->method('getHeader') + ->willReturn(self::$faker->userAgent); + $this->request->method('isHttps') + ->willReturn(self::$faker->boolean); + + $this->accountService = $this->createMock(AccountServiceInterface::class); + $this->crypt = $this->createMock(CryptInterface::class); + + $this->publicLinkService = + new PublicLinkService( + $this->application, + $this->publicLinkRepository, + $this->request, + $this->accountService, + $this->crypt + ); + } +} diff --git a/tests/SP/Generators/PublicLinkDataGenerator.php b/tests/SP/Generators/PublicLinkDataGenerator.php new file mode 100644 index 00000000..3514233a --- /dev/null +++ b/tests/SP/Generators/PublicLinkDataGenerator.php @@ -0,0 +1,85 @@ +. + */ + +namespace SP\Tests\Generators; + +use SP\DataModel\PublicLinkData; +use SP\DataModel\PublicLinkListData; + +/** + * Class PublicLinkDataGenerator + */ +final class PublicLinkDataGenerator extends DataGenerator +{ + public function buildPublicLink(): PublicLinkData + { + return new PublicLinkData($this->getPublicLinkProperties()); + } + + private function getPublicLinkProperties(): array + { + return [ + 'id' => $this->faker->randomNumber(), + 'itemId' => $this->faker->randomNumber(), + 'hash' => $this->faker->randomNumber(), + 'userId' => $this->faker->randomNumber(), + 'typeId' => $this->faker->randomNumber(), + 'notify' => $this->faker->boolean, + 'dateAdd' => $this->faker->unixTime(), + 'dateUpdate' => $this->faker->unixTime(), + 'dateExpire' => $this->faker->unixTime(), + 'countViews' => $this->faker->randomNumber(), + 'totalCountViews' => $this->faker->randomNumber(), + 'maxCountViews' => $this->faker->randomNumber(), + 'useInfo' => serialize($this->getUseInfo()), + 'data' => $this->faker->text, + ]; + } + + private function getUseInfo(): array + { + return array_map( + fn() => [ + 'who' => $this->faker->ipv4, + 'time' => $this->faker->unixTime, + 'hash' => $this->faker->sha1, + 'agent' => $this->faker->userAgent, + 'https' => $this->faker->boolean, + ], + range(0, 9) + ); + } + + public function buildPublicLinkList(): PublicLinkListData + { + return new PublicLinkListData( + array_merge($this->getPublicLinkProperties(), [ + 'userName' => $this->faker->name, + 'userLogin' => $this->faker->userName, + 'accountName' => $this->faker->colorName, + 'clientName' => $this->faker->company, + ]) + ); + } +} diff --git a/tests/SP/UnitaryTestCase.php b/tests/SP/UnitaryTestCase.php index 5ebd53e1..0b20c8a3 100644 --- a/tests/SP/UnitaryTestCase.php +++ b/tests/SP/UnitaryTestCase.php @@ -4,7 +4,7 @@ * * @author nuxsmin * @link https://syspass.org - * @copyright 2012-2022, Rubén Domínguez nuxsmin@$syspass.org + * @copyright 2012-2023, Rubén Domínguez nuxsmin@$syspass.org * * This file is part of sysPass. * @@ -86,6 +86,7 @@ abstract class UnitaryTestCase extends TestCase $this->context->initialize(); $this->context->setUserData($userLogin); $this->context->setUserProfile(new ProfileData()); + $this->context->setTrasientKey(ContextInterface::MASTER_PASSWORD_KEY, self::$faker->password); $configData = ConfigDataGenerator::factory()->buildConfigData();