v0.19.3 - hotfix: dossier 403 (hidden-page pattern -> options.php parent) (#159)

Dossier registers under options.php parent (stable admin_page_* hookname at
registration AND request time); hide-by-unset removed; enqueue gate fixed;
request-time-faithful regression tests (fail on v0.19.2 by construction);
hidden-admin-page convention codified in CONVENTIONS.md.

QA: REJECT-P1 (ae32876, test file 551 lines) -> split -> APPROVE (6689126).
Verdicts: qa-reviews/prautoblogger/2026-06-12-{ae32876,6689126}.md
Thread: pipeline-v2-phase2-admin seq 21-28

Agent-Session: cto-dossier-403-hotfix-merge-20260612
This commit is contained in:
peptiderepo
2026-06-12 18:41:46 +07:00
committed by GitHub
parent 9c8e500244
commit 5c3328dee2
9 changed files with 681 additions and 131 deletions

View File

@@ -0,0 +1,198 @@
<?php
/**
* Tests for Dossier admin page hookname faithfulness — request-time mechanism.
*
* ## Why the v0.19.2 hide-by-unset pattern caused a 403
*
* WordPress computes a page's hook suffix at TWO separate moments:
*
* 1. Registration time (inside add_submenu_page):
* WP resolves the parent slug against $admin_page_hooks. If found, the hookname
* becomes "{parent_key}_page_{slug}" (e.g. "prautoblogger_page_prautoblogger-dossier").
*
* 2. Request time (inside wp-admin/admin.php):
* WP calls get_admin_page_parent() which scans $submenu for the slug. If not found
* (because it was unset post-registration), it falls back to "admin_page_{slug}".
*
* When these two hooknames diverge, the render callback lives under name-A but WP
* dispatches to name-B -- no handler found -- wp_die(403).
*
* The fix: options.php-parent guarantees "admin_page_{slug}" at BOTH moments.
* No $submenu mutation is needed or permitted.
*
* ## Discriminator function: simulate_request_time_hookname
*
* Replicates get_admin_page_parent() + hookname derivation in wp-admin/admin.php.
* Returns different hooknames for hide-by-unset vs options.php-parent, making the
* 403 divergence mechanically verifiable in a unit test.
*
* Structural invariant tests (parent slug, URL shape, priority) live in
* DossierMenuRegistrationTest.
*
* @see DossierMenuRegistrationTest -- structural invariants + deep-link URLs + priority.
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
class DossierHooknameMechanismTest extends DossierMenuRegistrationTestCase {
// =========================================================================
// § 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<string, array<int, array<int, string>>> $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}.'
);
}
}

View File

@@ -1,76 +1,203 @@
<?php
/**
* Tests for Article Dossier admin page menu registration.
* Tests for Dossier admin page menu registration — structural invariants.
*
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
* any submenu registered BEFORE add_menu_page fires will resolve under the
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
* a wp_die 404 at request time.
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
*
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
* registration) at priority 12 -- after board (11) and parent (10).
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
*
* @see DossierHooknameMechanismTest -- request-time discriminator + regression proof.
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
* @see CONVENTIONS.md -- §Hidden Admin Pages.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
class DossierMenuRegistrationTest extends BaseTestCase {
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
/** @var array<int, string> Call sequence spy. */
private array $call_sequence = [];
// =========================================================================
// § STRUCTURAL INVARIANTS
// =========================================================================
protected function setUp(): void {
parent::setUp();
$this->call_sequence = [];
/**
* Parent slug must be PARENT_SLUG constant ('options.php').
*
* FAILS on v0.19.2 (parent was 'prautoblogger-settings').
* PASSES after v0.19.3 fix (parent is 'options.php').
*/
public function test_dossier_parent_slug_is_options_php(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
Functions\when( 'add_menu_page' )->alias(
function () {
$this->call_sequence[] = 'add_menu_page';
return 'prautoblogger-settings';
}
);
Functions\when( 'add_submenu_page' )->alias(
function ( string $parent_slug, string $page_title, string $menu_title, string $capability, string $menu_slug ) {
$this->call_sequence[] = 'add_submenu_page:' . $menu_slug;
return 'prautoblogger_page_' . $menu_slug;
}
$this->assertSame(
'options.php',
$this->captured_parent_slug,
'Dossier must use options.php as parent (canonical hidden-page pattern). '
. 'Never use a plugin-owned parent with post-registration $submenu unset -- '
. 'that is the v0.19.2 403 bug class. See CONVENTIONS.md §Hidden Admin Pages.'
);
Functions\when( 'current_user_can' )->justReturn( true );
Functions\when( 'get_option' )->justReturn( false );
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
} );
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
$GLOBALS['submenu'] = array();
}
protected function tearDown(): void {
$this->call_sequence = [];
unset( $GLOBALS['submenu'] );
parent::tearDown();
$this->assertSame(
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
$this->captured_parent_slug,
'Captured parent slug must match PARENT_SLUG constant.'
);
}
/**
* Dossier on_register_menu must be hooked at priority > board (11).
* Mirrors the bindings in register_admin_hooks():
* parent at 10, board at 11, dossier at 12.
* No $submenu mutation after registration.
*
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
* options.php-parent pages are hidden by construction; the slug must remain
* in $submenu so get_admin_page_parent() can resolve the parent at request time.
*
* FAILS on v0.19.2 (slug was unset from $submenu after registration).
* PASSES after v0.19.3 fix (no mutation).
*/
public function test_dossier_hook_registered_after_board(): void {
public function test_no_submenu_mutation_after_registration(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
global $submenu;
// The dossier slug must still be present somewhere in $submenu.
$slug_found = false;
foreach ( $submenu as $parent => $items ) {
foreach ( $items as $item ) {
if ( isset( $item[2] ) && \PRAutoBlogger_Dossier_Page::PAGE_SLUG === $item[2] ) {
$slug_found = true;
break 2;
}
}
}
$this->assertTrue(
$slug_found,
'Dossier slug must remain in $submenu after on_register_menu() completes. '
. 'Unsetting it causes get_admin_page_parent() to fail at request time and '
. 'produces the admin_page_* orphan hookname -- the v0.19.2 403 bug class. '
. 'options.php-parent pages are hidden by construction; no mutation needed.'
);
}
/**
* Page slug must be PAGE_SLUG constant ('prautoblogger-dossier').
*/
public function test_dossier_uses_correct_slug(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$this->assertSame(
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
$this->captured_menu_slug,
'Dossier must register under PAGE_SLUG.'
);
}
/**
* Asset enqueue gate must use 'admin_page_' prefix (not 'prautoblogger_page_').
*
* options.php-parent pages always resolve to 'admin_page_{slug}'.
* FAILS on v0.19.2 (gate checked 'prautoblogger_page_prautoblogger-dossier').
* PASSES after v0.19.3 fix (gate checks 'admin_page_prautoblogger-dossier').
*/
public function test_asset_enqueue_gate_uses_admin_page_prefix(): void {
Functions\when( 'wp_enqueue_style' )->justReturn( null );
Functions\when( 'wp_enqueue_script' )->justReturn( null );
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$expected_hook = 'admin_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG;
// Wrong hook -- assets must NOT enqueue.
$enqueued = false;
Functions\when( 'wp_enqueue_style' )->alias( function () use ( &$enqueued ) {
$enqueued = true;
} );
$dossier_page->on_enqueue_assets( 'wrong_hook_prautoblogger_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG );
$this->assertFalse(
$enqueued,
'Assets must NOT enqueue when hook_suffix is the old prautoblogger_page_* hook.'
);
// Correct hook -- assets must enqueue.
$dossier_page->on_enqueue_assets( $expected_hook );
$this->assertTrue(
$enqueued,
sprintf(
'Assets must enqueue when hook_suffix is "%s" (admin_page_* from options.php parent).',
$expected_hook
)
);
}
// =========================================================================
// § DEEP-LINK URL INVARIANCE
// =========================================================================
/**
* url_for_post() must produce admin.php?page=prautoblogger-dossier&post_id=N.
*
* Deep-link URLs are parent-agnostic -- they reference only the page slug.
* The fix changes only the registration parent; the URL shape is unchanged.
*/
public function test_url_for_post_contains_slug_and_post_id(): void {
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
$this->assertStringContainsString(
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
$url,
'url_for_post() must contain the dossier page slug.'
);
$this->assertStringContainsString(
'42',
$url,
'url_for_post() must contain the post_id.'
);
$this->assertStringContainsString(
'admin.php',
$url,
'url_for_post() must use admin.php (parent-agnostic deep-link).'
);
}
/**
* url_for_post() URL shape is unchanged from v0.19.2.
*
* Confirms the exact URL format that board cards and the metabox link depend on.
*/
public function test_url_for_post_exact_shape(): void {
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 930 );
// Must contain admin.php?page=prautoblogger-dossier&post_id=930
// (or equivalent query string order -- admin_url may vary order).
$this->assertStringContainsString( 'page=prautoblogger-dossier', $url );
$this->assertStringContainsString( 'post_id=930', $url );
}
// =========================================================================
// § HOOK REGISTRATION ORDERING (priority guard)
// =========================================================================
/**
* Dossier on_register_menu must remain at priority 12 (after board 11, parent 10).
*
* The options.php-parent fix removes the dependency on ordering relative to the
* plugin's own parent menu (since we no longer add a submenu under it), but we
* keep the priority assertion to prevent accidental priority regression.
*/
public function test_dossier_hook_registered_at_priority_12(): void {
$admin_page = new \PRAutoBlogger_Admin_Page();
$board_page = new \PRAutoBlogger_Board_Page();
$dossier_page = new \PRAutoBlogger_Dossier_Page();
// Mirror class-prautoblogger.php register_admin_hooks() priorities.
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
@@ -94,56 +221,15 @@ class DossierMenuRegistrationTest extends BaseTestCase {
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
}
$this->assertGreaterThan( $parent_priority, $board_priority,
'Board priority must exceed parent priority.' );
$this->assertGreaterThan( $board_priority, $dossier_priority,
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
}
/**
* Dossier submenu slug must be 'prautoblogger-dossier'.
*/
public function test_dossier_uses_correct_slug(): void {
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$found_slug = null;
foreach ( $this->call_sequence as $entry ) {
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
$found_slug = substr( $entry, strlen( 'add_submenu_page:' ) );
break;
}
}
$this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug,
'Dossier must register under PAGE_SLUG.' );
}
/**
* Dossier submenu parent must be 'prautoblogger-settings'.
*/
public function test_dossier_parent_slug_is_settings(): void {
$captured_parent = null;
Functions\when( 'add_submenu_page' )->alias(
function ( string $parent_slug ) use ( &$captured_parent ) {
$captured_parent = $parent_slug;
return null;
}
$this->assertGreaterThan(
$parent_priority,
$board_priority,
'Board priority must exceed parent priority.'
);
$this->assertGreaterThan(
$board_priority,
$dossier_priority,
'Dossier priority must exceed board priority.'
);
$dossier_page = new \PRAutoBlogger_Dossier_Page();
$dossier_page->on_register_menu();
$this->assertSame( 'prautoblogger-settings', $captured_parent,
'Dossier must use prautoblogger-settings as parent.' );
}
/**
* url_for_post() returns a URL containing the dossier slug and post_id.
*/
public function test_url_for_post_contains_slug_and_post_id(): void {
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
$this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url );
$this->assertStringContainsString( '42', $url );
}
}

View File

@@ -0,0 +1,120 @@
<?php
/**
* Shared test harness for Dossier menu registration test suites.
*
* Provides the Brain\Monkey spy stubs (add_menu_page, add_submenu_page) and the
* $submenu isolation that both DossierMenuRegistrationTest and
* DossierHooknameMechanismTest depend on.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
/** @var array<int, string> 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();
}
}