diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 795fb8eb63e92..e2cd0f60fff12 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -959,10 +959,17 @@ 'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => $baseDir . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php', 'OCP\\Share\\IShareProviderWithNotification' => $baseDir . '/lib/public/Share/IShareProviderWithNotification.php', 'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => $baseDir . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php', + 'OCP\\Share\\ShareReview\\IPaginatedShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php', 'OCP\\Share\\ShareReview\\IShareReviewSource' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSource.php', + 'OCP\\Share\\ShareReview\\IShareReviewSourceRemediation' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php', + 'OCP\\Share\\ShareReview\\IShareReviewSourceSnapshot' => $baseDir . '/lib/public/Share/ShareReview/IShareReviewSourceSnapshot.php', 'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => $baseDir . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php', + 'OCP\\Share\\ShareReview\\ShareReviewActionContext' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewActionContext.php', + 'OCP\\Share\\ShareReview\\ShareReviewCounts' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewCounts.php', 'OCP\\Share\\ShareReview\\ShareReviewEntry' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewEntry.php', + 'OCP\\Share\\ShareReview\\ShareReviewPage' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPage.php', 'OCP\\Share\\ShareReview\\ShareReviewPermission' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewPermission.php', + 'OCP\\Share\\ShareReview\\ShareReviewQuery' => $baseDir . '/lib/public/Share/ShareReview/ShareReviewQuery.php', 'OCP\\Snowflake\\ISnowflakeDecoder' => $baseDir . '/lib/public/Snowflake/ISnowflakeDecoder.php', 'OCP\\Snowflake\\ISnowflakeGenerator' => $baseDir . '/lib/public/Snowflake/ISnowflakeGenerator.php', 'OCP\\Snowflake\\Snowflake' => $baseDir . '/lib/public/Snowflake/Snowflake.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index b9114de204f71..1faa9e9af2959 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1000,10 +1000,17 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Share\\IShareProviderSupportsAllSharesInFolder' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderSupportsAllSharesInFolder.php', 'OCP\\Share\\IShareProviderWithNotification' => __DIR__ . '/../../..' . '/lib/public/Share/IShareProviderWithNotification.php', 'OCP\\Share\\ShareReview\\Events\\ShareReviewAccessCheckEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php', + 'OCP\\Share\\ShareReview\\IPaginatedShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php', 'OCP\\Share\\ShareReview\\IShareReviewSource' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSource.php', + 'OCP\\Share\\ShareReview\\IShareReviewSourceRemediation' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php', + 'OCP\\Share\\ShareReview\\IShareReviewSourceSnapshot' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/IShareReviewSourceSnapshot.php', 'OCP\\Share\\ShareReview\\RegisterShareReviewSourceEvent' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/RegisterShareReviewSourceEvent.php', + 'OCP\\Share\\ShareReview\\ShareReviewActionContext' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewActionContext.php', + 'OCP\\Share\\ShareReview\\ShareReviewCounts' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewCounts.php', 'OCP\\Share\\ShareReview\\ShareReviewEntry' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewEntry.php', + 'OCP\\Share\\ShareReview\\ShareReviewPage' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPage.php', 'OCP\\Share\\ShareReview\\ShareReviewPermission' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewPermission.php', + 'OCP\\Share\\ShareReview\\ShareReviewQuery' => __DIR__ . '/../../..' . '/lib/public/Share/ShareReview/ShareReviewQuery.php', 'OCP\\Snowflake\\ISnowflakeDecoder' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeDecoder.php', 'OCP\\Snowflake\\ISnowflakeGenerator' => __DIR__ . '/../../..' . '/lib/public/Snowflake/ISnowflakeGenerator.php', 'OCP\\Snowflake\\Snowflake' => __DIR__ . '/../../..' . '/lib/public/Snowflake/Snowflake.php', diff --git a/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php b/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php index 38f998ab156bb..5b0d11f0cd3b7 100644 --- a/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php +++ b/lib/public/Share/ShareReview/Events/ShareReviewAccessCheckEvent.php @@ -13,7 +13,9 @@ use OCP\EventDispatcher\Event; /** - * Authorization gate for deleting an app-managed share through a share-review app. + * Authorization gate for acting on an app-managed share through a share-review + * app: deleting it, remediating it (password, expiration) or restoring it from + * a snapshot. * * Background: Apps such as Deck or Tables manage their own shares outside of * the regular sharing backend ({@see \OCP\Share\IManager}). They can expose @@ -65,10 +67,83 @@ * ignored and propagation is stopped immediately. * - Multiple grants are harmless; the last listener to deny is authoritative. * + * Actions and scopes (since 35.0.1): the same gate covers every operation a + * share-review app can request, so the owning app dispatches one event type + * and the share-review app answers it from one listener: + * - ACTION_DELETE — dispatched by {@see \OCP\Share\ShareReview\IShareReviewSource::deleteShare()}. + * This is the default and the only action of the 34.0.2 event. + * - ACTION_REMEDIATE — dispatched by the mutators of + * {@see \OCP\Share\ShareReview\IShareReviewSourceRemediation} before a + * password or expiration date is changed. + * - ACTION_RESTORE — dispatched by + * {@see \OCP\Share\ShareReview\IShareReviewSourceSnapshot::restoreShare()} + * before a share is re-created from a snapshot. + * - SCOPE_OPERATOR (default) — the acting user is a share-review operator + * reviewing the whole instance; the listener grants based on operator + * membership, exactly as for the 34.0.2 event. + * - SCOPE_SELF — the acting user reviews their own shares (a personal + * self-audit). The listener must additionally verify, e.g. through + * {@see \OCP\Share\ShareReview\IPaginatedShareReviewSource::getShare()}, + * that the acting user is the initiator of the share before granting. + * For ACTION_RESTORE the share no longer exists, so initiatorship must + * be verified against the initiator recorded with the snapshot instead. + * - The acting user defaults to the session user (null); a background job + * acting for a user passes the user id explicitly. The listener must use + * getActingUserId() when set instead of the session. + * - Both values reach the owning app through the + * {@see \OCP\Share\ShareReview\ShareReviewActionContext} a share-review + * app passes to the mutators of the 35.0.1 source interfaces; the owning + * app forwards them into this event verbatim and never decides them itself. + * Listeners that predate 35.0.1 see every action as a plain access check and + * grant or deny by operator membership: non-operators stay denied (fail + * closed), but for operators the new actions extend the granted capability + * set — ACTION_REMEDIATE includes removing a link share's password, which + * can expose content deletion never could. A listener that distinguishes + * reviewers with delete-only rights must check getAction() and deny actions + * it does not recognize. + * * @since 34.0.2 */ #[Consumable(since: '34.0.2')] class ShareReviewAccessCheckEvent extends Event { + /** + * The share is about to be deleted + * @since 35.0.1 + */ + public const ACTION_DELETE = 'delete'; + /** + * The share's password or expiration date is about to be changed + * @since 35.0.1 + */ + public const ACTION_REMEDIATE = 'remediate'; + /** + * The share is about to be re-created from a snapshot + * @since 35.0.1 + */ + public const ACTION_RESTORE = 'restore'; + + /** + * The acting user reviews the whole instance as a share-review operator + * @since 35.0.1 + */ + public const SCOPE_OPERATOR = 'operator'; + /** + * The acting user reviews their own shares only + * @since 35.0.1 + */ + public const SCOPE_SELF = 'self'; + + /** + * All valid values for $action + * @since 35.0.1 + */ + public const ACTIONS = [self::ACTION_DELETE, self::ACTION_REMEDIATE, self::ACTION_RESTORE]; + + /** + * All valid values for $scope + * @since 35.0.1 + */ + public const SCOPES = [self::SCOPE_OPERATOR, self::SCOPE_SELF]; private bool $handled = false; private bool $granted = false; @@ -77,15 +152,33 @@ class ShareReviewAccessCheckEvent extends Event { /** * @param string $sourceName Stable, non-translated identifier for the app * registering the share source (e.g. 'Deck', 'Tables'). - * @param string $shareId App-internal identifier of the share being deleted. + * @param string $shareId App-internal identifier of the share being acted on. + * @param self::ACTION_* $action The operation being authorized (since 35.0.1). + * @param string|null $actingUserId The user the operation is performed + * for; null means the session user + * (since 35.0.1). + * @param self::SCOPE_* $scope Whether the acting user acts as an operator + * over all shares or on their own shares only + * (since 35.0.1). + * + * @throws \InvalidArgumentException on an unknown $action or $scope * * @since 34.0.2 */ public function __construct( private readonly string $sourceName, private readonly string $shareId, + private readonly string $action = self::ACTION_DELETE, + private readonly ?string $actingUserId = null, + private readonly string $scope = self::SCOPE_OPERATOR, ) { parent::__construct(); + if (!in_array($action, self::ACTIONS, true)) { + throw new \InvalidArgumentException('Unknown share review action'); + } + if (!in_array($scope, self::SCOPES, true)) { + throw new \InvalidArgumentException('Unknown share review scope'); + } } /** @@ -98,7 +191,7 @@ public function getSourceName(): string { } /** - * App-internal identifier of the share being deleted. + * App-internal identifier of the share being acted on. * * @since 34.0.2 */ @@ -107,7 +200,37 @@ public function getShareId(): string { } /** - * Grant access to delete the share. + * The operation being authorized, one of the ACTION_* constants. + * + * @return self::ACTION_* + * @since 35.0.1 + */ + public function getAction(): string { + return $this->action; + } + + /** + * The user the operation is performed for, or null for the session user. + * + * @since 35.0.1 + */ + public function getActingUserId(): ?string { + return $this->actingUserId; + } + + /** + * Whether the acting user acts as an operator over all shares or on their + * own shares only, one of the SCOPE_* constants. + * + * @return self::SCOPE_* + * @since 35.0.1 + */ + public function getScope(): string { + return $this->scope; + } + + /** + * Grant access to perform the action on the share. * * Has no effect if denyAccess() was already called on this event — deny wins. * diff --git a/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php b/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php new file mode 100644 index 0000000000000..c1420917ae222 --- /dev/null +++ b/lib/public/Share/ShareReview/IPaginatedShareReviewSource.php @@ -0,0 +1,134 @@ + map of share type to filtered count + * + * @since 35.0.1 + */ + public function countSharesByType(ShareReviewQuery $query): array; + + /** + * Return the filtered count per initiator for the initiators with the most + * shares, in one grouped scan. All search and filter fields of the query + * apply; limit, offset and sorting are ignored. The result is ordered by + * count descending, then by initiator id ascending, and holds at most + * $limit entries; initiators without a matching share are omitted. + * + * Serves aggregate views such as "top sharers" and the discovery of + * initiators that no longer exist as users, which a consumer cannot derive + * from paging without reading every share. + * + * @param int $limit Maximum number of initiators returned, + * 1..{@see ShareReviewQuery::MAX_LIMIT}. + * @return array map of initiator id to filtered count + * + * @throws \InvalidArgumentException on a $limit outside 1..MAX_LIMIT + * + * @since 35.0.1 + */ + public function countSharesByInitiator(ShareReviewQuery $query, int $limit): array; + + /** + * Look up a single share by its deletion identifier — the value of + * {@see ShareReviewEntry::$action} if non-empty, else + * {@see ShareReviewEntry::$id} — i.e. the same id + * {@see IShareReviewSource::deleteShare()} accepts. + * + * @return ShareReviewEntry|null null if no such share exists + * + * @since 35.0.1 + */ + public function getShare(string $shareId): ?ShareReviewEntry; + + /** + * Delete a share, forwarding the action context into the access check. + * Identical to {@see IShareReviewSource::deleteShare()} except that the + * {@see \OCP\Share\ShareReview\Events\ShareReviewAccessCheckEvent} an + * implementation dispatches MUST carry $context->actingUserId and + * $context->scope. A null context means the session user acts as an + * operator, exactly like the 34.0.2 method. + * + * @param string $shareId The deletion identifier, as accepted by + * {@see IShareReviewSource::deleteShare()}. + * @param ShareReviewActionContext|null $context Who acts, and in which + * scope. + * @return bool whether the share was deleted + * + * @since 35.0.1 + */ + #[\Override] + public function deleteShare(string $shareId, ?ShareReviewActionContext $context = null): bool; +} diff --git a/lib/public/Share/ShareReview/IShareReviewSource.php b/lib/public/Share/ShareReview/IShareReviewSource.php index 0e958f91c2020..d6cc19c203f60 100644 --- a/lib/public/Share/ShareReview/IShareReviewSource.php +++ b/lib/public/Share/ShareReview/IShareReviewSource.php @@ -17,12 +17,22 @@ * {@see RegisterShareReviewSourceEvent} and resolved from the dependency * injection container. * + * Sources with many shares should implement the extending + * {@see IPaginatedShareReviewSource}, which lists shares page by page with + * sorting, search, filters and counts instead of everything at once. + * * @since 34.0.2 */ #[Implementable(since: '34.0.2')] interface IShareReviewSource { /** - * The name of the app, used in the review table + * Stable, non-translated identifier of this source, e.g. 'Deck'. Used as + * the key of the source in the review app (tab id, per-source review + * state) and as the source name of + * {@see Events\ShareReviewAccessCheckEvent}, so it must never change and + * must not be translated. Use + * {@see IPaginatedShareReviewSource::getDisplayName()} for a localized + * label. * * @since 34.0.2 */ @@ -31,7 +41,10 @@ public function getName(): string; /** * Return all app-specific shares. * - * The app name is added by the share-review app from getName(). + * The app name is added by the share-review app from getName(). A source + * implementing {@see IPaginatedShareReviewSource} is queried page by page + * through queryShares() instead; it may implement this method by iterating + * over all pages. * * @return list * diff --git a/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php b/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php new file mode 100644 index 0000000000000..f0ea990456001 --- /dev/null +++ b/lib/public/Share/ShareReview/IShareReviewSourceRemediation.php @@ -0,0 +1,86 @@ +filteredCount = min($filteredCount, $totalCount); + } +} diff --git a/lib/public/Share/ShareReview/ShareReviewPage.php b/lib/public/Share/ShareReview/ShareReviewPage.php new file mode 100644 index 0000000000000..ba7597cc61144 --- /dev/null +++ b/lib/public/Share/ShareReview/ShareReviewPage.php @@ -0,0 +1,36 @@ + $entries The shares of this page, in the + * requested sort order. + * @param ShareReviewCounts $counts Total and filtered counts of the query + * that produced this page. + * + * @since 35.0.1 + */ + public function __construct( + public readonly array $entries, + public readonly ShareReviewCounts $counts, + ) { + } +} diff --git a/lib/public/Share/ShareReview/ShareReviewQuery.php b/lib/public/Share/ShareReview/ShareReviewQuery.php new file mode 100644 index 0000000000000..e7083ad8a78bc --- /dev/null +++ b/lib/public/Share/ShareReview/ShareReviewQuery.php @@ -0,0 +1,247 @@ += 0. + * @param string|null $search Case-insensitive substring matched against + * object, initiator and recipient (OR). + * @param self::SORT_* $sortField Field to sort by. + * @param bool $sortDescending Sort direction; the default lists the most + * recently modified shares first. + * @param int|null $modifiedSinceTimestamp Unix timestamp; only shares + * whose lastModifiedTimestamp is + * strictly greater match (the + * "new since last review" filter). + * @param int|null $modifiedBeforeTimestamp Unix timestamp; exclusive upper + * bound on lastModifiedTimestamp. + * Together with the lower bound + * this forms the half-open range + * `since < lastModified < before`, + * e.g. one bucket of a trend. + * @param list|null $shareTypes Exact-match share types + * (IN); null matches all types. + * A requested type the source + * cannot produce — and, as for + * every list filter, an empty + * list — matches nothing. + * @param bool|null $hasPassword null = no filter, true = only + * password-protected shares, false = only + * unprotected ones. Defined against + * {@see ShareReviewEntry::$hasPassword}. + * @param bool|null $hasExpiration null = no filter, true = only shares with + * an expiration date, false = only shares + * without one. Defined against + * {@see ShareReviewEntry::$expirationTimestamp} + * being non-null. + * @param int|null $expiresAfterTimestamp Unix timestamp; inclusive lower + * bound on the expiration date. + * Matches only shares that have one. + * @param int|null $expiresBeforeTimestamp Unix timestamp; exclusive upper + * bound on the expiration date. + * Matches only shares that have one. + * Together with the lower bound this + * forms the half-open range + * `after <= expiration < before`. + * @param string|null $initiatorSearch Case-insensitive substring scoped to + * the initiator; OR-combined with + * $initiatorIds. + * @param string|null $recipientSearch Case-insensitive substring scoped to + * the recipient (including a link + * share's token); OR-combined with + * $recipientIds. + * @param string|null $objectSearch Case-insensitive substring scoped to the + * object. + * @param list|null $objectSearchAny Case-insensitive substrings + * scoped to the object, of which + * at least one must match (OR + * within the list, AND with + * everything else, including + * $objectSearch). Objects have no + * stable cross-source id, so this + * pattern list stands in for the + * exact id lists of the identity + * fields; alongside $objectSearch + * it lets one user term be + * combined with a fixed set of + * name patterns. An empty list + * matches nothing. + * @param list|null $initiatorIds Exact-match initiator ids (IN); + * an empty list matches nothing. + * @param list|null $recipientIds Exact-match recipient ids (IN); + * group, team and other container + * ids are allowed; an empty list + * matches nothing. + * @param list|null $permissionIds ANY-of filter on opaque + * {@see ShareReviewPermission::$id} + * values: a share matches if it + * grants at least one of them. Ids + * of a foreign namespace — and an + * empty list — match nothing. + * @param list|null $tokens Exact-match access tokens (IN) of any + * share type, for looking up the share a + * known link belongs to; an empty list, + * and every share of a source that has no + * tokens, match nothing. Tokens are bearer + * credentials: a source MUST compare them + * exactly — never as a substring or + * prefix, which would let a caller + * discover a token it does not have — and + * MUST NOT match the token column for any + * other filter unless the token is the + * share's recipient (link shares). + * + * @throws \InvalidArgumentException on an out-of-range $limit or $offset + * or an unknown $sortField + * + * @since 35.0.1 + */ + public function __construct( + public readonly int $limit = 100, + public readonly int $offset = 0, + public readonly ?string $search = null, + public readonly string $sortField = self::SORT_TIME, + public readonly bool $sortDescending = true, + public readonly ?int $modifiedSinceTimestamp = null, + public readonly ?int $modifiedBeforeTimestamp = null, + public readonly ?array $shareTypes = null, + public readonly ?bool $hasPassword = null, + public readonly ?bool $hasExpiration = null, + public readonly ?int $expiresAfterTimestamp = null, + public readonly ?int $expiresBeforeTimestamp = null, + public readonly ?string $initiatorSearch = null, + public readonly ?string $recipientSearch = null, + public readonly ?string $objectSearch = null, + public readonly ?array $objectSearchAny = null, + public readonly ?array $initiatorIds = null, + public readonly ?array $recipientIds = null, + public readonly ?array $permissionIds = null, + public readonly ?array $tokens = null, + ) { + if ($limit < 1 || $limit > self::MAX_LIMIT) { + throw new \InvalidArgumentException('limit must be between 1 and ' . self::MAX_LIMIT); + } + if ($offset < 0) { + throw new \InvalidArgumentException('offset must not be negative'); + } + if (!in_array($sortField, self::SORTABLE_FIELDS, true)) { + throw new \InvalidArgumentException('Unknown sort field'); + } + } + + /** + * Whether any search or filter narrows the result. When this is false a + * source can skip the filtered count, as it equals the total count. + * + * @since 35.0.1 + */ + public function isFiltered(): bool { + return $this->search !== null + || $this->modifiedSinceTimestamp !== null + || $this->modifiedBeforeTimestamp !== null + || $this->shareTypes !== null + || $this->hasPassword !== null + || $this->hasExpiration !== null + || $this->expiresAfterTimestamp !== null + || $this->expiresBeforeTimestamp !== null + || $this->initiatorSearch !== null + || $this->recipientSearch !== null + || $this->objectSearch !== null + || $this->objectSearchAny !== null + || $this->initiatorIds !== null + || $this->recipientIds !== null + || $this->permissionIds !== null + || $this->tokens !== null; + } +} diff --git a/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php b/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php index 61e049f16c5ab..db768a338edce 100644 --- a/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php +++ b/tests/lib/Share20/ShareReview/Events/ShareReviewAccessCheckEventTest.php @@ -93,4 +93,57 @@ public function testMultipleGrantsAreIdempotent(): void { $this->assertTrue($event->isGranted()); $this->assertFalse($event->isPropagationStopped()); } + + public function testActionContextDefaultsToOperatorDeletionBySessionUser(): void { + $event = $this->makeEvent(); + + $this->assertSame(ShareReviewAccessCheckEvent::ACTION_DELETE, $event->getAction()); + $this->assertNull($event->getActingUserId()); + $this->assertSame(ShareReviewAccessCheckEvent::SCOPE_OPERATOR, $event->getScope()); + } + + public function testActionContextPayload(): void { + $event = new ShareReviewAccessCheckEvent( + 'Tables', + '7', + ShareReviewAccessCheckEvent::ACTION_REMEDIATE, + 'alice', + ShareReviewAccessCheckEvent::SCOPE_SELF, + ); + + $this->assertSame(ShareReviewAccessCheckEvent::ACTION_REMEDIATE, $event->getAction()); + $this->assertSame('alice', $event->getActingUserId()); + $this->assertSame(ShareReviewAccessCheckEvent::SCOPE_SELF, $event->getScope()); + $this->assertFalse($event->isHandled()); + } + + public function testRestoreAction(): void { + $event = new ShareReviewAccessCheckEvent('Deck', '3', ShareReviewAccessCheckEvent::ACTION_RESTORE); + + $this->assertSame(ShareReviewAccessCheckEvent::ACTION_RESTORE, $event->getAction()); + } + + public function testConstantListsCoverEveryValue(): void { + $this->assertSame([ + ShareReviewAccessCheckEvent::ACTION_DELETE, + ShareReviewAccessCheckEvent::ACTION_REMEDIATE, + ShareReviewAccessCheckEvent::ACTION_RESTORE, + ], ShareReviewAccessCheckEvent::ACTIONS); + $this->assertSame([ + ShareReviewAccessCheckEvent::SCOPE_OPERATOR, + ShareReviewAccessCheckEvent::SCOPE_SELF, + ], ShareReviewAccessCheckEvent::SCOPES); + } + + public function testRejectsUnknownAction(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewAccessCheckEvent('Deck', '3', 'purge'); + } + + public function testRejectsUnknownScope(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewAccessCheckEvent('Deck', '3', ShareReviewAccessCheckEvent::ACTION_DELETE, null, 'admin'); + } } diff --git a/tests/lib/Share20/ShareReview/ShareReviewActionContextTest.php b/tests/lib/Share20/ShareReview/ShareReviewActionContextTest.php new file mode 100644 index 0000000000000..66b48be3b186d --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewActionContextTest.php @@ -0,0 +1,46 @@ +assertNull($context->actingUserId); + $this->assertSame(ShareReviewAccessCheckEvent::SCOPE_OPERATOR, $context->scope); + } + + public function testHoldsActingUserAndScope(): void { + $context = new ShareReviewActionContext('alice', ShareReviewAccessCheckEvent::SCOPE_SELF); + + $this->assertSame('alice', $context->actingUserId); + $this->assertSame(ShareReviewAccessCheckEvent::SCOPE_SELF, $context->scope); + } + + public function testRejectsUnknownScope(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewActionContext(null, 'admin'); + } + + public function testForwardsIntoTheEventUnchanged(): void { + $context = new ShareReviewActionContext('alice', ShareReviewAccessCheckEvent::SCOPE_SELF); + + $event = new ShareReviewAccessCheckEvent('Deck', '3', ShareReviewAccessCheckEvent::ACTION_DELETE, $context->actingUserId, $context->scope); + + $this->assertSame('alice', $event->getActingUserId()); + $this->assertSame(ShareReviewAccessCheckEvent::SCOPE_SELF, $event->getScope()); + } +} diff --git a/tests/lib/Share20/ShareReview/ShareReviewCountsTest.php b/tests/lib/Share20/ShareReview/ShareReviewCountsTest.php new file mode 100644 index 0000000000000..6ea51251ce6b1 --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewCountsTest.php @@ -0,0 +1,49 @@ +assertSame(1200, $counts->totalCount); + $this->assertSame(37, $counts->filteredCount); + } + + public function testZeroCounts(): void { + $counts = new ShareReviewCounts(0, 0); + + $this->assertSame(0, $counts->totalCount); + $this->assertSame(0, $counts->filteredCount); + } + + public function testRejectsNegativeTotal(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewCounts(-1, 0); + } + + public function testRejectsNegativeFilteredCount(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewCounts(5, -1); + } + + public function testFilteredCountAboveTheTotalIsClampedToIt(): void { + // two independent count queries can race a concurrent insert + $counts = new ShareReviewCounts(100, 250); + + $this->assertSame(100, $counts->filteredCount); + } +} diff --git a/tests/lib/Share20/ShareReview/ShareReviewPageTest.php b/tests/lib/Share20/ShareReview/ShareReviewPageTest.php new file mode 100644 index 0000000000000..4b0f20ea1a418 --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewPageTest.php @@ -0,0 +1,40 @@ +assertSame($entries, $page->entries); + $this->assertSame($counts, $page->counts); + } + + public function testEmptyPage(): void { + $page = new ShareReviewPage([], new ShareReviewCounts(0, 0)); + + $this->assertSame([], $page->entries); + $this->assertSame(0, $page->counts->totalCount); + $this->assertSame(0, $page->counts->filteredCount); + } +} diff --git a/tests/lib/Share20/ShareReview/ShareReviewQueryTest.php b/tests/lib/Share20/ShareReview/ShareReviewQueryTest.php new file mode 100644 index 0000000000000..3ab92197a78dc --- /dev/null +++ b/tests/lib/Share20/ShareReview/ShareReviewQueryTest.php @@ -0,0 +1,171 @@ +assertSame(100, $query->limit); + $this->assertSame(0, $query->offset); + $this->assertNull($query->search); + $this->assertSame(ShareReviewQuery::SORT_TIME, $query->sortField); + $this->assertTrue($query->sortDescending); + $this->assertNull($query->modifiedSinceTimestamp); + $this->assertNull($query->modifiedBeforeTimestamp); + $this->assertNull($query->shareTypes); + $this->assertNull($query->hasPassword); + $this->assertNull($query->hasExpiration); + $this->assertNull($query->expiresAfterTimestamp); + $this->assertNull($query->expiresBeforeTimestamp); + $this->assertNull($query->initiatorSearch); + $this->assertNull($query->recipientSearch); + $this->assertNull($query->objectSearch); + $this->assertNull($query->objectSearchAny); + $this->assertNull($query->initiatorIds); + $this->assertNull($query->recipientIds); + $this->assertNull($query->permissionIds); + $this->assertNull($query->tokens); + $this->assertFalse($query->isFiltered()); + } + + public function testHoldsAllFields(): void { + $query = new ShareReviewQuery( + limit: 25, + offset: 50, + search: 'report', + sortField: ShareReviewQuery::SORT_OBJECT, + sortDescending: false, + modifiedSinceTimestamp: 1783764000, + modifiedBeforeTimestamp: 1786442400, + shareTypes: [IShare::TYPE_LINK, IShare::TYPE_EMAIL], + hasPassword: false, + hasExpiration: true, + expiresAfterTimestamp: 1785837600, + expiresBeforeTimestamp: 1786442400, + initiatorSearch: 'ali', + recipientSearch: 'bo', + objectSearch: '.xlsx', + objectSearchAny: ['salary', '.pem'], + initiatorIds: ['alice'], + recipientIds: ['bob', 'developers'], + permissionIds: ['files:reshare'], + tokens: ['sToKeN'], + ); + + $this->assertSame(25, $query->limit); + $this->assertSame(50, $query->offset); + $this->assertSame('report', $query->search); + $this->assertSame(ShareReviewQuery::SORT_OBJECT, $query->sortField); + $this->assertFalse($query->sortDescending); + $this->assertSame(1783764000, $query->modifiedSinceTimestamp); + $this->assertSame(1786442400, $query->modifiedBeforeTimestamp); + $this->assertSame([IShare::TYPE_LINK, IShare::TYPE_EMAIL], $query->shareTypes); + $this->assertFalse($query->hasPassword); + $this->assertTrue($query->hasExpiration); + $this->assertSame(1785837600, $query->expiresAfterTimestamp); + $this->assertSame(1786442400, $query->expiresBeforeTimestamp); + $this->assertSame('ali', $query->initiatorSearch); + $this->assertSame('bo', $query->recipientSearch); + $this->assertSame('.xlsx', $query->objectSearch); + $this->assertSame(['salary', '.pem'], $query->objectSearchAny); + $this->assertSame(['alice'], $query->initiatorIds); + $this->assertSame(['bob', 'developers'], $query->recipientIds); + $this->assertSame(['files:reshare'], $query->permissionIds); + $this->assertSame(['sToKeN'], $query->tokens); + $this->assertTrue($query->isFiltered()); + } + + public function testSortFieldsListsEveryConstant(): void { + $this->assertSame([ + ShareReviewQuery::SORT_TIME, + ShareReviewQuery::SORT_OBJECT, + ShareReviewQuery::SORT_INITIATOR, + ShareReviewQuery::SORT_RECIPIENT, + ShareReviewQuery::SORT_TYPE, + ], ShareReviewQuery::SORTABLE_FIELDS); + foreach (ShareReviewQuery::SORTABLE_FIELDS as $sortField) { + $this->assertSame($sortField, (new ShareReviewQuery(sortField: $sortField))->sortField); + } + } + + /** + * @return array}> + */ + public static function singleFilterProvider(): array { + return [ + 'search' => [['search' => 'x']], + 'modifiedSince' => [['modifiedSinceTimestamp' => 1]], + 'modifiedBefore' => [['modifiedBeforeTimestamp' => 1]], + 'shareTypes' => [['shareTypes' => [IShare::TYPE_LINK]]], + 'hasPassword false' => [['hasPassword' => false]], + 'hasExpiration false' => [['hasExpiration' => false]], + 'expiresAfter' => [['expiresAfterTimestamp' => 1]], + 'expiresBefore' => [['expiresBeforeTimestamp' => 1]], + 'initiatorSearch' => [['initiatorSearch' => 'a']], + 'recipientSearch' => [['recipientSearch' => 'b']], + 'objectSearch' => [['objectSearch' => 'c']], + 'objectSearchAny' => [['objectSearchAny' => []]], + 'initiatorIds' => [['initiatorIds' => []]], + 'recipientIds' => [['recipientIds' => []]], + 'permissionIds' => [['permissionIds' => []]], + 'tokens' => [['tokens' => []]], + ]; + } + + /** + * @param array $arguments + */ + #[DataProvider('singleFilterProvider')] + public function testEverySingleFilterCountsAsFiltered(array $arguments): void { + $this->assertTrue((new ShareReviewQuery(...$arguments))->isFiltered()); + } + + public function testPaginationAndSortDoNotCountAsFiltered(): void { + $query = new ShareReviewQuery(limit: 1, offset: 999, sortField: ShareReviewQuery::SORT_TYPE, sortDescending: false); + + $this->assertFalse($query->isFiltered()); + } + + public function testLimitBounds(): void { + $this->assertSame(1, (new ShareReviewQuery(limit: 1))->limit); + $this->assertSame(ShareReviewQuery::MAX_LIMIT, (new ShareReviewQuery(limit: ShareReviewQuery::MAX_LIMIT))->limit); + } + + public function testRejectsZeroLimit(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewQuery(limit: 0); + } + + public function testRejectsLimitAboveMaximum(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewQuery(limit: ShareReviewQuery::MAX_LIMIT + 1); + } + + public function testRejectsNegativeOffset(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewQuery(offset: -1); + } + + public function testRejectsUnknownSortField(): void { + $this->expectException(\InvalidArgumentException::class); + + new ShareReviewQuery(sortField: 'permissions'); + } +}