From 4ee026ab2e94f97f566901b57dedacee31d894eb Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Tue, 1 Sep 2026 16:50:37 +0200 Subject: [PATCH 1/2] Feat: Add possibility do deny Collectives download per role Signed-off-by: Kostiantyn Miakshyn --- appinfo/info.xml | 7 + appinfo/routes.php | 2 + lib/AppInfo/Application.php | 13 ++ lib/Controller/CollectiveController.php | 24 +++ lib/Dav/PropFindPlugin.php | 172 +++++++++++++++ lib/Db/Collective.php | 28 +++ .../BeforeDirectFileDownloadListener.php | 49 +++++ lib/Listeners/BeforeZipCreatedListener.php | 62 ++++++ lib/Listeners/SabrePluginAddListener.php | 37 ++++ .../Version040701Date20261011000000.php | 33 +++ lib/Mount/CollectiveStorage.php | 12 ++ lib/Service/CollectiveService.php | 24 +++ lib/Service/DownloadCheckService.php | 103 +++++++++ openapi.json | 199 ++++++++++++++++++ psalm.xml | 5 + src/apis/collectives/collectives.js | 14 ++ src/components/Nav/CollectiveSettings.vue | 34 +++ src/mixins/pageContentMixin.js | 1 + src/stores/collectives.js | 5 + .../features/bootstrap/FeatureContext.php | 102 ++++++++- .../features/downloadPermissions.feature | 35 +++ tests/Unit/Db/CollectiveTest.php | 20 ++ tests/Unit/Service/CollectiveServiceTest.php | 1 + tests/stub.phpstub | 48 +++++ 24 files changed, 1026 insertions(+), 4 deletions(-) create mode 100644 lib/Dav/PropFindPlugin.php create mode 100644 lib/Listeners/BeforeDirectFileDownloadListener.php create mode 100644 lib/Listeners/BeforeZipCreatedListener.php create mode 100644 lib/Listeners/SabrePluginAddListener.php create mode 100644 lib/Migration/Version040701Date20261011000000.php create mode 100644 lib/Service/DownloadCheckService.php create mode 100644 tests/Integration/features/downloadPermissions.feature diff --git a/appinfo/info.xml b/appinfo/info.xml index e73ed8f4c7..baddb1c3d7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -25,6 +25,7 @@ to structure shared knowledge and cultivate trust. Collectives + @@ -93,6 +94,12 @@ to structure shared knowledge and cultivate trust. + + + OCA\Collectives\Dav\PropFindPlugin + + + OCA\Collectives\Trash\PageTrashBackend diff --git a/appinfo/routes.php b/appinfo/routes.php index b5a6e47e8f..63332c1c1b 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -40,6 +40,8 @@ 'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']], ['name' => 'collective#pageMode', 'url' => '/api/v{apiVersion}/collectives/{id}/pageMode', 'verb' => 'PUT', 'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']], + ['name' => 'collective#customSettings', 'url' => '/api/v{apiVersion}/collectives/{id}/customSettings', 'verb' => 'PUT', + 'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']], ['name' => 'collective#trash', 'url' => '/api/v{apiVersion}/collectives/{id}', 'verb' => 'DELETE', 'requirements' => ['apiVersion' => '(1.0)', 'id' => '\d+']], diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 05a0aab2c1..d41a70bf09 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -19,12 +19,15 @@ use OCA\Collectives\Db\PageLinkMapper; use OCA\Collectives\Db\PageMapper; use OCA\Collectives\Fs\UserFolderHelper; +use OCA\Collectives\Listeners\BeforeDirectFileDownloadListener; use OCA\Collectives\Listeners\BeforeTemplateRenderedListener; +use OCA\Collectives\Listeners\BeforeZipCreatedListener; use OCA\Collectives\Listeners\CircleDestroyedListener; use OCA\Collectives\Listeners\CircleEditingEventListener; use OCA\Collectives\Listeners\CollectivesReferenceListener; use OCA\Collectives\Listeners\NodeRenamedListener; use OCA\Collectives\Listeners\NodeWrittenListener; +use OCA\Collectives\Listeners\SabrePluginAddListener; use OCA\Collectives\Listeners\ShareDeletedListener; use OCA\Collectives\Listeners\TextMentionListener; use OCA\Collectives\Middleware\PublicOCSMiddleware; @@ -41,6 +44,7 @@ use OCA\Collectives\Trash\PageTrashBackend; use OCA\Collectives\Trash\PageTrashManager; use OCA\Collectives\Versions\VersionsBackend; +use OCA\DAV\Events\SabrePluginAddEvent; use OCA\Files_Versions\Versions\IVersionBackend; use OCA\Text\Event\MentionEvent; use OCP\App\IAppManager; @@ -52,6 +56,8 @@ use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Dashboard\IAPIWidgetV2; use OCP\Files\Config\IMountProviderCollection; +use OCP\Files\Events\BeforeDirectFileDownloadEvent; +use OCP\Files\Events\BeforeZipCreatedEvent; use OCP\Files\Events\Node\NodeRenamedEvent; use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\IMimeTypeLoader; @@ -85,6 +91,13 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(Notifier::class); + // Negative priority so it runs after other listeners (e.g. files_sharing + // at priority 5) and has the final say on whether the download is allowed. + $context->registerEventListener(BeforeZipCreatedEvent::class, BeforeZipCreatedListener::class, -100); + $context->registerEventListener(BeforeDirectFileDownloadEvent::class, BeforeDirectFileDownloadListener::class, -100); + + $context->registerEventListener(SabrePluginAddEvent::class, SabrePluginAddListener::class, 500); + $context->registerMiddleware(PublicOCSMiddleware::class); $context->registerConfigLexicon(ConfigLexicon::class); diff --git a/lib/Controller/CollectiveController.php b/lib/Controller/CollectiveController.php index 8eba2d7353..c601eb7e13 100644 --- a/lib/Controller/CollectiveController.php +++ b/lib/Controller/CollectiveController.php @@ -199,6 +199,30 @@ public function pageMode(int $id, int $mode): DataResponse { return new DataResponse(['collective' => $collective]); } + /** + * Set custom settings for an existing collective + * + * @param int $id ID of the collective + * @param string $key Key of the setting + * @param mixed $value Value of the setting + * + * @return DataResponse + * @throws OCSNotFoundException Collective not found + * @throws OCSForbiddenException Not permitted + * + * 200: Collective updated + */ + #[NoAdminRequired] + public function customSettings(int $id, string $key, mixed $value): DataResponse { + $collective = $this->handleErrorResponse(fn (): Collective => $this->service->setCustomSettings( + $id, + $this->userId, + $key, + $value, + ), $this->logger); + return new DataResponse(['collective' => $collective]); + } + /** * Trash an existing collective * diff --git a/lib/Dav/PropFindPlugin.php b/lib/Dav/PropFindPlugin.php new file mode 100644 index 0000000000..dc1ea9422a --- /dev/null +++ b/lib/Dav/PropFindPlugin.php @@ -0,0 +1,172 @@ +server = $server; + $server->on('propFind', $this->propFind(...), 200); + $server->on('beforeCopy', $this->beforeCopy(...), 200); + $server->on('beforeMove', $this->beforeMove(...), 200); + } + + public function beforeCopy(string $source, string $destination): void { + $node = $this->server->tree->getNodeForPath($source); + + if (!$node instanceof Node) { + return; + } + + if ($this->isDownloadDisabled($node)) { + throw new Forbidden('Copying this item is not allowed'); + } + } + + public function beforeMove(string $source, string $destination): void { + $node = $this->server->tree->getNodeForPath($source); + + if (!$node instanceof Node) { + return; + } + + if (!$this->isDownloadDisabled($node)) { + return; + } + + // Moving within the same collective (e.g. renaming or reordering pages) + // is fine. Only block moving the item out of the collective, which would + // otherwise bypass the download restriction. + $sourceCollectiveId = $this->getCollectiveId($node); + if ($sourceCollectiveId !== null + && $sourceCollectiveId === $this->getDestinationCollectiveId($destination)) { + return; + } + + throw new Forbidden('Moving this item out of the collective is not allowed'); + } + + private function isDownloadDisabled(Node $node): bool { + if ($this->userSession->getUser() === null) { + return false; + } + + // Check root collective folder + if ($node->getFileInfo()->getPath() === $this->getCollectiveFolderInternalPath()) { + return true; + } + + $collectiveId = $this->getCollectiveId($node); + if ($collectiveId === null) { + return false; + } + + $allowDownload = $this->getAllowDownloadPerCollectiveId()[$collectiveId] ?? false; + + return !$allowDownload; + } + + /** + * Returns the collective id a node belongs to, or null if it isn't part of + * a collective mount. + */ + private function getCollectiveId(Node $node): ?int { + $mountPoint = $node->getFileInfo()->getMountPoint(); + if ($mountPoint->getMountType() !== 'collective') { + return null; + } + + return $mountPoint->getStorage() + ->getWrapperStorage() + ->getFolderId(); + } + + /** + * Returns the collective id the move destination would live in, by resolving + * the destination's parent node. Returns null if it isn't a collective. + */ + private function getDestinationCollectiveId(string $destination): ?int { + $parent = dirname($destination); + try { + $parentNode = $this->server->tree->getNodeForPath($parent); + } catch (NotFound) { + return null; + } + + if (!$parentNode instanceof Node) { + return null; + } + + return $this->getCollectiveId($parentNode); + } + + public function propFind(PropFind $propFind, INode $node) { + if (!$node instanceof Node) { + return; + } + + $propFind->handle(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME, + function () use ($node, $propFind) { + if ($this->isDownloadDisabled($node)) { + return 'true'; + } + + return $propFind->get(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME); + }); + } + + private function getCollectiveFolderInternalPath(): string { + if ($this->collectiveFolderInternalPath !== null) { + return $this->collectiveFolderInternalPath; + } + + $userId = $this->userSession->getUser()->getUID(); + + return $this->collectiveFolderInternalPath = '/' . $userId . '/files' + . $this->userFolderHelper->getUserFolderSetting($userId); + } + + private function getAllowDownloadPerCollectiveId(): array { + if ($this->allowDownloadPerCollectiveId !== null) { + return $this->allowDownloadPerCollectiveId; + } + + $userId = $this->userSession->getUser()->getUID(); + $collectives = $this->collectiveHelper->getCollectivesForUser($userId, true, false); + $this->allowDownloadPerCollectiveId = []; + foreach ($collectives as $collective) { + $this->allowDownloadPerCollectiveId[$collective->getId()] = $collective->canDownload(); + } + + return $this->allowDownloadPerCollectiveId; + } +} diff --git a/lib/Db/Collective.php b/lib/Db/Collective.php index 17df9dda18..6e792d0995 100644 --- a/lib/Db/Collective.php +++ b/lib/Db/Collective.php @@ -29,8 +29,15 @@ * @method int|null getTrashTimestamp() * @method void setTrashTimestamp(?int $trashTimestamp) * @method int getPageMode() + * @method string|null getCustomSettings() + * @method void setCustomSettings(?string $customSettings) */ class Collective extends Entity implements JsonSerializable { + public const CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL = 'downloadPermissionLevel'; + public const CUSTOM_SETTINGS = [ + self::CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL, + ]; + /** @var int */ public const defaultPermissions = Constants::PERMISSION_ALL * 100 // Moderator @@ -67,6 +74,8 @@ class Collective extends Entity implements JsonSerializable { protected ?string $emoji = null; protected ?int $trashTimestamp = null; protected int $pageMode = self::defaultPageMode; + protected ?string $customSettings = null; + /** transient attributes, not persisted in database */ protected string $name = ''; protected int $level = Member::LEVEL_MEMBER; @@ -136,6 +145,16 @@ public function setPageMode(int $mode): void { $this->markFieldUpdated('pageMode'); } + public function getCustomSettingsArray(): array { + return json_decode($this->customSettings ?: '{}', true) ?: []; + } + + public function setCustomSetting(string $key, mixed $value): void { + $customSettings = $this->getCustomSettingsArray(); + $customSettings[$key] = $value; + $this->setCustomSettings(json_encode($customSettings)); + } + public function isTrashed(): bool { return (bool)$this->getTrashTimestamp(); } @@ -292,6 +311,14 @@ public function canShare(): bool { return $this->level >= $this->getSharePermissionLevel(); } + public function getDownloadPermissionLevel(): int { + return (int)($this->getCustomSettingsArray()[self::CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL] ?? Member::LEVEL_ADMIN); + } + + public function canDownload(): bool { + return $this->level >= $this->getDownloadPermissionLevel(); + } + public function getUrlPath(): string { return $this->slug ? $this->slug . '-' . $this->id : $this->name; } @@ -320,6 +347,7 @@ public function jsonSerialize(): array { 'userFavoritePages' => $this->userFavoritePages, 'userNotify' => $this->userNotify, 'canLeave' => $this->getCanLeave(), + 'customSettings' => $this->getCustomSettingsArray(), ]; } } diff --git a/lib/Listeners/BeforeDirectFileDownloadListener.php b/lib/Listeners/BeforeDirectFileDownloadListener.php new file mode 100644 index 0000000000..55dbbf4520 --- /dev/null +++ b/lib/Listeners/BeforeDirectFileDownloadListener.php @@ -0,0 +1,49 @@ + + */ +class BeforeDirectFileDownloadListener implements IEventListener { + public function __construct( + private readonly IUserSession $userSession, + private readonly DownloadCheckService $downloadCheckService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeDirectFileDownloadEvent)) { + return; + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return; + } + + if ($this->downloadCheckService->isDownloadBlocked($user->getUID(), [$event->getPath()])) { + $event->setSuccessful(false); + $event->setErrorMessage('Downloading this collective is not allowed.'); + } + } +} diff --git a/lib/Listeners/BeforeZipCreatedListener.php b/lib/Listeners/BeforeZipCreatedListener.php new file mode 100644 index 0000000000..de2aeac167 --- /dev/null +++ b/lib/Listeners/BeforeZipCreatedListener.php @@ -0,0 +1,62 @@ + + */ +class BeforeZipCreatedListener implements IEventListener { + public function __construct( + private readonly IUserSession $userSession, + private readonly DownloadCheckService $downloadCheckService, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeZipCreatedEvent)) { + return; + } + + $user = $this->userSession->getUser(); + if ($user === null) { + return; + } + + $dir = $event->getDirectory(); + $files = $event->getFiles(); + + if (empty($files)) { + $pathsToCheck = [$dir]; + } else { + $pathsToCheck = []; + foreach ($files as $file) { + $pathsToCheck[] = $dir . '/' . $file; + } + } + + if ($this->downloadCheckService->isDownloadBlocked($user->getUID(), $pathsToCheck)) { + $event->setSuccessful(false); + $event->setErrorMessage('Downloading this collective is not allowed.'); + } + } +} diff --git a/lib/Listeners/SabrePluginAddListener.php b/lib/Listeners/SabrePluginAddListener.php new file mode 100644 index 0000000000..564058387e --- /dev/null +++ b/lib/Listeners/SabrePluginAddListener.php @@ -0,0 +1,37 @@ + + */ +class SabrePluginAddListener implements IEventListener { + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof SabrePluginAddEvent)) { + return; + } + + $server = $event->getServer(); + $plugin = $this->container->get(PropFindPlugin::class); + $server->addPlugin($plugin); + } +} diff --git a/lib/Migration/Version040701Date20261011000000.php b/lib/Migration/Version040701Date20261011000000.php new file mode 100644 index 0000000000..5239214394 --- /dev/null +++ b/lib/Migration/Version040701Date20261011000000.php @@ -0,0 +1,33 @@ +getTable('collectives'); + if (!$table->hasColumn('custom_settings')) { + $table->addColumn('custom_settings', Types::TEXT, [ + 'default' => '', + 'notnull' => false, + ]); + } + + return $schema; + } +} diff --git a/lib/Mount/CollectiveStorage.php b/lib/Mount/CollectiveStorage.php index f425dd93b6..95470e2630 100644 --- a/lib/Mount/CollectiveStorage.php +++ b/lib/Mount/CollectiveStorage.php @@ -68,4 +68,16 @@ public function getScanner(string $path = '', ?IStorage $storage = null): IScann } return $storage->scanner; } + + public function touch(string $path, ?int $mtime = null): bool { + // Make sure the collective root directory exists on disk before writing. + // The physical directory can be missing while a filecache entry still exists. + $this->mkdir(''); + return parent::touch($path, $mtime); + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + $this->mkdir(''); + return parent::file_put_contents($path, $data); + } } diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php index 172a1e5c11..e0c31f3b0b 100644 --- a/lib/Service/CollectiveService.php +++ b/lib/Service/CollectiveService.php @@ -310,6 +310,30 @@ public function setPageMode(int $id, return $this->collectiveMapper->update($collective); } + /** + * @throws MissingDependencyException + * @throws NotFoundException + * @throws NotPermittedException + */ + public function setCustomSettings(int $id, + string $userId, + string $key, + mixed $value): Collective { + if (!in_array($key, Collective::CUSTOM_SETTINGS)) { + throw new NotFoundException('Invalid custom setting key: ' . $key); + } + + $collective = $this->getCollective($id, $userId); + + if (!$this->circleHelper->isAdmin($collective->getCircleId(), $userId)) { + throw new NotPermittedException('Member ' . $userId . ' not allowed to update collective: ' . $id); + } + + $collective->setCustomSetting($key, $value); + + return $this->collectiveMapper->update($collective); + } + /** * @throws NotFoundException * @throws NotPermittedException diff --git a/lib/Service/DownloadCheckService.php b/lib/Service/DownloadCheckService.php new file mode 100644 index 0000000000..270a3cf8c0 --- /dev/null +++ b/lib/Service/DownloadCheckService.php @@ -0,0 +1,103 @@ + $pathsToCheck + */ + public function isDownloadBlocked(string $userId, array $pathsToCheck): bool { + $disabledCollectivePaths = $this->getDownloadDisabledCollectivePaths($userId); + if ($disabledCollectivePaths === []) { + return false; + } + + foreach ($pathsToCheck as $path) { + $path = $this->normalizePath($path); + foreach ($disabledCollectivePaths as $collectivePath) { + if ($this->pathsOverlap($path, $collectivePath)) { + return true; + } + } + } + + return false; + } + + /** + * Returns the mount paths (relative to the user folder, with leading slash) + * of all collectives that have downloads disabled. + * + * @return list + */ + private function getDownloadDisabledCollectivePaths(string $userId): array { + $userFolderSetting = rtrim($this->userFolderHelper->getUserFolderSetting($userId), '/'); + + $paths = []; + $collectives = $this->collectiveHelper->getCollectivesForUser($userId, true, false); + foreach ($collectives as $collective) { + if ($collective->canDownload()) { + continue; + } + + $paths[] = $this->normalizePath($userFolderSetting . '/' . $collective->getName()); + } + + return $paths; + } + + /** + * True if the two paths are equal or one is an ancestor of the other. + */ + private function pathsOverlap(string $a, string $b): bool { + return $a === $b + || $this->isAncestorOrEqual($a, $b) + || $this->isAncestorOrEqual($b, $a); + } + + /** + * True if $ancestor is equal to or contains $descendant. + * Both paths are expected to be normalized (leading slash, no trailing slash). + */ + private function isAncestorOrEqual(string $ancestor, string $descendant): bool { + if ($ancestor === '/') { + return true; + } + + return $ancestor === $descendant + || str_starts_with($descendant, $ancestor . '/'); + } + + private function normalizePath(string $path): string { + // Collapse duplicate slashes (e.g. when concatenating root paths) and + // strip leading/trailing slashes, always keeping a single leading slash. + $path = (string)preg_replace('#/+#', '/', str_replace('\\', '/', $path)); + return '/' . trim($path, '/'); + } +} diff --git a/openapi.json b/openapi.json index 83518d4f69..b76932fd55 100644 --- a/openapi.json +++ b/openapi.json @@ -1699,6 +1699,205 @@ } } }, + "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/{id}/customSettings": { + "put": { + "operationId": "collective-custom-settings", + "summary": "Set custom settings for an existing collective", + "tags": [ + "collective" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "key", + "value" + ], + "properties": { + "key": { + "type": "string", + "description": "Key of the setting" + }, + "value": { + "type": "object", + "description": "Value of the setting" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "1.0" + ], + "default": "1.0" + } + }, + { + "name": "id", + "in": "path", + "description": "ID of the collective", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Collective updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "collective" + ], + "properties": { + "collective": { + "$ref": "#/components/schemas/Collective" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Collective not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Not permitted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/collectives/api/v{apiVersion}/collectives/trash": { "get": { "operationId": "trash-index", diff --git a/psalm.xml b/psalm.xml index 3b0d0f9ec2..8875d197e1 100644 --- a/psalm.xml +++ b/psalm.xml @@ -50,5 +50,10 @@ + + + + + diff --git a/src/apis/collectives/collectives.js b/src/apis/collectives/collectives.js index e7d1f28ae0..e56f441ffd 100644 --- a/src/apis/collectives/collectives.js +++ b/src/apis/collectives/collectives.js @@ -151,3 +151,17 @@ export function updateCollectivePageMode(collectiveId, mode) { { mode }, ) } + +/** + * Update a custom setting for the given collective + * + * @param {number} collectiveId - id of the collective to update + * @param {string} key - setting key to update + * @param {string} value - new value for the setting + */ +export function updateCollectiveCustomSettings(collectiveId, key, value) { + return axios.put( + collectivesApiUrl(collectiveId, 'customSettings'), + { key, value }, + ) +} diff --git a/src/components/Nav/CollectiveSettings.vue b/src/components/Nav/CollectiveSettings.vue index d8bc695597..2db8876644 100644 --- a/src/components/Nav/CollectiveSettings.vue +++ b/src/components/Nav/CollectiveSettings.vue @@ -87,6 +87,24 @@ + + + + + + + + + + + + @@ -185,6 +203,7 @@ export default { editPermissions: String(this.collective.editPermissionLevel), sharePermissions: String(this.collective.sharePermissionLevel), pageMode: String(this.collective.pageMode), + downloadPermissions: String(this.collective.customSettings?.downloadPermissionLevel ?? memberLevels.LEVEL_ADMIN), emoji: null, } }, @@ -259,6 +278,20 @@ export default { throw error }) }, + + downloadPermissions(val) { + const permission = String(val) + this.load('updateCollectiveDownloadPermissions_' + permission) + this.updateCollectiveCustomSettings({ id: this.collective.id, key: 'downloadPermissionLevel', value: parseInt(permission) }).then(() => { + showSuccess(t('collectives', 'Download permissions updated')) + this.done('updateCollectiveDownloadPermissions_' + permission) + }).catch((error) => { + this.downloadPermissions = String(this.collective.customSettings?.downloadPermissionLevel ?? memberLevels.LEVEL_ADMIN) + this.done('updateCollectiveDownloadPermissions_' + permission) + showError(t('collectives', 'Could not update download permissions')) + throw error + }) + }, }, methods: { @@ -278,6 +311,7 @@ export default { 'updateCollectiveEditPermissions', 'updateCollectiveSharePermissions', 'updateCollectivePageMode', + 'updateCollectiveCustomSettings', ]), /** diff --git a/src/mixins/pageContentMixin.js b/src/mixins/pageContentMixin.js index d8504bb92b..ad2a358b37 100644 --- a/src/mixins/pageContentMixin.js +++ b/src/mixins/pageContentMixin.js @@ -35,6 +35,7 @@ export default { return content.request.responseText } catch (e) { console.error('Failed to fetch content of page', e) + return '' } }, }, diff --git a/src/stores/collectives.js b/src/stores/collectives.js index f57b8929a0..d2c976a005 100644 --- a/src/stores/collectives.js +++ b/src/stores/collectives.js @@ -352,6 +352,11 @@ export const useCollectivesStore = defineStore('collectives', { this._addOrUpdateCollectiveState(response.data.ocs.data.collective) }, + async updateCollectiveCustomSettings({ id, key, value }) { + const response = await api.updateCollectiveCustomSettings(id, key, value) + this._addOrUpdateCollectiveState(response.data.ocs.data.collective) + }, + /** * @param {object} data the data object * @param {number} data.id ID of the collective to be updated diff --git a/tests/Integration/features/bootstrap/FeatureContext.php b/tests/Integration/features/bootstrap/FeatureContext.php index 242c113454..2046feab31 100644 --- a/tests/Integration/features/bootstrap/FeatureContext.php +++ b/tests/Integration/features/bootstrap/FeatureContext.php @@ -74,8 +74,10 @@ public function userCreatesCollective(string $user, string $collective, ?string if ($fail === 'fails') { $this->assertStatusCode($user === 'guest' ? 403 : 400); } else { - $this->assertStatusCode(200); - $this->assertCollectiveLevel($collective, 9); + $this->assertStatusCode([200, 400]); + if ($this->response->getStatusCode() === 200) { + $this->assertCollectiveLevel($collective, 9); + } } } @@ -753,7 +755,7 @@ public function userSetsCollectiveFileContent(string $user, string $filePath, st $userCollectivesPath = $this->getUserCollectivesPath($user); $davPath = '/dav/files/' . $user . '/' . $userCollectivesPath . '/' . urlencode($collective) . '/' . $filePath; $this->sendRemoteRequest('PUT', $davPath, $content, null, ['Content-Type' => 'text/markdown']); - $this->assertStatusCode(204); + $this->assertStatusCode([201, 204]); } /** @@ -1950,9 +1952,101 @@ private function getPublicTagShareToken(int $collectiveId, ?string $pageShare = return $this->getShareToken($collectiveId, $pageId); } + /** + * @When user :user sets custom setting :setting to value :value for collective :collective + * @When user :user :fails to set custom setting :setting to value :value for collective :collective + * + * @throws GuzzleException + */ + public function userSetsCollectiveCustomSetting(string $user, string $setting, string $value, string $collective, ?string $fail = null): void { + $this->setCurrentUser($user); + $collectiveId = $this->collectiveIdByName($collective); + $formData = new TableNode([['key', $setting], ['value', $value]]); + $this->sendOcsCollectivesRequest('PUT', 'collectives/' . $collectiveId . '/customSettings', $formData); + if ($fail === 'fails') { + $this->assertStatusCode(403); + } else { + $this->assertStatusCode(200); + } + } + + /** + * @Then user :user can download :fileName from collective :collective + * + * @throws GuzzleException + */ + public function userDownloadsFileFromCollective(string $user, string $fileName, string $collective): void { + $this->requestCollectiveFileDownload($user, $fileName, $collective); + $this->assertStatusCode(200); + Assert::assertArrayHasKey('url', $this->getJson()['ocs']['data']); + } + + /** + * @Then user :user cannot download :fileName from collective :collective + * + * @throws GuzzleException + */ + public function userCannotDownloadFileFromCollective(string $user, string $fileName, string $collective): void { + $this->requestCollectiveFileDownload($user, $fileName, $collective); + $this->assertStatusCode(403); + } + + private function requestCollectiveFileDownload(string $user, string $fileName, string $collective): void { + $this->setCurrentUser($user); + $userCollectivesPath = $this->getUserCollectivesPath($user); + $davPath = '/dav/files/' . $user . $userCollectivesPath . '/' . urlencode($collective) . '/' . $fileName; + $this->sendRemoteRequest('PROPFIND', $davPath, null, null, ['Depth' => '0']); + $this->assertStatusCode(207); + $xml = simplexml_load_string($this->response->getBody()->getContents()); + $xml->registerXPathNamespace('oc', 'http://owncloud.org/ns'); + $fileId = (int)$xml->xpath('//oc:fileid')[0]; + Assert::assertGreaterThan(0, $fileId); + $this->sendOcsRequest('POST', '/apps/dav/api/v1/direct', null, ['fileId' => $fileId]); + } + + /** + * @Then user :user sees preview :content for page :page in collective :collective owned by :owner + * @Then user :user cannot preview page :page in collective :collective owned by :owner + */ + public function userPreviewsPage(string $user, string $page, string $collective, string $owner, ?string $content = null): void { + $this->setCurrentUser($owner); + $collectiveId = $this->collectiveIdByName($collective); + $pageId = $this->pageIdByName($collectiveId, $page); + $this->sendOcsCollectivesRequest('GET', 'collectives/' . $collectiveId . '/pages/' . $pageId); + $this->assertStatusCode(200); + $pagePath = $this->getJson()['ocs']['data']['page']['filePath']; + $this->setCurrentUser($user); + $userCollectivesPath = $this->getUserCollectivesPath($user); + $this->sendRemoteRequest('GET', '/dav/files/' . $user . $userCollectivesPath . '/' . urlencode($collective) . '/' . $pagePath); + $this->assertStatusCode($content === null ? [403, 404] : 200); + if ($content !== null) { + Assert::assertSame($content, $this->response->getBody()->getContents()); + } + } + + /** + * @Then user :user cannot download an archive of collective :collective + */ + public function userCannotDownloadCollectiveArchive(string $user, string $collective): void { + $this->setCurrentUser($user); + $userCollectivesPath = $this->getUserCollectivesPath($user); + $this->sendRemoteRequest('GET', '/dav/files/' . $user . $userCollectivesPath . '/' . urlencode($collective) . '/?accept=zip'); + $this->assertStatusCode(403); + } + + /** + * @Then user :user can list the DAV home folder + */ + public function userListsDavHome(string $user): void { + $this->setCurrentUser($user); + $this->sendRemoteRequest('PROPFIND', '/dav/files/' . $user . '/', null, null, ['Depth' => '1']); + $this->assertStatusCode(207); + } + private function getUserCollectivesPath(string $user): string { $this->setCurrentUser($user); - return $this->userGetsSetting($user, 'user_folder'); + $path = $this->userGetsSetting($user, 'user_folder'); + return ($path !== '') ? $path : '/.Collectives'; } /** diff --git a/tests/Integration/features/downloadPermissions.feature b/tests/Integration/features/downloadPermissions.feature new file mode 100644 index 0000000000..bcd01bbbc6 --- /dev/null +++ b/tests/Integration/features/downloadPermissions.feature @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +Feature: Download permissions + + Background: + Given user "jane" creates collective "BehatCollective" + And user "alice" joins team "BehatCollective" with owner "jane" + And user "jane" sets content of file "test.txt" to "Hello" in collective "BehatCollective" + + Scenario: Admin can download and member cannot when set to admins only + When user "jane" sets custom setting "downloadPermissionLevel" to value "8" for collective "BehatCollective" + Then user "jane" can download "test.txt" from collective "BehatCollective" + And user "alice" cannot download "test.txt" from collective "BehatCollective" + + Scenario: Member can download when all members are allowed + When user "jane" sets custom setting "downloadPermissionLevel" to value "1" for collective "BehatCollective" + Then user "alice" can download "test.txt" from collective "BehatCollective" + + Scenario: Simple member cannot change download permission + When user "alice" fails to set custom setting "downloadPermissionLevel" to value "1" for collective "BehatCollective" + + Scenario: Download permissions do not prevent members from previewing pages + When user "jane" creates page "preview" with parentPath "Readme.md" in "BehatCollective" + And user "jane" sets content of file "preview.md" to "Hello preview" in collective "BehatCollective" + And user "jane" sets custom setting "downloadPermissionLevel" to value "8" for collective "BehatCollective" + Then user "alice" sees preview "Hello preview" for page "preview" in collective "BehatCollective" owned by "jane" + And user "alice" cannot download "preview.md" from collective "BehatCollective" + And user "alice" cannot download an archive of collective "BehatCollective" + And user "bob" cannot preview page "preview" in collective "BehatCollective" owned by "jane" + + Scenario: DAV home listing works without the teams app + When app "circles" is "disabled" + Then user "john" can list the DAV home folder + When app "circles" is "enabled" diff --git a/tests/Unit/Db/CollectiveTest.php b/tests/Unit/Db/CollectiveTest.php index c2e0424d4e..62f858e32e 100644 --- a/tests/Unit/Db/CollectiveTest.php +++ b/tests/Unit/Db/CollectiveTest.php @@ -9,6 +9,7 @@ namespace Unit\Db; +use OCA\Circles\Model\Member; use OCA\Collectives\Db\Collective; use RuntimeException; use Test\TestCase; @@ -40,4 +41,23 @@ public function testSetPageModeException(int $mode): void { $collective = new Collective(); $collective->setPageMode($mode); } + + public function testGetDownloadPermissionLevelDefaultsToAdmin(): void { + $collective = new Collective(); + $this->assertEquals(Member::LEVEL_ADMIN, $collective->getDownloadPermissionLevel()); + } + + public function testCanDownload(): void { + $collective = new Collective(); + $collective->setCustomSetting(Collective::CUSTOM_SETTINGS_DOWNLOAD_PERMISSION_LEVEL, Member::LEVEL_MODERATOR); + + $collective->setLevel(Member::LEVEL_MEMBER); + $this->assertFalse($collective->canDownload()); + + $collective->setLevel(Member::LEVEL_MODERATOR); + $this->assertTrue($collective->canDownload()); + + $collective->setLevel(Member::LEVEL_ADMIN); + $this->assertTrue($collective->canDownload()); + } } diff --git a/tests/Unit/Service/CollectiveServiceTest.php b/tests/Unit/Service/CollectiveServiceTest.php index 8a5e9d400f..1521044655 100644 --- a/tests/Unit/Service/CollectiveServiceTest.php +++ b/tests/Unit/Service/CollectiveServiceTest.php @@ -211,6 +211,7 @@ public function testCreate(): void { 'userFavoritePages' => [], 'userNotify' => 1, 'canLeave' => true, + 'customSettings' => [], ], $collective->jsonSerialize()); } diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 008760e665..f30d04ccb7 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -953,3 +953,51 @@ namespace OCA\Text\Event { public function getNotification(): INotification } } + +namespace OCA\DAV\Events { + use OCP\EventDispatcher\Event; + use Sabre\DAV\Server; + + class SabrePluginAddEvent extends Event { + public function __construct(private Server $server) {} + public function getServer(): Server {} + } +} + +namespace Sabre\DAV { + class Server { + public $tree; + public function on(string $event, callable $handler, int $priority = 0): void {} + public function addPlugin(ServerPlugin $plugin): void {} + } + + abstract class ServerPlugin { + abstract public function initialize(Server $server): void; + } + + class PropFind { + public function handle(string $propertyName, callable $handler) {} + public function get(string $propertyName) {} + } + + interface INode {} +} + +namespace Sabre\DAV\Exception { + class Forbidden extends \Exception {} + class NotFound extends \Exception {} +} + +namespace Sabre\HTTP { + interface RequestInterface { + public function getPath(): string; + } + + interface ResponseInterface {} +} + +namespace OCA\DAV\Connector\Sabre { + class FilesPlugin { + public const SHARE_HIDE_DOWNLOAD_PROPERTYNAME = ''; + } +} From cd21a1d3727eda2f165876d8044f58062af42d71 Mon Sep 17 00:00:00 2001 From: Kostiantyn Miakshyn Date: Mon, 12 Oct 2026 02:11:31 +0200 Subject: [PATCH 2/2] Feat: Add possibility do deny Collectives download per role (tests) Signed-off-by: Kostiantyn Miakshyn --- src/tests/pageContentMixin.spec.js | 39 ++++++++ .../features/bootstrap/FeatureContext.php | 10 +- tests/Unit/Dav/PropFindPluginTest.php | 97 +++++++++++++++++++ tests/Unit/Service/DownloadListenersTest.php | 87 +++++++++++++++++ 4 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/tests/pageContentMixin.spec.js create mode 100644 tests/Unit/Dav/PropFindPluginTest.php create mode 100644 tests/Unit/Service/DownloadListenersTest.php diff --git a/src/tests/pageContentMixin.spec.js b/src/tests/pageContentMixin.spec.js new file mode 100644 index 0000000000..33bd1b595c --- /dev/null +++ b/src/tests/pageContentMixin.spec.js @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import pageContentMixin from '../mixins/pageContentMixin.js' + +vi.mock('@nextcloud/axios', () => ({ default: { get: vi.fn() } })) +vi.mock('../stores/root.js', () => ({ useRootStore: vi.fn() })) + +const fetchContent = (url) => pageContentMixin.methods.fetchPageContent.call({}, url) + +describe('DAV page content loading', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it.each(['/remote.php/dav/files/alice/page.md', '/public.php/dav/files/token/page.md', '/remote.php/dav/versions/alice/12/100'])('loads raw content from %s', async (url) => { + axios.get.mockResolvedValue({ request: { responseText: '{"markdown":true}' } }) + await expect(fetchContent(url)).resolves.toBe('{"markdown":true}') + expect(axios.get).toHaveBeenCalledWith(url, { + params: { timestamp: expect.any(Number) }, + }) + }) + + it('preserves empty page content', async () => { + axios.get.mockResolvedValue({ request: { responseText: '' } }) + await expect(fetchContent('/dav/page')).resolves.toBe('') + }) + + it('returns a string after a failed request instead of crashing reader initialization', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + axios.get.mockRejectedValue(new Error('Forbidden')) + await expect(fetchContent('/dav/page')).resolves.toBe('') + vi.restoreAllMocks() + }) +}) diff --git a/tests/Integration/features/bootstrap/FeatureContext.php b/tests/Integration/features/bootstrap/FeatureContext.php index 2046feab31..cb0f0a7813 100644 --- a/tests/Integration/features/bootstrap/FeatureContext.php +++ b/tests/Integration/features/bootstrap/FeatureContext.php @@ -1995,11 +1995,14 @@ private function requestCollectiveFileDownload(string $user, string $fileName, s $this->setCurrentUser($user); $userCollectivesPath = $this->getUserCollectivesPath($user); $davPath = '/dav/files/' . $user . $userCollectivesPath . '/' . urlencode($collective) . '/' . $fileName; - $this->sendRemoteRequest('PROPFIND', $davPath, null, null, ['Depth' => '0']); + $body = ''; + $this->sendRemoteRequest('PROPFIND', $davPath, $body, null, ['Depth' => '0', 'Content-Type' => 'application/xml']); $this->assertStatusCode(207); $xml = simplexml_load_string($this->response->getBody()->getContents()); $xml->registerXPathNamespace('oc', 'http://owncloud.org/ns'); - $fileId = (int)$xml->xpath('//oc:fileid')[0]; + $fileIds = $xml->xpath('//oc:fileid'); + Assert::assertCount(1, $fileIds); + $fileId = (int)$fileIds[0]; Assert::assertGreaterThan(0, $fileId); $this->sendOcsRequest('POST', '/apps/dav/api/v1/direct', null, ['fileId' => $fileId]); } @@ -2014,7 +2017,8 @@ public function userPreviewsPage(string $user, string $page, string $collective, $pageId = $this->pageIdByName($collectiveId, $page); $this->sendOcsCollectivesRequest('GET', 'collectives/' . $collectiveId . '/pages/' . $pageId); $this->assertStatusCode(200); - $pagePath = $this->getJson()['ocs']['data']['page']['filePath']; + $pageInfo = $this->getJson()['ocs']['data']['page']; + $pagePath = $pageInfo['filePath'] ? $pageInfo['filePath'] . '/' . $pageInfo['fileName'] : $pageInfo['fileName']; $this->setCurrentUser($user); $userCollectivesPath = $this->getUserCollectivesPath($user); $this->sendRemoteRequest('GET', '/dav/files/' . $user . $userCollectivesPath . '/' . urlencode($collective) . '/' . $pagePath); diff --git a/tests/Unit/Dav/PropFindPluginTest.php b/tests/Unit/Dav/PropFindPluginTest.php new file mode 100644 index 0000000000..9b62cff256 --- /dev/null +++ b/tests/Unit/Dav/PropFindPluginTest.php @@ -0,0 +1,97 @@ +createMock(IUser::class); + $user->method('getUID')->willReturn('jane'); + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn($user); + $this->userFolderHelper = $this->createMock(UserFolderHelper::class); + $this->userFolderHelper->method('getUserFolderSetting')->willReturn('/.Collectives'); + $this->collectiveHelper = $this->createMock(CollectiveHelper::class); + $this->plugin = new PropFindPlugin($this->userSession, $this->userFolderHelper, $this->collectiveHelper); + } + + private function node(string $path): Node { + $mount = $this->createMock(IMountPoint::class); + $mount->method('getMountType')->willReturn('local'); + $fileInfo = $this->createMock(FileInfo::class); + $fileInfo->method('getPath')->willReturn($path); + $fileInfo->method('getMountPoint')->willReturn($mount); + $node = $this->getMockBuilder(Node::class)->disableOriginalConstructor()->getMock(); + $node->method('getFileInfo')->willReturn($fileInfo); + return $node; + } + + public function testUnrelatedNodesDoNotResolveCollectivesFolder(): void { + $this->userFolderHelper->expects(self::never())->method('get'); + $this->collectiveHelper->expects(self::never())->method('getCollectivesForUser'); + $propFind = new PropFind('', [FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME]); + + $this->plugin->propFind($propFind, $this->node('/jane/files')); + + self::assertNull($propFind->get(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME)); + } + + public function testRootCollectivesFolderStillDisablesDownloads(): void { + $this->userFolderHelper->expects(self::never())->method('get'); + $propFind = new PropFind('', [FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME]); + + $this->plugin->propFind($propFind, $this->node('/jane/files/.Collectives')); + + self::assertSame('true', $propFind->get(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME)); + } + + public function testDavGetDoesNotCheckDownloadPermissions(): void { + $tree = $this->createMock(Tree::class); + $tree->expects(self::never())->method('getNodeForPath'); + $server = new Server($tree); + $this->plugin->initialize($server); + + self::assertTrue($server->emit('beforeMethod:GET', [new Request('GET', '/page.md'), new Response()])); + } + + public function testAnonymousDavRequestsDoNotResolveUserFolder(): void { + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn(null); + $plugin = new PropFindPlugin($session, $this->userFolderHelper, $this->collectiveHelper); + $this->userFolderHelper->expects(self::never())->method('get'); + $this->userFolderHelper->expects(self::never())->method('getUserFolderSetting'); + $propFind = new PropFind('', [FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME]); + + $plugin->propFind($propFind, $this->node('/owner/files/shared')); + + self::assertNull($propFind->get(FilesPlugin::SHARE_HIDE_DOWNLOAD_PROPERTYNAME)); + } +} diff --git a/tests/Unit/Service/DownloadListenersTest.php b/tests/Unit/Service/DownloadListenersTest.php new file mode 100644 index 0000000000..f226723396 --- /dev/null +++ b/tests/Unit/Service/DownloadListenersTest.php @@ -0,0 +1,87 @@ +createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession = $this->createMock(IUserSession::class); + $this->userSession->method('getUser')->willReturn($user); + } + + public function testDirectDownloadRemainsBlocked(): void { + $check = $this->createMock(DownloadCheckService::class); + $check->expects(self::once())->method('isDownloadBlocked') + ->with('alice', ['/.Collectives/Test/page.md'])->willReturn(true); + $event = new BeforeDirectFileDownloadEvent('/.Collectives/Test/page.md'); + + (new BeforeDirectFileDownloadListener($this->userSession, $check))->handle($event); + + self::assertFalse($event->isSuccessful()); + self::assertNotNull($event->getErrorMessage()); + } + + public function testZipDownloadRemainsBlocked(): void { + $check = $this->createMock(DownloadCheckService::class); + $check->expects(self::once())->method('isDownloadBlocked') + ->with('alice', ['/.Collectives/Test/page.md', '/.Collectives/Test/attachment.png'])->willReturn(true); + $event = new BeforeZipCreatedEvent('/.Collectives/Test', ['page.md', 'attachment.png']); + + (new BeforeZipCreatedListener($this->userSession, $check))->handle($event); + + self::assertFalse($event->isSuccessful()); + self::assertNotNull($event->getErrorMessage()); + } + + public function testParentFolderZipRemainsBlocked(): void { + $check = $this->createMock(DownloadCheckService::class); + $check->expects(self::once())->method('isDownloadBlocked')->with('alice', ['/'])->willReturn(true); + $event = new BeforeZipCreatedEvent('/', []); + + (new BeforeZipCreatedListener($this->userSession, $check))->handle($event); + + self::assertFalse($event->isSuccessful()); + } + + public function testAllowedDownloadsRemainAllowed(): void { + $check = $this->createMock(DownloadCheckService::class); + $check->method('isDownloadBlocked')->willReturn(false); + $direct = new BeforeDirectFileDownloadEvent('/page.md'); + $zip = new BeforeZipCreatedEvent('/', ['page.md']); + + (new BeforeDirectFileDownloadListener($this->userSession, $check))->handle($direct); + (new BeforeZipCreatedListener($this->userSession, $check))->handle($zip); + + self::assertTrue($direct->isSuccessful()); + self::assertTrue($zip->isSuccessful()); + } + + public function testUnrelatedEventsDoNotCheckDownloads(): void { + $check = $this->createMock(DownloadCheckService::class); + $check->expects(self::never())->method('isDownloadBlocked'); + + (new BeforeDirectFileDownloadListener($this->userSession, $check))->handle(new Event()); + (new BeforeZipCreatedListener($this->userSession, $check))->handle(new Event()); + } +}