diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 37737da59..b46465f5d 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -17,11 +17,13 @@ use OCA\Forms\Listener\UserDeletedListener; use OCA\Forms\Middleware\ThrottleFormAccessMiddleware; use OCA\Forms\Search\SearchProvider; +use OCA\Forms\ShareReview\ShareReviewListener; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\Comments\CommentsEntityEvent; +use OCP\Share\ShareReview\RegisterShareReviewSourceEvent; use OCP\User\Events\UserDeletedEvent; class Application extends App implements IBootstrap { @@ -47,6 +49,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(DatasourceEvent::class, AnalyticsDatasourceListener::class); $context->registerEventListener(CommentsEntityEvent::class, CommentsEntityListener::class); + $context->registerEventListener(RegisterShareReviewSourceEvent::class, ShareReviewListener::class); $context->registerMiddleware(ThrottleFormAccessMiddleware::class); $context->registerSearchProvider(SearchProvider::class); $context->registerUserMigrator(FormsMigrator::class); diff --git a/lib/Db/ShareMapper.php b/lib/Db/ShareMapper.php index fc146959d..e1570536c 100644 --- a/lib/Db/ShareMapper.php +++ b/lib/Db/ShareMapper.php @@ -12,6 +12,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\Share\IShare; @@ -87,6 +88,32 @@ public function findPublicShareByHash(string $hash): Share { return $this->findEntity($qb); } + /** + * Fetch all share rows with their form title, owner and timestamps for ShareReview. + * + * @return list> + * @throws Exception + */ + public function findAllForShareReview(): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('s.id', 's.form_id', 's.share_type', 's.share_with', 's.permissions_json') + ->selectAlias('f.title', 'form_title') + ->selectAlias('f.owner_id', 'form_owner') + ->selectAlias('f.created', 'form_created') + ->selectAlias('f.last_updated', 'form_last_updated') + ->selectAlias('f.expires', 'form_expires') + ->from($this->getTableName(), 's') + ->leftJoin('s', 'forms_v2_forms', 'f', $qb->expr()->eq('s.form_id', 'f.id')) + ->orderBy('s.id', 'ASC'); + + $result = $qb->executeQuery(); + /** @var list> $rows */ + $rows = $result->fetchAll(); + $result->closeCursor(); + return $rows; + } + /** * Delete all Shares of a form. * @param int $formId diff --git a/lib/ShareReview/ShareReviewListener.php b/lib/ShareReview/ShareReviewListener.php new file mode 100644 index 000000000..328fd558b --- /dev/null +++ b/lib/ShareReview/ShareReviewListener.php @@ -0,0 +1,24 @@ + */ +class ShareReviewListener implements IEventListener { + public function handle(Event $event): void { + if (!$event instanceof RegisterShareReviewSourceEvent) { + return; + } + $event->registerSource(ShareReviewSource::class); + } +} diff --git a/lib/ShareReview/ShareReviewSource.php b/lib/ShareReview/ShareReviewSource.php new file mode 100644 index 000000000..242667d8e --- /dev/null +++ b/lib/ShareReview/ShareReviewSource.php @@ -0,0 +1,191 @@ +|null */ + private ?array $permissionCatalog = null; + + public function __construct( + private readonly ShareMapper $shareMapper, + private readonly FormMapper $formMapper, + private readonly UploadedFilesShareService $uploadedFilesShareService, + private readonly IEventDispatcher $eventDispatcher, + private readonly LoggerInterface $logger, + private readonly IL10N $l10n, + ) { + } + + public function getName(): string { + return 'Forms'; + } + + /** + * @return list + */ + public function getShares(): array { + try { + $rawShares = $this->shareMapper->findAllForShareReview(); + } catch (Exception $e) { + $this->logger->error('Forms ShareReview: failed to fetch shares: {message}', ['message' => $e->getMessage()]); + return []; + } + return array_map( + fn (array $share) => $this->buildEntry($share), + $rawShares, + ); + } + + public function deleteShare(string $shareId): bool { + // Digits only, so the ID the access-check event carries is exactly the row being deleted + // (is_numeric would also accept '1e3' or '7.5', which (int) casts to a different value) + if (!ctype_digit($shareId)) { + return false; + } + $numericShareId = (int)$shareId; + + $event = new ShareReviewAccessCheckEvent('Forms', (string)$numericShareId); + $this->eventDispatcher->dispatchTyped($event); + + if (!$event->isHandled() || !$event->isGranted()) { + return false; + } + + try { + $share = $this->shareMapper->findById($numericShareId); + $form = $this->formMapper->findById($share->getFormId()); + } catch (IMapperException) { + return false; + } + + try { + // Revoke any linked Files share before deleting the Forms share + if (in_array(Constants::PERMISSION_RESULTS, $share->getPermissions(), true)) { + $this->uploadedFilesShareService->removeForCollaborator($form, $share); + } + $this->shareMapper->delete($share); + // Bump the form's last_updated timestamp, matching the regular deletion flow + $this->formMapper->update($form); + return true; + } catch (\Exception $e) { + $this->logger->error('Forms ShareReview: failed to delete share {id}: {message}', ['id' => $shareId, 'message' => $e->getMessage()]); + return false; + } + } + + /** @param array $share */ + private function buildEntry(array $share): ShareReviewEntry { + // last_updated is bumped on every share change of the form, created is the lower bound + $time = (int)($share['form_last_updated'] ?? 0) ?: (int)($share['form_created'] ?? 0); + $expires = (int)($share['form_expires'] ?? 0); + + return new ShareReviewEntry( + id: (string)$share['id'], + object: $this->resolveObjectName($share), + initiator: (string)$share['form_owner'], + type: $this->mapShareType((int)$share['share_type']), + recipient: (string)$share['share_with'], + lastModifiedTimestamp: $time, + permissions: $this->buildPermissions($this->decodePermissions($share)), + expirationTimestamp: $expires > 0 ? $expires : null, + ); + } + + /** @param array $share */ + private function resolveObjectName(array $share): string { + $title = (string)($share['form_title'] ?? ''); + $formId = (int)($share['form_id'] ?? $share['id']); + $label = $title !== '' ? $title : $this->l10n->t('Form %d', [$formId]); + return $this->l10n->t('%s (Form)', [$label]); + } + + private function mapShareType(int $type): int { + if (in_array($type, Constants::SHARE_TYPES_USED, true)) { + return $type; + } + $this->logger->warning('Forms ShareReview: unknown share type {type}, defaulting to user share', ['type' => $type]); + return IShare::TYPE_USER; + } + + /** + * @param array $share + * @return list + */ + private function decodePermissions(array $share): array { + // Same fallback to submit permission as OCA\Forms\Db\Share::getPermissions() + return json_decode((string)($share['permissions_json'] ?? '') ?: 'null', true) ?? [Constants::PERMISSION_SUBMIT]; + } + + /** + * @param list $formPermissions + * @return list + */ + private function buildPermissions(array $formPermissions): array { + $catalog = $this->permissionCatalog(); + // Any share grants seeing the form itself + $permissions = [$catalog[self::PERMISSION_READ]]; + foreach ([ + Constants::PERMISSION_EDIT => self::PERMISSION_EDIT, + Constants::PERMISSION_SUBMIT => self::PERMISSION_SUBMIT, + Constants::PERMISSION_RESULTS => self::PERMISSION_RESULTS, + Constants::PERMISSION_RESULTS_DELETE => self::PERMISSION_RESULTS_DELETE, + Constants::PERMISSION_EMBED => self::PERMISSION_EMBED, + ] as $formPermission => $permissionId) { + if (in_array($formPermission, $formPermissions, true)) { + $permissions[] = $catalog[$permissionId]; + } + } + return $permissions; + } + + /** + * The permission objects are immutable and identical for every share row, + * so they are built once per request instead of once per row. + * + * All permission IDs are namespaced to this app, and labels and hints are + * translated from this app's own catalog — the app owning a permission + * also owns its wording in every language. + * + * @return array + */ + private function permissionCatalog(): array { + return $this->permissionCatalog ??= [ + self::PERMISSION_READ => new ShareReviewPermission(self::PERMISSION_READ, $this->l10n->t('Read'), priority: 80), + self::PERMISSION_EDIT => new ShareReviewPermission(self::PERMISSION_EDIT, $this->l10n->t('Edit form'), priority: 70), + self::PERMISSION_SUBMIT => new ShareReviewPermission(self::PERMISSION_SUBMIT, $this->l10n->t('Submit'), $this->l10n->t('Fill in the form'), 60), + self::PERMISSION_RESULTS => new ShareReviewPermission(self::PERMISSION_RESULTS, $this->l10n->t('View results'), priority: 50), + self::PERMISSION_RESULTS_DELETE => new ShareReviewPermission(self::PERMISSION_RESULTS_DELETE, $this->l10n->t('Delete results'), priority: 45), + self::PERMISSION_EMBED => new ShareReviewPermission(self::PERMISSION_EMBED, $this->l10n->t('Embed'), $this->l10n->t('Embed the form in external websites'), 35), + ]; + } +} diff --git a/psalm.xml b/psalm.xml index 85b3918f9..972fce4d4 100644 --- a/psalm.xml +++ b/psalm.xml @@ -48,5 +48,6 @@ + diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php new file mode 100644 index 000000000..1da72020b --- /dev/null +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -0,0 +1,35 @@ +createMock(IRegistrationContext::class); + $context->method('registerEventListener') + ->willReturnCallback(function (string $event, string $listener) use (&$registeredListeners): void { + $registeredListeners[$event] = $listener; + }); + + (new Application())->register($context); + + $this->assertSame( + ShareReviewListener::class, + $registeredListeners[RegisterShareReviewSourceEvent::class] ?? null, + ); + } +} diff --git a/tests/Unit/Db/ShareMapperTest.php b/tests/Unit/Db/ShareMapperTest.php new file mode 100644 index 000000000..8259f4595 --- /dev/null +++ b/tests/Unit/Db/ShareMapperTest.php @@ -0,0 +1,94 @@ +qb = $this->createMock(IQueryBuilder::class); + $this->qb->method('select')->willReturnSelf(); + $this->qb->method('selectAlias')->willReturnSelf(); + $this->qb->method('from')->willReturnSelf(); + $this->qb->method('leftJoin')->willReturnSelf(); + $this->qb->method('orderBy')->willReturnSelf(); + $this->qb->method('expr')->willReturn($this->createMock(IExpressionBuilder::class)); + + $this->db = $this->createMock(IDBConnection::class); + $this->db->method('getQueryBuilder')->willReturn($this->qb); + + $this->shareMapper = new ShareMapper($this->db); + } + + public function testFindAllForShareReviewSelectsSharesJoinedWithForms(): void { + $rows = [ + [ + 'id' => 1, + 'form_id' => 10, + 'share_type' => 0, + 'share_with' => 'bob', + 'permissions_json' => '["submit"]', + 'form_title' => 'My Form', + 'form_owner' => 'alice', + 'form_created' => 1000, + 'form_last_updated' => 2000, + 'form_expires' => 0, + ], + ]; + + $this->qb->expects($this->once()) + ->method('select') + ->with('s.id', 's.form_id', 's.share_type', 's.share_with', 's.permissions_json'); + $this->qb->expects($this->once()) + ->method('from') + ->with('forms_v2_shares', 's'); + $this->qb->expects($this->once()) + ->method('leftJoin') + ->with('s', 'forms_v2_forms', 'f', $this->anything()); + $this->qb->expects($this->once()) + ->method('orderBy') + ->with('s.id', 'ASC'); + + $result = $this->createMock(IResult::class); + $result->expects($this->once()) + ->method('fetchAll') + ->willReturn($rows); + $result->expects($this->once()) + ->method('closeCursor'); + + $this->qb->expects($this->once()) + ->method('executeQuery') + ->willReturn($result); + + $this->assertSame($rows, $this->shareMapper->findAllForShareReview()); + } + + public function testFindAllForShareReviewWithoutShares(): void { + $result = $this->createMock(IResult::class); + $result->method('fetchAll')->willReturn([]); + $result->expects($this->once()) + ->method('closeCursor'); + $this->qb->method('executeQuery')->willReturn($result); + + $this->assertSame([], $this->shareMapper->findAllForShareReview()); + } +} diff --git a/tests/Unit/Service/UploadedFilesShareServiceTest.php b/tests/Unit/Service/UploadedFilesShareServiceTest.php index 8d957a2bd..24ad72712 100644 --- a/tests/Unit/Service/UploadedFilesShareServiceTest.php +++ b/tests/Unit/Service/UploadedFilesShareServiceTest.php @@ -18,6 +18,7 @@ use OCP\Files\Folder; use OCP\Files\IFilenameValidator; use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; use OCP\Share\IManager; use OCP\Share\IShare; use PHPUnit\Framework\MockObject\MockObject; @@ -96,4 +97,39 @@ public function testRemoveAllForFormCleansUpResultsShares(): void { $this->service->removeAllForForm($form); } + + public function testRemoveForCollaboratorSkipsShareTypesWithoutFilesShare(): void { + $form = new Form(); + $form->setId(2); + $form->setTitle('test'); + $form->setOwnerId('alice'); + + $linkShare = new Share(); + $linkShare->setShareType(IShare::TYPE_LINK); + $linkShare->setPermissions([Constants::PERMISSION_RESULTS]); + + $this->rootFolder->expects($this->never())->method('getUserFolder'); + $this->shareManager->expects($this->never())->method('getSharesBy'); + + $this->service->removeForCollaborator($form, $linkShare); + } + + public function testRemoveForCollaboratorIgnoresMissingUploadedFilesFolder(): void { + $form = new Form(); + $form->setId(2); + $form->setTitle('test'); + $form->setOwnerId('alice'); + + $share = new Share(); + $share->setShareType(IShare::TYPE_USER); + $share->setShareWith('bob'); + $share->setPermissions([Constants::PERMISSION_RESULTS]); + + $userFolder = $this->createMock(Folder::class); + $userFolder->method('get')->willThrowException(new NotFoundException()); + $this->rootFolder->method('getUserFolder')->with('alice')->willReturn($userFolder); + $this->shareManager->expects($this->never())->method('getSharesBy'); + + $this->service->removeForCollaborator($form, $share); + } } diff --git a/tests/Unit/ShareReview/ShareReviewListenerTest.php b/tests/Unit/ShareReview/ShareReviewListenerTest.php new file mode 100644 index 000000000..2bff13542 --- /dev/null +++ b/tests/Unit/ShareReview/ShareReviewListenerTest.php @@ -0,0 +1,41 @@ +listener = new ShareReviewListener(); + } + + public function testHandleRegistersShareReviewSource(): void { + $event = $this->createMock(RegisterShareReviewSourceEvent::class); + $event->expects($this->once()) + ->method('registerSource') + ->with(ShareReviewSource::class); + + $this->listener->handle($event); + } + + public function testHandleIgnoresUnrelatedEvent(): void { + $event = $this->createMock(UserCreatedEvent::class); + + $this->listener->handle($event); + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/ShareReview/ShareReviewSourceTest.php b/tests/Unit/ShareReview/ShareReviewSourceTest.php new file mode 100644 index 000000000..85bb0c6c2 --- /dev/null +++ b/tests/Unit/ShareReview/ShareReviewSourceTest.php @@ -0,0 +1,410 @@ +shareMapper = $this->createMock(ShareMapper::class); + $this->formMapper = $this->createMock(FormMapper::class); + $this->uploadedFilesShareService = $this->createMock(UploadedFilesShareService::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->l10n = $this->createMock(IL10N::class); + $this->l10n->method('t')->willReturnCallback( + function (string $text, array $params = []): string { + return empty($params) ? $text : vsprintf($text, $params); + } + ); + $this->source = new ShareReviewSource( + $this->shareMapper, + $this->formMapper, + $this->uploadedFilesShareService, + $this->eventDispatcher, + $this->logger, + $this->l10n, + ); + } + + /** @param array $overrides */ + private function makeShareRow(array $overrides = []): array { + return array_merge([ + 'id' => 1, + 'form_id' => 10, + 'share_type' => IShare::TYPE_USER, + 'share_with' => 'bob', + 'permissions_json' => json_encode(['submit']), + 'form_title' => 'My Form', + 'form_owner' => 'alice', + 'form_created' => 1700000000, + 'form_last_updated' => 1700000000, + 'form_expires' => 0, + ], $overrides); + } + + private function makeShare(int $id = 7, array $permissions = ['submit'], int $shareType = IShare::TYPE_USER): Share { + $share = new Share(); + $share->setId($id); + $share->setFormId(10); + $share->setShareType($shareType); + $share->setShareWith('bob'); + $share->setPermissions($permissions); + return $share; + } + + private function makeForm(): Form { + $form = new Form(); + $form->setId(10); + $form->setOwnerId('alice'); + return $form; + } + + public function testGetName(): void { + $this->assertSame('Forms', $this->source->getName()); + } + + public function testGetSharesEmpty(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn([]); + + $this->assertSame([], $this->source->getShares()); + } + + public function testGetSharesUserShare(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn([$this->makeShareRow()]); + + $shares = $this->source->getShares(); + + $this->assertCount(1, $shares); + $share = $shares[0]; + $this->assertInstanceOf(ShareReviewEntry::class, $share); + $this->assertSame('1', $share->id); + $this->assertSame('My Form (Form)', $share->object); + $this->assertSame('alice', $share->initiator); + $this->assertSame(IShare::TYPE_USER, $share->type); + $this->assertSame('bob', $share->recipient); + $this->assertSame([ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_SUBMIT], $this->permissionIds($share->permissions)); + $this->assertFalse($share->hasPassword); + $this->assertSame(1700000000, $share->lastModifiedTimestamp); + $this->assertNull($share->expirationTimestamp); + $this->assertSame('', $share->action); + } + + public function testGetSharesLinkShare(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['share_type' => IShare::TYPE_LINK, 'share_with' => 'publicHash123'])] + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_LINK, $shares[0]->type); + $this->assertSame('publicHash123', $shares[0]->recipient); + } + + public function testGetSharesGroupShare(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['share_type' => IShare::TYPE_GROUP, 'share_with' => 'developers'])] + ); + + $shares = $this->source->getShares(); + + $this->assertSame(IShare::TYPE_GROUP, $shares[0]->type); + $this->assertSame('developers', $shares[0]->recipient); + } + + public function testGetSharesCircleShare(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['share_type' => IShare::TYPE_CIRCLE, 'share_with' => 'circle-uid'])] + ); + + $this->assertSame(IShare::TYPE_CIRCLE, $this->source->getShares()[0]->type); + } + + public function testGetSharesUnknownTypeLogsWarningAndFallsBackToUser(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['share_type' => 99])] + ); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame(IShare::TYPE_USER, $this->source->getShares()[0]->type); + } + + public function testGetSharesMissingTitleFallback(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['form_id' => 42, 'form_title' => null, 'form_owner' => null])] + ); + + $shares = $this->source->getShares(); + + $this->assertSame('Form 42 (Form)', $shares[0]->object); + } + + public function testGetSharesExpirationFromForm(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['form_expires' => 1800000000])] + ); + + $this->assertSame(1800000000, $this->source->getShares()[0]->expirationTimestamp); + } + + public function testGetSharesUsesLastUpdatedWhenSet(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['form_created' => 1700000000, 'form_last_updated' => 1800000000])] + ); + + $this->assertSame(1800000000, $this->source->getShares()[0]->lastModifiedTimestamp); + } + + public function testGetSharesFallsBackToCreatedTime(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['form_created' => 1700000000, 'form_last_updated' => 0])] + ); + + $this->assertSame(1700000000, $this->source->getShares()[0]->lastModifiedTimestamp); + } + + public function testGetSharesReturnsEmptyOnDbException(): void { + $this->shareMapper->method('findAllForShareReview')->willThrowException($this->createMock(Exception::class)); + $this->logger->expects($this->once())->method('error'); + + $this->assertSame([], $this->source->getShares()); + } + + public function testPermissionsDefaultToSubmit(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => null])] + ); + + $this->assertSame( + [ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_SUBMIT], + $this->permissionIds($this->source->getShares()[0]->permissions) + ); + } + + public function testPermissionsResultsEmitsOwnPermission(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => json_encode(['results'])])] + ); + + $permissions = $this->source->getShares()[0]->permissions; + $this->assertSame( + [ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_RESULTS], + $this->permissionIds($permissions) + ); + $this->assertSame('View results', $permissions[1]->displayName); + } + + public function testPermissionsEdit(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => json_encode(['edit'])])] + ); + + $this->assertSame( + [ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_EDIT], + $this->permissionIds($this->source->getShares()[0]->permissions) + ); + } + + public function testPermissionsResultsDelete(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => json_encode(['results_delete'])])] + ); + + $this->assertSame( + [ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_RESULTS_DELETE], + $this->permissionIds($this->source->getShares()[0]->permissions) + ); + } + + public function testPermissionsEmbed(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => json_encode(['embed'])])] + ); + + $this->assertSame( + [ShareReviewSource::PERMISSION_READ, ShareReviewSource::PERMISSION_EMBED], + $this->permissionIds($this->source->getShares()[0]->permissions) + ); + } + + public function testPermissionsAllCapabilitiesMapOneToOne(): void { + $this->shareMapper->method('findAllForShareReview')->willReturn( + [$this->makeShareRow(['permissions_json' => json_encode(['edit', 'embed', 'results', 'results_delete', 'submit'])])] + ); + + $this->assertSame( + [ + ShareReviewSource::PERMISSION_READ, + ShareReviewSource::PERMISSION_EDIT, + ShareReviewSource::PERMISSION_SUBMIT, + ShareReviewSource::PERMISSION_RESULTS, + ShareReviewSource::PERMISSION_RESULTS_DELETE, + ShareReviewSource::PERMISSION_EMBED, + ], + $this->permissionIds($this->source->getShares()[0]->permissions) + ); + } + + public function testPermissionIdentifiers(): void { + $this->assertSame('forms:read', ShareReviewSource::PERMISSION_READ); + $this->assertSame('forms:edit', ShareReviewSource::PERMISSION_EDIT); + $this->assertSame('forms:submit', ShareReviewSource::PERMISSION_SUBMIT); + $this->assertSame('forms:results', ShareReviewSource::PERMISSION_RESULTS); + $this->assertSame('forms:results_delete', ShareReviewSource::PERMISSION_RESULTS_DELETE); + $this->assertSame('forms:embed', ShareReviewSource::PERMISSION_EMBED); + } + + /** + * @param list $permissions + * @return list + */ + private function permissionIds(array $permissions): array { + return array_map(static fn (ShareReviewPermission $permission): string => $permission->id, $permissions); + } + + public function testDeleteShareNonNumericReturnsFalse(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + + $this->assertFalse($this->source->deleteShare('abc')); + } + + public function testDeleteShareRejectsNonDigitNumericForms(): void { + $this->eventDispatcher->expects($this->never())->method('dispatchTyped'); + $this->shareMapper->expects($this->never())->method('findById'); + + // is_numeric-style inputs whose (int) cast differs from the literal string + $this->assertFalse($this->source->deleteShare('1e3')); + $this->assertFalse($this->source->deleteShare('7.5')); + $this->assertFalse($this->source->deleteShare('-1')); + $this->assertFalse($this->source->deleteShare(' 7')); + $this->assertFalse($this->source->deleteShare('')); + } + + public function testDeleteShareEventCarriesCanonicalShareId(): void { + $capturedShareId = null; + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event) use (&$capturedShareId): void { + $capturedShareId = $event->getShareId(); + // leave unhandled — default-deny stops the flow after the capture + }); + + $this->assertFalse($this->source->deleteShare('007')); + $this->assertSame('7', $capturedShareId); + } + + public function testDeleteShareEventNotHandledReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(ShareReviewAccessCheckEvent::class)); + $this->shareMapper->expects($this->never())->method('findById'); + $this->shareMapper->expects($this->never())->method('delete'); + + $this->assertFalse($this->source->deleteShare('7')); + } + + public function testDeleteShareEventDeniedReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->denyAccess('not in group'); + }); + $this->shareMapper->expects($this->never())->method('delete'); + + $this->assertFalse($this->source->deleteShare('7')); + } + + public function testDeleteShareNotFoundReturnsFalse(): void { + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->shareMapper->method('findById')->willThrowException(new DoesNotExistException('not found')); + $this->shareMapper->expects($this->never())->method('delete'); + + $this->assertFalse($this->source->deleteShare('7')); + } + + public function testDeleteShareGrantedDeletesShareAndBumpsForm(): void { + $share = $this->makeShare(); + $form = $this->makeForm(); + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->shareMapper->method('findById')->with(7)->willReturn($share); + $this->formMapper->method('findById')->with(10)->willReturn($form); + $this->shareMapper->expects($this->once())->method('delete')->with($share); + $this->formMapper->expects($this->once())->method('update')->with($form); + // No results permission — the uploaded-files share must not be touched + $this->uploadedFilesShareService->expects($this->never())->method('removeForCollaborator'); + + $this->assertTrue($this->source->deleteShare('7')); + } + + public function testDeleteShareWithResultsPermissionRevokesUploadedFilesShare(): void { + $share = $this->makeShare(7, ['results']); + $form = $this->makeForm(); + $this->eventDispatcher->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->shareMapper->method('findById')->willReturn($share); + $this->formMapper->method('findById')->willReturn($form); + $this->uploadedFilesShareService->expects($this->once()) + ->method('removeForCollaborator') + ->with($form, $share); + $this->shareMapper->expects($this->once())->method('delete')->with($share); + + $this->assertTrue($this->source->deleteShare('7')); + } + + public function testDeleteShareDbErrorReturnsFalse(): void { + $share = $this->makeShare(); + $form = $this->makeForm(); + $this->eventDispatcher->method('dispatchTyped') + ->willReturnCallback(function (ShareReviewAccessCheckEvent $event): void { + $event->grantAccess(); + }); + $this->shareMapper->method('findById')->willReturn($share); + $this->formMapper->method('findById')->willReturn($form); + $this->shareMapper->method('delete')->willThrowException($this->createMock(Exception::class)); + $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..6c1bf9b4d --- /dev/null +++ b/tests/Unit/ShareReview/Stubs.php @@ -0,0 +1,114 @@ +> */ + private array $sources = []; + + public function registerSource(string $source): void { + $this->sources[] = $source; + } + + public function getSources(): array { + return $this->sources; + } + } + + final class ShareReviewPermission { + public function __construct( + public readonly string $id, + public readonly string $displayName, + public readonly ?string $hint = null, + public readonly int $priority = 50, + ) { + } + } + + final class ShareReviewEntry { + public function __construct( + public readonly string $id, + public readonly string $object, + public readonly string $initiator, + public readonly int $type, + public readonly string $recipient, + public readonly int $lastModifiedTimestamp, + public readonly array $permissions = [], + public readonly string $action = '', + public readonly bool $hasPassword = false, + public readonly ?int $expirationTimestamp = null, + public readonly ?string $parent = null, + ) { + } + } +} + +namespace OCP\Share\ShareReview\Events { + + class ShareReviewAccessCheckEvent extends \OCP\EventDispatcher\Event { + private bool $handled = false; + private bool $granted = false; + private ?string $reason = null; + + public function __construct( + private readonly string $sourceName, + private readonly string $shareId, + ) { + parent::__construct(); + } + + public function getSourceName(): string { + return $this->sourceName; + } + + public function getShareId(): string { + return $this->shareId; + } + + public function grantAccess(): void { + if ($this->handled && !$this->granted) { + return; + } + $this->handled = true; + $this->granted = true; + } + + public function denyAccess(string $reason): void { + $this->handled = true; + $this->granted = false; + $this->reason = $reason; + $this->stopPropagation(); + } + + public function isHandled(): bool { + return $this->handled; + } + + public function isGranted(): bool { + return $this->granted; + } + + public function getReason(): ?string { + return $this->reason; + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b593058cc..5bec5b5c8 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -19,3 +19,7 @@ require_once __DIR__ . '/../vendor/autoload.php'; Server::get(IAppManager::class)->loadApp('forms'); + +if (!interface_exists('OCP\Share\ShareReview\IShareReviewSource')) { + require_once __DIR__ . '/Unit/ShareReview/Stubs.php'; +} diff --git a/tests/stubs/ocp-share-sharereview.php b/tests/stubs/ocp-share-sharereview.php new file mode 100644 index 000000000..cf8072853 --- /dev/null +++ b/tests/stubs/ocp-share-sharereview.php @@ -0,0 +1,82 @@ +