fix: dossier page 403 — options.php-parent pattern (v0.19.3)

Dossier page dead on prod (403) since v0.19.2 deploy. Root cause:
on_register_menu() registered under 'prautoblogger-settings' parent then
unset the slug from $submenu to hide the nav entry. At request time
get_admin_page_parent() scanned $submenu, found nothing, fell back to
orphan hookname 'admin_page_prautoblogger-dossier'. Render callback was
registered under 'prautoblogger_page_prautoblogger-dossier'. Mismatch ->
wp_die(403). Same hookname-mismatch class as the board 404 (v0.19.1).

Fix (seq 21 §Required fix items 1-6):

1. options.php-parent: add_submenu_page('options.php', ...) -> hookname
   is 'admin_page_prautoblogger-dossier' at BOTH registration and request
   time. Hidden by construction; no $submenu mutation needed or permitted.
   Add PARENT_SLUG = 'options.php' constant.

2. Asset enqueue gate: updated from 'prautoblogger_page_*' to
   'admin_page_*' prefix to match options.php-parent hookname.

3. Deep-link URLs (admin.php?page=prautoblogger-dossier&post_id=N):
   parent-agnostic; unchanged. Asserted in
   test_url_for_post_contains_slug_and_post_id +
   test_url_for_post_exact_shape.

4. Regression test upgraded to request-time faithful:
   test_request_time_hookname_matches_registration_hookname simulates
   get_admin_page_parent() against post-registration $submenu state.
   FAILS on v0.19.2 hide-by-unset (4 failures, captured in evidence):
     - test_request_time_hookname_matches_registration_hookname FAIL
     - test_dossier_parent_slug_is_options_php FAIL
     - test_no_submenu_mutation_after_registration FAIL
     - test_asset_enqueue_gate_uses_admin_page_prefix FAIL
   PASSES on v0.19.3 options.php-parent (9/9 OK, captured in evidence).
   test_hide_by_unset_would_fail_request_time_check documents broken
   pattern inline.

5. ARCHITECTURE.md §22b: hidden-page convention cross-reference + v0.19.3
   incident record added. §23: page registration description updated to
   options.php-parent. File tree entry updated.
   CONVENTIONS.md §Hidden Admin Pages: full rule + both incident history
   (board 404 v0.19.1, dossier 403 v0.19.2) + test methodology.

6. Version 0.19.2 -> 0.19.3, CHANGELOG entry. tests/bootstrap.php:
   PRAUTOBLOGGER_PLUGIN_URL + PRAUTOBLOGGER_VERSION constants added
   (resolves 'Undefined constant' in enqueue tests).

Deletion audit: removed lines are the broken 'prautoblogger-settings'
parent string, the 12-line $submenu mutation block, the wrong hook prefix
'prautoblogger_page_*'. No other logic removed.

Agent-Session: dossier-403-hotfix-20260612
This commit is contained in:
peptiderepo
2026-06-12 11:18:15 +00:00
parent 9c8e500244
commit ae32876b6b
7 changed files with 654 additions and 106 deletions

View File

@@ -502,3 +502,73 @@ public function my_operation( $input ) {
```
That's it. No config, no feature-flag checks, no try/catch needed.
---
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
**options.php-parent pattern** exclusively:
```php
add_submenu_page(
'options.php', // parent: built-in WP page; pages hidden by construction
__( 'My Page', 'prautoblogger' ), // page title
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
'manage_options', // capability
'my-page-slug', // page slug
array( $this, 'render_page' ), // callback
);
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
```
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
### Why the hide-by-unset pattern causes 403 (the incident class)
WordPress computes the page hookname at two distinct moments:
**Registration time** (inside `add_submenu_page`):
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
**Request time** (inside `wp-admin/admin.php`):
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
### Incident history
| Version | Symptom | Root cause | Fix |
|---------|---------|------------|-----|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
### Test pattern
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
Apply the same test strategy to any new hidden admin page.
### Asset enqueue gate
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
```php
// options.php parent → 'admin_page_{slug}'
public function on_enqueue_assets( string $hook_suffix ): void {
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
return;
}
// ... enqueue
}
```