From fe25fdd6e4a586ee4bb83c4b226e3dc24311da5e Mon Sep 17 00:00:00 2001 From: Nikolay Strikhar Date: Thu, 13 Aug 2026 12:00:06 +0200 Subject: [PATCH] Resolve a standalone conflict end to end, every policy branch Seven scenarios that put a real basename into the real active_plugins option and let core's own deactivate_plugins() be what turns it off, so what is asserted is the option WordPress actually holds rather than a stub's recollection of being called. DEACTIVATE deactivates, queues the merge notice and redirects to an asserted destination; NOTICE_ONLY talks without acting; DEFER stands aside with the standalone's own guard constant already defined, which is the state wp-settings.php really leaves the request in. Two of them run a second request, because that is the only way to cover what this library is for: the merge takes one request to resolve and the next one to load. The second request is also where a redirect loop would show, and it is asserted not to halt rather than described as not looping. The capability gate gets a scenario of its own, from both sides. A subscriber resolves nothing and consumes nothing -- the standalone is still there to detect on the next request, from someone who can act on it, which is the half that would pass just as well if the gate had eaten the notice. The reactivation scenario drives the wp_admin_notice_markup filter through core's own dispatch rather than calling the rewriter, because the admin-only add_filter() is half of what has to work: a filter that never wired leaves core's useless sentence on the one screen least able to afford it. Each case is written up in tests/README with a mermaid diagram of its flow, including the policy branch the conflict step takes and the order the two gates are asked in. --- tests/README.md | 179 +++++++++++++++++++ tests/unit/Scenario/ConflictTest.php | 256 +++++++++++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 tests/unit/Scenario/ConflictTest.php diff --git a/tests/README.md b/tests/README.md index 6e332a5..dee0b20 100644 --- a/tests/README.md +++ b/tests/README.md @@ -536,3 +536,182 @@ sequenceDiagram Q-->>WP: draws it as notice-error Q->>Q: clears the queue ``` + +#### `Scenario/ConflictTest.php` — a standalone copy is still installed + +Each of these puts a real basename into the real `active_plugins` option, so +core's own `deactivate_plugins()` is what turns it off and the real option is +what says whether it worked. Priority 5 is the step under test, and which branch +it takes is the sub-plugin's `conflict_policy`: + +```mermaid +flowchart TD + A[plugins_loaded priority 5] --> B{request may resolve?} + B -- "not an admin GET, or carries an action" --> Z[return] + B -- yes --> C{standalone active?} + C -- no --> Z + C -- yes --> D{user may resolve?} + D -- "cannot activate_plugins" --> Z + D -- yes --> E{conflict_policy} + E -- DEFER --> Z + E -- NOTICE_ONLY --> F[queue a notice] + E -- "DEACTIVATE (default)" --> G[deactivate, queue, redirect, exit] +``` + +The gates are asked in that order on purpose: the detector reports and changes +nothing, so the cheap question goes in front of `current_user_can()`, which +resolves and caches the current user for the rest of the request. + +**DEACTIVATE deactivates, notifies and redirects.** The default policy, against +core's own `deactivate_plugins()` rather than a stub of it. The standalone +leaves `active_plugins`, a merge notice is queued, and the user is sent back to +re-render what they asked for now that the standalone's code is out of memory. +The destination is asserted, not merely that a redirect happened. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant R as Conflict Resolver + participant D as Plugin Deactivator + participant Q as Notice queue + participant Rd as Redirector + + Note over WP: active_plugins holds the standalone + WP->>R: plugins_loaded priority 5 + R->>D: deactivate( standalone ) — silent, network-aware + R->>Q: queue_merge_notice() + R->>Rd: after_deactivation( request URI ) + Rd-->>R: admin_url( 'plugins.php' ) + R->>WP: wp_safe_redirect() then exit + Note over WP: the load pass at priority 6 never runs +``` + +**The merge notice renders on the next admin screen, and clears.** All the way +to the screen. This notice is raised exactly once and never re-queued, so the +admin page load after the deactivation has to draw it — and consume it, or the +owner reads the same deactivation report for ever. It is a warning, not an +error: the library has already handled it. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant R as Conflict Resolver + participant Q as Notice queue + + WP->>R: request one — resolves and redirects + R->>Q: queue_merge_notice() + Note over WP,Q: request two, the screen the user landed on + WP->>Q: all_admin_notices + Q-->>WP: draws it as notice-warning + Q->>Q: clears the queue +``` + +**The request after a deactivation does not loop.** The failure mode a merge +notice queued on every request would produce: a redirect loop, or a screen +reporting the same deactivation for ever. Nothing is re-registered between the +two requests — a duplicate slug throws — because this is the next page view, not +a second bootstrap. The second request must *not* halt, and the helper fails the +test if it does. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant R as Conflict Resolver + participant L as Loader + + WP->>R: request one — deactivates, queues, redirects + Note over WP: the queue is emptied, so a second notice would be visible + WP->>R: request two + R->>R: no standalone active — nothing to resolve + WP->>L: plugins_loaded priority 6 + L->>L: with the standalone gone, the bundled copy takes over +``` + +**DEFER leaves the standalone active and loads nothing.** The policy hands the +request to the standalone. WordPress includes an active plugin from +`wp-settings.php` long before `plugins_loaded`, so by the time the resolver runs +the standalone has already defined the guard constant — which is what stands the +bundled copy down. Defining it up front is what makes this the scenario the +policy describes, rather than a resolver that merely declined to act. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant R as Conflict Resolver + participant L as Loader + + Note over WP: the standalone loaded from wp-settings.php and defined the guard + WP->>R: plugins_loaded priority 5 + R->>R: policy is DEFER — no-op + WP->>L: plugins_loaded priority 6 + L->>L: the guard is defined — stand down + Note right of L: standalone still active, nothing queued +``` + +**NOTICE_ONLY notifies without deactivating.** A policy that only talks must not +end the request, which is what the non-halting helper asserts. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant R as Conflict Resolver + participant Q as Notice queue + + WP->>R: plugins_loaded priority 5 + R->>Q: queue_conflict_notice() — the host's own sentence + R-->>WP: returns; no deactivation, no redirect + Note over WP: the standalone is still in active_plugins +``` + +**A user who cannot activate plugins resolves nothing.** The gate that survives +every policy and every rebinding: whoever cannot activate a plugin must not be +able to deactivate one by loading an admin page. Nothing is consumed by +refusing — the standalone is still there to detect on the next request, from +someone who can act on it, which is what the second half asserts. + +```mermaid +sequenceDiagram + autonumber + participant WP as WordPress + participant G as Gatekeeper + participant R as Conflict Resolver + + Note over WP: signed in as a subscriber + WP->>G: user_may_resolve() + G-->>WP: false — cannot activate_plugins + WP--xR: no resolver is built + Note over WP: same site, now signed in as an administrator + WP->>G: user_may_resolve() + G-->>WP: true + WP->>R: resolve_all() — deactivates and queues +``` + +**A reactivation attempt yields the friendly message.** The one conflict the +load guard cannot prevent: the owner reinstalls the standalone and presses +Activate, WordPress includes it on top of the bundled copy, and the +re-declaration is a real fatal that core's sandbox reports as "the plugin +triggered a fatal error" — true, and useless. All the library gets to do is +reword the sentence. Driven through core's own filter dispatch rather than by +calling the rewriter, because the admin-only `add_filter()` is half of what has +to work. The notice box stays core's — its classes, its dismiss button, its +wrapper; only the sentence inside is ours. + +```mermaid +sequenceDiagram + autonumber + participant Owner + participant WP as WordPress + participant Rw as Conflict Rewriter + + Owner->>WP: presses Activate on the standalone + WP->>WP: sandbox includes it — re-declaration fatal + WP->>WP: redirects to plugins.php with plugin and _error_nonce + WP->>Rw: wp_admin_notice_markup filter + Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies + Rw-->>WP: core's sentence swapped for the host's, wrapper untouched +``` diff --git a/tests/unit/Scenario/ConflictTest.php b/tests/unit/Scenario/ConflictTest.php new file mode 100644 index 0000000..c057464 --- /dev/null +++ b/tests/unit/Scenario/ConflictTest.php @@ -0,0 +1,256 @@ +fatal error.'; + + /** + * The notice core is about to print, as `wp_admin_notice_markup` hands it over. + * + * @var string + */ + private const MARKUP = '

' . self::CORE_TEXT . '

'; + + /** + * Here rather than in the parent because only this file builds an activation-error request: a + * `$_GET` left standing would make a later test look like one, and the rewrite would fire on a + * screen that never asked for it. + * + * @return void + */ + public function tearDown(): void { + unset( $_GET['plugin'], $_GET['_error_nonce'] ); + + parent::tearDown(); + } + + /** + * The default policy, against core's own `deactivate_plugins()` and the real `active_plugins` + * option rather than a stub of either. + */ + public function test_deactivate_deactivates_notifies_and_redirects(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEACTIVATE, + ] + ); + + $this->boot(); + + $location = $this->run_halted_request(); + + $this->assertNotContains( self::STANDALONE, $this->active_plugins() ); + $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() ); + + // The destination, not merely that one was asked for: a redirect somewhere else entirely + // would satisfy "the request ended in a redirect" without sending anyone anywhere useful. + $this->assertSame( admin_url( 'plugins.php' ), $location ); + + // The request really ended in the resolver. The bundled copy loads on the next one, which is + // what the standalone's own guard constant forces in production. + $this->assertSame( 0, $this->bundled_plugin_loads() ); + } + + /** + * All the way to the screen. The merge notice is the one this library raises exactly once and + * never re-queues, so the admin page load after the deactivation has to draw it — and consume it, + * or the owner reads the same deactivation report for ever. + */ + public function test_the_merge_notice_renders_on_the_next_admin_screen_and_clears(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + $this->boot(); + $this->run_halted_request(); + + $rendered = $this->render_admin_notices(); + + $this->assertStringContainsString( self::SLUG, $rendered ); + $this->assertStringContainsString( 'has been deactivated', $rendered ); + $this->assertStringContainsString( + 'notice-warning', + $rendered, + 'A conflict the library has already handled is a warning, not an error.' + ); + $this->assertSame( [], $this->queued_notices(), 'Rendering consumes the queue.' ); + } + + /** + * The failure mode a merge notice queued on every request would produce: a redirect loop, or an + * admin screen that reports the same deactivation for ever. Nothing is re-registered between the + * two requests — a duplicate slug throws — because this is the next page view, not a second + * bootstrap. + */ + public function test_the_request_after_a_deactivation_does_not_loop(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $constant = $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + $this->boot(); + $this->run_halted_request(); + + $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() ); + + // The owner has been told. Emptying the queue is what makes a second notice visible at all: + // re-queuing writes the same `slug:merge` key, so a queue left as it is would look identical + // whether or not the resolver ran again. + $this->clear_notices(); + + // This one must not halt, and run_request() fails the test if it does — which is the + // redirect loop, asserted rather than described. + $this->run_request(); + + $this->assertSame( [], $this->queued_notices(), 'Nothing is left to resolve, so nothing is left to say.' ); + $this->assertSame( 1, $this->bundled_plugin_loads(), 'With the standalone gone the bundled copy takes over.' ); + $this->assertTrue( defined( $constant ) ); + } + + /** + * DEFER hands the request to the standalone, and WordPress includes an active plugin from + * wp-settings.php long before plugins_loaded — so by the time the resolver runs, the standalone + * has already defined the guard constant. Defining it up front is what makes this the scenario + * the policy actually describes rather than a resolver that merely declined to act. + */ + public function test_defer_leaves_the_standalone_active_and_loads_nothing(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $constant = $this->define_guard( 'ABSORBER_E2E_DEFERRED_GUARD' ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::DEFER, + ], + $constant + ); + + $this->boot(); + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins() ); + $this->assertSame( 0, $this->bundled_plugin_loads(), 'The standalone won; the guard stands the bundled copy down.' ); + $this->assertSame( [], $this->queued_notices() ); + } + + /** + * A policy that only talks: the standalone stays exactly where it was, and the host's own sentence + * is what the owner is left with. + */ + public function test_notice_only_notifies_without_deactivating(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_policy' => Conflict_Policy::NOTICE_ONLY, + 'conflict_notice_message' => static fn() => 'Deactivate the standalone when you get a chance.', + ] + ); + + $this->boot(); + + // A policy that only talks must not end the request, which is what run_request() asserts. + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins() ); + $this->assertSame( + [ self::SLUG . ':conflict' => 'Deactivate the standalone when you get a chance.' ], + $this->queued_notices() + ); + } + + /** + * The gate that survives every policy and every rebinding: whoever cannot activate a plugin must + * not be able to deactivate one by loading an admin page. Nothing is consumed by refusing — the + * standalone is still there to detect on the next request, from someone who can act on it, which + * is what the second half asserts. + */ + public function test_a_user_who_cannot_activate_plugins_resolves_nothing(): void { + update_option( 'active_plugins', [ self::STANDALONE ] ); + + $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] ); + + wp_set_current_user( $this->create_user( 'subscriber' ) ); + + $this->boot(); + $this->run_request(); + + $this->assertContains( self::STANDALONE, $this->active_plugins(), 'A subscriber must not deactivate anything.' ); + $this->assertSame( [], $this->queued_notices(), 'A user who could never read the notice must not consume it.' ); + + $this->become_plugin_administrator(); + + $this->run_halted_request(); + + $this->assertNotContains( self::STANDALONE, $this->active_plugins() ); + $this->assertArrayHasKey( self::SLUG . ':merge', $this->queued_notices() ); + } + + /** + * The conflict the load guard cannot prevent: the owner reinstalls the standalone and presses + * Activate, WordPress includes it on top of the bundled copy, and the re-declaration is a real + * fatal that core's sandbox reports as "the plugin triggered a fatal error" — true, and useless. + * + * Driven through `Absorber::boot()` and core's own filter dispatch rather than by calling + * `Conflict\Rewriter` directly, because the wiring is half of what has to work: an admin-only + * `add_filter()` that never ran leaves the useless sentence on the screen. + */ + public function test_a_reactivation_attempt_yields_the_friendly_message(): void { + $this->register( + [ + 'standalone_plugin_basename' => self::STANDALONE, + 'conflict_notice_message' => static fn() => 'Recurring is already bundled with the host plugin.', + ] + ); + + $this->boot(); + + // The request core redirects to once the sandboxed activation has fataled. + $_GET['plugin'] = self::STANDALONE; + $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . self::STANDALONE ); + + $rewritten = apply_filters( 'wp_admin_notice_markup', self::MARKUP, self::CORE_TEXT, [] ); + + $this->assertIsString( $rewritten, 'The filter must hand back markup, whatever it did with it.' ); + + $filtered = is_string( $rewritten ) ? $rewritten : ''; + + $this->assertStringContainsString( 'Recurring is already bundled with the host plugin.', $filtered ); + $this->assertStringNotContainsString( self::CORE_TEXT, $filtered ); + + // The notice box stays core's to draw — its classes, its dismiss button, its wrapper. Only + // the sentence inside belongs to this library. + $this->assertStringStartsWith( '

', $filtered ); + } +}