fix: lit/library-list search by title, own entries for authors, cursor without hidden rows (L3)
search uses search_columns post_title; users without edit_others_posts on et_pb_layout query their own entries only (PostGuard::ownPostsOnly); the cursor counts the entries shown, never the rows left out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -70,8 +70,9 @@ class DiviLibraryStore
|
|||||||
/**
|
/**
|
||||||
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
|
* 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
|
* @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters
|
||||||
* scope: "global" or "normal"; category: name of a layout_category term.
|
* scope: "global" or "normal"; search: in the title; category: name of a layout_category
|
||||||
|
* term; author: only this user's entries.
|
||||||
* @return list<WP_Post>
|
* @return list<WP_Post>
|
||||||
*/
|
*/
|
||||||
public function find(array $filters, int $limit, int $offset): array
|
public function find(array $filters, int $limit, int $offset): array
|
||||||
@@ -101,6 +102,12 @@ class DiviLibraryStore
|
|||||||
];
|
];
|
||||||
if (($filters['search'] ?? '') !== '') {
|
if (($filters['search'] ?? '') !== '') {
|
||||||
$args['s'] = $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 !== []) {
|
if ($taxQuery !== []) {
|
||||||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
|
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ defined('ABSPATH') || exit;
|
|||||||
*/
|
*/
|
||||||
final class LibraryListAbility implements Ability
|
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 const MAX_SCAN = 500;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -158,42 +158,48 @@ final class LibraryListAbility implements Ability
|
|||||||
],
|
],
|
||||||
static fn (mixed $value): bool => is_string($value) && $value !== ''
|
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);
|
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
|
* One page of the entries the user may edit. The cursor counts the entries this user was shown
|
||||||
* left out never shift a page; next_cursor points at the next readable entry, null when none
|
* ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call
|
||||||
* follows (or the scan cap is reached with nothing left).
|
* 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<string, string> $filters
|
* @param array<string, string|int> $filters
|
||||||
* @return array{items: list<array<string, mixed>>, next_cursor: ?string}
|
* @return array{items: list<array<string, mixed>>, 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();
|
$store = $this->access->store();
|
||||||
$items = [];
|
$items = [];
|
||||||
$position = $offset;
|
$readable = 0;
|
||||||
while ($position - $offset < self::MAX_SCAN) {
|
$position = 0;
|
||||||
|
while ($position < $skip + self::MAX_SCAN) {
|
||||||
$rows = $store->find($filters, $limit, $position);
|
$rows = $store->find($filters, $limit, $position);
|
||||||
foreach ($rows as $post) {
|
foreach ($rows as $post) {
|
||||||
if ($this->access->canRead($post) !== null) {
|
++$position;
|
||||||
++$position;
|
if ($this->access->canRead($post) !== null || $readable++ < $skip) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (count($items) === $limit) {
|
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);
|
$items[] = $store->describe($post);
|
||||||
++$position;
|
|
||||||
}
|
}
|
||||||
if (count($rows) <= $limit) {
|
if (count($rows) <= $limit) {
|
||||||
return ['items' => $items, 'next_cursor' => null];
|
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))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ final class DiviLibraryStoreTest extends TestCase
|
|||||||
self::assertSame(21, $captured['posts_per_page']);
|
self::assertSame(21, $captured['posts_per_page']);
|
||||||
self::assertSame(40, $captured['offset']);
|
self::assertSame(40, $captured['offset']);
|
||||||
self::assertSame('Kopf', $captured['s']);
|
self::assertSame('Kopf', $captured['s']);
|
||||||
|
self::assertSame(['post_title'], $captured['search_columns'], 'audit L3: the title only');
|
||||||
|
self::assertArrayNotHasKey('author', $captured);
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
[
|
[
|
||||||
'relation' => 'AND',
|
'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
|
public function testReportsMissingTaxonomies(): void
|
||||||
{
|
{
|
||||||
Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope');
|
Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope');
|
||||||
|
|||||||
@@ -133,6 +133,44 @@ final class LibraryListAbilityTest extends TestCase
|
|||||||
self::assertSame(['items', 'next_cursor'], array_keys($first));
|
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
|
public function testNoNextCursorWhenOnlyHiddenEntriesFollow(): void
|
||||||
{
|
{
|
||||||
LibraryFixtures::given([]);
|
LibraryFixtures::given([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user