From 66891267beac19866a83f03da26c5ad972efabdc Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 11:37:05 +0000 Subject: [PATCH] =?UTF-8?q?test(dossier):=20split=20DossierMenuRegistratio?= =?UTF-8?q?nTest=20(551=E2=86=92235+198+120=20lines,=20P1=20fix)=20+=20?= =?UTF-8?q?=C2=A722c=E2=86=92=C2=A722b=20(P3=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: DossierMenuRegistrationTest.php was 551 lines (cap 300). Split into: - DossierMenuRegistrationTestCase.php (120 lines): shared Brain\Monkey harness, add_submenu_page spy stub, $submenu isolation — abstract base class. - DossierMenuRegistrationTest.php (235 lines): structural invariants (parent slug, $submenu non-mutation, page slug constant, asset enqueue gate prefix), deep-link URL shape, and hook priority ordering — 7 test methods. - DossierHooknameMechanismTest.php (198 lines): request-time hookname faithfulness (simulate_request_time_hookname discriminator) and regression proof — 2 test methods. Method inventory parity: 9 before = 9 after. Before: test_request_time_hookname_matches_registration_hookname, test_hide_by_unset_would_fail_request_time_check, test_dossier_parent_slug_is_options_php, test_no_submenu_mutation_after_registration, test_dossier_uses_correct_slug, test_asset_enqueue_gate_uses_admin_page_prefix, test_url_for_post_contains_slug_and_post_id, test_url_for_post_exact_shape, test_dossier_hook_registered_at_priority_12. After: same 9, split 7/2 across two files. Zero assertions dropped. P3: class-dossier-page.php docblock §22c → §22b (two occurrences). No §22c heading exists in ARCHITECTURE.md; convention content is in §22b. Agent-Session: dossier-403-hotfix-20260612 --- includes/admin/class-dossier-page.php | 4 +- .../Admin/DossierHooknameMechanismTest.php | 198 +++++++++++ .../Admin/DossierMenuRegistrationTest.php | 332 +----------------- .../Admin/DossierMenuRegistrationTestCase.php | 120 +++++++ 4 files changed, 328 insertions(+), 326 deletions(-) create mode 100644 tests/unit/Admin/DossierHooknameMechanismTest.php create mode 100644 tests/unit/Admin/DossierMenuRegistrationTestCase.php diff --git a/includes/admin/class-dossier-page.php b/includes/admin/class-dossier-page.php index 9d7bc8c..f970fa2 100644 --- a/includes/admin/class-dossier-page.php +++ b/includes/admin/class-dossier-page.php @@ -19,7 +19,7 @@ declare(strict_types=1); * → at request time get_admin_page_parent() no longer finds the slug in $submenu * → WP recomputes hookname in the 'admin_page_*' orphan namespace * → no callback registered there → wp_die(403). - * See ARCHITECTURE.md §22c + CONVENTIONS.md §Hidden Admin Pages for the full incident + * See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident * history (board 404 v0.19.1, dossier 403 v0.19.2). * * Board cards and post-metabox deep-link here via @@ -34,7 +34,7 @@ declare(strict_types=1); * * @see admin/class-dossier-data-assembler.php -- Builds the view model. * @see templates/admin/dossier-page.php -- HTML template. - * @see ARCHITECTURE.md -- §22c (hidden admin page convention). + * @see ARCHITECTURE.md -- §22b (hidden admin page convention). * @see CONVENTIONS.md -- §Hidden Admin Pages. */ class PRAutoBlogger_Dossier_Page { diff --git a/tests/unit/Admin/DossierHooknameMechanismTest.php b/tests/unit/Admin/DossierHooknameMechanismTest.php new file mode 100644 index 0000000..7d1b79c --- /dev/null +++ b/tests/unit/Admin/DossierHooknameMechanismTest.php @@ -0,0 +1,198 @@ +>> $submenu_state $submenu after all registration + mutations. + * @param string $slug Page slug to resolve. + * @return string Request-time hookname. + */ + private function simulate_request_time_hookname( array $submenu_state, string $slug ): string { + foreach ( $submenu_state as $parent => $items ) { + foreach ( $items as $item ) { + if ( isset( $item[2] ) && $slug === $item[2] ) { + // Found the slug; derive hookname the same way as registration. + if ( 'options.php' === $parent ) { + return 'admin_page_' . $slug; + } + $parent_key = str_replace( '.php', '', $parent ); + return $parent_key . '_page_' . $slug; + } + } + } + // Slug absent from $submenu -- WP falls back to the orphan namespace. + return 'admin_page_' . $slug; + } + + /** + * REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname. + * + * This test directly discriminates the two patterns: + * + * HIDE-BY-UNSET (v0.19.2, BROKEN): + * - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier' + * - unset($submenu['prautoblogger-settings'][$idx]) + * - post-registration $submenu does NOT contain the slug + * - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier' + * - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state) + * + * OPTIONS.PHP-PARENT (v0.19.3, FIXED): + * - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier' + * - no $submenu mutation + * - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier' + * - MATCH → PASS ✓ + * + * This test ALSO fails if any future change reintroduces $submenu mutation, + * or switches back to a plugin-owned parent without preserving the submenu entry. + */ + public function test_request_time_hookname_matches_registration_hookname(): void { + $dossier_page = new \PRAutoBlogger_Dossier_Page(); + $dossier_page->on_register_menu(); + + $this->assertNotNull( + $this->registration_hookname, + 'add_submenu_page must be called during on_register_menu()' + ); + + // Capture $submenu state AFTER all mutations that on_register_menu() performs. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read. + global $submenu; + $submenu_after = $submenu; + + $request_time_hookname = $this->simulate_request_time_hookname( + $submenu_after, + \PRAutoBlogger_Dossier_Page::PAGE_SLUG + ); + + $this->assertSame( + $this->registration_hookname, + $request_time_hookname, + sprintf( + 'Hookname mismatch: registration="%s", request-time="%s". ' + . 'This is the 403 failure class. ' + . 'The hide-by-unset pattern unsets the slug from $submenu after registration, ' + . 'so get_admin_page_parent() fails at request time, WP recomputes under ' + . 'the orphan "admin_page_*" namespace, finds no registered handler, ' + . 'and calls wp_die(403). Fix: use options.php as parent -- hookname is ' + . '"admin_page_{slug}" at both registration and request time, no mutation needed.', + $this->registration_hookname, + $request_time_hookname + ) + ); + } + + /** + * REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the + * request-time test above. + * + * Directly simulates the broken implementation, applies simulate_request_time_hookname, + * and asserts the MISMATCH -- proving the discriminator is effective. + * + * This test documents the failure state and serves as evidence that the test methodology + * is correct. It passes by verifying the broken pattern produces the wrong hookname. + */ + public function test_hide_by_unset_would_fail_request_time_check(): void { + // --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION --- + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation. + global $submenu; + + // Step 1: Registration (add_submenu_page with plugin parent). + // Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier' + // when parent is 'prautoblogger-settings'. Populate $submenu as WP does. + $broken_parent_slug = 'prautoblogger-settings'; + $slug = 'prautoblogger-dossier'; + + $submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array(); + $submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' ); + + // The hookname WP computed at registration time with plugin-owned parent: + $broken_registration_hookname = 'prautoblogger_page_' . $slug; + + // Step 2: The unset mutation (the bug). + // This is what v0.19.2's on_register_menu() did after add_submenu_page. + foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) { + if ( isset( $item[2] ) && $slug === $item[2] ) { + unset( $submenu[ $broken_parent_slug ][ $idx ] ); + break; + } + } + + // Step 3: Request time -- slug is now ABSENT from $submenu. + $request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug ); + + // THE MISMATCH that caused the 403: + $this->assertNotSame( + $broken_registration_hookname, + $request_time_hookname, + 'Test methodology error: hide-by-unset must produce a hookname mismatch. ' + . 'If this assertion fails the discriminator is broken.' + ); + + // Specifically: request-time falls back to admin_page_* orphan namespace. + $this->assertSame( + 'admin_page_' . $slug, + $request_time_hookname, + 'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.' + ); + $this->assertSame( + 'prautoblogger_page_' . $slug, + $broken_registration_hookname, + 'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.' + ); + } +} diff --git a/tests/unit/Admin/DossierMenuRegistrationTest.php b/tests/unit/Admin/DossierMenuRegistrationTest.php index ef62cba..1d79e5c 100644 --- a/tests/unit/Admin/DossierMenuRegistrationTest.php +++ b/tests/unit/Admin/DossierMenuRegistrationTest.php @@ -1,341 +1,25 @@ Call sequence spy. */ - private array $call_sequence = []; - - /** - * Captured parent slug from the most recent add_submenu_page call. - * - * @var string|null - */ - private ?string $captured_parent_slug = null; - - /** - * Captured menu slug from the most recent add_submenu_page call. - * - * @var string|null - */ - private ?string $captured_menu_slug = null; - - /** - * Hookname returned by the most recent add_submenu_page call. - * - * In real WordPress this is `{parent_key}_page_{slug}` when parent is known, - * or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php - * children WordPress always emits `admin_page_{slug}`. - * - * @var string|null - */ - private ?string $registration_hookname = null; - - protected function setUp(): void { - parent::setUp(); - $this->call_sequence = []; - $this->captured_parent_slug = null; - $this->captured_menu_slug = null; - $this->registration_hookname = null; - - // Spy stub: tracks call order, captures slugs, returns a hookname that - // reflects how WordPress actually resolves it for known parents. - Functions\when( 'add_menu_page' )->alias( - function () { - $this->call_sequence[] = 'add_menu_page'; - return 'prautoblogger-settings'; - } - ); - - // Simulate WordPress hookname computation: - // - options.php parent → 'admin_page_{slug}' (WP built-in; options.php children always resolve here) - // - known plugin parent → '{parent_key}_page_{slug}' - // - unknown parent → 'admin_page_{slug}' (orphan fallback) - Functions\when( 'add_submenu_page' )->alias( - function ( - string $parent_slug, - string $page_title, - string $menu_title, - string $capability, - string $menu_slug, - $callback = '' - ) { - $this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug; - $this->captured_parent_slug = $parent_slug; - $this->captured_menu_slug = $menu_slug; - - // Replicate WordPress hookname logic: - // options.php-parent pages always resolve to admin_page_{slug}. - // Plugin-parent pages (like prautoblogger-settings) resolve to - // {parent_key}_page_{slug} at registration time. - if ( 'options.php' === $parent_slug ) { - $hookname = 'admin_page_' . $menu_slug; - } else { - // Derive parent key: strip .php extension if present, then - // replace - with _ for the namespace prefix. - $parent_key = str_replace( '.php', '', $parent_slug ); - $hookname = $parent_key . '_page_' . $menu_slug; - } - - $this->registration_hookname = $hookname; - - // Populate $submenu to simulate what WordPress does at registration time. - // This is the state that get_admin_page_parent() scans at request time. - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation. - global $submenu; - $submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array(); - $submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title ); - - return $hookname; - } - ); - - Functions\when( 'current_user_can' )->justReturn( true ); - Functions\when( 'get_option' )->justReturn( false ); - Functions\when( 'admin_url' )->alias( - function ( string $path = '' ) { - return 'https://example.com/wp-admin/' . ltrim( $path, '/' ); - } - ); - Functions\when( '__' )->returnArg( 1 ); - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation. - $GLOBALS['submenu'] = array(); - } - - protected function tearDown(): void { - $this->call_sequence = []; - $this->captured_parent_slug = null; - $this->captured_menu_slug = null; - $this->registration_hookname = null; - unset( $GLOBALS['submenu'] ); - parent::tearDown(); - } - - // ========================================================================= - // § REQUEST-TIME HOOKNAME FAITHFULNESS - // The primary regression guard for the 403 incident class. - // ========================================================================= - - /** - * Simulate the request-time hookname resolution algorithm. - * - * Replicates get_admin_page_parent() + the hookname derivation in - * wp-admin/admin.php::wp_admin_canonical_url() / do_action("admin_{$page}"): - * - * scan $submenu for the slug - * if found at parent P → return hookname derived from P - * if not found → return "admin_page_{slug}" - * - * This is the discriminator function: it WILL return a different hookname than - * add_submenu_page returned when the slug has been unset from $submenu. - * - * @param array>> $submenu_state $submenu after all registration + mutations. - * @param string $slug Page slug to resolve. - * @return string Request-time hookname. - */ - private function simulate_request_time_hookname( array $submenu_state, string $slug ): string { - foreach ( $submenu_state as $parent => $items ) { - foreach ( $items as $item ) { - if ( isset( $item[2] ) && $slug === $item[2] ) { - // Found the slug; derive hookname the same way as registration. - if ( 'options.php' === $parent ) { - return 'admin_page_' . $slug; - } - $parent_key = str_replace( '.php', '', $parent ); - return $parent_key . '_page_' . $slug; - } - } - } - // Slug absent from $submenu -- WP falls back to the orphan namespace. - return 'admin_page_' . $slug; - } - - /** - * REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname. - * - * This test directly discriminates the two patterns: - * - * HIDE-BY-UNSET (v0.19.2, BROKEN): - * - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier' - * - unset($submenu['prautoblogger-settings'][$idx]) - * - post-registration $submenu does NOT contain the slug - * - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier' - * - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state) - * - * OPTIONS.PHP-PARENT (v0.19.3, FIXED): - * - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier' - * - no $submenu mutation - * - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier' - * - MATCH → PASS ✓ - * - * This test ALSO fails if any future change reintroduces $submenu mutation, - * or switches back to a plugin-owned parent without preserving the submenu entry. - */ - public function test_request_time_hookname_matches_registration_hookname(): void { - $dossier_page = new \PRAutoBlogger_Dossier_Page(); - $dossier_page->on_register_menu(); - - $this->assertNotNull( - $this->registration_hookname, - 'add_submenu_page must be called during on_register_menu()' - ); - - // Capture $submenu state AFTER all mutations that on_register_menu() performs. - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read. - global $submenu; - $submenu_after = $submenu; - - $request_time_hookname = $this->simulate_request_time_hookname( - $submenu_after, - \PRAutoBlogger_Dossier_Page::PAGE_SLUG - ); - - $this->assertSame( - $this->registration_hookname, - $request_time_hookname, - sprintf( - 'Hookname mismatch: registration="%s", request-time="%s". ' - . 'This is the 403 failure class. ' - . 'The hide-by-unset pattern unsets the slug from $submenu after registration, ' - . 'so get_admin_page_parent() fails at request time, WP recomputes under ' - . 'the orphan "admin_page_*" namespace, finds no registered handler, ' - . 'and calls wp_die(403). Fix: use options.php as parent -- hookname is ' - . '"admin_page_{slug}" at both registration and request time, no mutation needed.', - $this->registration_hookname, - $request_time_hookname - ) - ); - } - - /** - * REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the - * request-time test above. - * - * Directly simulates the broken implementation, applies simulate_request_time_hookname, - * and asserts the MISMATCH -- proving the discriminator is effective. - * - * This test documents the failure state and serves as evidence that the test methodology - * is correct. It passes by verifying the broken pattern produces the wrong hookname. - */ - public function test_hide_by_unset_would_fail_request_time_check(): void { - // --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION --- - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation. - global $submenu; - - // Step 1: Registration (add_submenu_page with plugin parent). - // Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier' - // when parent is 'prautoblogger-settings'. Populate $submenu as WP does. - $broken_parent_slug = 'prautoblogger-settings'; - $slug = 'prautoblogger-dossier'; - - $submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array(); - $submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' ); - - // The hookname WP computed at registration time with plugin-owned parent: - $broken_registration_hookname = 'prautoblogger_page_' . $slug; - - // Step 2: The unset mutation (the bug). - // This is what v0.19.2's on_register_menu() did after add_submenu_page. - foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) { - if ( isset( $item[2] ) && $slug === $item[2] ) { - unset( $submenu[ $broken_parent_slug ][ $idx ] ); - break; - } - } - - // Step 3: Request time -- slug is now ABSENT from $submenu. - $request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug ); - - // THE MISMATCH that caused the 403: - $this->assertNotSame( - $broken_registration_hookname, - $request_time_hookname, - 'Test methodology error: hide-by-unset must produce a hookname mismatch. ' - . 'If this assertion fails the discriminator is broken.' - ); - - // Specifically: request-time falls back to admin_page_* orphan namespace. - $this->assertSame( - 'admin_page_' . $slug, - $request_time_hookname, - 'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.' - ); - $this->assertSame( - 'prautoblogger_page_' . $slug, - $broken_registration_hookname, - 'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.' - ); - } +class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase { // ========================================================================= // § STRUCTURAL INVARIANTS diff --git a/tests/unit/Admin/DossierMenuRegistrationTestCase.php b/tests/unit/Admin/DossierMenuRegistrationTestCase.php new file mode 100644 index 0000000..7f06bcb --- /dev/null +++ b/tests/unit/Admin/DossierMenuRegistrationTestCase.php @@ -0,0 +1,120 @@ + Call sequence spy. */ + protected array $call_sequence = []; + + /** + * Captured parent slug from the most recent add_submenu_page call. + * + * @var string|null + */ + protected ?string $captured_parent_slug = null; + + /** + * Captured menu slug from the most recent add_submenu_page call. + * + * @var string|null + */ + protected ?string $captured_menu_slug = null; + + /** + * Hookname returned by the most recent add_submenu_page call. + * + * In real WordPress this is `{parent_key}_page_{slug}` when parent is known, + * or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php + * children WordPress always emits `admin_page_{slug}`. + * + * @var string|null + */ + protected ?string $registration_hookname = null; + + protected function setUp(): void { + parent::setUp(); + $this->call_sequence = []; + $this->captured_parent_slug = null; + $this->captured_menu_slug = null; + $this->registration_hookname = null; + + // Spy stub: tracks call order, captures slugs, returns a hookname that + // reflects how WordPress actually resolves it for known parents. + Functions\when( 'add_menu_page' )->alias( + function () { + $this->call_sequence[] = 'add_menu_page'; + return 'prautoblogger-settings'; + } + ); + + // Simulate WordPress hookname computation: + // - options.php parent → 'admin_page_{slug}' + // - known plugin parent → '{parent_key}_page_{slug}' + // - unknown parent → 'admin_page_{slug}' (orphan fallback) + Functions\when( 'add_submenu_page' )->alias( + function ( + string $parent_slug, + string $page_title, + string $menu_title, + string $capability, + string $menu_slug, + $callback = '' + ) { + $this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug; + $this->captured_parent_slug = $parent_slug; + $this->captured_menu_slug = $menu_slug; + + if ( 'options.php' === $parent_slug ) { + $hookname = 'admin_page_' . $menu_slug; + } else { + $parent_key = str_replace( '.php', '', $parent_slug ); + $hookname = $parent_key . '_page_' . $menu_slug; + } + + $this->registration_hookname = $hookname; + + // Populate $submenu as WordPress does at registration time. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation. + global $submenu; + $submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array(); + $submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title ); + + return $hookname; + } + ); + + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\when( 'get_option' )->justReturn( false ); + Functions\when( 'admin_url' )->alias( + function ( string $path = '' ) { + return 'https://example.com/wp-admin/' . ltrim( $path, '/' ); + } + ); + Functions\when( '__' )->returnArg( 1 ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation. + $GLOBALS['submenu'] = array(); + } + + protected function tearDown(): void { + $this->call_sequence = []; + $this->captured_parent_slug = null; + $this->captured_menu_slug = null; + $this->registration_hookname = null; + unset( $GLOBALS['submenu'] ); + parent::tearDown(); + } +}