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 ); + } +}