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 @@
+