From ff126070fe59a95d2e76ae06e310fc35e4107c9f Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:28:41 +0200 Subject: [PATCH 01/12] feat(sharereview): add listener registering Deck as a share source Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewListener.php | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 lib/ShareReview/ShareReviewListener.php diff --git a/lib/ShareReview/ShareReviewListener.php b/lib/ShareReview/ShareReviewListener.php new file mode 100644 index 000000000..ee8db8c1e --- /dev/null +++ b/lib/ShareReview/ShareReviewListener.php @@ -0,0 +1,27 @@ + */ +class ShareReviewListener implements IEventListener { + public function __construct() { + } + + public function handle(Event $event): void { + if (!$event instanceof SourceEvent) { + return; + } + $event->registerSource(ShareReviewSource::class); + } +} From ca82100d7849cf33d105e72e75a972bbd6ce820d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:29:21 +0200 Subject: [PATCH 02/12] feat(sharereview): add ShareReviewSource with constructor and getName() Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewSource.php | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 lib/ShareReview/ShareReviewSource.php diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php new file mode 100644 index 000000000..d153ef05a --- /dev/null +++ b/lib/ShareReview/ShareReviewSource.php @@ -0,0 +1,39 @@ + Date: Thu, 11 Jun 2026 14:30:05 +0200 Subject: [PATCH 03/12] feat(sharereview): implement getShares() with board name lookup via JOIN Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewSource.php | 82 ++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index d153ef05a..5fd73c3fe 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -9,8 +9,13 @@ namespace OCA\Deck\ShareReview; +use OCA\Deck\Db\Acl; use OCA\ShareReview\Sources\ISource; +use OCP\Constants; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; +use OCP\Share\IShare; use Psr\Log\LoggerInterface; class ShareReviewSource implements ISource { @@ -29,11 +34,86 @@ public function getName(): string { return 'Deck'; } + /** + * @return list + */ public function getShares(): array { - return []; + $rawShares = $this->fetchAllShares(); + $appName = $this->getName(); + $formatted = []; + foreach ($rawShares as $share) { + $formatted[] = [ + 'id' => (int)$share['id'], + 'app' => $appName, + 'object' => $this->resolveObjectName($share), + 'initiator' => (string)$share['board_owner'], + 'type' => $this->mapParticipantType((int)$share['type']), + 'recipient' => (string)$share['participant'], + 'permissions' => $this->computePermissions($share), + 'password' => false, + 'time' => '1970-01-01 01:00:00', + 'action' => '', + ]; + } + return $formatted; } public function deleteShare(string $shareId): bool { return false; } + + /** @return list> */ + private function fetchAllShares(): array { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select( + 'a.id', 'a.type', 'a.participant', + 'a.permission_edit', 'a.permission_share', 'a.permission_manage' + ) + ->addSelect($qb->createFunction('b.title AS board_title')) + ->addSelect($qb->createFunction('b.owner AS board_owner')) + ->from(self::ACL_TABLE, 'a') + ->leftJoin('a', self::BOARDS_TABLE, 'b', $qb->expr()->eq('a.board_id', 'b.id')) + ->orderBy('a.id', 'ASC'); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); + return []; + } + } + + /** @param array $share */ + private function resolveObjectName(array $share): string { + $title = (string)($share['board_title'] ?? ''); + $boardId = (int)$share['id']; + return ($title !== '' ? $title : "Board $boardId") . ' (Board)'; + } + + private function mapParticipantType(int $type): int { + return match($type) { + Acl::PERMISSION_TYPE_USER => IShare::TYPE_USER, + Acl::PERMISSION_TYPE_GROUP => IShare::TYPE_GROUP, + Acl::PERMISSION_TYPE_REMOTE => IShare::TYPE_REMOTE, + Acl::PERMISSION_TYPE_CIRCLE => IShare::TYPE_CIRCLE, + default => IShare::TYPE_USER, + }; + } + + /** @param array $share */ + private function computePermissions(array $share): int { + $permissions = Constants::PERMISSION_READ; + if ($share['permission_edit']) { + $permissions |= Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; + } + if ($share['permission_share']) { + $permissions |= Constants::PERMISSION_SHARE; + } + if ($share['permission_manage']) { + $permissions |= self::PERMISSION_MANAGE; + } + return $permissions; + } } From 6d601fad9604cdf4772e19d04fcdd162802678bd Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:30:28 +0200 Subject: [PATCH 04/12] feat(sharereview): implement deleteShare() via direct SQL with logging Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewSource.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index 5fd73c3fe..c558e0067 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -59,7 +59,16 @@ public function getShares(): array { } public function deleteShare(string $shareId): bool { - return false; + $this->logger->info('Deck ShareReview: deleting share {id}', ['id' => $shareId]); + try { + $qb = $this->db->getQueryBuilder(); + $qb->delete(self::ACL_TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareId, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement() > 0; + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]); + return false; + } } /** @return list> */ From 32f9d96ea1df9f438dd5d5565a911189c174c7b0 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:31:04 +0200 Subject: [PATCH 05/12] feat(sharereview): register ShareReview listener on SourceEvent Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/AppInfo/Application.php | 456 ++++++++++++++++++------------------ 1 file changed, 230 insertions(+), 226 deletions(-) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 51efc0a48..db2166d72 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1,226 +1,230 @@ -getContainer(); - $eventDispatcher = $container->get(IEventDispatcher::class); - $eventDispatcher->addListener(RenderReferenceEvent::class, function (RenderReferenceEvent $e) use ($eventDispatcher) { - Util::addScript(self::APP_ID, self::APP_ID . '-reference'); - if (!$this->referenceLoaded && class_exists(LoadEditor::class)) { - $this->referenceLoaded = true; - $eventDispatcher->dispatchTyped(new LoadEditor()); - } - }); - } - - public function boot(IBootContext $context): void { - $context->injectFn($this->registerCommentsEntity(...)); - $context->injectFn($this->registerCollaborationResources(...)); - - $context->injectFn(function (IManager $shareManager) { - $shareManager->registerShareProvider(DeckShareProvider::class); - }); - - $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { - $listener->register($eventDispatcher); - }); - $context->injectFn([$this, 'registerCloudFederationProviderManager']); - } - - public function register(IRegistrationContext $context): void { - if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) { - throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); - } - - $context->registerCapability(Capabilities::class); - $context->registerMiddleWare(FederationMiddleware::class); - $context->registerMiddleWare(ExceptionMiddleware::class); - $context->registerMiddleWare(DefaultBoardMiddleware::class); - - $context->registerService('databaseType', static function (ContainerInterface $c) { - return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite'); - }); - $context->registerService('database4ByteSupport', static function (ContainerInterface $c) { - return $c->get(IDBConnection::class)->supports4ByteText(); - }); - - $context->registerSearchProvider(DeckProvider::class); - $context->registerSearchProvider(CardCommentProvider::class); - $context->registerDashboardWidget(DeckWidgetUpcoming::class); - $context->registerDashboardWidget(DeckWidgetToday::class); - $context->registerDashboardWidget(DeckWidgetTomorrow::class); - - $context->registerReferenceProvider(CreateCardReferenceProvider::class); - - // reference widget - $context->registerReferenceProvider(CardReferenceProvider::class); - $context->registerReferenceProvider(BoardReferenceProvider::class); - $context->registerReferenceProvider(CommentReferenceProvider::class); - - $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); - $context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class); - - // Event listening to emit UserShareAccessUpdatedEvent for files_sharing - $context->registerEventListener(AclCreatedEvent::class, AclCreatedRemovedListener::class); - $context->registerEventListener(AclDeletedEvent::class, AclCreatedRemovedListener::class); - - // Event listening for full text search indexing - $context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CardDeletedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclCreatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclUpdatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclDeletedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CommentAddedEvent::class, CommentEventListener::class); - $context->registerEventListener(BeforeCommentUpdatedEvent::class, CommentEventListener::class); - $context->registerEventListener(CommentUpdatedEvent::class, CommentEventListener::class); - $context->registerEventListener(CommentDeletedEvent::class, CommentEventListener::class); - - // Handling cache invalidation for collections - $context->registerEventListener(AclCreatedEvent::class, ResourceListener::class); - $context->registerEventListener(AclDeletedEvent::class, ResourceListener::class); - - $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); - $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); - $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); - - // Event listening for realtime updates via notify_push - $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(BoardUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclDeletedEvent::class, LiveUpdateListener::class); - - $context->registerNotifierService(Notifier::class); - $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); - - $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); - - $context->registerUserMigrator(DeckMigrator::class); - } - - public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { - $eventDispatcher->addListener(CommentsEntityEvent::EVENT_ENTITY, function (CommentsEntityEvent $event) { - $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function ($name) { - /** @var CardMapper */ - $cardMapper = $this->getContainer()->get(CardMapper::class); - /** @var PermissionService $permissionService */ - $permissionService = $this->getContainer()->get(PermissionService::class); - - try { - return $permissionService->checkPermission($cardMapper, (int)$name, Acl::PERMISSION_READ); - } catch (\Exception $e) { - return false; - } - }); - }); - } - - protected function registerCollaborationResources(IProviderManager $resourceManager): void { - $resourceManager->registerResourceProvider(ResourceProvider::class); - $resourceManager->registerResourceProvider(ResourceProviderCard::class); - } - - public function registerCloudFederationProviderManager( - IConfig $config, - ICloudFederationProviderManager $manager, - ): void { - $manager->addCloudFederationProvider( - DeckFederationProvider::PROVIDER_ID, - 'Deck Federation', - static fn (): ICloudFederationProvider => Server::get(DeckFederationProvider::class), - ); - } -} +getContainer(); + $eventDispatcher = $container->get(IEventDispatcher::class); + $eventDispatcher->addListener(RenderReferenceEvent::class, function (RenderReferenceEvent $e) use ($eventDispatcher) { + Util::addScript(self::APP_ID, self::APP_ID . '-reference'); + if (!$this->referenceLoaded && class_exists(LoadEditor::class)) { + $this->referenceLoaded = true; + $eventDispatcher->dispatchTyped(new LoadEditor()); + } + }); + } + + public function boot(IBootContext $context): void { + $context->injectFn($this->registerCommentsEntity(...)); + $context->injectFn($this->registerCollaborationResources(...)); + + $context->injectFn(function (IManager $shareManager) { + $shareManager->registerShareProvider(DeckShareProvider::class); + }); + + $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { + $listener->register($eventDispatcher); + }); + $context->injectFn([$this, 'registerCloudFederationProviderManager']); + } + + public function register(IRegistrationContext $context): void { + if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) { + throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); + } + + $context->registerCapability(Capabilities::class); + $context->registerMiddleWare(FederationMiddleware::class); + $context->registerMiddleWare(ExceptionMiddleware::class); + $context->registerMiddleWare(DefaultBoardMiddleware::class); + + $context->registerService('databaseType', static function (ContainerInterface $c) { + return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite'); + }); + $context->registerService('database4ByteSupport', static function (ContainerInterface $c) { + return $c->get(IDBConnection::class)->supports4ByteText(); + }); + + $context->registerSearchProvider(DeckProvider::class); + $context->registerSearchProvider(CardCommentProvider::class); + $context->registerDashboardWidget(DeckWidgetUpcoming::class); + $context->registerDashboardWidget(DeckWidgetToday::class); + $context->registerDashboardWidget(DeckWidgetTomorrow::class); + + $context->registerReferenceProvider(CreateCardReferenceProvider::class); + + // reference widget + $context->registerReferenceProvider(CardReferenceProvider::class); + $context->registerReferenceProvider(BoardReferenceProvider::class); + $context->registerReferenceProvider(CommentReferenceProvider::class); + + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class); + + // Event listening to emit UserShareAccessUpdatedEvent for files_sharing + $context->registerEventListener(AclCreatedEvent::class, AclCreatedRemovedListener::class); + $context->registerEventListener(AclDeletedEvent::class, AclCreatedRemovedListener::class); + + // Event listening for full text search indexing + $context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CardDeletedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclCreatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclUpdatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclDeletedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CommentAddedEvent::class, CommentEventListener::class); + $context->registerEventListener(BeforeCommentUpdatedEvent::class, CommentEventListener::class); + $context->registerEventListener(CommentUpdatedEvent::class, CommentEventListener::class); + $context->registerEventListener(CommentDeletedEvent::class, CommentEventListener::class); + + // Handling cache invalidation for collections + $context->registerEventListener(AclCreatedEvent::class, ResourceListener::class); + $context->registerEventListener(AclDeletedEvent::class, ResourceListener::class); + + $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); + $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); + $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); + + // Event listening for realtime updates via notify_push + $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(BoardUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclDeletedEvent::class, LiveUpdateListener::class); + + $context->registerNotifierService(Notifier::class); + $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); + + $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); + + $context->registerUserMigrator(DeckMigrator::class); + + $context->registerEventListener(SourceEvent::class, ShareReviewListener::class); + } + + public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { + $eventDispatcher->addListener(CommentsEntityEvent::EVENT_ENTITY, function (CommentsEntityEvent $event) { + $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function ($name) { + /** @var CardMapper */ + $cardMapper = $this->getContainer()->get(CardMapper::class); + /** @var PermissionService $permissionService */ + $permissionService = $this->getContainer()->get(PermissionService::class); + + try { + return $permissionService->checkPermission($cardMapper, (int)$name, Acl::PERMISSION_READ); + } catch (\Exception $e) { + return false; + } + }); + }); + } + + protected function registerCollaborationResources(IProviderManager $resourceManager): void { + $resourceManager->registerResourceProvider(ResourceProvider::class); + $resourceManager->registerResourceProvider(ResourceProviderCard::class); + } + + public function registerCloudFederationProviderManager( + IConfig $config, + ICloudFederationProviderManager $manager, + ): void { + $manager->addCloudFederationProvider( + DeckFederationProvider::PROVIDER_ID, + 'Deck Federation', + static fn (): ICloudFederationProvider => Server::get(DeckFederationProvider::class), + ); + } +} From cfcdc45dc15ce85ff2cd935f5c9857bd19d64c0d Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:31:55 +0200 Subject: [PATCH 06/12] style(sharereview): apply coding standards and Psalm fixes Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- tests/stub.phpstub | 1466 ++++++++++++++++++++++---------------------- 1 file changed, 739 insertions(+), 727 deletions(-) diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 8ca73d9dd..ccedacf04 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -1,727 +1,739 @@ - - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - - -namespace GuzzleHttp\Exception { - class ClientException extends \RuntimeException { - public function getResponse() { - } - } -} - - -namespace OC\Http\Client { - class Response implements \OCP\Http\Client\IResponse { - public function __construct(Psr\Http\Message\ResponseInterface $response, $stream = false) { - } - - public function getBody() { - } - - public function getStatusCode(): int { - } - - public function getHeader(string $key): string { - } - - public function getHeaders(): array { - } - } -} - -namespace OCA\Circles\Model { - class Member { - public const LEVEL_NONE = 0; - public const LEVEL_MEMBER = 1; - public const LEVEL_MODERATOR = 4; - public const LEVEL_ADMIN = 8; - public const LEVEL_OWNER = 9; - - public const TYPE_SINGLE = 0; - public const TYPE_USER = 1; - public const TYPE_GROUP = 2; - public const TYPE_MAIL = 4; - public const TYPE_CONTACT = 8; - public const TYPE_CIRCLE = 16; - public const TYPE_APP = 10000; - - public const ALLOWING_ALL_TYPES = 31; - - public const APP_CIRCLES = 10001; - public const APP_OCC = 10002; - public const APP_DEFAULT = 11000; - - public function getLevel(): int {} - public function getUserType(): int{} - } - - class Circle { - public function getUniqueId(): string {} - public function getDisplayName(): string {} - public function getOwner(): string {} - public function getSingleId(): string {} - public function getInheritedMembers(): array {} - public function getInitiator(): Member {} - } - - class FederatedUser { - } -} - -namespace OCA\Circles\Events { - class CircleDestroyedEvent extends \OCP\EventDispatcher\Event { - public function __construct(FederatedEvent $federatedEvent, array $results) {} - abstract public function getCircle(): \OCA\Circles\Model\Circle {} - } -} -namespace OCA\Circles\Model\Probes { - class CircleProbe { - public function __construct() {} - public function mustBeMember(bool $must = true): self {} - } - class DataProbe { - public const OWNER = 'd'; - public const INITIATOR = 'h'; - public function __construct() {} - public function add(string $key, array $path = []): self {} - public function mustBeMember(bool $must = true): self {} - } -} - -namespace OCA\Circles { - use OCA\Circles\Model\Circle; - use OCA\Circles\Model\FederatedUser; - use OCA\Circles\Model\Probes\CircleProbe; - use OCA\Circles\Model\Probes\DataProbe; - class CirclesManager { - public function startSuperSession(): void {} - public function startSession(?FederatedUser $federatedUser = null): void {} - public function getCircles(?CircleProbe $probe = null): array {} - public function getCircle(string $singleId, ?CircleProbe $probe = null): Circle {} - public function probeCircles(?CircleProbe $circleProbe = null, ?DataProbe $dataProbe = null): array {} - public function probeCircle(string $singleId, ?CircleProbe $probe = null, ?DataProbe $dataProbe = null): Circle {} - public function getFederatedUser(string $federatedId, int $type = Member::TYPE_SINGLE): FederatedUser {} - } -} - -namespace OCA\Files_Sharing\Event{ - class UserShareAccessUpdatedEvent extends \OCP\EventDispatcher\Event{ - public function __construct($user){} - } -} - -namespace { - - use OCP\IServerContainer; - - class OC { - static $CLI = false; - /** @var IServerContainer */ - static $server; - } -} - -namespace OC\Files\Node { - use OCP\Files\FileInfo; - abstract class Node implements \OCP\Files\Node { - /** @return FileInfo|\ArrayAccess */ - public function getFileInfo() {} - - /** @return \OCP\Files\Mount\IMountPoint */ - public function getMountPoint() {} - } -} - -namespace OC\Hooks { - class Emitter { - public function emit(string $class, string $value, array $option) {} - /** Closure $closure */ - public function listen(string $class, string $value, $closure) {} - } - class BasicEmitter extends Emitter { - } -} - -namespace OC\Cache { - /** - * @template T - */ - class CappedMemoryCache implements \ArrayAccess { - /** @return ?T */ - public function get($key) {} - /** @param T $value */ - public function set($key, $value, $ttl = '') {} - #[\ReturnTypeWillChange] - public function &offsetGet($offset) { } - public function offsetSet($offset, $value): void { } - public function offsetUnset($offset): void { } - } -} - -namespace OC\Core\Command { - use Symfony\Component\Console\Input\InputInterface; - use Symfony\Component\Console\Output\OutputInterface; - class Base { - public const OUTPUT_FORMAT_PLAIN = 'plain'; - public const OUTPUT_FORMAT_JSON = 'json'; - public const OUTPUT_FORMAT_JSON_PRETTY = 'json_pretty'; - - public function __construct() {} - protected function configure() {} - public function run(InputInterface $input, OutputInterface $output) {} - public function setName(string $name) {} - public function getHelper(string $name) {} - protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, $items, $prefix = ' - ') { - } - } -} - -namespace OC\Files\ObjectStore { - class NoopScanner {} -} - -namespace Symfony\Component\Console\Helper { - use Symfony\Component\Console\Output\OutputInterface; - class Table { - public function __construct(OutputInterface $text) {} - public function setHeaders(array $header) {} - public function setRows(array $rows) {} - public function render() {} - } -} - -namespace Symfony\Component\Console\Input { - class InputInterface { - public function getOption(string $key) {} - public function setOption(string $key, $value) {} - public function getArgument(string $key) {} - } - class InputArgument { - const REQUIRED = 0; - const OPTIONAL = 1; - const IS_ARRAY = 1; - } - class InputOption { - const VALUE_NONE = 1; - const VALUE_REQUIRED = 1; - const VALUE_OPTIONAL = 1; - } -} - -namespace Symfony\Component\Console\Question { - class ConfirmationQuestion { - public function __construct(string $text, bool $default) {} - } -} - -namespace Symfony\Component\Console\Output { - class OutputInterface { - public const VERBOSITY_VERBOSE = 1; - public function writeln($text, int $flat = 0) {} - public function isVerbose(): bool {} - public function isVeryVerbose(): bool {} - } -} - -namespace OC\Files\Cache { - use OCP\Files\Cache\ICache; - use OCP\Files\Cache\ICacheEntry; - use OCP\Files\Search\ISearchQuery; - use OCP\Files\Search\ISearchOperator; - use OCP\Files\Search\ISearchQuery; - use OCP\Files\IMimeTypeLoader; - - class Cache implements ICache { - /** - * @param \OCP\Files\Cache\ICache $cache - */ - public function __construct($cache) { - $this->cache = $cache; - } - public function getNumericStorageId() { } - public function get() { } - public function getIncomplete() {} - public function getPathById($id) {} - public function getAll() {} - public function get($file) {} - public function getFolderContents($folder) {} - public function getFolderContentsById($fileId) {} - public function put($file, array $data) {} - public function insert($file, array $data) {} - public function update($id, array $data) {} - public function getId($file) {} - public function getParentId($file) {} - public function inCache($file) {} - public function remove($file) {} - public function move($source, $target) {} - public function moveFromCache(ICache $sourceCache, $sourcePath, $targetPath) {} - public function clear() {} - public function getStatus($file) {} - public function search($pattern) {} - public function searchByMime($mimetype) {} - public function searchQuery(ISearchQuery $query) {} - public function correctFolderSize($path, $data = null, $isBackgroundScan = false) {} - public function copyFromCache(ICache $sourceCache, ICacheEntry $sourceEntry, string $targetPath): int {} - public function normalize($path) {} - public function getQueryFilterForStorage(): ISearchOperator {} - public function getCacheEntryFromSearchResult(ICacheEntry $rawEntry): ?ICacheEntry {} - public static function cacheEntryFromData($data, IMimeTypeLoader $mimetypeLoader): ICacheEntry {} - } -} - -namespace OC\Files\Cache\Wrapper { - use OC\Files\Cache\Cache; - class CacheWrapper extends Cache {} -} - - -namespace OC\Files { - use OCP\Files\Cache\ICacheEntry; - use OCP\Files\Mount\IMountPoint; - use OCP\IUser; - - class Filesystem { - public static function addStorageWrapper(string $wrapperName, callable $wrapper, int $priority = 50) { - } - public static function normalizePath(string $path): string {} - } - - class FileInfo implements \OCP\Files\FileInfo { - /** - * @param string|boolean $path - * @param \OCP\Files\Storage\IStorage $storage - * @param string $internalPath - * @param array|ICacheEntry $data - * @param \OCP\Files\Mount\IMountPoint $mount - * @param \OCP\IUser|null $owner - */ - public function __construct($path, $storage, $internalPath, $data, $mount, $owner = null) {} - } - class View { - public function __construct(string $path) {} - public function unlink($path) {} - public function is_dir($path): bool {} - public function mkdir($path) {} - public function getRoot(): string {} - public function getOwner(string $path): string {} - } -} - -namespace OC\User { - use OCP\UserInterface; - use OCP\IUser; - use Symfony\Component\EventDispatcher\EventDispatcherInterface; - class User implements IUser { - public function __construct(string $uid, ?UserInterface $backend, EventDispatcherInterface $dispatcher, $emitter = null, IConfig $config = null, $urlGenerator = null) {} - } -} - -namespace OCA\DAV\Upload { - - use Sabre\DAV\File; - - abstract class FutureFile extends File {} -} - -namespace OCA\DAV\Connector\Sabre { - - class Node { - public function getFileInfo(): \OCP\Files\FileInfo {} - } -} - -namespace OC\BackgroundJob { - - use OCP\BackgroundJob\IJob; - use OCP\BackgroundJob\IJobList; - use OCP\ILogger; - - abstract class TimedJob implements IJob { - public function execute(IJobList $jobList, ILogger $logger = null) { - } - - abstract protected function run($argument); - - public function setId(int $id) { - } - - public function setLastRun(int $lastRun) { - } - - public function setArgument($argument) { - } - - public function getId() { - } - - public function getLastRun() { - } - - public function getArgument() { - } - } -} - -namespace OC\Files\Mount { - use OC\Files\Filesystem; - use OC\Files\Storage\Storage; - use OC\Files\Storage\StorageFactory; - use OCP\Files\Mount\IMountPoint; - - class MountPoint implements IMountPoint { - /** - * @var \OC\Files\Storage\Storage $storage - */ - protected $storage = null; - protected $class; - protected $storageId; - protected $rootId = null; - - /** @var int|null */ - protected $mountId; - - /** - * @param string|\OCP\Files\Storage\IStorage $storage - * @param string $mountpoint - * @param array $arguments (optional) configuration for the storage backend - * @param \OCP\Files\Storage\IStorageFactory $loader - * @param array $mountOptions mount specific options - * @param int|null $mountId - * @throws \Exception - */ - public function __construct($storage, $mountpoint, $arguments = null, $loader = null, $mountOptions = null, $mountId = null) { - throw new \Exception('stub'); - } - - /** - * get complete path to the mount point, relative to data/ - * - * @return string - */ - public function getMountPoint() { - throw new \Exception('stub'); - } - - /** - * Sets the mount point path, relative to data/ - * - * @param string $mountPoint new mount point - */ - public function setMountPoint($mountPoint) { - throw new \Exception('stub'); - } - - /** - * @return \OCP\Files\Storage\IStorage - */ - public function getStorage() { - throw new \Exception('stub'); - } - - /** - * @return string - */ - public function getStorageId() { - throw new \Exception('stub'); - } - - /** - * @return int - */ - public function getNumericStorageId() { - throw new \Exception('stub'); - } - - /** - * @param string $path - * @return string - */ - public function getInternalPath($path) { - throw new \Exception('stub'); - } - - /** - * @param callable $wrapper - */ - public function wrapStorage($wrapper) { - throw new \Exception('stub'); - } - - /** - * Get a mount option - * - * @param string $name Name of the mount option to get - * @param mixed $default Default value for the mount option - * @return mixed - */ - public function getOption($name, $default) { - throw new \Exception('stub'); - } - - /** - * Get all options for the mount - * - * @return array - */ - public function getOptions() { - throw new \Exception('stub'); - } - - /** - * @return int - */ - public function getStorageRootId() { - throw new \Exception('stub'); - } - - public function getMountId() { - throw new \Exception('stub'); - } - - public function getMountType() { - throw new \Exception('stub'); - } - - public function getMountProvider(): string { - throw new \Exception('stub'); - } - } -} - -namespace OC\Files\Storage\Wrapper{ - - use OCP\Files\Cache\ICache; - use OCP\Files\Cache\ICacheEntry; - use OCP\Files\Search\ISearchQuery; - use OCP\Files\Storage\IStorage; - - class Wrapper implements IStorage { - public function __construct(array $parameters) { - } - - public function getWrapperStorage(): ?IStorage {} - - public function getId() {} - - public function mkdir($path) {} - - public function rmdir($path) {} - - public function opendir($path) { - throw new \Exception('stub'); - } - - public function is_dir($path) { - throw new \Exception('stub'); - } - - public function is_file($path) { - throw new \Exception('stub'); - } - - public function stat($path) { - throw new \Exception('stub'); - } - - public function filetype($path) { - throw new \Exception('stub'); - } - - public function filesize($path) { - throw new \Exception('stub'); - } - - public function isCreatable($path) { - throw new \Exception('stub'); - } - - public function isReadable($path) { - throw new \Exception('stub'); - } - - public function isUpdatable($path) { - throw new \Exception('stub'); - } - - public function isDeletable($path) { - throw new \Exception('stub'); - } - - public function isSharable($path) { - throw new \Exception('stub'); - } - - public function getPermissions($path) { - throw new \Exception('stub'); - } - - public function file_exists($path) { - throw new \Exception('stub'); - } - - public function filemtime($path) { - throw new \Exception('stub'); - } - - public function file_get_contents($path) { - throw new \Exception('stub'); - } - - public function file_put_contents($path, $data) { - throw new \Exception('stub'); - } - - public function unlink($path) { - throw new \Exception('stub'); - } - - public function rename($path1, $path2) { - throw new \Exception('stub'); - } - - public function copy($path1, $path2) { - throw new \Exception('stub'); - } - - public function fopen($path, $mode) { - throw new \Exception('stub'); - } - - public function getMimeType($path) { - throw new \Exception('stub'); - } - - public function hash($type, $path, $raw = false) { - throw new \Exception('stub'); - } - - public function free_space($path) { - throw new \Exception('stub'); - } - - public function touch($path, $mtime = null) { - throw new \Exception('stub'); - } - - public function getLocalFile($path) { - throw new \Exception('stub'); - } - - public function hasUpdated($path, $time) { - throw new \Exception('stub'); - } - - public function getETag($path) { - throw new \Exception('stub'); - } - - public function isLocal() { - throw new \Exception('stub'); - } - - public function instanceOfStorage($class) { - throw new \Exception('stub'); - } - - public function getDirectDownload($path) { - throw new \Exception('stub'); - } - - public function verifyPath($path, $fileName) { - throw new \Exception('stub'); - } - - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - throw new \Exception('stub'); - } - - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - throw new \Exception('stub'); - } - - public function test() { - throw new \Exception('stub'); - } - - public function getAvailability() { - throw new \Exception('stub'); - } - - public function setAvailability($isAvailable) { - throw new \Exception('stub'); - } - - public function getOwner($path) { - throw new \Exception('stub'); - } - - public function getCache() { - throw new \Exception('stub'); - } - - public function getPropagator() { - throw new \Exception('stub'); - } - - public function getScanner() { - throw new \Exception('stub'); - } - - public function getUpdater() { - throw new \Exception('stub'); - } - - public function getWatcher() { - throw new \Exception('stub'); - } - } - - class Jail extends Wrapper { - public function getUnjailedPath(string $path): string {} - } - - class Quota extends Wrapper { - public function getQuota() {} - } - - class PermissionsMask extends Wrapper { - public function getQuota() {} - } -} - - -namespace OCA\NotifyPush\Queue { - - interface IQueue { - /** - * @param string $channel - * @param mixed $message - * @return void - */ - public function push(string $channel, $message); - } -} - -namespace OCA\Text\Event { - class LoadEditor extends \OCP\EventDispatcher\Event {} -} + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + + +namespace GuzzleHttp\Exception { + class ClientException extends \RuntimeException { + public function getResponse() { + } + } +} + + +namespace OC\Http\Client { + class Response implements \OCP\Http\Client\IResponse { + public function __construct(Psr\Http\Message\ResponseInterface $response, $stream = false) { + } + + public function getBody() { + } + + public function getStatusCode(): int { + } + + public function getHeader(string $key): string { + } + + public function getHeaders(): array { + } + } +} + +namespace OCA\Circles\Model { + class Member { + public const LEVEL_NONE = 0; + public const LEVEL_MEMBER = 1; + public const LEVEL_MODERATOR = 4; + public const LEVEL_ADMIN = 8; + public const LEVEL_OWNER = 9; + + public const TYPE_SINGLE = 0; + public const TYPE_USER = 1; + public const TYPE_GROUP = 2; + public const TYPE_MAIL = 4; + public const TYPE_CONTACT = 8; + public const TYPE_CIRCLE = 16; + public const TYPE_APP = 10000; + + public const ALLOWING_ALL_TYPES = 31; + + public const APP_CIRCLES = 10001; + public const APP_OCC = 10002; + public const APP_DEFAULT = 11000; + + public function getLevel(): int {} + public function getUserType(): int{} + } + + class Circle { + public function getUniqueId(): string {} + public function getDisplayName(): string {} + public function getOwner(): string {} + public function getSingleId(): string {} + public function getInheritedMembers(): array {} + public function getInitiator(): Member {} + } + + class FederatedUser { + } +} + +namespace OCA\Circles\Events { + class CircleDestroyedEvent extends \OCP\EventDispatcher\Event { + public function __construct(FederatedEvent $federatedEvent, array $results) {} + abstract public function getCircle(): \OCA\Circles\Model\Circle {} + } +} +namespace OCA\Circles\Model\Probes { + class CircleProbe { + public function __construct() {} + public function mustBeMember(bool $must = true): self {} + } + class DataProbe { + public const OWNER = 'd'; + public const INITIATOR = 'h'; + public function __construct() {} + public function add(string $key, array $path = []): self {} + public function mustBeMember(bool $must = true): self {} + } +} + +namespace OCA\Circles { + use OCA\Circles\Model\Circle; + use OCA\Circles\Model\FederatedUser; + use OCA\Circles\Model\Probes\CircleProbe; + use OCA\Circles\Model\Probes\DataProbe; + class CirclesManager { + public function startSuperSession(): void {} + public function startSession(?FederatedUser $federatedUser = null): void {} + public function getCircles(?CircleProbe $probe = null): array {} + public function getCircle(string $singleId, ?CircleProbe $probe = null): Circle {} + public function probeCircles(?CircleProbe $circleProbe = null, ?DataProbe $dataProbe = null): array {} + public function probeCircle(string $singleId, ?CircleProbe $probe = null, ?DataProbe $dataProbe = null): Circle {} + public function getFederatedUser(string $federatedId, int $type = Member::TYPE_SINGLE): FederatedUser {} + } +} + +namespace OCA\Files_Sharing\Event{ + class UserShareAccessUpdatedEvent extends \OCP\EventDispatcher\Event{ + public function __construct($user){} + } +} + +namespace { + + use OCP\IServerContainer; + + class OC { + static $CLI = false; + /** @var IServerContainer */ + static $server; + } +} + +namespace OC\Files\Node { + use OCP\Files\FileInfo; + abstract class Node implements \OCP\Files\Node { + /** @return FileInfo|\ArrayAccess */ + public function getFileInfo() {} + + /** @return \OCP\Files\Mount\IMountPoint */ + public function getMountPoint() {} + } +} + +namespace OC\Hooks { + class Emitter { + public function emit(string $class, string $value, array $option) {} + /** Closure $closure */ + public function listen(string $class, string $value, $closure) {} + } + class BasicEmitter extends Emitter { + } +} + +namespace OC\Cache { + /** + * @template T + */ + class CappedMemoryCache implements \ArrayAccess { + /** @return ?T */ + public function get($key) {} + /** @param T $value */ + public function set($key, $value, $ttl = '') {} + #[\ReturnTypeWillChange] + public function &offsetGet($offset) { } + public function offsetSet($offset, $value): void { } + public function offsetUnset($offset): void { } + } +} + +namespace OC\Core\Command { + use Symfony\Component\Console\Input\InputInterface; + use Symfony\Component\Console\Output\OutputInterface; + class Base { + public const OUTPUT_FORMAT_PLAIN = 'plain'; + public const OUTPUT_FORMAT_JSON = 'json'; + public const OUTPUT_FORMAT_JSON_PRETTY = 'json_pretty'; + + public function __construct() {} + protected function configure() {} + public function run(InputInterface $input, OutputInterface $output) {} + public function setName(string $name) {} + public function getHelper(string $name) {} + protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, $items, $prefix = ' - ') { + } + } +} + +namespace OC\Files\ObjectStore { + class NoopScanner {} +} + +namespace Symfony\Component\Console\Helper { + use Symfony\Component\Console\Output\OutputInterface; + class Table { + public function __construct(OutputInterface $text) {} + public function setHeaders(array $header) {} + public function setRows(array $rows) {} + public function render() {} + } +} + +namespace Symfony\Component\Console\Input { + class InputInterface { + public function getOption(string $key) {} + public function setOption(string $key, $value) {} + public function getArgument(string $key) {} + } + class InputArgument { + const REQUIRED = 0; + const OPTIONAL = 1; + const IS_ARRAY = 1; + } + class InputOption { + const VALUE_NONE = 1; + const VALUE_REQUIRED = 1; + const VALUE_OPTIONAL = 1; + } +} + +namespace Symfony\Component\Console\Question { + class ConfirmationQuestion { + public function __construct(string $text, bool $default) {} + } +} + +namespace Symfony\Component\Console\Output { + class OutputInterface { + public const VERBOSITY_VERBOSE = 1; + public function writeln($text, int $flat = 0) {} + public function isVerbose(): bool {} + public function isVeryVerbose(): bool {} + } +} + +namespace OC\Files\Cache { + use OCP\Files\Cache\ICache; + use OCP\Files\Cache\ICacheEntry; + use OCP\Files\Search\ISearchQuery; + use OCP\Files\Search\ISearchOperator; + use OCP\Files\Search\ISearchQuery; + use OCP\Files\IMimeTypeLoader; + + class Cache implements ICache { + /** + * @param \OCP\Files\Cache\ICache $cache + */ + public function __construct($cache) { + $this->cache = $cache; + } + public function getNumericStorageId() { } + public function get() { } + public function getIncomplete() {} + public function getPathById($id) {} + public function getAll() {} + public function get($file) {} + public function getFolderContents($folder) {} + public function getFolderContentsById($fileId) {} + public function put($file, array $data) {} + public function insert($file, array $data) {} + public function update($id, array $data) {} + public function getId($file) {} + public function getParentId($file) {} + public function inCache($file) {} + public function remove($file) {} + public function move($source, $target) {} + public function moveFromCache(ICache $sourceCache, $sourcePath, $targetPath) {} + public function clear() {} + public function getStatus($file) {} + public function search($pattern) {} + public function searchByMime($mimetype) {} + public function searchQuery(ISearchQuery $query) {} + public function correctFolderSize($path, $data = null, $isBackgroundScan = false) {} + public function copyFromCache(ICache $sourceCache, ICacheEntry $sourceEntry, string $targetPath): int {} + public function normalize($path) {} + public function getQueryFilterForStorage(): ISearchOperator {} + public function getCacheEntryFromSearchResult(ICacheEntry $rawEntry): ?ICacheEntry {} + public static function cacheEntryFromData($data, IMimeTypeLoader $mimetypeLoader): ICacheEntry {} + } +} + +namespace OC\Files\Cache\Wrapper { + use OC\Files\Cache\Cache; + class CacheWrapper extends Cache {} +} + + +namespace OC\Files { + use OCP\Files\Cache\ICacheEntry; + use OCP\Files\Mount\IMountPoint; + use OCP\IUser; + + class Filesystem { + public static function addStorageWrapper(string $wrapperName, callable $wrapper, int $priority = 50) { + } + public static function normalizePath(string $path): string {} + } + + class FileInfo implements \OCP\Files\FileInfo { + /** + * @param string|boolean $path + * @param \OCP\Files\Storage\IStorage $storage + * @param string $internalPath + * @param array|ICacheEntry $data + * @param \OCP\Files\Mount\IMountPoint $mount + * @param \OCP\IUser|null $owner + */ + public function __construct($path, $storage, $internalPath, $data, $mount, $owner = null) {} + } + class View { + public function __construct(string $path) {} + public function unlink($path) {} + public function is_dir($path): bool {} + public function mkdir($path) {} + public function getRoot(): string {} + public function getOwner(string $path): string {} + } +} + +namespace OC\User { + use OCP\UserInterface; + use OCP\IUser; + use Symfony\Component\EventDispatcher\EventDispatcherInterface; + class User implements IUser { + public function __construct(string $uid, ?UserInterface $backend, EventDispatcherInterface $dispatcher, $emitter = null, IConfig $config = null, $urlGenerator = null) {} + } +} + +namespace OCA\DAV\Upload { + + use Sabre\DAV\File; + + abstract class FutureFile extends File {} +} + +namespace OCA\DAV\Connector\Sabre { + + class Node { + public function getFileInfo(): \OCP\Files\FileInfo {} + } +} + +namespace OC\BackgroundJob { + + use OCP\BackgroundJob\IJob; + use OCP\BackgroundJob\IJobList; + use OCP\ILogger; + + abstract class TimedJob implements IJob { + public function execute(IJobList $jobList, ILogger $logger = null) { + } + + abstract protected function run($argument); + + public function setId(int $id) { + } + + public function setLastRun(int $lastRun) { + } + + public function setArgument($argument) { + } + + public function getId() { + } + + public function getLastRun() { + } + + public function getArgument() { + } + } +} + +namespace OC\Files\Mount { + use OC\Files\Filesystem; + use OC\Files\Storage\Storage; + use OC\Files\Storage\StorageFactory; + use OCP\Files\Mount\IMountPoint; + + class MountPoint implements IMountPoint { + /** + * @var \OC\Files\Storage\Storage $storage + */ + protected $storage = null; + protected $class; + protected $storageId; + protected $rootId = null; + + /** @var int|null */ + protected $mountId; + + /** + * @param string|\OCP\Files\Storage\IStorage $storage + * @param string $mountpoint + * @param array $arguments (optional) configuration for the storage backend + * @param \OCP\Files\Storage\IStorageFactory $loader + * @param array $mountOptions mount specific options + * @param int|null $mountId + * @throws \Exception + */ + public function __construct($storage, $mountpoint, $arguments = null, $loader = null, $mountOptions = null, $mountId = null) { + throw new \Exception('stub'); + } + + /** + * get complete path to the mount point, relative to data/ + * + * @return string + */ + public function getMountPoint() { + throw new \Exception('stub'); + } + + /** + * Sets the mount point path, relative to data/ + * + * @param string $mountPoint new mount point + */ + public function setMountPoint($mountPoint) { + throw new \Exception('stub'); + } + + /** + * @return \OCP\Files\Storage\IStorage + */ + public function getStorage() { + throw new \Exception('stub'); + } + + /** + * @return string + */ + public function getStorageId() { + throw new \Exception('stub'); + } + + /** + * @return int + */ + public function getNumericStorageId() { + throw new \Exception('stub'); + } + + /** + * @param string $path + * @return string + */ + public function getInternalPath($path) { + throw new \Exception('stub'); + } + + /** + * @param callable $wrapper + */ + public function wrapStorage($wrapper) { + throw new \Exception('stub'); + } + + /** + * Get a mount option + * + * @param string $name Name of the mount option to get + * @param mixed $default Default value for the mount option + * @return mixed + */ + public function getOption($name, $default) { + throw new \Exception('stub'); + } + + /** + * Get all options for the mount + * + * @return array + */ + public function getOptions() { + throw new \Exception('stub'); + } + + /** + * @return int + */ + public function getStorageRootId() { + throw new \Exception('stub'); + } + + public function getMountId() { + throw new \Exception('stub'); + } + + public function getMountType() { + throw new \Exception('stub'); + } + + public function getMountProvider(): string { + throw new \Exception('stub'); + } + } +} + +namespace OC\Files\Storage\Wrapper{ + + use OCP\Files\Cache\ICache; + use OCP\Files\Cache\ICacheEntry; + use OCP\Files\Search\ISearchQuery; + use OCP\Files\Storage\IStorage; + + class Wrapper implements IStorage { + public function __construct(array $parameters) { + } + + public function getWrapperStorage(): ?IStorage {} + + public function getId() {} + + public function mkdir($path) {} + + public function rmdir($path) {} + + public function opendir($path) { + throw new \Exception('stub'); + } + + public function is_dir($path) { + throw new \Exception('stub'); + } + + public function is_file($path) { + throw new \Exception('stub'); + } + + public function stat($path) { + throw new \Exception('stub'); + } + + public function filetype($path) { + throw new \Exception('stub'); + } + + public function filesize($path) { + throw new \Exception('stub'); + } + + public function isCreatable($path) { + throw new \Exception('stub'); + } + + public function isReadable($path) { + throw new \Exception('stub'); + } + + public function isUpdatable($path) { + throw new \Exception('stub'); + } + + public function isDeletable($path) { + throw new \Exception('stub'); + } + + public function isSharable($path) { + throw new \Exception('stub'); + } + + public function getPermissions($path) { + throw new \Exception('stub'); + } + + public function file_exists($path) { + throw new \Exception('stub'); + } + + public function filemtime($path) { + throw new \Exception('stub'); + } + + public function file_get_contents($path) { + throw new \Exception('stub'); + } + + public function file_put_contents($path, $data) { + throw new \Exception('stub'); + } + + public function unlink($path) { + throw new \Exception('stub'); + } + + public function rename($path1, $path2) { + throw new \Exception('stub'); + } + + public function copy($path1, $path2) { + throw new \Exception('stub'); + } + + public function fopen($path, $mode) { + throw new \Exception('stub'); + } + + public function getMimeType($path) { + throw new \Exception('stub'); + } + + public function hash($type, $path, $raw = false) { + throw new \Exception('stub'); + } + + public function free_space($path) { + throw new \Exception('stub'); + } + + public function touch($path, $mtime = null) { + throw new \Exception('stub'); + } + + public function getLocalFile($path) { + throw new \Exception('stub'); + } + + public function hasUpdated($path, $time) { + throw new \Exception('stub'); + } + + public function getETag($path) { + throw new \Exception('stub'); + } + + public function isLocal() { + throw new \Exception('stub'); + } + + public function instanceOfStorage($class) { + throw new \Exception('stub'); + } + + public function getDirectDownload($path) { + throw new \Exception('stub'); + } + + public function verifyPath($path, $fileName) { + throw new \Exception('stub'); + } + + public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + throw new \Exception('stub'); + } + + public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + throw new \Exception('stub'); + } + + public function test() { + throw new \Exception('stub'); + } + + public function getAvailability() { + throw new \Exception('stub'); + } + + public function setAvailability($isAvailable) { + throw new \Exception('stub'); + } + + public function getOwner($path) { + throw new \Exception('stub'); + } + + public function getCache() { + throw new \Exception('stub'); + } + + public function getPropagator() { + throw new \Exception('stub'); + } + + public function getScanner() { + throw new \Exception('stub'); + } + + public function getUpdater() { + throw new \Exception('stub'); + } + + public function getWatcher() { + throw new \Exception('stub'); + } + } + + class Jail extends Wrapper { + public function getUnjailedPath(string $path): string {} + } + + class Quota extends Wrapper { + public function getQuota() {} + } + + class PermissionsMask extends Wrapper { + public function getQuota() {} + } +} + + +namespace OCA\NotifyPush\Queue { + + interface IQueue { + /** + * @param string $channel + * @param mixed $message + * @return void + */ + public function push(string $channel, $message); + } +} + +namespace OCA\Text\Event { + class LoadEditor extends \OCP\EventDispatcher\Event {} +} + +namespace OCA\ShareReview\Sources { + class SourceEvent extends \OCP\EventDispatcher\Event { + abstract public function registerSource(string $source): void {} + } + + interface ISource { + public function getName(): string; + public function getShares(): array; + public function deleteShare(string $shareId): bool; + } +} From 70a69615ed9d69162cc459e46adb8672f371f15b Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 11 Jun 2026 14:33:26 +0200 Subject: [PATCH 07/12] test(sharereview): add unit tests for ShareReviewSource Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- tests/bootstrap.php | 56 ++-- .../ShareReview/ShareReviewSourceTest.php | 264 ++++++++++++++++++ tests/unit/ShareReview/Stubs.php | 22 ++ 3 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 tests/unit/ShareReview/ShareReviewSourceTest.php create mode 100644 tests/unit/ShareReview/Stubs.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dd0c08c49..4b91f1f30 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,26 +1,30 @@ - - * - * @author Julius Härtl - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -require_once __DIR__ . '/../../../tests/bootstrap.php'; -require_once __DIR__ . '/../appinfo/autoload.php'; + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +require_once __DIR__ . '/../../../tests/bootstrap.php'; +require_once __DIR__ . '/../appinfo/autoload.php'; + +if (!interface_exists('OCA\ShareReview\Sources\ISource')) { + require_once __DIR__ . '/unit/ShareReview/Stubs.php'; +} diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php new file mode 100644 index 000000000..3332493b8 --- /dev/null +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -0,0 +1,264 @@ +db = $this->createMock(IDBConnection::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->source = new ShareReviewSource($this->db, $this->logger); + } + + private function makeResult(array $rows): MockObject { + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($rows); + $result->method('closeCursor')->willReturn(true); + return $result; + } + + private function makeQb(array $fetchRows = [], int $statementRows = 0): MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('addSelect')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('delete')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('createFunction')->willReturnArgument(0); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willReturn($this->makeResult($fetchRows)); + $qb->method('executeStatement')->willReturn($statementRows); + + return $qb; + } + + private function makeThrowingQb(): MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('addSelect')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('delete')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('createFunction')->willReturnArgument(0); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willThrowException($this->createMock(Exception::class)); + $qb->method('executeStatement')->willThrowException($this->createMock(Exception::class)); + return $qb; + } + + /** @param array $overrides */ + private function makeShareRow(array $overrides = []): array { + return array_merge([ + 'id' => 1, + 'type' => 0, + 'participant' => 'bob', + 'board_title' => 'My Board', + 'board_owner' => 'alice', + 'permission_edit' => 0, + 'permission_share' => 0, + 'permission_manage' => 0, + ], $overrides); + } + + public function testGetName(): void { + $this->assertSame('Deck', $this->source->getName()); + } + + public function testGetSharesEmpty(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb()); + + $this->assertSame([], $this->source->getShares()); + } + + public function testGetSharesUserShare(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([$this->makeShareRow()])); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $share = $shares[0]; + $this->assertSame(1, $share['id']); + $this->assertSame('Deck', $share['app']); + $this->assertSame('My Board (Board)', $share['object']); + $this->assertSame('alice', $share['initiator']); + $this->assertSame(IShare::TYPE_USER, $share['type']); + $this->assertSame('bob', $share['recipient']); + $this->assertSame(Constants::PERMISSION_READ, $share['permissions']); + $this->assertFalse($share['password']); + $this->assertSame('1970-01-01 01:00:00', $share['time']); + $this->assertSame('', $share['action']); + } + + public function testGetSharesGroupShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 1, 'participant' => 'developers'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $this->assertSame(IShare::TYPE_GROUP, $shares[0]['type']); + $this->assertSame('developers', $shares[0]['recipient']); + } + + public function testGetSharesCircleShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 7, 'participant' => 'circle-uid'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_CIRCLE, $shares[0]['type']); + } + + public function testGetSharesRemoteShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 6, 'participant' => 'user@remote.example'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_REMOTE, $shares[0]['type']); + } + + public function testGetSharesMissingBoardFallback(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['id' => 42, 'board_title' => null, 'board_owner' => null])]) + ); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $this->assertSame('Board 42 (Board)', $shares[0]['object']); + } + + public function testGetSharesReturnsEmptyOnDbException(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); + $this->logger->expects($this->once())->method('error'); + + $this->assertSame([], $this->source->getShares()); + } + + public function testComputePermissionsAllFalse(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow([ + 'permission_edit' => 0, + 'permission_share' => 0, + 'permission_manage' => 0, + ])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(Constants::PERMISSION_READ, $shares[0]['permissions']); + } + + public function testComputePermissionsEditFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_edit' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsShareFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_share' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_SHARE; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsManageFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_manage' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | 32; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsAllTrue(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow([ + 'permission_edit' => 1, + 'permission_share' => 1, + 'permission_manage' => 1, + ])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ + | Constants::PERMISSION_UPDATE + | Constants::PERMISSION_CREATE + | Constants::PERMISSION_DELETE + | Constants::PERMISSION_SHARE + | 32; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testDeleteShareSuccess(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 1)); + $this->logger->expects($this->once())->method('info'); + + $this->assertTrue($this->source->deleteShare('7')); + } + + public function testDeleteShareNotFound(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 0)); + $this->logger->expects($this->once())->method('info'); + + $this->assertFalse($this->source->deleteShare('99')); + } + + public function testDeleteShareReturnsFalseOnDbException(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); + $this->logger->expects($this->once())->method('info'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($this->source->deleteShare('7')); + } +} diff --git a/tests/unit/ShareReview/Stubs.php b/tests/unit/ShareReview/Stubs.php new file mode 100644 index 000000000..80ec8a517 --- /dev/null +++ b/tests/unit/ShareReview/Stubs.php @@ -0,0 +1,22 @@ + Date: Thu, 11 Jun 2026 14:36:41 +0200 Subject: [PATCH 08/12] fix(sharereview): harden and optimize implementation and testing Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewSource.php | 8 ++++---- tests/unit/ShareReview/ShareReviewSourceTest.php | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index c558e0067..dddf26d8d 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -76,11 +76,11 @@ private function fetchAllShares(): array { try { $qb = $this->db->getQueryBuilder(); $qb->select( - 'a.id', 'a.type', 'a.participant', + 'a.id', 'a.board_id', 'a.type', 'a.participant', 'a.permission_edit', 'a.permission_share', 'a.permission_manage' ) - ->addSelect($qb->createFunction('b.title AS board_title')) - ->addSelect($qb->createFunction('b.owner AS board_owner')) + ->selectAlias('b.title', 'board_title') + ->selectAlias('b.owner', 'board_owner') ->from(self::ACL_TABLE, 'a') ->leftJoin('a', self::BOARDS_TABLE, 'b', $qb->expr()->eq('a.board_id', 'b.id')) ->orderBy('a.id', 'ASC'); @@ -97,7 +97,7 @@ private function fetchAllShares(): array { /** @param array $share */ private function resolveObjectName(array $share): string { $title = (string)($share['board_title'] ?? ''); - $boardId = (int)$share['id']; + $boardId = (int)($share['board_id'] ?? $share['id']); return ($title !== '' ? $title : "Board $boardId") . ' (Board)'; } diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php index 3332493b8..683e77df7 100644 --- a/tests/unit/ShareReview/ShareReviewSourceTest.php +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -47,13 +47,13 @@ private function makeQb(array $fetchRows = [], int $statementRows = 0): MockObje $qb = $this->createMock(IQueryBuilder::class); $qb->method('select')->willReturnSelf(); $qb->method('addSelect')->willReturnSelf(); + $qb->method('selectAlias')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('leftJoin')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('orderBy')->willReturnSelf(); $qb->method('delete')->willReturnSelf(); $qb->method('createNamedParameter')->willReturn('?'); - $qb->method('createFunction')->willReturnArgument(0); $qb->method('expr')->willReturn($expr); $qb->method('executeQuery')->willReturn($this->makeResult($fetchRows)); $qb->method('executeStatement')->willReturn($statementRows); @@ -67,13 +67,13 @@ private function makeThrowingQb(): MockObject { $qb = $this->createMock(IQueryBuilder::class); $qb->method('select')->willReturnSelf(); $qb->method('addSelect')->willReturnSelf(); + $qb->method('selectAlias')->willReturnSelf(); $qb->method('from')->willReturnSelf(); $qb->method('leftJoin')->willReturnSelf(); $qb->method('where')->willReturnSelf(); $qb->method('orderBy')->willReturnSelf(); $qb->method('delete')->willReturnSelf(); $qb->method('createNamedParameter')->willReturn('?'); - $qb->method('createFunction')->willReturnArgument(0); $qb->method('expr')->willReturn($expr); $qb->method('executeQuery')->willThrowException($this->createMock(Exception::class)); $qb->method('executeStatement')->willThrowException($this->createMock(Exception::class)); @@ -84,6 +84,7 @@ private function makeThrowingQb(): MockObject { private function makeShareRow(array $overrides = []): array { return array_merge([ 'id' => 1, + 'board_id' => 10, 'type' => 0, 'participant' => 'bob', 'board_title' => 'My Board', @@ -157,7 +158,7 @@ public function testGetSharesRemoteShare(): void { public function testGetSharesMissingBoardFallback(): void { $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['id' => 42, 'board_title' => null, 'board_owner' => null])]) + $this->makeQb([$this->makeShareRow(['board_id' => 42, 'board_title' => null, 'board_owner' => null])]) ); $shares = $this->source->getShares(); From 28a991f588c122bcd0e7e890f760bc493af34478 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Fri, 19 Jun 2026 20:22:03 +0200 Subject: [PATCH 09/12] fix: Use the new share meta-data instead of a hard-coded date Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/ShareReview/ShareReviewSource.php | 4 ++-- tests/unit/ShareReview/ShareReviewSourceTest.php | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index dddf26d8d..1c8014a4f 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -51,7 +51,7 @@ public function getShares(): array { 'recipient' => (string)$share['participant'], 'permissions' => $this->computePermissions($share), 'password' => false, - 'time' => '1970-01-01 01:00:00', + 'time' => date('Y-m-d H:i:s', max((int)$share['created_at'], (int)$share['last_modified_at'])), 'action' => '', ]; } @@ -77,7 +77,7 @@ private function fetchAllShares(): array { $qb = $this->db->getQueryBuilder(); $qb->select( 'a.id', 'a.board_id', 'a.type', 'a.participant', - 'a.permission_edit', 'a.permission_share', 'a.permission_manage' + 'a.permission_edit', 'a.permission_share', 'a.permission_manage', 'a.created_at', 'a.last_modified_at' ) ->selectAlias('b.title', 'board_title') ->selectAlias('b.owner', 'board_owner') diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php index 683e77df7..82dfbf0c8 100644 --- a/tests/unit/ShareReview/ShareReviewSourceTest.php +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -92,6 +92,8 @@ private function makeShareRow(array $overrides = []): array { 'permission_edit' => 0, 'permission_share' => 0, 'permission_manage' => 0, + 'created_at' => 1700000000, + 'last_modified_at' => 0, ], $overrides); } @@ -120,10 +122,20 @@ public function testGetSharesUserShare(): void { $this->assertSame('bob', $share['recipient']); $this->assertSame(Constants::PERMISSION_READ, $share['permissions']); $this->assertFalse($share['password']); - $this->assertSame('1970-01-01 01:00:00', $share['time']); + $this->assertSame(date('Y-m-d H:i:s', 1700000000), $share['time']); $this->assertSame('', $share['action']); } + public function testGetSharesUsesLastModifiedAtWhenNewer(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['created_at' => 1700000000, 'last_modified_at' => 1800000000])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(date('Y-m-d H:i:s', 1800000000), $shares[0]['time']); + } + public function testGetSharesGroupShare(): void { $this->db->method('getQueryBuilder')->willReturn( $this->makeQb([$this->makeShareRow(['type' => 1, 'participant' => 'developers'])]) From dbdce8ed838bdec2b0bec4bde83aaae2108d7a1e Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 22 Jun 2026 14:20:03 +0200 Subject: [PATCH 10/12] feat(sharereview): gate ACL deletion behind event-based access check Assisted-by: Claude Code:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/Service/BoardService.php | 16 +++++ .../ShareReviewAccessCheckEvent.php | 37 ++++++++++ lib/ShareReview/ShareReviewSource.php | 26 +++++-- tests/unit/Service/BoardServiceTest.php | 30 ++++++++ .../ShareReviewAccessCheckEventTest.php | 58 ++++++++++++++++ .../ShareReview/ShareReviewSourceTest.php | 69 +++++++++++++++---- 6 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 lib/ShareReview/ShareReviewAccessCheckEvent.php create mode 100644 tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 1610352ae..9a0408261 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -515,6 +515,22 @@ public function deleteAcl(int $id): ?Acl { return $deletedAcl; } + /** + * Delete an ACL entry on behalf of a trusted share-review operation. + * + * PERMISSION_MANAGE is intentionally not checked. The caller must verify + * operator access via ShareReviewAccessCheckEvent before invoking this + * method. All other side effects are preserved so the deletion is auditable. + * + * @throws \OCP\AppFramework\Db\DoesNotExistException if $aclId does not exist + */ + public function deleteAclForShareReview(int $aclId): void { + $acl = $this->aclMapper->find($aclId); + $this->aclMapper->delete($acl); + $this->changeHelper->boardChanged($acl->getBoardId()); + $this->eventDispatcher->dispatchTyped(new AclDeletedEvent($acl)); + } + public function leave(int $boardId): ?Acl { if ($this->permissionService->userIsBoardOwner($boardId)) { throw new BadRequestException('Board owner cannot leave board'); diff --git a/lib/ShareReview/ShareReviewAccessCheckEvent.php b/lib/ShareReview/ShareReviewAccessCheckEvent.php new file mode 100644 index 000000000..5f559d105 --- /dev/null +++ b/lib/ShareReview/ShareReviewAccessCheckEvent.php @@ -0,0 +1,37 @@ +handled && !$this->granted) { + return; // a prior denyAccess() cannot be escalated to a grant — deny wins + } + $this->handled = true; + $this->granted = true; + } + + public function denyAccess(string $reason): void { + $this->handled = true; + $this->granted = false; + $this->reason = $reason; + } + + public function isHandled(): bool { return $this->handled; } + public function isGranted(): bool { return $this->granted; } + public function getReason(): ?string { return $this->reason; } +} diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index 1c8014a4f..293805925 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -10,10 +10,13 @@ namespace OCA\Deck\ShareReview; use OCA\Deck\Db\Acl; +use OCA\Deck\Service\BoardService; use OCA\ShareReview\Sources\ISource; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use OCP\Share\IShare; use Psr\Log\LoggerInterface; @@ -27,6 +30,8 @@ class ShareReviewSource implements ISource { public function __construct( private IDBConnection $db, private LoggerInterface $logger, + private readonly BoardService $boardService, + private readonly IEventDispatcher $eventDispatcher, ) { } @@ -59,14 +64,21 @@ public function getShares(): array { } public function deleteShare(string $shareId): bool { - $this->logger->info('Deck ShareReview: deleting share {id}', ['id' => $shareId]); + if (!is_numeric($shareId)) { + return false; + } + + $event = new ShareReviewAccessCheckEvent(); + $this->eventDispatcher->dispatchTyped($event); + + if (!$event->isHandled() || !$event->isGranted()) { + return false; + } + try { - $qb = $this->db->getQueryBuilder(); - $qb->delete(self::ACL_TABLE) - ->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareId, IQueryBuilder::PARAM_INT))); - return $qb->executeStatement() > 0; - } catch (Exception $e) { - $this->logger->error('Deck ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]); + $this->boardService->deleteAclForShareReview((int) $shareId); + return true; + } catch (DoesNotExistException) { return false; } } diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index e28a133a7..dd8105646 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -502,4 +502,34 @@ public function testDeleteAcl() { ->with(new AclDeletedEvent($acl)); $this->assertEquals($acl, $this->service->deleteAcl(123)); } + + public function testDeleteAclForShareReviewCallsSideEffects(): void { + $acl = new Acl(); + $acl->setBoardId(99); + $this->aclMapper->expects($this->once()) + ->method('find') + ->with(42) + ->willReturn($acl); + $this->aclMapper->expects($this->once()) + ->method('delete') + ->with($acl); + $this->changeHelper->expects($this->once()) + ->method('boardChanged') + ->with(99); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with(new AclDeletedEvent($acl)); + + $this->service->deleteAclForShareReview(42); + } + + public function testDeleteAclForShareReviewPropagatesDoesNotExist(): void { + $this->aclMapper->expects($this->once()) + ->method('find') + ->with(99) + ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('')); + + $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class); + $this->service->deleteAclForShareReview(99); + } } diff --git a/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php b/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php new file mode 100644 index 000000000..93637d7fe --- /dev/null +++ b/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php @@ -0,0 +1,58 @@ +assertFalse($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertNull($event->getReason()); + } + + public function testGrantAccess(): void { + $event = new ShareReviewAccessCheckEvent(); + $event->grantAccess(); + + $this->assertTrue($event->isHandled()); + $this->assertTrue($event->isGranted()); + } + + public function testDenyAccess(): void { + $event = new ShareReviewAccessCheckEvent(); + $event->denyAccess('not in group'); + + $this->assertTrue($event->isHandled()); + $this->assertFalse($event->isGranted()); + $this->assertSame('not in group', $event->getReason()); + } + + public function testGrantThenDenyIsGrantedFalse(): void { + $event = new ShareReviewAccessCheckEvent(); + $event->grantAccess(); + $event->denyAccess('revoked'); + + $this->assertFalse($event->isGranted()); + $this->assertSame('revoked', $event->getReason()); + } + + public function testDenyThenGrantIsGrantedFalse(): void { + $event = new ShareReviewAccessCheckEvent(); + $event->denyAccess('not allowed'); + $event->grantAccess(); + + $this->assertFalse($event->isGranted()); + } +} diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php index 82dfbf0c8..fb3bee37f 100644 --- a/tests/unit/ShareReview/ShareReviewSourceTest.php +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -9,12 +9,16 @@ namespace OCA\Deck\Tests\Unit\ShareReview; +use OCA\Deck\Service\BoardService; +use OCA\Deck\ShareReview\ShareReviewAccessCheckEvent; use OCA\Deck\ShareReview\ShareReviewSource; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants; use OCP\DB\Exception; use OCP\DB\IResult; use OCP\DB\QueryBuilder\IExpressionBuilder; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; @@ -24,13 +28,22 @@ final class ShareReviewSourceTest extends TestCase { private MockObject $db; private MockObject $logger; + private MockObject $boardService; + private MockObject $eventDispatcher; private ShareReviewSource $source; protected function setUp(): void { parent::setUp(); $this->db = $this->createMock(IDBConnection::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->source = new ShareReviewSource($this->db, $this->logger); + $this->boardService = $this->createMock(BoardService::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->source = new ShareReviewSource( + $this->db, + $this->logger, + $this->boardService, + $this->eventDispatcher, + ); } private function makeResult(array $rows): MockObject { @@ -253,24 +266,54 @@ public function testComputePermissionsAllTrue(): void { $this->assertSame($expected, $shares[0]['permissions']); } - public function testDeleteShareSuccess(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 1)); - $this->logger->expects($this->once())->method('info'); + public function testDeleteShareNonNumericReturnsFalse(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); - $this->assertTrue($this->source->deleteShare('7')); + $this->assertFalse($this->source->deleteShare('abc')); } - public function testDeleteShareNotFound(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 0)); - $this->logger->expects($this->once())->method('info'); + public function testDeleteShareEventNotHandledReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(ShareReviewAccessCheckEvent::class)); + $this->boardService->expects($this->never())->method('deleteAclForShareReview'); - $this->assertFalse($this->source->deleteShare('99')); + $this->assertFalse($this->source->deleteShare('7')); } - public function testDeleteShareReturnsFalseOnDbException(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); - $this->logger->expects($this->once())->method('info'); - $this->logger->expects($this->once())->method('error'); + public function testDeleteShareEventDeniedReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(ShareReviewAccessCheckEvent::class)) + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->denyAccess('not in group'); + }); + $this->boardService->expects($this->never())->method('deleteAclForShareReview'); + + $this->assertFalse($this->source->deleteShare('7')); + } + + public function testDeleteShareEventGrantedReturnsTrue(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(ShareReviewAccessCheckEvent::class)) + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->boardService->expects($this->once())->method('deleteAclForShareReview')->with(7); + + $this->assertTrue($this->source->deleteShare('7')); + } + + public function testDeleteShareDoesNotExistReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->boardService->expects($this->once()) + ->method('deleteAclForShareReview') + ->willThrowException($this->createMock(DoesNotExistException::class)); $this->assertFalse($this->source->deleteShare('7')); } From b4e6d1480c17d75c44bbab6394aefeed9c1d6fbd Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Mon, 22 Jun 2026 15:21:34 +0200 Subject: [PATCH 11/12] refactor(sharereview): address pre-review feedback on ShareReviewSource - Remove 'app' key from getShares() return values; the ShareReview consumer already injects the app name from getName() - Add TABLE_NAME constants to AclMapper and BoardMapper so table names have a canonical owner and don't leak into unrelated classes - Move the ACL+board JOIN query to AclMapper::findAllForShareReview() and drop the IDBConnection dependency from ShareReviewSource - Introduce ShareReviewShare DTO; getShares() now builds typed objects via buildShare() and converts them with toArray() - Log a warning for unknown ACL participant types instead of silently defaulting to TYPE_USER - Localize user-facing strings in resolveObjectName() via IL10N Assisted-by: ClaudeCode:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- lib/AppInfo/Application.php | 460 +++++++++--------- lib/Db/AclMapper.php | 28 +- lib/Db/BoardMapper.php | 3 +- .../ShareReviewAccessCheckEvent.php | 14 +- lib/ShareReview/ShareReviewShare.php | 38 ++ lib/ShareReview/ShareReviewSource.php | 83 ++-- tests/bootstrap.php | 60 +-- tests/phpunit.xml | 2 +- .../ShareReview/ShareReviewSourceTest.php | 159 ++---- 9 files changed, 425 insertions(+), 422 deletions(-) create mode 100644 lib/ShareReview/ShareReviewShare.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index db2166d72..2d85902f7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -1,230 +1,230 @@ -getContainer(); - $eventDispatcher = $container->get(IEventDispatcher::class); - $eventDispatcher->addListener(RenderReferenceEvent::class, function (RenderReferenceEvent $e) use ($eventDispatcher) { - Util::addScript(self::APP_ID, self::APP_ID . '-reference'); - if (!$this->referenceLoaded && class_exists(LoadEditor::class)) { - $this->referenceLoaded = true; - $eventDispatcher->dispatchTyped(new LoadEditor()); - } - }); - } - - public function boot(IBootContext $context): void { - $context->injectFn($this->registerCommentsEntity(...)); - $context->injectFn($this->registerCollaborationResources(...)); - - $context->injectFn(function (IManager $shareManager) { - $shareManager->registerShareProvider(DeckShareProvider::class); - }); - - $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { - $listener->register($eventDispatcher); - }); - $context->injectFn([$this, 'registerCloudFederationProviderManager']); - } - - public function register(IRegistrationContext $context): void { - if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) { - throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); - } - - $context->registerCapability(Capabilities::class); - $context->registerMiddleWare(FederationMiddleware::class); - $context->registerMiddleWare(ExceptionMiddleware::class); - $context->registerMiddleWare(DefaultBoardMiddleware::class); - - $context->registerService('databaseType', static function (ContainerInterface $c) { - return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite'); - }); - $context->registerService('database4ByteSupport', static function (ContainerInterface $c) { - return $c->get(IDBConnection::class)->supports4ByteText(); - }); - - $context->registerSearchProvider(DeckProvider::class); - $context->registerSearchProvider(CardCommentProvider::class); - $context->registerDashboardWidget(DeckWidgetUpcoming::class); - $context->registerDashboardWidget(DeckWidgetToday::class); - $context->registerDashboardWidget(DeckWidgetTomorrow::class); - - $context->registerReferenceProvider(CreateCardReferenceProvider::class); - - // reference widget - $context->registerReferenceProvider(CardReferenceProvider::class); - $context->registerReferenceProvider(BoardReferenceProvider::class); - $context->registerReferenceProvider(CommentReferenceProvider::class); - - $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); - $context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class); - - // Event listening to emit UserShareAccessUpdatedEvent for files_sharing - $context->registerEventListener(AclCreatedEvent::class, AclCreatedRemovedListener::class); - $context->registerEventListener(AclDeletedEvent::class, AclCreatedRemovedListener::class); - - // Event listening for full text search indexing - $context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CardDeletedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclCreatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclUpdatedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(AclDeletedEvent::class, FullTextSearchEventListener::class); - $context->registerEventListener(CommentAddedEvent::class, CommentEventListener::class); - $context->registerEventListener(BeforeCommentUpdatedEvent::class, CommentEventListener::class); - $context->registerEventListener(CommentUpdatedEvent::class, CommentEventListener::class); - $context->registerEventListener(CommentDeletedEvent::class, CommentEventListener::class); - - // Handling cache invalidation for collections - $context->registerEventListener(AclCreatedEvent::class, ResourceListener::class); - $context->registerEventListener(AclDeletedEvent::class, ResourceListener::class); - - $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); - $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); - $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); - - // Event listening for realtime updates via notify_push - $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(BoardUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclCreatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclUpdatedEvent::class, LiveUpdateListener::class); - $context->registerEventListener(AclDeletedEvent::class, LiveUpdateListener::class); - - $context->registerNotifierService(Notifier::class); - $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); - - $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); - - $context->registerUserMigrator(DeckMigrator::class); - - $context->registerEventListener(SourceEvent::class, ShareReviewListener::class); - } - - public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { - $eventDispatcher->addListener(CommentsEntityEvent::EVENT_ENTITY, function (CommentsEntityEvent $event) { - $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function ($name) { - /** @var CardMapper */ - $cardMapper = $this->getContainer()->get(CardMapper::class); - /** @var PermissionService $permissionService */ - $permissionService = $this->getContainer()->get(PermissionService::class); - - try { - return $permissionService->checkPermission($cardMapper, (int)$name, Acl::PERMISSION_READ); - } catch (\Exception $e) { - return false; - } - }); - }); - } - - protected function registerCollaborationResources(IProviderManager $resourceManager): void { - $resourceManager->registerResourceProvider(ResourceProvider::class); - $resourceManager->registerResourceProvider(ResourceProviderCard::class); - } - - public function registerCloudFederationProviderManager( - IConfig $config, - ICloudFederationProviderManager $manager, - ): void { - $manager->addCloudFederationProvider( - DeckFederationProvider::PROVIDER_ID, - 'Deck Federation', - static fn (): ICloudFederationProvider => Server::get(DeckFederationProvider::class), - ); - } -} +getContainer(); + $eventDispatcher = $container->get(IEventDispatcher::class); + $eventDispatcher->addListener(RenderReferenceEvent::class, function (RenderReferenceEvent $e) use ($eventDispatcher) { + Util::addScript(self::APP_ID, self::APP_ID . '-reference'); + if (!$this->referenceLoaded && class_exists(LoadEditor::class)) { + $this->referenceLoaded = true; + $eventDispatcher->dispatchTyped(new LoadEditor()); + } + }); + } + + public function boot(IBootContext $context): void { + $context->injectFn($this->registerCommentsEntity(...)); + $context->injectFn($this->registerCollaborationResources(...)); + + $context->injectFn(function (IManager $shareManager) { + $shareManager->registerShareProvider(DeckShareProvider::class); + }); + + $context->injectFn(function (Listener $listener, IEventDispatcher $eventDispatcher) { + $listener->register($eventDispatcher); + }); + $context->injectFn([$this, 'registerCloudFederationProviderManager']); + } + + public function register(IRegistrationContext $context): void { + if ((@include_once __DIR__ . '/../../vendor/autoload.php') === false) { + throw new Exception('Cannot include autoload. Did you run install dependencies using composer?'); + } + + $context->registerCapability(Capabilities::class); + $context->registerMiddleWare(FederationMiddleware::class); + $context->registerMiddleWare(ExceptionMiddleware::class); + $context->registerMiddleWare(DefaultBoardMiddleware::class); + + $context->registerService('databaseType', static function (ContainerInterface $c) { + return $c->get(IConfig::class)->getSystemValue('dbtype', 'sqlite'); + }); + $context->registerService('database4ByteSupport', static function (ContainerInterface $c) { + return $c->get(IDBConnection::class)->supports4ByteText(); + }); + + $context->registerSearchProvider(DeckProvider::class); + $context->registerSearchProvider(CardCommentProvider::class); + $context->registerDashboardWidget(DeckWidgetUpcoming::class); + $context->registerDashboardWidget(DeckWidgetToday::class); + $context->registerDashboardWidget(DeckWidgetTomorrow::class); + + $context->registerReferenceProvider(CreateCardReferenceProvider::class); + + // reference widget + $context->registerReferenceProvider(CardReferenceProvider::class); + $context->registerReferenceProvider(BoardReferenceProvider::class); + $context->registerReferenceProvider(CommentReferenceProvider::class); + + $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); + $context->registerEventListener(ResourceTypeRegisterEvent::class, ResourceTypeRegisterListener::class); + + // Event listening to emit UserShareAccessUpdatedEvent for files_sharing + $context->registerEventListener(AclCreatedEvent::class, AclCreatedRemovedListener::class); + $context->registerEventListener(AclDeletedEvent::class, AclCreatedRemovedListener::class); + + // Event listening for full text search indexing + $context->registerEventListener(CardCreatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CardUpdatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CardDeletedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclCreatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclUpdatedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(AclDeletedEvent::class, FullTextSearchEventListener::class); + $context->registerEventListener(CommentAddedEvent::class, CommentEventListener::class); + $context->registerEventListener(BeforeCommentUpdatedEvent::class, CommentEventListener::class); + $context->registerEventListener(CommentUpdatedEvent::class, CommentEventListener::class); + $context->registerEventListener(CommentDeletedEvent::class, CommentEventListener::class); + + // Handling cache invalidation for collections + $context->registerEventListener(AclCreatedEvent::class, ResourceListener::class); + $context->registerEventListener(AclDeletedEvent::class, ResourceListener::class); + + $context->registerEventListener(UserDeletedEvent::class, ParticipantCleanupListener::class); + $context->registerEventListener(GroupDeletedEvent::class, ParticipantCleanupListener::class); + $context->registerEventListener(CircleDestroyedEvent::class, ParticipantCleanupListener::class); + + // Event listening for realtime updates via notify_push + $context->registerEventListener(SessionCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(SessionClosedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(BoardUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(CardDeletedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclCreatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclUpdatedEvent::class, LiveUpdateListener::class); + $context->registerEventListener(AclDeletedEvent::class, LiveUpdateListener::class); + + $context->registerNotifierService(Notifier::class); + $context->registerEventListener(LoadAdditionalScriptsEvent::class, ResourceAdditionalScriptsListener::class); + + $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); + + $context->registerUserMigrator(DeckMigrator::class); + + $context->registerEventListener(SourceEvent::class, ShareReviewListener::class); + } + + public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { + $eventDispatcher->addListener(CommentsEntityEvent::EVENT_ENTITY, function (CommentsEntityEvent $event) { + $event->addEntityCollection(self::COMMENT_ENTITY_TYPE, function ($name) { + /** @var CardMapper */ + $cardMapper = $this->getContainer()->get(CardMapper::class); + /** @var PermissionService $permissionService */ + $permissionService = $this->getContainer()->get(PermissionService::class); + + try { + return $permissionService->checkPermission($cardMapper, (int)$name, Acl::PERMISSION_READ); + } catch (\Exception $e) { + return false; + } + }); + }); + } + + protected function registerCollaborationResources(IProviderManager $resourceManager): void { + $resourceManager->registerResourceProvider(ResourceProvider::class); + $resourceManager->registerResourceProvider(ResourceProviderCard::class); + } + + public function registerCloudFederationProviderManager( + IConfig $config, + ICloudFederationProviderManager $manager, + ): void { + $manager->addCloudFederationProvider( + DeckFederationProvider::PROVIDER_ID, + 'Deck Federation', + static fn (): ICloudFederationProvider => Server::get(DeckFederationProvider::class), + ); + } +} diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index defba19d1..16fafb178 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -10,13 +10,16 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\Entity; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @template-extends DeckMapper */ class AclMapper extends DeckMapper implements IPermissionMapper { + public const TABLE_NAME = 'deck_board_acl'; + public function __construct(IDBConnection $db) { - parent::__construct($db, 'deck_board_acl', Acl::class); + parent::__construct($db, self::TABLE_NAME, Acl::class); } public function findByAccessToken(string $accessToken) { @@ -129,6 +132,29 @@ public function findByType(int $type): array { return $this->findEntities($qb); } + /** + * Fetch all ACL rows with their board title and owner for ShareReview. + * + * @return list> + * @throws Exception + */ + public function findAllForShareReview(): array { + $qb = $this->db->getQueryBuilder(); + $qb->select( + 'a.id', 'a.board_id', 'a.type', 'a.participant', + 'a.permission_edit', 'a.permission_share', 'a.permission_manage', 'a.created_at', 'a.last_modified_at' + ) + ->selectAlias('b.title', 'board_title') + ->selectAlias('b.owner', 'board_owner') + ->from(self::TABLE_NAME, 'a') + ->leftJoin('a', 'deck_boards', 'b', $qb->expr()->eq('a.board_id', 'b.id')) + ->orderBy('a.id', 'ASC'); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + public function insert(Entity $entity): Entity { /** @var Acl $entity */ $now = time(); diff --git a/lib/Db/BoardMapper.php b/lib/Db/BoardMapper.php index 5c8baa5ce..cc67d2c83 100644 --- a/lib/Db/BoardMapper.php +++ b/lib/Db/BoardMapper.php @@ -20,6 +20,7 @@ /** @template-extends QBMapper */ class BoardMapper extends QBMapper implements IPermissionMapper { + public const TABLE_NAME = 'deck_boards'; /** @var CappedMemoryCache */ private CappedMemoryCache $userBoardCache; /** @var CappedMemoryCache */ @@ -36,7 +37,7 @@ public function __construct( private ICloudIdManager $cloudIdManager, private LoggerInterface $logger, ) { - parent::__construct($db, 'deck_boards', Board::class); + parent::__construct($db, self::TABLE_NAME, Board::class); $this->userBoardCache = new CappedMemoryCache(); $this->boardCache = new CappedMemoryCache(); diff --git a/lib/ShareReview/ShareReviewAccessCheckEvent.php b/lib/ShareReview/ShareReviewAccessCheckEvent.php index 5f559d105..5efccccf9 100644 --- a/lib/ShareReview/ShareReviewAccessCheckEvent.php +++ b/lib/ShareReview/ShareReviewAccessCheckEvent.php @@ -28,10 +28,16 @@ public function grantAccess(): void { public function denyAccess(string $reason): void { $this->handled = true; $this->granted = false; - $this->reason = $reason; + $this->reason = $reason; } - public function isHandled(): bool { return $this->handled; } - public function isGranted(): bool { return $this->granted; } - public function getReason(): ?string { return $this->reason; } + public function isHandled(): bool { + return $this->handled; + } + public function isGranted(): bool { + return $this->granted; + } + public function getReason(): ?string { + return $this->reason; + } } diff --git a/lib/ShareReview/ShareReviewShare.php b/lib/ShareReview/ShareReviewShare.php new file mode 100644 index 000000000..43bff761c --- /dev/null +++ b/lib/ShareReview/ShareReviewShare.php @@ -0,0 +1,38 @@ + $this->id, + 'object' => $this->object, + 'initiator' => $this->initiator, + 'type' => $this->type, + 'recipient' => $this->recipient, + 'permissions' => $this->permissions, + 'password' => false, + 'time' => $this->time, + 'action' => '', + ]; + } +} diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index 293805925..cbab3868e 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -10,28 +10,27 @@ namespace OCA\Deck\ShareReview; use OCA\Deck\Db\Acl; +use OCA\Deck\Db\AclMapper; use OCA\Deck\Service\BoardService; use OCA\ShareReview\Sources\ISource; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants; use OCP\DB\Exception; -use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; -use OCP\IDBConnection; +use OCP\IL10N; use OCP\Share\IShare; use Psr\Log\LoggerInterface; class ShareReviewSource implements ISource { - private const ACL_TABLE = 'deck_board_acl'; - private const BOARDS_TABLE = 'deck_boards'; private const PERMISSION_MANAGE = 32; public function __construct( - private IDBConnection $db, - private LoggerInterface $logger, + private readonly AclMapper $aclMapper, + private readonly LoggerInterface $logger, private readonly BoardService $boardService, private readonly IEventDispatcher $eventDispatcher, + private readonly IL10N $l, ) { } @@ -40,27 +39,19 @@ public function getName(): string { } /** - * @return list + * @return list */ public function getShares(): array { - $rawShares = $this->fetchAllShares(); - $appName = $this->getName(); - $formatted = []; - foreach ($rawShares as $share) { - $formatted[] = [ - 'id' => (int)$share['id'], - 'app' => $appName, - 'object' => $this->resolveObjectName($share), - 'initiator' => (string)$share['board_owner'], - 'type' => $this->mapParticipantType((int)$share['type']), - 'recipient' => (string)$share['participant'], - 'permissions' => $this->computePermissions($share), - 'password' => false, - 'time' => date('Y-m-d H:i:s', max((int)$share['created_at'], (int)$share['last_modified_at'])), - 'action' => '', - ]; + try { + $rawShares = $this->aclMapper->findAllForShareReview(); + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); + return []; } - return $formatted; + return array_map( + fn (array $share) => $this->buildShare($share)->toArray(), + $rawShares, + ); } public function deleteShare(string $shareId): bool { @@ -76,41 +67,32 @@ public function deleteShare(string $shareId): bool { } try { - $this->boardService->deleteAclForShareReview((int) $shareId); + $this->boardService->deleteAclForShareReview((int)$shareId); return true; } catch (DoesNotExistException) { return false; } } - /** @return list> */ - private function fetchAllShares(): array { - try { - $qb = $this->db->getQueryBuilder(); - $qb->select( - 'a.id', 'a.board_id', 'a.type', 'a.participant', - 'a.permission_edit', 'a.permission_share', 'a.permission_manage', 'a.created_at', 'a.last_modified_at' - ) - ->selectAlias('b.title', 'board_title') - ->selectAlias('b.owner', 'board_owner') - ->from(self::ACL_TABLE, 'a') - ->leftJoin('a', self::BOARDS_TABLE, 'b', $qb->expr()->eq('a.board_id', 'b.id')) - ->orderBy('a.id', 'ASC'); - $result = $qb->executeQuery(); - $rows = $result->fetchAll(); - $result->closeCursor(); - return $rows; - } catch (Exception $e) { - $this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); - return []; - } + /** @param array $share */ + private function buildShare(array $share): ShareReviewShare { + return new ShareReviewShare( + id: (int)$share['id'], + object: $this->resolveObjectName($share), + initiator: (string)$share['board_owner'], + type: $this->mapParticipantType((int)$share['type']), + recipient: (string)$share['participant'], + permissions: $this->computePermissions($share), + time: date('Y-m-d H:i:s', max((int)$share['created_at'], (int)$share['last_modified_at'])), + ); } /** @param array $share */ private function resolveObjectName(array $share): string { $title = (string)($share['board_title'] ?? ''); $boardId = (int)($share['board_id'] ?? $share['id']); - return ($title !== '' ? $title : "Board $boardId") . ' (Board)'; + $label = $title !== '' ? $title : $this->l->t('Board %d', [$boardId]); + return $this->l->t('%s (Board)', [$label]); } private function mapParticipantType(int $type): int { @@ -119,10 +101,15 @@ private function mapParticipantType(int $type): int { Acl::PERMISSION_TYPE_GROUP => IShare::TYPE_GROUP, Acl::PERMISSION_TYPE_REMOTE => IShare::TYPE_REMOTE, Acl::PERMISSION_TYPE_CIRCLE => IShare::TYPE_CIRCLE, - default => IShare::TYPE_USER, + default => $this->fallbackParticipantType($type), }; } + private function fallbackParticipantType(int $type): int { + $this->logger->warning('Deck ShareReview: unknown ACL participant type {type}, defaulting to user share', ['type' => $type]); + return IShare::TYPE_USER; + } + /** @param array $share */ private function computePermissions(array $share): int { $permissions = Constants::PERMISSION_READ; diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4b91f1f30..6a27ef53c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,30 +1,30 @@ - - * - * @author Julius Härtl - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - */ - -require_once __DIR__ . '/../../../tests/bootstrap.php'; -require_once __DIR__ . '/../appinfo/autoload.php'; - -if (!interface_exists('OCA\ShareReview\Sources\ISource')) { - require_once __DIR__ . '/unit/ShareReview/Stubs.php'; -} + + * + * @author Julius Härtl + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +require_once __DIR__ . '/../../../tests/bootstrap.php'; +require_once __DIR__ . '/../appinfo/autoload.php'; + +if (!interface_exists('OCA\ShareReview\Sources\ISource')) { + require_once __DIR__ . '/unit/ShareReview/Stubs.php'; +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml index af2ea49bd..94370cecb 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -1,5 +1,5 @@ - + ./../lib diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php index fb3bee37f..7158f4d96 100644 --- a/tests/unit/ShareReview/ShareReviewSourceTest.php +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -9,90 +9,49 @@ namespace OCA\Deck\Tests\Unit\ShareReview; +use OCA\Deck\Db\AclMapper; use OCA\Deck\Service\BoardService; use OCA\Deck\ShareReview\ShareReviewAccessCheckEvent; use OCA\Deck\ShareReview\ShareReviewSource; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants; use OCP\DB\Exception; -use OCP\DB\IResult; -use OCP\DB\QueryBuilder\IExpressionBuilder; -use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; -use OCP\IDBConnection; +use OCP\IL10N; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; final class ShareReviewSourceTest extends TestCase { - private MockObject $db; + private MockObject $aclMapper; private MockObject $logger; private MockObject $boardService; private MockObject $eventDispatcher; + private MockObject $l; private ShareReviewSource $source; protected function setUp(): void { parent::setUp(); - $this->db = $this->createMock(IDBConnection::class); + $this->aclMapper = $this->createMock(AclMapper::class); $this->logger = $this->createMock(LoggerInterface::class); $this->boardService = $this->createMock(BoardService::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->l = $this->createMock(IL10N::class); + $this->l->method('t')->willReturnCallback( + function (string $text, array $params = []): string { + return empty($params) ? $text : vsprintf($text, $params); + } + ); $this->source = new ShareReviewSource( - $this->db, + $this->aclMapper, $this->logger, $this->boardService, $this->eventDispatcher, + $this->l, ); } - private function makeResult(array $rows): MockObject { - $result = $this->createMock(IResult::class); - $result->method('fetchAll')->willReturn($rows); - $result->method('closeCursor')->willReturn(true); - return $result; - } - - private function makeQb(array $fetchRows = [], int $statementRows = 0): MockObject { - $expr = $this->createMock(IExpressionBuilder::class); - $expr->method('eq')->willReturn('1=1'); - - $qb = $this->createMock(IQueryBuilder::class); - $qb->method('select')->willReturnSelf(); - $qb->method('addSelect')->willReturnSelf(); - $qb->method('selectAlias')->willReturnSelf(); - $qb->method('from')->willReturnSelf(); - $qb->method('leftJoin')->willReturnSelf(); - $qb->method('where')->willReturnSelf(); - $qb->method('orderBy')->willReturnSelf(); - $qb->method('delete')->willReturnSelf(); - $qb->method('createNamedParameter')->willReturn('?'); - $qb->method('expr')->willReturn($expr); - $qb->method('executeQuery')->willReturn($this->makeResult($fetchRows)); - $qb->method('executeStatement')->willReturn($statementRows); - - return $qb; - } - - private function makeThrowingQb(): MockObject { - $expr = $this->createMock(IExpressionBuilder::class); - - $qb = $this->createMock(IQueryBuilder::class); - $qb->method('select')->willReturnSelf(); - $qb->method('addSelect')->willReturnSelf(); - $qb->method('selectAlias')->willReturnSelf(); - $qb->method('from')->willReturnSelf(); - $qb->method('leftJoin')->willReturnSelf(); - $qb->method('where')->willReturnSelf(); - $qb->method('orderBy')->willReturnSelf(); - $qb->method('delete')->willReturnSelf(); - $qb->method('createNamedParameter')->willReturn('?'); - $qb->method('expr')->willReturn($expr); - $qb->method('executeQuery')->willThrowException($this->createMock(Exception::class)); - $qb->method('executeStatement')->willThrowException($this->createMock(Exception::class)); - return $qb; - } - /** @param array $overrides */ private function makeShareRow(array $overrides = []): array { return array_merge([ @@ -115,20 +74,20 @@ public function testGetName(): void { } public function testGetSharesEmpty(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeQb()); + $this->aclMapper->method('findAllForShareReview')->willReturn([]); $this->assertSame([], $this->source->getShares()); } public function testGetSharesUserShare(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeQb([$this->makeShareRow()])); + $this->aclMapper->method('findAllForShareReview')->willReturn([$this->makeShareRow()]); $shares = $this->source->getShares(); $this->assertCount(1, $shares); $share = $shares[0]; $this->assertSame(1, $share['id']); - $this->assertSame('Deck', $share['app']); + $this->assertArrayNotHasKey('app', $share); $this->assertSame('My Board (Board)', $share['object']); $this->assertSame('alice', $share['initiator']); $this->assertSame(IShare::TYPE_USER, $share['type']); @@ -140,8 +99,8 @@ public function testGetSharesUserShare(): void { } public function testGetSharesUsesLastModifiedAtWhenNewer(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['created_at' => 1700000000, 'last_modified_at' => 1800000000])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['created_at' => 1700000000, 'last_modified_at' => 1800000000])] ); $shares = $this->source->getShares(); @@ -150,8 +109,8 @@ public function testGetSharesUsesLastModifiedAtWhenNewer(): void { } public function testGetSharesGroupShare(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['type' => 1, 'participant' => 'developers'])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 1, 'participant' => 'developers'])] ); $shares = $this->source->getShares(); @@ -162,28 +121,33 @@ public function testGetSharesGroupShare(): void { } public function testGetSharesCircleShare(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['type' => 7, 'participant' => 'circle-uid'])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 7, 'participant' => 'circle-uid'])] ); - $shares = $this->source->getShares(); - - $this->assertSame(IShare::TYPE_CIRCLE, $shares[0]['type']); + $this->assertSame(IShare::TYPE_CIRCLE, $this->source->getShares()[0]['type']); } public function testGetSharesRemoteShare(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['type' => 6, 'participant' => 'user@remote.example'])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 6, 'participant' => 'user@remote.example'])] ); - $shares = $this->source->getShares(); + $this->assertSame(IShare::TYPE_REMOTE, $this->source->getShares()[0]['type']); + } - $this->assertSame(IShare::TYPE_REMOTE, $shares[0]['type']); + public function testGetSharesUnknownTypeLogsWarningAndFallsBackToUser(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 99])] + ); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame(IShare::TYPE_USER, $this->source->getShares()[0]['type']); } public function testGetSharesMissingBoardFallback(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['board_id' => 42, 'board_title' => null, 'board_owner' => null])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['board_id' => 42, 'board_title' => null, 'board_owner' => null])] ); $shares = $this->source->getShares(); @@ -193,77 +157,58 @@ public function testGetSharesMissingBoardFallback(): void { } public function testGetSharesReturnsEmptyOnDbException(): void { - $this->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); + $this->aclMapper->method('findAllForShareReview')->willThrowException($this->createMock(Exception::class)); $this->logger->expects($this->once())->method('error'); $this->assertSame([], $this->source->getShares()); } public function testComputePermissionsAllFalse(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow([ - 'permission_edit' => 0, - 'permission_share' => 0, - 'permission_manage' => 0, - ])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 0, 'permission_share' => 0, 'permission_manage' => 0])] ); - $shares = $this->source->getShares(); - - $this->assertSame(Constants::PERMISSION_READ, $shares[0]['permissions']); + $this->assertSame(Constants::PERMISSION_READ, $this->source->getShares()[0]['permissions']); } public function testComputePermissionsEditFlag(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['permission_edit' => 1])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 1])] ); - $shares = $this->source->getShares(); - $expected = Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; - $this->assertSame($expected, $shares[0]['permissions']); + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); } public function testComputePermissionsShareFlag(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['permission_share' => 1])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_share' => 1])] ); - $shares = $this->source->getShares(); - $expected = Constants::PERMISSION_READ | Constants::PERMISSION_SHARE; - $this->assertSame($expected, $shares[0]['permissions']); + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); } public function testComputePermissionsManageFlag(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow(['permission_manage' => 1])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_manage' => 1])] ); - $shares = $this->source->getShares(); - - $expected = Constants::PERMISSION_READ | 32; - $this->assertSame($expected, $shares[0]['permissions']); + $this->assertSame(Constants::PERMISSION_READ | 32, $this->source->getShares()[0]['permissions']); } public function testComputePermissionsAllTrue(): void { - $this->db->method('getQueryBuilder')->willReturn( - $this->makeQb([$this->makeShareRow([ - 'permission_edit' => 1, - 'permission_share' => 1, - 'permission_manage' => 1, - ])]) + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 1, 'permission_share' => 1, 'permission_manage' => 1])] ); - $shares = $this->source->getShares(); - $expected = Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE | Constants::PERMISSION_SHARE | 32; - $this->assertSame($expected, $shares[0]['permissions']); + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); } public function testDeleteShareNonNumericReturnsFalse(): void { From b43a910a5c80260dd3eb341b83e68a7bff73fcae Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 23 Jun 2026 16:50:15 +0200 Subject: [PATCH 12/12] refactor(sharereview): use OCP ShareReviewAccessCheckEvent from server Replaces the local OCA\Deck\ShareReview\ShareReviewAccessCheckEvent with the canonical OCP\Share\Events\ShareReviewAccessCheckEvent now provided by the server. Passes 'Deck' and the share ID as constructor arguments. Assisted-by: ClaudeCode:claude-sonnet-4-6 Signed-off-by: Andy Scherzinger --- .../ShareReviewAccessCheckEvent.php | 43 -------------- lib/ShareReview/ShareReviewSource.php | 3 +- .../ShareReviewAccessCheckEventTest.php | 58 ------------------- .../ShareReview/ShareReviewSourceTest.php | 2 +- 4 files changed, 3 insertions(+), 103 deletions(-) delete mode 100644 lib/ShareReview/ShareReviewAccessCheckEvent.php delete mode 100644 tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php diff --git a/lib/ShareReview/ShareReviewAccessCheckEvent.php b/lib/ShareReview/ShareReviewAccessCheckEvent.php deleted file mode 100644 index 5efccccf9..000000000 --- a/lib/ShareReview/ShareReviewAccessCheckEvent.php +++ /dev/null @@ -1,43 +0,0 @@ -handled && !$this->granted) { - return; // a prior denyAccess() cannot be escalated to a grant — deny wins - } - $this->handled = true; - $this->granted = true; - } - - public function denyAccess(string $reason): void { - $this->handled = true; - $this->granted = false; - $this->reason = $reason; - } - - public function isHandled(): bool { - return $this->handled; - } - public function isGranted(): bool { - return $this->granted; - } - public function getReason(): ?string { - return $this->reason; - } -} diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php index cbab3868e..3849b66b3 100644 --- a/lib/ShareReview/ShareReviewSource.php +++ b/lib/ShareReview/ShareReviewSource.php @@ -14,6 +14,7 @@ use OCA\Deck\Service\BoardService; use OCA\ShareReview\Sources\ISource; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Share\Events\ShareReviewAccessCheckEvent; use OCP\Constants; use OCP\DB\Exception; use OCP\EventDispatcher\IEventDispatcher; @@ -59,7 +60,7 @@ public function deleteShare(string $shareId): bool { return false; } - $event = new ShareReviewAccessCheckEvent(); + $event = new ShareReviewAccessCheckEvent('Deck', $shareId); $this->eventDispatcher->dispatchTyped($event); if (!$event->isHandled() || !$event->isGranted()) { diff --git a/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php b/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php deleted file mode 100644 index 93637d7fe..000000000 --- a/tests/unit/ShareReview/ShareReviewAccessCheckEventTest.php +++ /dev/null @@ -1,58 +0,0 @@ -assertFalse($event->isHandled()); - $this->assertFalse($event->isGranted()); - $this->assertNull($event->getReason()); - } - - public function testGrantAccess(): void { - $event = new ShareReviewAccessCheckEvent(); - $event->grantAccess(); - - $this->assertTrue($event->isHandled()); - $this->assertTrue($event->isGranted()); - } - - public function testDenyAccess(): void { - $event = new ShareReviewAccessCheckEvent(); - $event->denyAccess('not in group'); - - $this->assertTrue($event->isHandled()); - $this->assertFalse($event->isGranted()); - $this->assertSame('not in group', $event->getReason()); - } - - public function testGrantThenDenyIsGrantedFalse(): void { - $event = new ShareReviewAccessCheckEvent(); - $event->grantAccess(); - $event->denyAccess('revoked'); - - $this->assertFalse($event->isGranted()); - $this->assertSame('revoked', $event->getReason()); - } - - public function testDenyThenGrantIsGrantedFalse(): void { - $event = new ShareReviewAccessCheckEvent(); - $event->denyAccess('not allowed'); - $event->grantAccess(); - - $this->assertFalse($event->isGranted()); - } -} diff --git a/tests/unit/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php index 7158f4d96..8d5a45dab 100644 --- a/tests/unit/ShareReview/ShareReviewSourceTest.php +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -11,7 +11,7 @@ use OCA\Deck\Db\AclMapper; use OCA\Deck\Service\BoardService; -use OCA\Deck\ShareReview\ShareReviewAccessCheckEvent; +use OCP\Share\Events\ShareReviewAccessCheckEvent; use OCA\Deck\ShareReview\ShareReviewSource; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Constants;