diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index b2c2c3322d..75f98180a6 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -20,6 +20,8 @@ permissions: jobs: static-analysis: runs-on: ubuntu-latest + env: + APP_NAME: libresign name: static-psalm-analysis steps: @@ -33,7 +35,23 @@ jobs: id: versions uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + - name: Checkout server + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ steps.versions.outputs.branches-max }} + + - name: Checkout app + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: true + path: apps/${{ env.APP_NAME }} + - name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml + working-directory: apps/${{ env.APP_NAME }} run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml - name: Set up php${{ steps.versions.outputs.php-available }} @@ -49,15 +67,19 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies + working-directory: apps/${{ env.APP_NAME }} run: | composer remove nextcloud/ocp --dev --no-scripts composer i - name: Check for vulnerable PHP dependencies + working-directory: apps/${{ env.APP_NAME }} run: composer require --dev roave/security-advisories:dev-latest - name: Install nextcloud/ocp + working-directory: apps/${{ env.APP_NAME }} run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies - name: Run coding standards check + working-directory: apps/${{ env.APP_NAME }} run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github diff --git a/.github/workflows/update-psalm-baseline.yml b/.github/workflows/update-psalm-baseline.yml index 42ff7036f5..a6be088633 100644 --- a/.github/workflows/update-psalm-baseline.yml +++ b/.github/workflows/update-psalm-baseline.yml @@ -15,6 +15,8 @@ on: jobs: update-psalm-baseline: runs-on: ubuntu-latest + env: + APP_NAME: libresign if: ${{ github.repository_owner != 'nextcloud-gmbh' }} @@ -37,6 +39,22 @@ jobs: id: versions uses: icewind1991/nextcloud-version-matrix@8a7bac6300b2f0f3100088b297995a229558ddba # v1.3.2.3.1.3.2 + - name: Checkout server + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: true + repository: nextcloud/server + ref: ${{ steps.versions.outputs.branches-max }} + + - name: Checkout app + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ matrix.branches }} + submodules: true + path: apps/${{ env.APP_NAME }} + - name: Set up php${{ steps.versions.outputs.php-available }} uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: @@ -48,15 +66,24 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies - run: composer i + working-directory: apps/${{ env.APP_NAME }} + run: | + composer remove nextcloud/ocp --dev --no-scripts + composer i + + - name: Install nextcloud/ocp + working-directory: apps/${{ env.APP_NAME }} + run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies - name: Psalm + working-directory: apps/${{ env.APP_NAME }} run: composer run psalm:update-baseline -- --monochrome --no-progress --output-format=text continue-on-error: true - name: Create Pull Request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: + path: apps/${{ env.APP_NAME }} token: ${{ secrets.COMMAND_BOT_PAT }} commit-message: Update psalm baseline committer: GitHub diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7aa3640063..9658c04361 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\AppInfo; -use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadSidebar; use OCA\Libresign\Activity\Listener as ActivityListener; use OCA\Libresign\Capabilities; @@ -66,7 +65,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(SignedEvent::class, SignedCallbackListener::class); // Files newFile listener - $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); + $context->registerEventListener('OCA\\Files\\Event\\LoadAdditionalScriptsEvent', LoadAdditionalListener::class); // Activity listeners $context->registerEventListener(SendSignNotificationEvent::class, ActivityListener::class); diff --git a/lib/Command/Configure/Cfssl.php b/lib/Command/Configure/Cfssl.php index d4154d85e8..928b20f00d 100644 --- a/lib/Command/Configure/Cfssl.php +++ b/lib/Command/Configure/Cfssl.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; class Cfssl extends Base { + #[\Override] protected function configure(): void { $this ->setName('libresign:configure:cfssl') @@ -69,6 +70,7 @@ protected function configure(): void { ); } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { if (!$this->installService->isCfsslBinInstalled()) { throw new InvalidArgumentException('CFSSL binary not found! run libresign:istall --cfssl first.'); diff --git a/lib/Command/Configure/Check.php b/lib/Command/Configure/Check.php index 9847f21bd8..e9752ef36c 100644 --- a/lib/Command/Configure/Check.php +++ b/lib/Command/Configure/Check.php @@ -26,6 +26,7 @@ public function __construct( parent::__construct(); } + #[\Override] protected function configure(): void { $this ->setName('libresign:configure:check') @@ -44,6 +45,7 @@ protected function configure(): void { ); } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $sign = $input->getOption('sign'); $certificate = $input->getOption('certificate'); diff --git a/lib/Command/Configure/OpenSsl.php b/lib/Command/Configure/OpenSsl.php index 09ba70cee3..ec9ce2588d 100644 --- a/lib/Command/Configure/OpenSsl.php +++ b/lib/Command/Configure/OpenSsl.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; class OpenSsl extends Base { + #[\Override] protected function configure(): void { $this ->setName('libresign:configure:openssl') @@ -63,6 +64,7 @@ protected function configure(): void { ); } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $names = []; if (!$commonName = $input->getOption('cn')) { diff --git a/lib/Command/Developer/Reset.php b/lib/Command/Developer/Reset.php index a17a563b77..3432b46ab7 100644 --- a/lib/Command/Developer/Reset.php +++ b/lib/Command/Developer/Reset.php @@ -28,10 +28,12 @@ public function __construct( parent::__construct(); } + #[\Override] public function isEnabled(): bool { return $this->config->getSystemValue('debug', false) === true; } + #[\Override] protected function configure(): void { $this ->setName('libresign:developer:reset') @@ -105,6 +107,7 @@ protected function configure(): void { ; } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $ok = false; diff --git a/lib/Command/Developer/SignSetup.php b/lib/Command/Developer/SignSetup.php index 090a169502..488a756152 100644 --- a/lib/Command/Developer/SignSetup.php +++ b/lib/Command/Developer/SignSetup.php @@ -29,10 +29,12 @@ public function __construct( parent::__construct(); } + #[\Override] public function isEnabled(): bool { return $this->config->getSystemValue('debug', false) === true; } + #[\Override] protected function configure(): void { $this ->setName('libresign:developer:sign-setup') @@ -42,6 +44,7 @@ protected function configure(): void { ; } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $privateKeyPath = $input->getOption('privateKey'); $keyBundlePath = $input->getOption('certificate'); diff --git a/lib/Command/Install.php b/lib/Command/Install.php index c2777a21ee..42e25820f4 100644 --- a/lib/Command/Install.php +++ b/lib/Command/Install.php @@ -26,6 +26,7 @@ public function __construct( parent::__construct($installService, $logger); } + #[\Override] protected function configure(): void { $this ->setName('libresign:install') @@ -82,6 +83,7 @@ protected function configure(): void { } } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $ok = false; $this->installService->setOutput($output); diff --git a/lib/Command/Uninstall.php b/lib/Command/Uninstall.php index 767fc3af7d..2dc2ff36a2 100644 --- a/lib/Command/Uninstall.php +++ b/lib/Command/Uninstall.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Output\OutputInterface; class Uninstall extends Base { + #[\Override] protected function configure(): void { $this ->setName('libresign:uninstall') @@ -55,6 +56,7 @@ protected function configure(): void { ); } + #[\Override] protected function execute(InputInterface $input, OutputInterface $output): int { $ok = false; diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index d419de581f..cb26ffac3b 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -10,10 +10,9 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Controller\Traits\UploadValidator; -use OCA\Libresign\Db\FileMapper; -use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\CertificateEngine\IEngineHandler; +use OCA\Libresign\Service\ActiveSigningsService; use OCA\Libresign\Service\Certificate\ValidateService; use OCA\Libresign\Service\CertificatePolicyService; use OCA\Libresign\Service\IdentifyMethodService; @@ -67,7 +66,7 @@ public function __construct( private CertificatePolicyService $certificatePolicyService, private ValidateService $validateService, private IdentifyMethodService $identifyMethodService, - private FileMapper $fileMapper, + private ActiveSigningsService $activeSigningsService, private SetupCheckResultService $setupCheckResultService, ) { parent::__construct(Application::APP_ID, $request); @@ -461,22 +460,8 @@ public function updateOid(string $oid): DataResponse { #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/admin/active-signings', requirements: ['apiVersion' => '(v1)'])] public function getActiveSignings(): DataResponse { try { - $activeSignings = $this->fileMapper->findByStatus(FileStatus::SIGNING_IN_PROGRESS->value); - - $result = []; - foreach ($activeSignings as $file) { - $result[] = [ - 'id' => $file->getId(), - 'uuid' => $file->getUuid(), - 'name' => $file->getName(), - 'signerEmail' => $file->getSignerEmail() ?? '', - 'signerDisplayName' => $file->getSignerName() ?? '', - 'updatedAt' => $file->getUpdatedAt(), - ]; - } - return new DataResponse([ - 'data' => $result, + 'data' => $this->activeSigningsService->getActiveSignings(), ]); } catch (\Exception $e) { return new DataResponse([ diff --git a/lib/Dav/SignatureStatusPlugin.php b/lib/Dav/SignatureStatusPlugin.php index 7eaa051878..e763275219 100644 --- a/lib/Dav/SignatureStatusPlugin.php +++ b/lib/Dav/SignatureStatusPlugin.php @@ -16,6 +16,7 @@ use Sabre\DAV\ServerPlugin; class SignatureStatusPlugin extends ServerPlugin { + #[\Override] public function initialize(Server $server): void { $server->on('propFind', $this->propFind(...)); } diff --git a/lib/Db/File.php b/lib/Db/File.php index 4dc8722c42..1f56a3a21c 100644 --- a/lib/Db/File.php +++ b/lib/Db/File.php @@ -24,7 +24,7 @@ * @method void setSignedHash(?string $hash) * @method ?string getSignedHash() * @method void setUserId(?string $userId) - * @method ?string getUserId() + * @method string getUserId() * @method void setUuid(string $uuid) * @method string getUuid() * @method void setCreatedAt(\DateTime $createdAt) diff --git a/lib/Db/IdDocsMapper.php b/lib/Db/IdDocsMapper.php index 112e74b03f..55f13c36e5 100644 --- a/lib/Db/IdDocsMapper.php +++ b/lib/Db/IdDocsMapper.php @@ -12,7 +12,6 @@ use OCA\Libresign\Helper\Pagination; use OCP\AppFramework\Db\QBMapper; use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\DB\Types; use OCP\IDBConnection; use OCP\IL10N; use OCP\IURLGenerator; @@ -210,7 +209,7 @@ private function getQueryBuilder(array $filter = [], bool $count = false): IQuer if (!empty($filter['approved']) && $filter['approved'] === 'yes') { $qb->andWhere( - $qb->expr()->eq('f.status', $qb->createNamedParameter(FileStatus::SIGNED->value, Types::INTEGER)) + $qb->expr()->eq('f.status', $qb->createNamedParameter(FileStatus::SIGNED->value, IQueryBuilder::PARAM_INT)) ); } diff --git a/lib/Db/IdentifyMethodMapper.php b/lib/Db/IdentifyMethodMapper.php index 43e0ad1767..5ac7933391 100644 --- a/lib/Db/IdentifyMethodMapper.php +++ b/lib/Db/IdentifyMethodMapper.php @@ -120,7 +120,7 @@ public function searchByIdentifierValue(string $search, string $userId, string $ $latestQb = $this->db->getQueryBuilder(); $latestQb->select('im2.identifier_key') ->addSelect('im2.identifier_value') - ->addSelect($latestQb->func()->max('sr2.created_at', 'created_at')) + ->selectAlias($latestQb->func()->max('sr2.created_at'), 'created_at') ->from('libresign_identify_method', 'im2') ->join('im2', 'libresign_sign_request', 'sr2', $latestQb->expr()->eq('sr2.id', 'im2.sign_request_id') diff --git a/lib/Db/PagerFantaQueryAdapter.php b/lib/Db/PagerFantaQueryAdapter.php index e8dd24d68f..7e6b8ff6a2 100644 --- a/lib/Db/PagerFantaQueryAdapter.php +++ b/lib/Db/PagerFantaQueryAdapter.php @@ -15,6 +15,8 @@ /** * Adapter which calculates pagination from a Doctrine DBAL QueryBuilder. + * + * @implements AdapterInterface> */ class PagerFantaQueryAdapter implements AdapterInterface { /** @@ -41,7 +43,7 @@ public function getNbResults(): int { /** * @psalm-suppress MixedReturnStatement * - * @return array + * @return array> */ #[\Override] public function getSlice(int $offset, int $length): iterable { diff --git a/lib/Db/PermissionSet.php b/lib/Db/PermissionSet.php index 556df9c4a8..e3db91cda5 100644 --- a/lib/Db/PermissionSet.php +++ b/lib/Db/PermissionSet.php @@ -22,7 +22,7 @@ * @method ?string getDescription() * @method void setScopeType(string $scopeType) * @method string getScopeType() - * @method void setEnabled(int $enabled) + * @method void setEnabled(bool $enabled) * @method void setPriority(int $priority) * @method int getPriority() * @method void setCreatedAt(\DateTime|string $createdAt) diff --git a/lib/Handler/CertificateEngine/AEngineHandler.php b/lib/Handler/CertificateEngine/AEngineHandler.php index 98e9d37379..827cb2fb46 100644 --- a/lib/Handler/CertificateEngine/AEngineHandler.php +++ b/lib/Handler/CertificateEngine/AEngineHandler.php @@ -896,9 +896,8 @@ private function createAndSignCrl(\OCA\Libresign\Vendor\phpseclib3\File\X509 $is $dateFormat = 'D, d M Y H:i:s O'; foreach ($revokedCertificates as $cert) { $serialNumber = $cert->getSerialNumber(); - $normalizedSerial = ltrim((string)$serialNumber, '0') ?: '0'; $crlToSign->revoke( - new \OCA\Libresign\Vendor\phpseclib3\Math\BigInteger($normalizedSerial, 16), + $this->normalizeRevokedCertificateSerialForCrl((string)$serialNumber), $cert->getRevokedAt()->format($dateFormat) ); } @@ -920,6 +919,16 @@ private function createAndSignCrl(\OCA\Libresign\Vendor\phpseclib3\File\X509 $is return $signedCrl; } + private function normalizeRevokedCertificateSerialForCrl(string $serialNumber): string { + $normalizedSerial = ltrim($serialNumber, '0') ?: '0'; + + if (ctype_digit($normalizedSerial)) { + return $normalizedSerial; + } + + return (new \OCA\Libresign\Vendor\phpseclib3\Math\BigInteger($normalizedSerial, 16))->toString(); + } + private function saveCrlToDer(array $signedCrl, string $configPath): string { $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der'; $crlToSign = new \OCA\Libresign\Vendor\phpseclib3\File\X509(); diff --git a/lib/Handler/CertificateEngine/IEngineHandler.php b/lib/Handler/CertificateEngine/IEngineHandler.php index 4d0c79ce61..464525dd65 100644 --- a/lib/Handler/CertificateEngine/IEngineHandler.php +++ b/lib/Handler/CertificateEngine/IEngineHandler.php @@ -79,9 +79,10 @@ public function toArray(): array; public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string; /** - * Parse an X.509 certificate and return its details with CRL validation - * @param string $certificate PEM-encoded certificate - * @return array Parsed certificate data including CRL validation information + * Scope certificate validation policy resolution to a specific user. + * + * @param string|null $userId User identifier used to resolve validation policies + * @return self */ public function setPolicyUserIdForValidation(?string $userId): self; diff --git a/lib/Helper/Pagination.php b/lib/Helper/Pagination.php index 39379c638b..b7b1d50f82 100644 --- a/lib/Helper/Pagination.php +++ b/lib/Helper/Pagination.php @@ -15,6 +15,7 @@ /** * @psalm-import-type LibresignPagination from \OCA\Libresign\ResponseDefinitions + * @extends Pagerfanta> */ class Pagination extends Pagerfanta { private string $routeName; diff --git a/lib/Listener/LoadAdditionalListener.php b/lib/Listener/LoadAdditionalListener.php index ba2e87dfcc..53def2647b 100644 --- a/lib/Listener/LoadAdditionalListener.php +++ b/lib/Listener/LoadAdditionalListener.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Listener; -use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCP\App\IAppManager; @@ -17,9 +16,11 @@ use OCP\Util; /** - * @template-implements IEventListener + * @template-implements IEventListener */ class LoadAdditionalListener implements IEventListener { + private const FILES_LOAD_ADDITIONAL_SCRIPTS_EVENT = 'OCA\\Files\\Event\\LoadAdditionalScriptsEvent'; + public function __construct( private IAppManager $appManager, private CertificateEngineFactory $certificateEngineFactory, @@ -27,7 +28,7 @@ public function __construct( } #[\Override] public function handle(Event $event): void { - if (!($event instanceof LoadAdditionalScriptsEvent)) { + if (!is_a($event, self::FILES_LOAD_ADDITIONAL_SCRIPTS_EVENT)) { return; } diff --git a/lib/Listener/TwofactorGatewayListener.php b/lib/Listener/TwofactorGatewayListener.php index aec5713cd2..e2a8e80bdb 100644 --- a/lib/Listener/TwofactorGatewayListener.php +++ b/lib/Listener/TwofactorGatewayListener.php @@ -17,15 +17,13 @@ use OCA\Libresign\Service\IdentifyMethod\IdentifyService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethodService; -use OCA\TwoFactorGateway\Provider\Gateway\Factory; -use OCP\App\IAppManager; +use OCA\Libresign\Service\TwofactorGatewayService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\IUserSession; -use OCP\Server; use Psr\Log\LoggerInterface; /** @template-implements IEventListener */ @@ -35,8 +33,8 @@ public function __construct( protected IUserManager $userManager, protected IdentifyService $identifyService, private SignRequestMapper $signRequestMapper, + private TwofactorGatewayService $twofactorGatewayService, private LoggerInterface $logger, - protected IAppManager $appManager, protected IL10N $l10n, protected IURLGenerator $urlGenerator, ) { @@ -72,7 +70,7 @@ protected function sendSignNotification( if (!in_array($entity->getIdentifierKey(), IdentifyMethodService::IDENTIFY_TWOFACTOR_GATEWAY_METHODS, true)) { return; } - if (!$this->appManager->isEnabledForAnyone('twofactor_gateway')) { + if (!$this->twofactorGatewayService->isEnabled()) { $this->logger->info('Twofactor Gateway app is not enabled'); return; } @@ -97,12 +95,9 @@ protected function sendSignNotification( $link = $this->urlGenerator->linkToRouteAbsolute('libresign.page.sign', ['uuid' => $signRequest->getUuid()]); $message .= $libreSignFile->getName() . ': ' . $link; - /** @var Factory */ - $gatewayFactory = Server::get(Factory::class); $gatewayName = IdentifyMethodService::resolveTwofactorGatewayName($entity->getIdentifierKey()); - $gateway = $gatewayFactory->get($gatewayName); try { - $gateway->send($identifier, $message); + $this->twofactorGatewayService->send($gatewayName, $identifier, $message); } catch (Exception $e) { $this->logger->error('Could not send 2FA message', [ 'identifier' => $identifier, @@ -129,7 +124,7 @@ protected function sendSignedNotification( if (!in_array($entity->getIdentifierKey(), IdentifyMethodService::IDENTIFY_TWOFACTOR_GATEWAY_METHODS, true)) { return; } - if (!$this->appManager->isEnabledForAnyone('twofactor_gateway')) { + if (!$this->twofactorGatewayService->isEnabled()) { $this->logger->info('Twofactor Gateway app is not enabled'); return; } @@ -148,12 +143,9 @@ protected function sendSignedNotification( $message .= "\n"; $message .= $libreSignFile->getName() . ': ' . $link; - /** @var Factory */ - $gatewayFactory = Server::get(Factory::class); $gatewayName = IdentifyMethodService::resolveTwofactorGatewayName($entity->getIdentifierKey()); - $gateway = $gatewayFactory->get($gatewayName); try { - $gateway->send($identifier, $message); + $this->twofactorGatewayService->send($gatewayName, $identifier, $message); } catch (Exception $e) { $this->logger->error('Could not send 2FA message', [ 'identifier' => $identifier, diff --git a/lib/Migration/Version16001Date20251227000000.php b/lib/Migration/Version16001Date20251227000000.php index 2a4ad09f0d..9b848ae2d8 100644 --- a/lib/Migration/Version16001Date20251227000000.php +++ b/lib/Migration/Version16001Date20251227000000.php @@ -10,6 +10,7 @@ use Closure; use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; @@ -82,7 +83,7 @@ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $updateQb->update('libresign_file') ->set('name', $updateQb->createNamedParameter($file['newName'])) - ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($file['id'], \OCP\DB\Types::INTEGER))) + ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($file['id'], IQueryBuilder::PARAM_INT))) ->executeStatement(); } diff --git a/lib/Migration/Version8000Date20240405142042.php b/lib/Migration/Version8000Date20240405142042.php index 04166915c8..52ecae5638 100644 --- a/lib/Migration/Version8000Date20240405142042.php +++ b/lib/Migration/Version8000Date20240405142042.php @@ -17,6 +17,7 @@ use OCP\Migration\SimpleMigrationStep; class PostgreSQLJsonType extends JsonType { + #[\Override] public function getSQLDeclaration(array $column, AbstractPlatform $platform) { $return = parent::getSQLDeclaration($column, $platform); $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); @@ -43,6 +44,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); $table = $schema->getTable('libresign_file'); + $changed = false; $newOptions = []; if ($schema->getDatabasePlatform() instanceof PostgreSQLPlatform) { diff --git a/lib/Service/ActiveSigningsService.php b/lib/Service/ActiveSigningsService.php new file mode 100644 index 0000000000..6aa4f50400 --- /dev/null +++ b/lib/Service/ActiveSigningsService.php @@ -0,0 +1,135 @@ + + */ + public function getActiveSignings(): array { + $activeSignings = $this->fileMapper->findByStatus(FileStatus::SIGNING_IN_PROGRESS->value); + + $result = []; + foreach ($activeSignings as $file) { + $activeSigner = $this->resolveActiveSigner($file); + $result[] = [ + 'id' => $file->getId(), + 'uuid' => $file->getUuid(), + 'name' => $file->getName(), + 'signerEmail' => $activeSigner['email'], + 'signerDisplayName' => $activeSigner['displayName'], + 'updatedAt' => $this->resolveActiveSigningUpdatedAt($file), + ]; + } + + return $result; + } + + /** + * @return array{email: string, displayName: string} + */ + private function resolveActiveSigner(FileEntity $file): array { + $signRequest = $this->findActiveSignRequest($this->signRequestMapper->getByFileId($file->getId())); + if (!$signRequest instanceof SignRequestEntity) { + return [ + 'email' => '', + 'displayName' => '', + ]; + } + + $identifiedMethod = $this->findPreferredIdentifyMethod( + $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($signRequest->getId()) + ); + + $signerEmail = ''; + if ($identifiedMethod instanceof IdentifyMethodEntity + && $identifiedMethod->getIdentifierKey() === self::EMAIL_IDENTIFY_METHOD + ) { + $signerEmail = $identifiedMethod->getIdentifierValue(); + } + + return [ + 'email' => $signerEmail, + 'displayName' => $signRequest->getDisplayName(), + ]; + } + + /** + * @param list $signRequests + */ + private function findActiveSignRequest(array $signRequests): ?SignRequestEntity { + foreach ($signRequests as $signRequest) { + if ($signRequest->getStatusEnum() === SignRequestStatus::ABLE_TO_SIGN) { + return $signRequest; + } + } + + foreach ($signRequests as $signRequest) { + if ($signRequest->getSigned() === null) { + return $signRequest; + } + } + + return $signRequests[0] ?? null; + } + + /** + * @param list $identifyMethods + */ + private function findPreferredIdentifyMethod(array $identifyMethods): ?IdentifyMethodEntity { + $firstMethod = null; + + foreach ($identifyMethods as $identifyMethod) { + $firstMethod ??= $identifyMethod; + + if ($identifyMethod->getIdentifiedAtDate() !== null) { + return $identifyMethod; + } + } + + return $firstMethod; + } + + private function resolveActiveSigningUpdatedAt(FileEntity $file): int { + $metadata = $file->getMetadata(); + if (is_array($metadata) && isset($metadata['status_changed_at']) && is_string($metadata['status_changed_at'])) { + $statusChangedAt = date_create_immutable($metadata['status_changed_at']); + if ($statusChangedAt instanceof \DateTimeImmutable) { + return $statusChangedAt->getTimestamp(); + } + } + + return $file->getCreatedAt()?->getTimestamp() ?? 0; + } +} diff --git a/lib/Service/File/ValidationMetadataNormalizer.php b/lib/Service/File/ValidationMetadataNormalizer.php index 04731a8413..be95164cad 100644 --- a/lib/Service/File/ValidationMetadataNormalizer.php +++ b/lib/Service/File/ValidationMetadataNormalizer.php @@ -21,7 +21,7 @@ final class ValidationMetadataNormalizer { /** * @param array $metadata - * @psalm-return array&LibresignValidateMetadata + * @psalm-return LibresignValidateMetadata */ public static function normalize(array $metadata, string $fileName, int $totalPages): array { $normalized = $metadata; @@ -31,6 +31,9 @@ public static function normalize(array $metadata, string $fileName, int $totalPa self::normalizeOptionalScalarFields($normalized); self::normalizeDimensionsField($normalized); + /** + * @var LibresignValidateMetadata $normalized + */ return $normalized; } diff --git a/lib/Service/IdentifyMethod/SignatureMethod/TokenService.php b/lib/Service/IdentifyMethod/SignatureMethod/TokenService.php index 1b55845178..983567f23d 100644 --- a/lib/Service/IdentifyMethod/SignatureMethod/TokenService.php +++ b/lib/Service/IdentifyMethod/SignatureMethod/TokenService.php @@ -8,14 +8,12 @@ namespace OCA\Libresign\Service\IdentifyMethod\SignatureMethod; -use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\MailService; +use OCA\Libresign\Service\TwofactorGatewayService; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\IL10N; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; -use OCP\Server; -use Psr\Container\NotFoundExceptionInterface; class TokenService { public const TOKEN_LENGTH = 6; @@ -25,34 +23,25 @@ public function __construct( private IHasher $hasher, private MailService $mail, private IL10N $l10n, + private TwofactorGatewayService $twofactorGatewayService, ) { } public function sendCodeByGateway(string $identifier, string $gatewayName): string { - $gateway = $this->getGateway($gatewayName); + $this->twofactorGatewayService->ensureAvailable($gatewayName); + if (!$this->twofactorGatewayService->isGatewayComplete($gatewayName)) { + throw new OCSForbiddenException($this->l10n->t('Gateway %s not configured on Two-Factor Gateway.', $gatewayName)); + } $code = $this->secureRandom->generate(self::TOKEN_LENGTH, ISecureRandom::CHAR_DIGITS); - $gateway->send($identifier, $this->l10n->t('%s is your LibreSign verification code.', $code)); + $this->twofactorGatewayService->send( + $gatewayName, + $identifier, + $this->l10n->t('%s is your LibreSign verification code.', $code) + ); return $this->hasher->hash($code); } - /** - * @throws OCSForbiddenException - * @return \OCA\TwoFactorGateway\Provider\Gateway\IGateway - */ - private function getGateway(string $gatewayName) { - try { - $factory = Server::get(\OCA\TwoFactorGateway\Provider\Gateway\Factory::class); - } catch (NotFoundExceptionInterface) { - throw new LibresignException('App Two-Factor Gateway is not installed.'); - } - $gateway = $factory->get($gatewayName); - if (!$gateway->isComplete()) { - throw new OCSForbiddenException($this->l10n->t('Gateway %s not configured on Two-Factor Gateway.', $gatewayName)); - } - return $gateway; - } - public function sendCodeByEmail(string $email, string $displayName): string { $code = $this->secureRandom->generate(self::TOKEN_LENGTH, ISecureRandom::CHAR_DIGITS); $this->mail->sendCodeToSign( diff --git a/lib/Service/IdentifyMethod/TwofactorGateway.php b/lib/Service/IdentifyMethod/TwofactorGateway.php index ca5f6f77e7..d28ec384fc 100644 --- a/lib/Service/IdentifyMethod/TwofactorGateway.php +++ b/lib/Service/IdentifyMethod/TwofactorGateway.php @@ -12,14 +12,10 @@ use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SessionService; -use OCA\TwoFactorGateway\Provider\Gateway\Factory; -use OCP\App\IAppManager; +use OCA\Libresign\Service\TwofactorGatewayService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\IRootFolder; use OCP\IUserSession; -use OCP\Server; -use Psr\Log\LoggerInterface; -use Throwable; class TwofactorGateway extends AbstractIdentifyMethod { public function __construct( @@ -30,8 +26,7 @@ public function __construct( private SessionService $sessionService, private FileElementMapper $fileElementMapper, private IUserSession $userSession, - private LoggerInterface $logger, - private IAppManager $appManager, + private TwofactorGatewayService $twofactorGatewayService, ) { parent::__construct( $identifyService, @@ -60,26 +55,7 @@ public function validateToSign(): void { } public function isTwofactorGatewayEnabled(): bool { - $isAppEnabled = $this->appManager->isEnabledForAnyone('twofactor_gateway'); - if (!$isAppEnabled) { - return false; - } - /** @var Factory */ - $gatewayFactory = Server::get(Factory::class); - - $gatewayName = $this->getGatewayName(); - - try { - $gateway = $gatewayFactory->get($gatewayName); - return $gateway->isComplete(); - } catch (Throwable $exception) { - $this->logger->warning('Unable to load twofactor gateway provider.', [ - 'gateway' => $gatewayName, - 'identifyMethod' => $this->getId(), - 'exception' => $exception, - ]); - return false; - } + return $this->twofactorGatewayService->isGatewayComplete($this->getGatewayName()); } private function getGatewayName(): string { diff --git a/lib/Service/Install/InstallService.php b/lib/Service/Install/InstallService.php index 10ecbaf7e3..f24ed55350 100644 --- a/lib/Service/Install/InstallService.php +++ b/lib/Service/Install/InstallService.php @@ -95,7 +95,7 @@ public function setArchitecture(string $architecture): self { return $this; } - private function getFolder(string $path = '', ?ISimpleFolder $folder = null, $needToBeEmpty = false): ISimpleFolder { + private function getFolder(string $path = '', ?ISimpleFolder $folder = null, bool $needToBeEmpty = false): ISimpleFolder { if (!$folder) { $folder = $this->appData->getFolder('/'); if (!$path) { @@ -112,7 +112,7 @@ private function getFolder(string $path = '', ?ISimpleFolder $folder = null, $ne return $folder; } try { - $folder = $folder->getFolder($path, $folder); + $folder = $folder->getFolder($path); if ($needToBeEmpty && $path !== $this->architecture) { $folder->delete(); $path = ''; diff --git a/lib/Service/Install/SignSetupService.php b/lib/Service/Install/SignSetupService.php index fff0e4cfa3..302a7e1d3c 100644 --- a/lib/Service/Install/SignSetupService.php +++ b/lib/Service/Install/SignSetupService.php @@ -176,7 +176,7 @@ public function getInstallPath(): string { try { $folder = $this->appData->getFolder('/'); $path = $this->architecture . '/' . $this->getLinuxDistributionToDownloadJava() . '/java'; - $folder = $folder->getFolder($path, $folder); + $folder = $folder->getFolder($path); $path = $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder); if (is_dir($path)) { return $path; @@ -204,7 +204,7 @@ public function getInstallPath(): string { try { $folder = $this->appData->getFolder('/'); $path = $this->architecture . '/jsignpdf'; - $folder = $folder->getFolder($path, $folder); + $folder = $folder->getFolder($path); $path = $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder); if (is_dir($path)) { return $path; @@ -223,7 +223,7 @@ public function getInstallPath(): string { try { $folder = $this->appData->getFolder('/'); $path = $this->architecture . '/pdftk'; - $folder = $folder->getFolder($path, $folder); + $folder = $folder->getFolder($path); $path = $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder); if (is_dir($path)) { return $path; @@ -242,7 +242,7 @@ public function getInstallPath(): string { try { $folder = $this->appData->getFolder('/'); $path = $this->architecture . '/cfssl'; - $folder = $folder->getFolder($path, $folder); + $folder = $folder->getFolder($path); $path = $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder); if (is_dir($path)) { return $path; @@ -559,7 +559,15 @@ private function getRootCertificatePublicKey(): string { return (string)file_get_contents($localCert); } } - return $this->fileAccessHelper->file_get_contents($this->environmentHelper->getServerRoot() . '/resources/codesigning/root.crt'); + + $rootCertificatePath = $this->environmentHelper->getServerRoot() . '/resources/codesigning/root.crt'; + $rootCertificate = $this->fileAccessHelper->file_get_contents($rootCertificatePath); + + if (!is_string($rootCertificate)) { + throw new LibresignException('Root certificate not found at ' . $rootCertificatePath); + } + + return $rootCertificate; } public function getDevelopCert(): array { diff --git a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php index 44528970e3..de5a0a6b32 100644 --- a/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php +++ b/lib/Service/Policy/Provider/IdentifyMethods/IdentifyMethodsPolicyValue.php @@ -36,7 +36,7 @@ public static function normalize(mixed $rawValue, ?IdentifyMethodService $identi } } } - $normalization = self::normalizeFactors($defaultFactors, null, null); + $normalization = self::normalizeFactors($defaultFactors, null); $normalized = $normalization['factors']; if ($catalogFactors !== []) { $normalized = self::mergeFactorsWithCatalog($normalized, $catalogFactors); diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index fb9b7b3214..c39501eb41 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -328,7 +328,20 @@ private function createFileForEnvelope( /** * Save file data * - * @param array{?userManager: IUser, ?signRequest: SignRequestEntity, name: string, callback: string, uuid?: ?string, status: int, file?: array{fileId?: int, fileNode?: Node}} $data + * @param array{ + * userManager?: IUser, + * signRequest?: SignRequestEntity, + * name?: string, + * callback?: string, + * uuid?: ?string, + * status?: int, + * parentFileId?: int, + * file?: array{fileId?: int, nodeId?: int, fileNode?: Node}, + * settings?: array, + * policyOverrides?: array, + * policyActiveContext?: ?array, + * uploadedFile?: array + * } $data */ public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 96c73f458b..49d236a2b7 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -1132,7 +1132,7 @@ protected function getFileToSign(): File { $userId = $this->libreSignFile->getUserId() ?? $this->user?->getUID() - ?? ($this->signRequest?->getUserId() ?? null); + ?? null; $nodeId = $this->libreSignFile->getNodeId(); if ($userId === null) { diff --git a/lib/Service/TwofactorGatewayService.php b/lib/Service/TwofactorGatewayService.php new file mode 100644 index 0000000000..68d5010786 --- /dev/null +++ b/lib/Service/TwofactorGatewayService.php @@ -0,0 +1,107 @@ +appManager->isEnabledForAnyone(self::APP_ID); + } + + /** + * @throws LibresignException + */ + public function ensureAvailable(string $gatewayName): void { + $this->callIntegrationMethod('ensureAvailable', [$gatewayName]); + } + + public function isGatewayComplete(string $gatewayName): bool { + if (!$this->isEnabled()) { + return false; + } + + try { + $isComplete = $this->callIntegrationMethod('isGatewayComplete', [$gatewayName]); + } catch (\Exception $exception) { + $this->logger->warning('Unable to determine twofactor gateway completeness.', [ + 'gateway' => $gatewayName, + 'exception' => $exception, + ]); + return false; + } + + if (!is_bool($isComplete)) { + $this->logger->warning('Twofactor gateway integration service returned an invalid completeness flag.', [ + 'gateway' => $gatewayName, + 'returnedType' => get_debug_type($isComplete), + ]); + return false; + } + + return $isComplete; + } + + /** + * @throws LibresignException + */ + public function send(string $gatewayName, string $identifier, string $message): void { + $this->callIntegrationMethod('send', [$gatewayName, $identifier, $message]); + } + + /** + * @throws LibresignException + */ + private function callIntegrationMethod(string $method, array $arguments = []): mixed { + $integrationService = $this->resolveIntegrationService(); + if (!is_callable([$integrationService, $method])) { + throw new \UnexpectedValueException(sprintf( + 'Twofactor gateway integration service does not expose %s().', + $method, + )); + } + + return call_user_func([$integrationService, $method], ...$arguments); + } + + /** + * @throws LibresignException + */ + private function resolveIntegrationService(): object { + if (!$this->isEnabled()) { + throw new LibresignException('App Two-Factor Gateway is not enabled.'); + } + + try { + $integrationService = $this->container->get(self::INTEGRATION_SERVICE_ID); + } catch (NotFoundExceptionInterface $exception) { + throw new LibresignException('App Two-Factor Gateway is not installed.', 0, $exception); + } + + if (!is_object($integrationService)) { + throw new \UnexpectedValueException('Twofactor gateway integration service is not an object.'); + } + + return $integrationService; + } +} diff --git a/psalm.xml b/psalm.xml index b00cef1986..3355aaa71a 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,7 @@ errorLevel="5" findUnusedBaselineEntry="true" findUnusedCode="false" + hideExternalErrors="true" resolveFromConfigFile="true" phpVersion="8.3" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" @@ -14,24 +15,45 @@ + + + + + + + + + + + + + + + + + + + + + - - - - @@ -44,7 +66,7 @@ - - + + diff --git a/tests/php/Unit/Controller/AdminControllerTest.php b/tests/php/Unit/Controller/AdminControllerTest.php new file mode 100644 index 0000000000..8658253b16 --- /dev/null +++ b/tests/php/Unit/Controller/AdminControllerTest.php @@ -0,0 +1,111 @@ +request = $this->createMock(IRequest::class); + $appConfig = $this->createMock(IAppConfig::class); + $installService = $this->createMock(InstallService::class); + $certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); + $eventSourceFactory = $this->createMock(IEventSourceFactory::class); + $l10n = $this->createMock(IL10N::class); + $session = $this->createMock(ISession::class); + $signatureBackgroundService = $this->createMock(SignatureBackgroundService::class); + $certificatePolicyService = $this->createMock(CertificatePolicyService::class); + $validateService = $this->createMock(ValidateService::class); + $identifyMethodService = $this->createMock(IdentifyMethodService::class); + $this->activeSigningsService = $this->createMock(ActiveSigningsService::class); + $setupCheckResultService = $this->createMock(SetupCheckResultService::class); + $eventSource = $this->createMock(IEventSource::class); + + $eventSourceFactory + ->method('create') + ->willReturn($eventSource); + + $this->controller = new AdminController( + $this->request, + $appConfig, + $installService, + $certificateEngineFactory, + $eventSourceFactory, + $l10n, + $session, + $signatureBackgroundService, + $certificatePolicyService, + $validateService, + $identifyMethodService, + $this->activeSigningsService, + $setupCheckResultService, + ); + } + + public function testGetActiveSigningsReturnsServicePayload(): void { + $this->activeSigningsService + ->expects($this->once()) + ->method('getActiveSignings') + ->willReturn([[ + 'id' => 7, + 'uuid' => 'uuid-7', + 'name' => 'Contract.pdf', + 'signerEmail' => 'signer@example.com', + 'signerDisplayName' => 'Active signer', + 'updatedAt' => 1751891696, + ]]); + + $response = $this->controller->getActiveSignings(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame([ + 'data' => [[ + 'id' => 7, + 'uuid' => 'uuid-7', + 'name' => 'Contract.pdf', + 'signerEmail' => 'signer@example.com', + 'signerDisplayName' => 'Active signer', + 'updatedAt' => 1751891696, + ]], + ], $response->getData()); + } + + public function testGetActiveSigningsReturnsErrorResponseWhenServiceFails(): void { + $this->activeSigningsService + ->expects($this->once()) + ->method('getActiveSignings') + ->willThrowException(new \RuntimeException('boom')); + + $response = $this->controller->getActiveSignings(); + + $this->assertSame(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $this->assertSame(['error' => 'boom'], $response->getData()); + } +} diff --git a/tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php b/tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php index 189e74dd5e..486bd79eeb 100644 --- a/tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php +++ b/tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php @@ -647,6 +647,10 @@ public function testGenerateCrlDerWithRevokedCertificates(array $certificates): public static function dataCrlSerialNumberNormalization(): array { return [ + 'Decimal serial stays decimal semantically and is rendered as hex in CRL' => [ + 'serialNumber' => '1234', + 'expectedInCrl' => '04D2' + ], 'Serial with leading zeros (20 chars)' => [ 'serialNumber' => '00e7a0b277a1008f5fe3', 'expectedInCrl' => 'E7A0B277A1008F5FE3' diff --git a/tests/php/Unit/Service/ActiveSigningsServiceTest.php b/tests/php/Unit/Service/ActiveSigningsServiceTest.php new file mode 100644 index 0000000000..c070642fbb --- /dev/null +++ b/tests/php/Unit/Service/ActiveSigningsServiceTest.php @@ -0,0 +1,254 @@ +> */ + private array $filesByStatus = []; + /** @var array> */ + private array $signRequestsByFileId = []; + /** @var array> */ + private array $identifyMethodsBySignRequestId = []; + + protected function setUp(): void { + $this->fileMapper = $this->createStub(FileMapper::class); + $this->fileMapper + ->method('findByStatus') + ->willReturnCallback(fn (int $status): array => $this->filesByStatus[$status] ?? []); + + $this->signRequestMapper = $this->createStub(SignRequestMapper::class); + $this->signRequestMapper + ->method('getByFileId') + ->willReturnCallback(fn (int $fileId): array => $this->signRequestsByFileId[$fileId] ?? []); + + $this->identifyMethodMapper = $this->createStub(IdentifyMethodMapper::class); + $this->identifyMethodMapper + ->method('getIdentifyMethodsFromSignRequestId') + ->willReturnCallback(fn (int $signRequestId): array => $this->identifyMethodsBySignRequestId[$signRequestId] ?? []); + + $this->service = new ActiveSigningsService( + $this->fileMapper, + $this->signRequestMapper, + $this->identifyMethodMapper, + ); + } + + #[DataProvider('provideActiveSigningScenarios')] + public function testGetActiveSignings( + array $files, + array $signRequestsByFileId, + array $identifyMethodsBySignRequestId, + array $expected, + ): void { + $this->filesByStatus[FileStatus::SIGNING_IN_PROGRESS->value] = array_map( + fn (array $definition): FileEntity => $this->buildFile($definition), + $files, + ); + $this->signRequestsByFileId = $this->buildSignRequestsByFileId($signRequestsByFileId); + $this->identifyMethodsBySignRequestId = $this->buildIdentifyMethodsBySignRequestId($identifyMethodsBySignRequestId); + + $this->assertSame($expected, $this->service->getActiveSignings()); + } + + public static function provideActiveSigningScenarios(): array { + return [ + 'resolves active signer and status-changed timestamp' => [ + 'files' => [[ + 'id' => 7, + 'uuid' => 'uuid-7', + 'name' => 'Contract.pdf', + 'status' => FileStatus::SIGNING_IN_PROGRESS->value, + 'createdAt' => '2026-07-07T10:00:00+00:00', + 'metadata' => [ + 'status_changed_at' => '2025-07-07T12:34:56+00:00', + ], + ]], + 'signRequestsByFileId' => [ + 7 => [ + [ + 'id' => 10, + 'fileId' => 7, + 'displayName' => 'Completed signer', + 'status' => SignRequestStatus::SIGNED, + 'signed' => '2026-07-07 10:10:00', + ], + [ + 'id' => 11, + 'fileId' => 7, + 'displayName' => 'Active signer', + 'status' => SignRequestStatus::ABLE_TO_SIGN, + ], + ], + ], + 'identifyMethodsBySignRequestId' => [ + 11 => [[ + 'identifierKey' => IdentifyMethodService::IDENTIFY_EMAIL, + 'identifierValue' => 'signer@example.com', + 'identifiedAtDate' => '2025-07-07T12:00:00+00:00', + ]], + ], + 'expected' => [[ + 'id' => 7, + 'uuid' => 'uuid-7', + 'name' => 'Contract.pdf', + 'signerEmail' => 'signer@example.com', + 'signerDisplayName' => 'Active signer', + 'updatedAt' => 1751891696, + ]], + ], + 'falls back to created-at and unsigned signer' => [ + 'files' => [[ + 'id' => 9, + 'uuid' => 'uuid-9', + 'name' => 'Fallback.pdf', + 'status' => FileStatus::SIGNING_IN_PROGRESS->value, + 'createdAt' => '2026-07-07T09:30:00+00:00', + 'metadata' => [ + 'status_changed_at' => 'not-a-date', + ], + ]], + 'signRequestsByFileId' => [ + 9 => [[ + 'id' => 19, + 'fileId' => 9, + 'displayName' => 'Pending signer', + 'status' => SignRequestStatus::DRAFT, + ]], + ], + 'identifyMethodsBySignRequestId' => [ + 19 => [], + ], + 'expected' => [[ + 'id' => 9, + 'uuid' => 'uuid-9', + 'name' => 'Fallback.pdf', + 'signerEmail' => '', + 'signerDisplayName' => 'Pending signer', + 'updatedAt' => 1783416600, + ]], + ], + 'returns empty signer data when no sign request exists' => [ + 'files' => [[ + 'id' => 22, + 'uuid' => 'uuid-22', + 'name' => 'NoSigner.pdf', + 'status' => FileStatus::SIGNING_IN_PROGRESS->value, + 'createdAt' => '2026-07-07T08:00:00+00:00', + 'metadata' => [], + ]], + 'signRequestsByFileId' => [ + 22 => [], + ], + 'identifyMethodsBySignRequestId' => [], + 'expected' => [[ + 'id' => 22, + 'uuid' => 'uuid-22', + 'name' => 'NoSigner.pdf', + 'signerEmail' => '', + 'signerDisplayName' => '', + 'updatedAt' => 1783411200, + ]], + ], + ]; + } + + /** + * @param array{ + * id: int, + * uuid: string, + * name: string, + * status: int, + * createdAt: string, + * metadata: array + * } $definition + */ + private function buildFile(array $definition): FileEntity { + $file = new FileEntity(); + $file->setId($definition['id']); + $file->setUuid($definition['uuid']); + $file->setName($definition['name']); + $file->setStatus($definition['status']); + $file->setCreatedAt(new \DateTime($definition['createdAt'])); + $file->setMetadata($definition['metadata']); + return $file; + } + + /** + * @param array> $definitionsByFileId + * @return array> + */ + private function buildSignRequestsByFileId(array $definitionsByFileId): array { + $signRequestsByFileId = []; + foreach ($definitionsByFileId as $fileId => $definitions) { + foreach ($definitions as $definition) { + $signRequestsByFileId[$fileId][] = $this->buildSignRequest($definition); + } + } + return $signRequestsByFileId; + } + + /** + * @param array{id: int, fileId: int, displayName: string, status: SignRequestStatus, signed?: string} $definition + */ + private function buildSignRequest(array $definition): SignRequestEntity { + $signRequest = new SignRequestEntity(); + $signRequest->setId($definition['id']); + $signRequest->setFileId($definition['fileId']); + $signRequest->setDisplayName($definition['displayName']); + $signRequest->setStatusEnum($definition['status']); + if (isset($definition['signed'])) { + $signRequest->setSigned($definition['signed']); + } + return $signRequest; + } + + /** + * @param array> $definitionsBySignRequestId + * @return array> + */ + private function buildIdentifyMethodsBySignRequestId(array $definitionsBySignRequestId): array { + $identifyMethodsBySignRequestId = []; + foreach ($definitionsBySignRequestId as $signRequestId => $definitions) { + foreach ($definitions as $definition) { + $identifyMethodsBySignRequestId[$signRequestId][] = $this->buildIdentifyMethod($signRequestId, $definition); + } + } + return $identifyMethodsBySignRequestId; + } + + /** + * @param array{identifierKey: string, identifierValue: string, identifiedAtDate?: ?string} $definition + */ + private function buildIdentifyMethod(int $signRequestId, array $definition): IdentifyMethodEntity { + $identifyMethod = new IdentifyMethodEntity(); + $identifyMethod->setSignRequestId($signRequestId); + $identifyMethod->setIdentifierKey($definition['identifierKey']); + $identifyMethod->setIdentifierValue($definition['identifierValue']); + $identifyMethod->setIdentifiedAtDate($definition['identifiedAtDate'] ?? null); + return $identifyMethod; + } +} diff --git a/tests/php/Unit/Service/IdentifyMethod/SignatureMethod/TokenServiceTest.php b/tests/php/Unit/Service/IdentifyMethod/SignatureMethod/TokenServiceTest.php new file mode 100644 index 0000000000..a0acd8d8ee --- /dev/null +++ b/tests/php/Unit/Service/IdentifyMethod/SignatureMethod/TokenServiceTest.php @@ -0,0 +1,127 @@ +secureRandom = $this->createMock(ISecureRandom::class); + $this->hasher = $this->createMock(IHasher::class); + $this->mailService = $this->createMock(MailService::class); + $this->l10n = $this->createMock(IL10N::class); + $this->container = $this->createMock(ContainerInterface::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + public function testSendCodeByGatewayThrowsWhenGatewayIsIncomplete(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willReturn(new TokenServiceGatewayIntegrationStub(false)); + $this->secureRandom->expects($this->never()) + ->method('generate'); + $this->l10n->method('t') + ->willReturnCallback(static fn (string $text, mixed $parameters = []): string => is_array($parameters) + ? vsprintf($text, $parameters) + : sprintf($text, $parameters)); + + $this->expectException(OCSForbiddenException::class); + $this->expectExceptionMessage('Gateway sms not configured on Two-Factor Gateway.'); + + $this->createService()->sendCodeByGateway('+5511999999999', 'sms'); + } + + public function testSendCodeByGatewayUsesGatewayServiceAndReturnsHashedCode(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $integrationService = new TokenServiceGatewayIntegrationStub(true); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willReturn($integrationService); + $this->secureRandom->expects($this->once()) + ->method('generate') + ->with(TokenService::TOKEN_LENGTH, ISecureRandom::CHAR_DIGITS) + ->willReturn('123456'); + $this->l10n->expects($this->once()) + ->method('t') + ->with('%s is your LibreSign verification code.', '123456') + ->willReturn('123456 is your LibreSign verification code.'); + $this->hasher->expects($this->once()) + ->method('hash') + ->with('123456') + ->willReturn('hashed-code'); + + self::assertSame('hashed-code', $this->createService()->sendCodeByGateway('+5511999999999', 'sms')); + self::assertSame([ + ['gateway' => 'sms', 'identifier' => '+5511999999999', 'message' => '123456 is your LibreSign verification code.'], + ], $integrationService->sentMessages); + } + + private function createService(): TokenService { + return new TokenService( + $this->secureRandom, + $this->hasher, + $this->mailService, + $this->l10n, + new TwofactorGatewayService( + $this->container, + $this->appManager, + $this->logger, + ), + ); + } +} + +final class TokenServiceGatewayIntegrationStub { + /** @var list */ + public array $sentMessages = []; + + public function __construct( + private bool $complete, + ) { + } + + public function ensureAvailable(string $gatewayName): void { + } + + public function isGatewayComplete(string $gatewayName): bool { + return $this->complete; + } + + public function send(string $gatewayName, string $identifier, string $message): void { + $this->sentMessages[] = [ + 'gateway' => $gatewayName, + 'identifier' => $identifier, + 'message' => $message, + ]; + } +} diff --git a/tests/php/Unit/Service/TwofactorGatewayServiceTest.php b/tests/php/Unit/Service/TwofactorGatewayServiceTest.php new file mode 100644 index 0000000000..111951eea9 --- /dev/null +++ b/tests/php/Unit/Service/TwofactorGatewayServiceTest.php @@ -0,0 +1,140 @@ +container = $this->createMock(ContainerInterface::class); + $this->appManager = $this->createMock(IAppManager::class); + $this->logger = $this->createMock(LoggerInterface::class); + } + + public function testEnsureAvailableThrowsWhenFactoryServiceIsMissing(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willThrowException(new class extends \Exception implements NotFoundExceptionInterface { + }); + + $this->expectException(LibresignException::class); + $this->expectExceptionMessage('App Two-Factor Gateway is not installed.'); + + $this->createService()->ensureAvailable('sms'); + } + + public function testIsGatewayCompleteReturnsFalseWhenAppIsDisabled(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(false); + $this->container->expects($this->never())->method('get'); + + self::assertFalse($this->createService()->isGatewayComplete('sms')); + } + + #[DataProvider('providerGatewayCompleteness')] + public function testIsGatewayCompleteReturnsGatewayStatus(bool $gatewayComplete): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $this->logger->expects($this->never())->method('warning'); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willReturn(new TwofactorGatewayIntegrationStub($gatewayComplete)); + + self::assertSame($gatewayComplete, $this->createService()->isGatewayComplete('sms')); + } + + public static function providerGatewayCompleteness(): array { + return [ + 'complete' => [true], + 'incomplete' => [false], + ]; + } + + public function testIsGatewayCompleteReturnsFalseWhenIntegrationContractChanges(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $this->logger->expects($this->once()) + ->method('warning') + ->with( + 'Unable to determine twofactor gateway completeness.', + $this->arrayHasKey('gateway') + ); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willReturn(new class { + public function ensureAvailable(string $gatewayName): void { + } + + public function send(string $gatewayName, string $identifier, string $message): void { + } + }); + + self::assertFalse($this->createService()->isGatewayComplete('sms')); + } + + public function testSendForwardsIdentifierAndMessage(): void { + $this->appManager->method('isEnabledForAnyone')->with('twofactor_gateway')->willReturn(true); + $integrationService = new TwofactorGatewayIntegrationStub(true); + $this->container->method('get') + ->with('OCA\\TwoFactorGateway\\Service\\GatewayDirectIntegrationService') + ->willReturn($integrationService); + + $this->createService()->send('sms', '+5511999999999', 'hello'); + + self::assertSame([ + ['gateway' => 'sms', 'identifier' => '+5511999999999', 'message' => 'hello'], + ], $integrationService->sentMessages); + } + + private function createService(): TwofactorGatewayService { + return new TwofactorGatewayService( + $this->container, + $this->appManager, + $this->logger, + ); + } +} + +final class TwofactorGatewayIntegrationStub { + /** @var list */ + public array $sentMessages = []; + + public function __construct( + private bool $complete, + ) { + } + + public function ensureAvailable(string $gatewayName): void { + } + + public function isGatewayComplete(string $gatewayName): bool { + return $this->complete; + } + + public function send(string $gatewayName, string $identifier, string $message): void { + $this->sentMessages[] = [ + 'gateway' => $gatewayName, + 'identifier' => $identifier, + 'message' => $message, + ]; + } +} diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index f65d7edac0..c67b56af2b 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,135 +1,2 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - connection->getDatabasePlatform()]]> - - - - - - - - - getDatabasePlatform()]]> - - - - - newUserMail]]> - newUserMail]]> - - - - - - - - - - - - - - - - - - - - - - + diff --git a/tests/stubs/psr_http_client.php b/tests/stubs/OC/Core/Command/Base.php similarity index 53% rename from tests/stubs/psr_http_client.php rename to tests/stubs/OC/Core/Command/Base.php index 6b82b9157a..d7fd3f702e 100644 --- a/tests/stubs/psr_http_client.php +++ b/tests/stubs/OC/Core/Command/Base.php @@ -6,10 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace Psr\Http\Client { - /** - * Minimal Psalm stub to satisfy OCP\Http\Client\IClient dependency. - */ - interface ClientInterface { +namespace OC\Core\Command { + abstract class Base extends \Symfony\Component\Console\Command\Command { } } diff --git a/tests/stubs/oc_hooks_emitter.php b/tests/stubs/OC/Hooks/Emitter.php similarity index 65% rename from tests/stubs/oc_hooks_emitter.php rename to tests/stubs/OC/Hooks/Emitter.php index 5eccd9f5c7..4a3e8f8c1c 100644 --- a/tests/stubs/oc_hooks_emitter.php +++ b/tests/stubs/OC/Hooks/Emitter.php @@ -8,10 +8,13 @@ namespace OC\Hooks { class Emitter { - public function emit(string $class, string $value, array $option):void { + /** + * @param array $option + */ + public function emit(string $class, string $value, array $option): void { } - public function listen(string $class, string $value, $closure):void { + public function listen(string $class, string $value, mixed $closure): void { } } }