>> $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}.' ); } }