From 681bb755ff85b985182d5275387538c378710a3f Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 01:07:53 +0200 Subject: [PATCH] fix: library entries read as not found without the library right (P12) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Divi/Library/LibraryAccess.php | 6 ++-- .../Integrations/Divi/DiviIntegrationTest.php | 15 +++++--- .../Divi/Library/LibraryAccessTest.php | 34 +++++++++++++++++-- .../Divi/Pages/DiviPageLoaderTest.php | 2 +- .../Divi/Pages/DiviReadAbilitiesTest.php | 6 ++-- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/Integrations/Divi/Library/LibraryAccess.php b/src/Integrations/Divi/Library/LibraryAccess.php index fba31c5..0a4d656 100644 --- a/src/Integrations/Divi/Library/LibraryAccess.php +++ b/src/Integrations/Divi/Library/LibraryAccess.php @@ -51,7 +51,9 @@ class LibraryAccess /** * The library entry $postId after the checks for reading, or their refusal; null when $postId * names no library entry (the caller goes on with PostGuard). With the library hidden (ruling - * P9), an entry is refused exactly like an ID that does not exist – before any rights check. + * P9) or without the library right at all (ruling P12), an entry is refused exactly like an ID + * that does not exist, so its type and existence stay unknown; only a user with the library + * right who lacks edit_post on the entry gets forbidden (like pages). */ public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null { @@ -61,7 +63,7 @@ class LibraryAccess return null; } - if ($this->visible !== null && ! ($this->visible)()) { + if (($this->visible !== null && ! ($this->visible)()) || $this->canList() !== null) { return PostGuard::notFound((int) $post->ID); } diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index 389e5b0..1987b26 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -66,10 +66,17 @@ final class DiviIntegrationTest extends TestCase $schema->allows('isManaged')->andReturn(false); $outline = static fn (DiviIntegration $divi): mixed => $divi->abilities()[0]->permission(['post_id' => 1055]); - // No Divi in unit tests: DiviRoles allows nothing, so a reachable entry is refused, not missing. - $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static fn (): bool => true); - self::assertToolError($outline($visible), 'forbidden'); - self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'forbidden'); + // No Divi in unit tests: DiviRoles allows nothing, so the entry is not found either way (ruling P12); + // the wiring shows in which gate the integration asks. + $asked = 0; + $visible = new DiviIntegration($schema, FakeChangeLog::forbidden(), null, static function () use (&$asked): bool { + ++$asked; + + return true; + }); + self::assertToolError($outline($visible), 'not_found'); + self::assertSame(1, $asked); + self::assertToolError($outline(new DiviIntegration($schema, FakeChangeLog::forbidden())), 'not_found'); $hidden = new DiviIntegration($schema, FakeChangeLog::forbidden(), libraryVisible: static fn (): bool => false); $refusal = $outline($hidden); diff --git a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php index e76d012..5f50357 100644 --- a/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php +++ b/tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php @@ -5,8 +5,12 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library; use LIT\MCP\Integrations\Divi\Library\LibraryAccess; +use LIT\MCP\Integrations\WordPress\PostGuard; +use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Tests\Support\LibraryFixtures; use LIT\MCP\Tests\Unit\TestCase; +use Mockery; +use WP_Error; use WP_Post; final class LibraryAccessTest extends TestCase @@ -24,15 +28,18 @@ final class LibraryAccessTest extends TestCase self::assertNull($access->canList()); } + /** Ruling P12: without the library right at all, library IDs read like missing IDs (no type oracle). */ public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void { $item = LibraryFixtures::item(1055); LibraryFixtures::given([$item], ['edit_posts']); - self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden'); + self::assertToolError(LibraryFixtures::access()->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access()->canList(), 'forbidden'); LibraryFixtures::given([$item]); - self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'not_found'); + self::assertToolError(LibraryFixtures::access(['divi_library'])->canList(), 'forbidden'); LibraryFixtures::given([$item], ['edit_post']); $refusal = LibraryFixtures::access()->item(1055, false); @@ -63,7 +70,7 @@ final class LibraryAccessTest extends TestCase foreach ([['edit_posts'], ['edit_post']] as $denied) { LibraryFixtures::given([$item], $denied); $refusal = LibraryFixtures::access()->item(1055, false); - self::assertToolError($refusal, 'forbidden'); + self::assertToolError($refusal, $denied === ['edit_post'] ? 'forbidden' : 'not_found'); self::assertStringNotContainsString('Geheimer Entwurf', $refusal->get_error_message()); self::assertStringNotContainsString('Geheimer Entwurf', (string) wp_json_encode($refusal->get_error_data())); } @@ -85,4 +92,25 @@ final class LibraryAccessTest extends TestCase self::assertNull($hidden->item(LibraryFixtures::PAGE, false), 'pages stay PostGuard\'s business'); self::assertNull($hidden->item(9999, false)); } + + /** Ruling P12: the refusal without the library right is byte-identical to a missing ID's. */ + public function testWithoutTheLibraryRightAnEntryIsIndistinguishableFromAMissingId(): void + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(false); + + foreach ([[['edit_posts'], []], [[], ['divi_library']]] as [$denied, $deniedRoles]) { + LibraryFixtures::given([LibraryFixtures::item(1055, ['post_title' => 'Geheimer Entwurf'])], $denied); + $refusal = LibraryFixtures::access($deniedRoles)->item(1055, false); + + LibraryFixtures::given([], $denied); + $missing = PostGuard::editable(1055, $schema, 'post_id', true); + + self::assertInstanceOf(WP_Error::class, $refusal); + self::assertInstanceOf(WP_Error::class, $missing); + self::assertSame($missing->get_error_code(), $refusal->get_error_code()); + self::assertSame($missing->get_error_message(), $refusal->get_error_message()); + self::assertSame($missing->get_error_data(), $refusal->get_error_data()); + } + } } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php index 3e9824f..6092aee 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php @@ -123,7 +123,7 @@ final class DiviPageLoaderTest extends TestCase self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found'); self::assertToolError( (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false), - 'forbidden' + 'not_found' ); } diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php index 11f9619..e934793 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php @@ -152,7 +152,7 @@ final class DiviReadAbilitiesTest extends TestCase self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']); } - public function testNodeGetOfALibraryItemAndRefusalsWithoutTheLibraryRight(): void + public function testNodeGetOfALibraryItemAndNotFoundWithoutTheLibraryRight(): void { LibraryFixtures::given([LibraryFixtures::item(1057, ['post_status' => 'private', 'post_title' => 'Geheim'])]); $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design()); @@ -162,9 +162,9 @@ final class DiviReadAbilitiesTest extends TestCase $denied = new DiviNodeGetAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); $refusal = $denied->permission(['post_id' => 1057, 'id' => 'm0']); - self::assertToolError($refusal, 'forbidden'); + self::assertToolError($refusal, 'not_found'); self::assertStringNotContainsString('Geheim', $refusal->get_error_message()); $outline = new DiviOutlineAbility(LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])), $this->design()); - self::assertToolError($outline->execute(['post_id' => 1057]), 'forbidden'); + self::assertToolError($outline->execute(['post_id' => 1057]), 'not_found'); } }