diff --git a/appinfo/info.xml b/appinfo/info.xml index 2e534010c..261f763a5 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -1,96 +1,97 @@ - - - - deck - Deck - Personal planning and team project organization - Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. - - -- ๐Ÿ“ฅ Add your tasks to cards and put them in order -- ๐Ÿ“„ Write down additional notes in Markdown -- ๐Ÿ”– Assign labels for even better organization -- ๐Ÿ‘ฅ Share with your team, friends or family -- ๐Ÿ“Ž Attach files and embed them in your Markdown description -- ๐Ÿ’ฌ Discuss with your team using comments -- โšก Keep track of changes in the activity stream -- ๐Ÿš€ Get your project organized - - - 4.0.0-dev.1 - agpl - Julius Hรคrtl - Deck - - - - - https://deck.readthedocs.io/en/latest/User_documentation_en/ - https://deck.readthedocs.io/en/latest/API/ - - organization - office - https://github.com/nextcloud/deck - https://github.com/nextcloud/deck/issues - https://github.com/nextcloud/deck.git - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png - https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png - - pgsql - sqlite - mysql - - - - OCA\Deck\Cron\DeleteCron - OCA\Deck\Cron\ScheduledNotifications - OCA\Deck\Cron\CardDescriptionActivity - OCA\Deck\Cron\SessionsCleanup - - - - OCA\Deck\Migration\DeletedCircleCleanup - - - OCA\Deck\Migration\LabelMismatchCleanup - - - - OCA\Deck\Command\UserExport - OCA\Deck\Command\BoardImport - OCA\Deck\Command\TransferOwnership - OCA\Deck\Command\CalendarToggle - - - - OCA\Deck\Activity\SettingChanges - OCA\Deck\Activity\SettingDescription - OCA\Deck\Activity\SettingComment - - - OCA\Deck\Activity\Filter - - - OCA\Deck\Activity\DeckProvider - - - - OCA\Deck\Provider\DeckProvider - - - - Deck - deck.page.index - deck.svg - 10 - - - - - OCA\Deck\DAV\CalendarPlugin - - - + + + + deck + Deck + Personal planning and team project organization + Deck is a kanban style organization tool aimed at personal planning and project organization for teams integrated with Nextcloud. + + +- ๐Ÿ“ฅ Add your tasks to cards and put them in order +- ๐Ÿ“„ Write down additional notes in Markdown +- ๐Ÿ”– Assign labels for even better organization +- ๐Ÿ‘ฅ Share with your team, friends or family +- ๐Ÿ“Ž Attach files and embed them in your Markdown description +- ๐Ÿ’ฌ Discuss with your team using comments +- โšก Keep track of changes in the activity stream +- ๐Ÿš€ Get your project organized + + + 4.0.0-dev.2 + agpl + Julius Hรคrtl + Deck + + + + + https://deck.readthedocs.io/en/latest/User_documentation_en/ + https://deck.readthedocs.io/en/latest/API/ + + organization + office + https://github.com/nextcloud/deck + https://github.com/nextcloud/deck/issues + https://github.com/nextcloud/deck.git + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-1.png + https://download.bitgrid.net/nextcloud/deck/screenshots/1.0/Deck-2.png + + pgsql + sqlite + mysql + + + + OCA\Deck\Cron\DeleteCron + OCA\Deck\Cron\ScheduledNotifications + OCA\Deck\Cron\CardDescriptionActivity + OCA\Deck\Cron\SessionsCleanup + + + + OCA\Deck\Migration\DeletedCircleCleanup + + + OCA\Deck\Migration\LabelMismatchCleanup + OCA\Deck\Migration\AclTimestampBackfill + + + + OCA\Deck\Command\UserExport + OCA\Deck\Command\BoardImport + OCA\Deck\Command\TransferOwnership + OCA\Deck\Command\CalendarToggle + + + + OCA\Deck\Activity\SettingChanges + OCA\Deck\Activity\SettingDescription + OCA\Deck\Activity\SettingComment + + + OCA\Deck\Activity\Filter + + + OCA\Deck\Activity\DeckProvider + + + + OCA\Deck\Provider\DeckProvider + + + + Deck + deck.page.index + deck.svg + 10 + + + + + OCA\Deck\DAV\CalendarPlugin + + + diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 51efc0a48..2d85902f7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -47,10 +47,12 @@ use OCA\Deck\Search\CardCommentProvider; use OCA\Deck\Search\DeckProvider; use OCA\Deck\Service\PermissionService; +use OCA\Deck\ShareReview\ShareReviewListener; use OCA\Deck\Sharing\DeckShareProvider; use OCA\Deck\Sharing\Listener; use OCA\Deck\Teams\DeckTeamResourceProvider; use OCA\Deck\UserMigration\DeckMigrator; +use OCA\ShareReview\Sources\SourceEvent; use OCA\Text\Event\LoadEditor; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -189,6 +191,8 @@ public function register(IRegistrationContext $context): void { $context->registerTeamResourceProvider(DeckTeamResourceProvider::class); $context->registerUserMigrator(DeckMigrator::class); + + $context->registerEventListener(SourceEvent::class, ShareReviewListener::class); } public function registerCommentsEntity(IEventDispatcher $eventDispatcher): void { diff --git a/lib/Db/Acl.php b/lib/Db/Acl.php index 735f9a19f..52aba43b1 100644 --- a/lib/Db/Acl.php +++ b/lib/Db/Acl.php @@ -21,6 +21,10 @@ * @method void setOwner(int $owner) * @method void setToken(string $token) * @method string getToken() + * @method int getCreatedAt() + * @method void setCreatedAt(int $createdAt) + * @method int getLastModifiedAt() + * @method void setLastModifiedAt(int $lastModifiedAt) * */ class Acl extends RelationalEntity { @@ -42,6 +46,8 @@ class Acl extends RelationalEntity { protected $permissionManage = false; protected $owner = false; protected $token = null; + protected $createdAt = 0; + protected $lastModifiedAt = 0; public function __construct() { $this->addType('id', 'integer'); @@ -52,6 +58,8 @@ public function __construct() { $this->addType('type', 'integer'); $this->addType('owner', 'boolean'); $this->addType('token', 'string'); + $this->addType('createdAt', 'integer'); + $this->addType('lastModifiedAt', 'integer'); $this->addRelation('owner'); $this->addResolvable('participant'); } diff --git a/lib/Db/AclMapper.php b/lib/Db/AclMapper.php index 669ca7a5e..16fafb178 100644 --- a/lib/Db/AclMapper.php +++ b/lib/Db/AclMapper.php @@ -8,19 +8,23 @@ namespace OCA\Deck\Db; 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) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('token', $qb->createNamedParameter($accessToken, IQueryBuilder::PARAM_STR))) ->setMaxResults(1); @@ -34,7 +38,7 @@ public function findByAccessToken(string $accessToken) { */ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'token', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT))) ->setMaxResults($limit) @@ -45,7 +49,7 @@ public function findAll(int $boardId, ?int $limit = null, ?int $offset = null) { public function findIn(array $boardIds, ?int $limit = null, ?int $offset = null): array { $qb = $this->db->getQueryBuilder(); - $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage') + $qb->select('id', 'board_id', 'type', 'participant', 'permission_edit', 'permission_share', 'permission_manage', 'created_at', 'last_modified_at') ->from('deck_board_acl') ->where($qb->expr()->in('board_id', $qb->createParameter('boardIds'))) ->setMaxResults($limit) @@ -127,4 +131,41 @@ public function findByType(int $type): array { ->where($qb->expr()->eq('type', $qb->createNamedParameter($type, IQueryBuilder::PARAM_INT))); 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(); + $entity->setCreatedAt($now); + $entity->setLastModifiedAt($now); + return parent::insert($entity); + } + + public function update(Entity $entity): Entity { + /** @var Acl $entity */ + $entity->setLastModifiedAt(time()); + return parent::update($entity); + } } 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/Migration/AclTimestampBackfill.php b/lib/Migration/AclTimestampBackfill.php new file mode 100644 index 000000000..aa49688d4 --- /dev/null +++ b/lib/Migration/AclTimestampBackfill.php @@ -0,0 +1,68 @@ +db->getQueryBuilder(); + $selectQb->select('a.id AS acl_id', 'b.last_modified AS board_last_modified') + ->from('deck_board_acl', 'a') + ->join('a', 'deck_boards', 'b', $selectQb->expr()->eq('b.id', 'a.board_id')) + ->where($selectQb->expr()->eq('a.created_at', $selectQb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + + $result = $selectQb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if ($rows === []) { + $output->info('AclTimestampBackfill: no rows to update'); + return; + } + + // Group ACL IDs by target timestamp to issue one UPDATE per group + // rather than one UPDATE per row (avoids N+1 queries). + $now = time(); + $groups = []; + foreach ($rows as $row) { + $timestamp = ((int)$row['board_last_modified'] > 0) ? (int)$row['board_last_modified'] : $now; + $groups[$timestamp][] = (int)$row['acl_id']; + } + + $updated = 0; + foreach ($groups as $timestamp => $ids) { + // Chunk at 1000 for Oracle compatibility (same limit used by chunkQuery). + foreach (array_chunk($ids, 1000) as $chunk) { + $updateQb = $this->db->getQueryBuilder(); + $updateQb->update('deck_board_acl') + ->set('created_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->set('last_modified_at', $updateQb->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT)) + ->where($updateQb->expr()->in('id', $updateQb->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))); + $updateQb->executeStatement(); + $updated += count($chunk); + } + } + + $output->info('AclTimestampBackfill: updated ' . $updated . ' row(s)'); + } +} diff --git a/lib/Migration/Version11002Date20260611000000.php b/lib/Migration/Version11002Date20260611000000.php new file mode 100644 index 000000000..79b1cd601 --- /dev/null +++ b/lib/Migration/Version11002Date20260611000000.php @@ -0,0 +1,41 @@ +getTable('deck_board_acl'); + + if (!$table->hasColumn('created_at')) { + $table->addColumn('created_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + if (!$table->hasColumn('last_modified_at')) { + $table->addColumn('last_modified_at', 'integer', [ + 'notnull' => true, + 'default' => 0, + 'unsigned' => true, + ]); + } + + return $schema; + } +} diff --git a/lib/Service/BoardService.php b/lib/Service/BoardService.php index 44bc4e29b..9a0408261 100644 --- a/lib/Service/BoardService.php +++ b/lib/Service/BoardService.php @@ -400,6 +400,9 @@ public function addAcl(int $boardId, int $type, $participant, bool $edit, bool $ $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $now = time(); + $acl->setCreatedAt($now); + $acl->setLastModifiedAt($now); $newAcl = $this->aclMapper->insert($acl); $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_BOARD, $newAcl, ActivityManager::SUBJECT_BOARD_SHARE, [], $this->userId); @@ -451,6 +454,7 @@ public function updateAcl(int $id, bool $edit, bool $share, bool $manage): Acl { $acl->setPermissionEdit($edit); $acl->setPermissionShare($share); $acl->setPermissionManage($manage); + $acl->setLastModifiedAt(time()); $this->boardMapper->mapAcl($acl); $acl = $this->aclMapper->update($acl); $this->changeHelper->boardChanged($acl->getBoardId()); @@ -511,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/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); + } +} 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 new file mode 100644 index 000000000..3849b66b3 --- /dev/null +++ b/lib/ShareReview/ShareReviewSource.php @@ -0,0 +1,128 @@ + + */ + public function getShares(): array { + try { + $rawShares = $this->aclMapper->findAllForShareReview(); + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); + return []; + } + return array_map( + fn (array $share) => $this->buildShare($share)->toArray(), + $rawShares, + ); + } + + public function deleteShare(string $shareId): bool { + if (!is_numeric($shareId)) { + return false; + } + + $event = new ShareReviewAccessCheckEvent('Deck', $shareId); + $this->eventDispatcher->dispatchTyped($event); + + if (!$event->isHandled() || !$event->isGranted()) { + return false; + } + + try { + $this->boardService->deleteAclForShareReview((int)$shareId); + return true; + } catch (DoesNotExistException) { + return false; + } + } + + /** @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']); + $label = $title !== '' ? $title : $this->l->t('Board %d', [$boardId]); + return $this->l->t('%s (Board)', [$label]); + } + + 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 => $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; + 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; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index dd0c08c49..6a27ef53c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -24,3 +24,7 @@ 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/integration/base-query-count.txt b/tests/integration/base-query-count.txt index ce8c60422..627806590 100644 --- a/tests/integration/base-query-count.txt +++ b/tests/integration/base-query-count.txt @@ -1 +1 @@ -93102 +96706 \ No newline at end of file diff --git a/tests/integration/import/ImportExportTest.php b/tests/integration/import/ImportExportTest.php index a9e00e0cb..586c11c3a 100644 --- a/tests/integration/import/ImportExportTest.php +++ b/tests/integration/import/ImportExportTest.php @@ -134,7 +134,7 @@ public function testReimportOcc() { ); } - public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token']): string { + public static function writeArrayStructure(string $prefix = '', array $array = [], array $skipKeyList = ['id', 'boardId', 'cardId', 'stackId', 'ETag', 'permissions', 'shared', 'version', 'done', 'referenceData', 'token', 'createdAt', 'lastModifiedAt']): string { $output = ''; $arrayIsList = array_keys($array) === range(0, count($array) - 1); foreach ($array as $key => $value) { 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/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; + } +} diff --git a/tests/unit/Db/AclMapperTest.php b/tests/unit/Db/AclMapperTest.php index af3f03196..0154ee62b 100644 --- a/tests/unit/Db/AclMapperTest.php +++ b/tests/unit/Db/AclMapperTest.php @@ -120,6 +120,34 @@ public function testFindBoardIdDatabase() { $this->assertEquals($this->boards[0]->getId(), $this->aclMapper->findBoardId($this->acls[1]->getId())); } + public function testInsertSetsCreatedAtAndLastModifiedAt(): void { + $before = time(); + $acl = $this->getAcl('user', 'timestamps_user', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $inserted->getCreatedAt()); + $this->assertLessThanOrEqual($after, $inserted->getCreatedAt()); + $this->assertGreaterThanOrEqual($before, $inserted->getLastModifiedAt()); + $this->assertLessThanOrEqual($after, $inserted->getLastModifiedAt()); + + $this->aclMapper->delete($inserted); + } + + public function testUpdateChangesLastModifiedAtButNotCreatedAt(): void { + $acl = $this->getAcl('user', 'timestamps_user2', false, false, false, $this->boards[0]->getId()); + $inserted = $this->aclMapper->insert($acl); + $originalCreatedAt = $inserted->getCreatedAt(); + + $inserted->setPermissionEdit(true); + $updated = $this->aclMapper->update($inserted); + + $this->assertSame($originalCreatedAt, $updated->getCreatedAt()); + $this->assertGreaterThan(0, $updated->getLastModifiedAt()); + + $this->aclMapper->delete($updated); + } + public function tearDown(): void { parent::tearDown(); foreach ($this->acls as $acl) { diff --git a/tests/unit/Db/AclTest.php b/tests/unit/Db/AclTest.php index e1538215e..069cb98dd 100644 --- a/tests/unit/Db/AclTest.php +++ b/tests/unit/Db/AclTest.php @@ -59,7 +59,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); $acl = $this->createAclGroup(); $this->assertEquals([ @@ -70,7 +72,9 @@ public function testJsonSerialize() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => false + 'owner' => false, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } @@ -85,7 +89,9 @@ public function testSetOwner() { 'permissionEdit' => true, 'permissionShare' => true, 'permissionManage' => true, - 'owner' => true + 'owner' => true, + 'createdAt' => 0, + 'lastModifiedAt' => 0, ], $acl->jsonSerialize()); } diff --git a/tests/unit/Migration/AclTimestampBackfillTest.php b/tests/unit/Migration/AclTimestampBackfillTest.php new file mode 100644 index 000000000..cc9ddc09e --- /dev/null +++ b/tests/unit/Migration/AclTimestampBackfillTest.php @@ -0,0 +1,181 @@ +db = $this->createMock(IDBConnection::class); + $this->output = $this->createMock(IOutput::class); + $this->backfill = new AclTimestampBackfill($this->db); + } + + public function testGetName(): void { + $this->assertNotEmpty($this->backfill->getName()); + } + + public function testRunNoRowsIsNoop(): void { + [$selectQb] = $this->buildSelectQb([]); + + $this->db->method('getQueryBuilder')->willReturn($selectQb); + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('no rows')); + + $this->backfill->run($this->output); + } + + public function testRunGroupsRowsWithSameTimestampIntoOneUpdate(): void { + // Two ACLs from the same board share the same timestamp โ†’ only 1 UPDATE + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 1000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb = $this->buildUpdateQb(1); // single IN-clause UPDATE for both IDs + + $this->db->expects($this->exactly(2)) // 1 SELECT + 1 UPDATE + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + + public function testRunIssuesSeparateUpdatePerDistinctTimestamp(): void { + // Two ACLs from different boards โ†’ two distinct timestamps โ†’ 2 UPDATEs + $rows = [ + ['acl_id' => 1, 'board_last_modified' => 1000000], + ['acl_id' => 2, 'board_last_modified' => 2000000], + ]; + [$selectQb] = $this->buildSelectQb($rows); + $updateQb1 = $this->buildUpdateQb(1); + $updateQb2 = $this->buildUpdateQb(1); + + $this->db->expects($this->exactly(3)) // 1 SELECT + 2 UPDATEs + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb1, $updateQb2); + + $this->output->expects($this->once()) + ->method('info') + ->with($this->stringContains('2')); + + $this->backfill->run($this->output); + } + + public function testRunUsesBoardTimestampWhenAvailable(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 1234567]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + + $this->assertSame(1234567, $capturedTs); + } + + public function testRunUsesCurrentTimeWhenBoardTimestampIsZero(): void { + $rows = [['acl_id' => 1, 'board_last_modified' => 0]]; + [$selectQb] = $this->buildSelectQb($rows); + + $capturedTs = null; + $before = time(); + $updateQb = $this->buildUpdateQb(1, function (mixed $value, int $type) use (&$capturedTs): void { + if ($type === IQueryBuilder::PARAM_INT) { + $capturedTs = $value; + } + }); + + $this->db->expects($this->exactly(2)) + ->method('getQueryBuilder') + ->willReturnOnConsecutiveCalls($selectQb, $updateQb); + $this->output->method('info'); + + $this->backfill->run($this->output); + $after = time(); + + $this->assertGreaterThanOrEqual($before, $capturedTs); + $this->assertLessThanOrEqual($after, $capturedTs); + } + + /** + * @return array{0: IQueryBuilder&MockObject} + */ + private function buildSelectQb(array $rows): array { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($rows); + $result->expects($this->once())->method('closeCursor'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('join')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willReturn($result); + + return [$qb]; + } + + private function buildUpdateQb(int $expectedExecutions, ?\Closure $onCreateNamedParameter = null): IQueryBuilder&MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('in')->willReturn('1=1'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('update')->willReturnSelf(); + $qb->method('set')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('expr')->willReturn($expr); + $qb->expects($this->exactly($expectedExecutions))->method('executeStatement'); + + if ($onCreateNamedParameter !== null) { + $qb->method('createNamedParameter')->willReturnCallback( + function (mixed $value, int $type) use ($onCreateNamedParameter): string { + $onCreateNamedParameter($value, $type); + return '?'; + } + ); + } else { + $qb->method('createNamedParameter')->willReturn('?'); + } + + return $qb; + } +} diff --git a/tests/unit/Service/BoardServiceTest.php b/tests/unit/Service/BoardServiceTest.php index 549021bf0..dd8105646 100644 --- a/tests/unit/Service/BoardServiceTest.php +++ b/tests/unit/Service/BoardServiceTest.php @@ -301,7 +301,16 @@ public function testAddAcl() { ->method('sendBoardShared'); $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->permissionService->expects($this->any()) ->method('findUsers') @@ -407,11 +416,30 @@ public function testAddAclExtendPermission($currentUserAcl, $providedAcl, $resul $expected = clone $acl; $this->aclMapper->expects($this->once()) ->method('insert') - ->with($acl) + ->with($this->callback(function (Acl $actual) use ($acl) { + return $actual->getBoardId() === $acl->getBoardId() + && $actual->getType() === $acl->getType() + && $actual->getParticipant() === $acl->getParticipant() + && $actual->getPermissionEdit() === $acl->getPermissionEdit() + && $actual->getPermissionShare() === $acl->getPermissionShare() + && $actual->getPermissionManage() === $acl->getPermissionManage() + && $actual->getCreatedAt() > 0 + && $actual->getLastModifiedAt() > 0; + })) ->willReturn($acl); $this->eventDispatcher->expects(self::once()) ->method('dispatchTyped') - ->with(new AclCreatedEvent($acl)); + ->with($this->callback(function (AclCreatedEvent $event) use ($acl) { + $eventAcl = $event->getAcl(); + return $eventAcl->getBoardId() === $acl->getBoardId() + && $eventAcl->getType() === $acl->getType() + && $eventAcl->getParticipant() === $acl->getParticipant() + && $eventAcl->getPermissionEdit() === $acl->getPermissionEdit() + && $eventAcl->getPermissionShare() === $acl->getPermissionShare() + && $eventAcl->getPermissionManage() === $acl->getPermissionManage() + && $eventAcl->getCreatedAt() > 0 + && $eventAcl->getLastModifiedAt() > 0; + })); $this->assertEquals($expected, $this->service->addAcl( 123, Acl::PERMISSION_TYPE_USER, 'admin', $providedAcl[0], $providedAcl[1], $providedAcl[2] )); @@ -474,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/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php new file mode 100644 index 000000000..8d5a45dab --- /dev/null +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -0,0 +1,265 @@ +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->aclMapper, + $this->logger, + $this->boardService, + $this->eventDispatcher, + $this->l, + ); + } + + /** @param array $overrides */ + private function makeShareRow(array $overrides = []): array { + return array_merge([ + 'id' => 1, + 'board_id' => 10, + 'type' => 0, + 'participant' => 'bob', + 'board_title' => 'My Board', + 'board_owner' => 'alice', + 'permission_edit' => 0, + 'permission_share' => 0, + 'permission_manage' => 0, + 'created_at' => 1700000000, + 'last_modified_at' => 0, + ], $overrides); + } + + public function testGetName(): void { + $this->assertSame('Deck', $this->source->getName()); + } + + public function testGetSharesEmpty(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn([]); + + $this->assertSame([], $this->source->getShares()); + } + + public function testGetSharesUserShare(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn([$this->makeShareRow()]); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $share = $shares[0]; + $this->assertSame(1, $share['id']); + $this->assertArrayNotHasKey('app', $share); + $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(date('Y-m-d H:i:s', 1700000000), $share['time']); + $this->assertSame('', $share['action']); + } + + public function testGetSharesUsesLastModifiedAtWhenNewer(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$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->aclMapper->method('findAllForShareReview')->willReturn( + [$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->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 7, 'participant' => 'circle-uid'])] + ); + + $this->assertSame(IShare::TYPE_CIRCLE, $this->source->getShares()[0]['type']); + } + + public function testGetSharesRemoteShare(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['type' => 6, 'participant' => 'user@remote.example'])] + ); + + $this->assertSame(IShare::TYPE_REMOTE, $this->source->getShares()[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->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['board_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->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->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 0, 'permission_share' => 0, 'permission_manage' => 0])] + ); + + $this->assertSame(Constants::PERMISSION_READ, $this->source->getShares()[0]['permissions']); + } + + public function testComputePermissionsEditFlag(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 1])] + ); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); + } + + public function testComputePermissionsShareFlag(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_share' => 1])] + ); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_SHARE; + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); + } + + public function testComputePermissionsManageFlag(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_manage' => 1])] + ); + + $this->assertSame(Constants::PERMISSION_READ | 32, $this->source->getShares()[0]['permissions']); + } + + public function testComputePermissionsAllTrue(): void { + $this->aclMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permission_edit' => 1, 'permission_share' => 1, 'permission_manage' => 1])] + ); + + $expected = Constants::PERMISSION_READ + | Constants::PERMISSION_UPDATE + | Constants::PERMISSION_CREATE + | Constants::PERMISSION_DELETE + | Constants::PERMISSION_SHARE + | 32; + $this->assertSame($expected, $this->source->getShares()[0]['permissions']); + } + + public function testDeleteShareNonNumericReturnsFalse(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->assertFalse($this->source->deleteShare('abc')); + } + + 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('7')); + } + + 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')); + } +} 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 @@ +