diff --git a/src/Integrations/Divi/Library/DiviLibraryStore.php b/src/Integrations/Divi/Library/DiviLibraryStore.php index 19432c9..363f99f 100644 --- a/src/Integrations/Divi/Library/DiviLibraryStore.php +++ b/src/Integrations/Divi/Library/DiviLibraryStore.php @@ -70,8 +70,9 @@ class DiviLibraryStore /** * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more). * - * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters - * scope: "global" or "normal"; category: name of a layout_category term. + * @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters + * scope: "global" or "normal"; search: in the title; category: name of a layout_category + * term; author: only this user's entries. * @return list */ public function find(array $filters, int $limit, int $offset): array @@ -101,6 +102,12 @@ class DiviLibraryStore ]; if (($filters['search'] ?? '') !== '') { $args['s'] = $filters['search']; + // Audit L3: the title only – content is block JSON, and entries the user may not read + // must not be findable by their content. + $args['search_columns'] = ['post_title']; + } + if (is_int($filters['author'] ?? null)) { + $args['author'] = $filters['author']; } if ($taxQuery !== []) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small. diff --git a/src/Integrations/Divi/Library/LibraryListAbility.php b/src/Integrations/Divi/Library/LibraryListAbility.php index 94f2154..e5b3e80 100644 --- a/src/Integrations/Divi/Library/LibraryListAbility.php +++ b/src/Integrations/Divi/Library/LibraryListAbility.php @@ -29,7 +29,7 @@ defined('ABSPATH') || exit; */ final class LibraryListAbility implements Ability { - /** At most this many entries are looked at per call while skipping entries the user may not edit. */ + /** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */ public const MAX_SCAN = 500; public function __construct( @@ -158,42 +158,48 @@ final class LibraryListAbility implements Ability ], static fn (mixed $value): bool => is_string($value) && $value !== '' ); + // Audit L3: without edit_others_posts the query holds the own entries only (PostGuard). + $author = PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE); + if ($author !== null) { + $filters['author'] = $author; + } return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset); } /** - * One page of the entries the user may edit. The cursor counts the store's rows, so entries - * left out never shift a page; next_cursor points at the next readable entry, null when none - * follows (or the scan cap is reached with nothing left). + * One page of the entries the user may edit. The cursor counts the entries this user was shown + * ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call + * reads the rows from the start again and skips the first $skip readable ones. next_cursor is + * null when no readable entry follows (or the scan cap is reached with nothing found). * - * @param array $filters + * @param array $filters * @return array{items: list>, next_cursor: ?string} */ - private function page(array $filters, int $limit, int $offset): array + private function page(array $filters, int $limit, int $skip): array { $store = $this->access->store(); $items = []; - $position = $offset; - while ($position - $offset < self::MAX_SCAN) { + $readable = 0; + $position = 0; + while ($position < $skip + self::MAX_SCAN) { $rows = $store->find($filters, $limit, $position); foreach ($rows as $post) { - if ($this->access->canRead($post) !== null) { - ++$position; + ++$position; + if ($this->access->canRead($post) !== null || $readable++ < $skip) { continue; } if (count($items) === $limit) { - return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + return ['items' => $items, 'next_cursor' => Cursor::encode($skip + $limit)]; } $items[] = $store->describe($post); - ++$position; } if (count($rows) <= $limit) { return ['items' => $items, 'next_cursor' => null]; } } - return ['items' => $items, 'next_cursor' => Cursor::encode($position)]; + return ['items' => $items, 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items))]; } /** diff --git a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php index 74aa01e..a14d665 100644 --- a/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php +++ b/tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php @@ -86,6 +86,8 @@ final class DiviLibraryStoreTest extends TestCase self::assertSame(21, $captured['posts_per_page']); self::assertSame(40, $captured['offset']); self::assertSame('Kopf', $captured['s']); + self::assertSame(['post_title'], $captured['search_columns'], 'audit L3: the title only'); + self::assertArrayNotHasKey('author', $captured); self::assertSame( [ 'relation' => 'AND', @@ -97,6 +99,21 @@ final class DiviLibraryStoreTest extends TestCase ); } + /** Audit L3: users without edit_others_posts query their own entries only. */ + public function testFindRestrictsToAnAuthor(): void + { + $captured = []; + Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array { + $captured = $args; + + return []; + }); + + (new DiviLibraryStore())->find(['author' => 12], 20, 0); + + self::assertSame(12, $captured['author']); + } + public function testReportsMissingTaxonomies(): void { Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope'); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php index f08a26d..6b6a24e 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php @@ -133,6 +133,44 @@ final class LibraryListAbilityTest extends TestCase self::assertSame(['items', 'next_cursor'], array_keys($first)); } + /** + * Audit L3: the cursor counts only the entries this user was shown – how many hidden entries + * match a search (and sort before) stays unknown. + */ + public function testTheCursorCountsNoHiddenEntries(): void + { + LibraryFixtures::given([]); + self::withoutEditPostOn([1054, 1055]); + $ids = [1054, 1055, 1056, 1057, 1058]; + + $first = $this->ability([], $ids)->execute(['limit' => 1]); + $second = $this->ability([], $ids)->execute(['limit' => 1, 'cursor' => $first['next_cursor']]); + + self::assertSame([1056], array_column($first['items'], 'id')); + self::assertSame(Cursor::encode(1), $first['next_cursor']); + self::assertSame([1057], array_column($second['items'], 'id')); + self::assertSame(Cursor::encode(2), $second['next_cursor']); + } + + /** Audit L3 (author oracle): without edit_others_posts the query itself holds only the own entries. */ + public function testAnAuthorQueriesOnlyTheirOwnEntries(): void + { + LibraryFixtures::given([]); + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts'); + Functions\when('get_current_user_id')->justReturn(12); + $store = LibraryFixtures::store(); + $store->expects('find')->with(['search' => 'Geheim', 'author' => 12], 20, 0)->andReturn([]); + $access = LibraryFixtures::access([], true, $store); + $ability = new LibraryListAbility( + $access, + LibraryFixtures::loader($access), + DiviWriterFixture::designReader(), + LibraryFixtures::usages([]) + ); + + self::assertSame(['items' => [], 'next_cursor' => null], $ability->execute(['search' => 'Geheim'])); + } + public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void { LibraryFixtures::given([]);