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/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/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php new file mode 100644 index 000000000..dddf26d8d --- /dev/null +++ b/lib/ShareReview/ShareReviewSource.php @@ -0,0 +1,128 @@ + + */ + public function getShares(): array { + $rawShares = $this->fetchAllShares(); + $appName = $this->getName(); + $formatted = []; + foreach ($rawShares as $share) { + $formatted[] = [ + 'id' => (int)$share['id'], + 'app' => $appName, + 'object' => $this->resolveObjectName($share), + 'initiator' => (string)$share['board_owner'], + 'type' => $this->mapParticipantType((int)$share['type']), + 'recipient' => (string)$share['participant'], + 'permissions' => $this->computePermissions($share), + 'password' => false, + 'time' => '1970-01-01 01:00:00', + 'action' => '', + ]; + } + return $formatted; + } + + public function deleteShare(string $shareId): bool { + $this->logger->info('Deck ShareReview: deleting share {id}', ['id' => $shareId]); + try { + $qb = $this->db->getQueryBuilder(); + $qb->delete(self::ACL_TABLE) + ->where($qb->expr()->eq('id', $qb->createNamedParameter((int)$shareId, IQueryBuilder::PARAM_INT))); + return $qb->executeStatement() > 0; + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]); + return false; + } + } + + /** @return list> */ + private function fetchAllShares(): array { + try { + $qb = $this->db->getQueryBuilder(); + $qb->select( + 'a.id', 'a.board_id', 'a.type', 'a.participant', + 'a.permission_edit', 'a.permission_share', 'a.permission_manage' + ) + ->selectAlias('b.title', 'board_title') + ->selectAlias('b.owner', 'board_owner') + ->from(self::ACL_TABLE, 'a') + ->leftJoin('a', self::BOARDS_TABLE, 'b', $qb->expr()->eq('a.board_id', 'b.id')) + ->orderBy('a.id', 'ASC'); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } catch (Exception $e) { + $this->logger->error('Deck ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); + return []; + } + } + + /** @param array $share */ + private function resolveObjectName(array $share): string { + $title = (string)($share['board_title'] ?? ''); + $boardId = (int)($share['board_id'] ?? $share['id']); + return ($title !== '' ? $title : "Board $boardId") . ' (Board)'; + } + + private function mapParticipantType(int $type): int { + return match($type) { + Acl::PERMISSION_TYPE_USER => IShare::TYPE_USER, + Acl::PERMISSION_TYPE_GROUP => IShare::TYPE_GROUP, + Acl::PERMISSION_TYPE_REMOTE => IShare::TYPE_REMOTE, + Acl::PERMISSION_TYPE_CIRCLE => IShare::TYPE_CIRCLE, + default => IShare::TYPE_USER, + }; + } + + /** @param array $share */ + private function computePermissions(array $share): int { + $permissions = Constants::PERMISSION_READ; + if ($share['permission_edit']) { + $permissions |= Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; + } + if ($share['permission_share']) { + $permissions |= Constants::PERMISSION_SHARE; + } + if ($share['permission_manage']) { + $permissions |= self::PERMISSION_MANAGE; + } + return $permissions; + } +} 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/stub.phpstub b/tests/stub.phpstub index 8ca73d9dd..28a862978 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -725,3 +725,15 @@ namespace OCA\NotifyPush\Queue { 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/ShareReview/ShareReviewSourceTest.php b/tests/unit/ShareReview/ShareReviewSourceTest.php new file mode 100644 index 000000000..683e77df7 --- /dev/null +++ b/tests/unit/ShareReview/ShareReviewSourceTest.php @@ -0,0 +1,265 @@ +db = $this->createMock(IDBConnection::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->source = new ShareReviewSource($this->db, $this->logger); + } + + private function makeResult(array $rows): MockObject { + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn($rows); + $result->method('closeCursor')->willReturn(true); + return $result; + } + + private function makeQb(array $fetchRows = [], int $statementRows = 0): MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + $expr->method('eq')->willReturn('1=1'); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('addSelect')->willReturnSelf(); + $qb->method('selectAlias')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('delete')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willReturn($this->makeResult($fetchRows)); + $qb->method('executeStatement')->willReturn($statementRows); + + return $qb; + } + + private function makeThrowingQb(): MockObject { + $expr = $this->createMock(IExpressionBuilder::class); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select')->willReturnSelf(); + $qb->method('addSelect')->willReturnSelf(); + $qb->method('selectAlias')->willReturnSelf(); + $qb->method('from')->willReturnSelf(); + $qb->method('leftJoin')->willReturnSelf(); + $qb->method('where')->willReturnSelf(); + $qb->method('orderBy')->willReturnSelf(); + $qb->method('delete')->willReturnSelf(); + $qb->method('createNamedParameter')->willReturn('?'); + $qb->method('expr')->willReturn($expr); + $qb->method('executeQuery')->willThrowException($this->createMock(Exception::class)); + $qb->method('executeStatement')->willThrowException($this->createMock(Exception::class)); + return $qb; + } + + /** @param array $overrides */ + private function makeShareRow(array $overrides = []): array { + return array_merge([ + '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, + ], $overrides); + } + + public function testGetName(): void { + $this->assertSame('Deck', $this->source->getName()); + } + + public function testGetSharesEmpty(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb()); + + $this->assertSame([], $this->source->getShares()); + } + + public function testGetSharesUserShare(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([$this->makeShareRow()])); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $share = $shares[0]; + $this->assertSame(1, $share['id']); + $this->assertSame('Deck', $share['app']); + $this->assertSame('My Board (Board)', $share['object']); + $this->assertSame('alice', $share['initiator']); + $this->assertSame(IShare::TYPE_USER, $share['type']); + $this->assertSame('bob', $share['recipient']); + $this->assertSame(Constants::PERMISSION_READ, $share['permissions']); + $this->assertFalse($share['password']); + $this->assertSame('1970-01-01 01:00:00', $share['time']); + $this->assertSame('', $share['action']); + } + + public function testGetSharesGroupShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 1, 'participant' => 'developers'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $this->assertSame(IShare::TYPE_GROUP, $shares[0]['type']); + $this->assertSame('developers', $shares[0]['recipient']); + } + + public function testGetSharesCircleShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 7, 'participant' => 'circle-uid'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_CIRCLE, $shares[0]['type']); + } + + public function testGetSharesRemoteShare(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['type' => 6, 'participant' => 'user@remote.example'])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_REMOTE, $shares[0]['type']); + } + + public function testGetSharesMissingBoardFallback(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['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->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); + $this->logger->expects($this->once())->method('error'); + + $this->assertSame([], $this->source->getShares()); + } + + public function testComputePermissionsAllFalse(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow([ + 'permission_edit' => 0, + 'permission_share' => 0, + 'permission_manage' => 0, + ])]) + ); + + $shares = $this->source->getShares(); + + $this->assertSame(Constants::PERMISSION_READ, $shares[0]['permissions']); + } + + public function testComputePermissionsEditFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_edit' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE | Constants::PERMISSION_CREATE | Constants::PERMISSION_DELETE; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsShareFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_share' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | Constants::PERMISSION_SHARE; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsManageFlag(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow(['permission_manage' => 1])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ | 32; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testComputePermissionsAllTrue(): void { + $this->db->method('getQueryBuilder')->willReturn( + $this->makeQb([$this->makeShareRow([ + 'permission_edit' => 1, + 'permission_share' => 1, + 'permission_manage' => 1, + ])]) + ); + + $shares = $this->source->getShares(); + + $expected = Constants::PERMISSION_READ + | Constants::PERMISSION_UPDATE + | Constants::PERMISSION_CREATE + | Constants::PERMISSION_DELETE + | Constants::PERMISSION_SHARE + | 32; + $this->assertSame($expected, $shares[0]['permissions']); + } + + public function testDeleteShareSuccess(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 1)); + $this->logger->expects($this->once())->method('info'); + + $this->assertTrue($this->source->deleteShare('7')); + } + + public function testDeleteShareNotFound(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeQb([], 0)); + $this->logger->expects($this->once())->method('info'); + + $this->assertFalse($this->source->deleteShare('99')); + } + + public function testDeleteShareReturnsFalseOnDbException(): void { + $this->db->method('getQueryBuilder')->willReturn($this->makeThrowingQb()); + $this->logger->expects($this->once())->method('info'); + $this->logger->expects($this->once())->method('error'); + + $this->assertFalse($this->source->deleteShare('7')); + } +} diff --git a/tests/unit/ShareReview/Stubs.php b/tests/unit/ShareReview/Stubs.php new file mode 100644 index 000000000..80ec8a517 --- /dev/null +++ b/tests/unit/ShareReview/Stubs.php @@ -0,0 +1,22 @@ +