diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a7e08..9c0a7cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,13 @@ jobs: - name: Run the test suite with the WooCommerce store rules active run: composer run test:woocommerce + # Two of this plugin's storage decisions are only wrong on multisite — + # a network-wide usermeta key standing in for a per-site one, and a + # notice hook that does not fire in Network Admin. Neither is visible to + # a single-site run. + - name: Run the test suite as a multisite network + run: composer run test:multisite + test-woocommerce: name: WooCommerce integration runs-on: ubuntu-latest diff --git a/citecue.php b/citecue.php index 535b52e..5a509da 100644 --- a/citecue.php +++ b/citecue.php @@ -3,7 +3,7 @@ * Plugin Name: CiteCue AI Auto-Fix * Plugin URI: https://github.com/citecue/wordpress-plugin * Description: Serves CiteCue-optimized versions of your pages to AI bots and crawlers, adds CiteCue's enriched SEO metadata to your live pages, publishes your llms.txt, and lets CiteCue push brand-building draft content into WordPress. - * Version: 1.1.0 + * Version: 1.1.1 * Requires at least: 5.8 * Requires PHP: 7.4 * Author: CiteCue @@ -19,44 +19,126 @@ exit; } +/* + * Stand down for a pre-WordPress.org copy, which cannot stand down for us. + * + * Releases before the move to WordPress.org unpacked to citecue/, and the only + * one that ever did is 1.0.0 — which predates the guard below and so defines + * the constants and runs its requires unconditionally. The directory's copy + * installs as citecue-ai-auto-fix/, so a site carrying the old one gains a + * second plugin rather than an upgrade, and `require_once` does not save us: + * the two copies are two paths, so the second to load redeclares every class + * and takes the site down. + * + * Which of them is second is not a coin toss. activate_plugin() sorts + * active_plugins before storing it, '-' sorts before '/', so + * citecue-ai-auto-fix/citecue.php is always included first and citecue/ is + * always the one that fatals. The guard below therefore never gets the chance + * to fire in the case it was written for: by the time 1.0.0 runs, it is this + * copy's classes it is redeclaring, and 1.0.0 has no guard to check. + * + * So this copy yields instead. The site keeps running — on 1.0.0, which is the + * worse version but a working one — and the notice says which directory to + * delete to get this one back. Deleting it is also what makes this branch stop + * running, so the check confirms the file is really still there rather than + * trusting a stale active_plugins entry, which would strand the site on a copy + * that is no longer installed. + */ +$citecue_legacy_is_running = ( static function () { + $legacy = 'citecue/citecue.php'; + + if ( plugin_basename( __FILE__ ) === $legacy ) { + return false; + } + + $active = (array) get_option( 'active_plugins', array() ); + if ( is_multisite() ) { + $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); + } + + return in_array( $legacy, $active, true ) && file_exists( WP_PLUGIN_DIR . '/' . $legacy ); +} )(); + +if ( $citecue_legacy_is_running ) { + $citecue_legacy_notice = static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + // Duplicated in the guard below rather than shared through a helper: + // this is the one file that can legitimately be included twice, and a + // named function here is a redeclaration waiting to happen. + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || ( 'plugins' !== $screen->id && 'plugins-network' !== $screen->id ) ) { + return; + } + printf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: the older plugin directory, e.g. citecue/. 2: this plugin's directory. */ + __( 'CiteCue AI Auto-Fix is installed twice. An older copy in %1$s is the one WordPress is running, so the copy in %2$s has not loaded. Deactivate and delete the older one to switch to this version — your settings and connection are stored in the database and carry over untouched.', 'citecue-ai-auto-fix' ), + 'citecue/', + dirname( plugin_basename( __FILE__ ) ) . '/' + ) + ) + ); + }; + + add_action( 'admin_notices', $citecue_legacy_notice ); + add_action( 'network_admin_notices', $citecue_legacy_notice ); + + unset( $citecue_legacy_is_running, $citecue_legacy_notice ); + return; +} + +unset( $citecue_legacy_is_running ); + /* * Stand down if another copy of this plugin already loaded. * - * Releases before the move to WordPress.org shipped an archive that unpacked - * to citecue/. The directory's copy installs as citecue-ai-auto-fix/, so a - * site carrying the old one gains a second plugin rather than an upgrade — - * and `require_once` does not save us, because the two copies are two paths. - * Both would run their requires, the second would redeclare every class, and - * the site would go down with a fatal error on the next request. + * The case above is the one duplicate this plugin has actually shipped. This + * one catches the rest: a GitHub "Download ZIP" unpacks to + * wordpress-plugin-main/, and any directory sorting after + * citecue-ai-auto-fix/ loads second, sees the constant and does nothing — + * which turns a white screen into an admin notice naming the directory to + * delete. * - * The copy that loses the race does nothing and says so, which turns a white - * screen into an admin notice naming the directory to delete. + * The notice belongs on a Plugins screen and nowhere else: deleting a plugin + * directory is a Plugins-screen job, and nothing about this is urgent enough to + * follow an administrator through the rest of their dashboard. Both Plugins + * screens count, though — a network-activated copy can only be deactivated from + * Network Admin, and `admin_notices` does not fire there at all. */ if ( defined( 'CITECUE_VERSION' ) ) { - add_action( - 'admin_notices', - static function () { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; - } - printf( - '

%s

', - esc_html( - sprintf( - /* translators: 1: plugin file that is running, e.g. citecue/citecue.php. 2: duplicate plugin file that did not load. */ - __( 'CiteCue AI Auto-Fix is installed twice. WordPress is running %1$s, so the copy in %2$s did not load. Deactivate and delete whichever of the two you do not want to keep.', 'citecue-ai-auto-fix' ), - plugin_basename( CITECUE_PLUGIN_FILE ), - plugin_basename( __FILE__ ) - ) - ) - ); + $citecue_duplicate_notice = static function () { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen || ( 'plugins' !== $screen->id && 'plugins-network' !== $screen->id ) ) { + return; } - ); + printf( + '

%s

', + esc_html( + sprintf( + /* translators: 1: plugin file that is running, e.g. citecue/citecue.php. 2: duplicate plugin file that did not load. */ + __( 'CiteCue AI Auto-Fix is installed twice. WordPress is running %1$s, so the copy in %2$s did not load. Deactivate and delete whichever of the two you do not want to keep.', 'citecue-ai-auto-fix' ), + plugin_basename( CITECUE_PLUGIN_FILE ), + plugin_basename( __FILE__ ) + ) + ) + ); + }; + + add_action( 'admin_notices', $citecue_duplicate_notice ); + add_action( 'network_admin_notices', $citecue_duplicate_notice ); + unset( $citecue_duplicate_notice ); return; } -define( 'CITECUE_VERSION', '1.1.0' ); +define( 'CITECUE_VERSION', '1.1.1' ); define( 'CITECUE_PLUGIN_FILE', __FILE__ ); define( 'CITECUE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); diff --git a/composer.json b/composer.json index 78408fc..8fc94af 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,11 @@ "phpcbf": "phpcbf", "test": [ "@test:core", - "@test:woocommerce" + "@test:woocommerce", + "@test:multisite" ], "test:core": "phpunit", - "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit" + "test:woocommerce": "CITECUE_STUB_WOOCOMMERCE=1 phpunit", + "test:multisite": "WP_MULTISITE=1 phpunit" } } diff --git a/includes/class-citecue-admin.php b/includes/class-citecue-admin.php index 95f7447..78e0986 100644 --- a/includes/class-citecue-admin.php +++ b/includes/class-citecue-admin.php @@ -20,6 +20,19 @@ */ class Citecue_Admin { + /** + * User-option prefix recording a notice this user has dismissed. + * + * A user option rather than user meta, because on multisite the usermeta + * table is shared across the whole network while the condition these + * notices report on comes from per-site options. Stored as meta, one + * administrator dismissing the prompt on one site in a network would + * silence an unrelated, still-true prompt on all the others; + * update_user_option() prefixes the key with the current blog's, so each + * site gets its own answer. + */ + const DISMISSED_OPTION_PREFIX = 'citecue_dismissed_'; + /** * Plugin container. * @@ -45,6 +58,7 @@ public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'maybe_claim_connect' ) ); + add_action( 'admin_init', array( $this, 'maybe_dismiss_notice' ) ); add_action( 'admin_notices', array( $this, 'notices' ) ); add_action( 'admin_post_citecue_connect_start', array( $this, 'handle_connect_start' ) ); add_action( 'admin_post_citecue_disconnect', array( $this, 'handle_disconnect' ) ); @@ -119,17 +133,45 @@ private function redirect_with( $code ) { } /** - * Admin notices: action feedback plus a persistent auth-failure warning. + * Whether the screen being rendered is one of the plugin's own. + * + * This plugin has no business putting messages on the comment queue, the + * media library or anyone's post editor, so every notice it emits is gated + * on this. Two screens qualify: the settings page the message is about, and + * the Plugins list, which is where an administrator looks when a plugin + * needs attention and the only screen on which some of these are actionable. + * + * @return bool + */ + private function is_plugin_screen() { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + if ( ! $screen ) { + return false; + } + + $id = (string) $screen->id; + + return 'plugins' === $id || false !== strpos( $id, 'citecue' ); + } + + /** + * Admin notices: action feedback plus the two conditions worth interrupting + * an administrator over. + * + * Confined to the plugin's own screens — see is_plugin_screen(). Nothing + * here is urgent enough to follow someone around their whole dashboard, and + * the settings screen states all of it a second time in its status card, so + * a dismissed or unseen notice costs no information. * * @return void */ public function notices() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! current_user_can( 'manage_options' ) || ! $this->is_plugin_screen() ) { return; } if ( get_option( 'citecue_auth_failed' ) ) { - echo '

' . esc_html__( 'CiteCue:', 'citecue-ai-auto-fix' ) . ' ' + echo '

' . esc_html__( 'CiteCue:', 'citecue-ai-auto-fix' ) . ' ' . esc_html__( 'the API key was rejected, so optimized pages are not being served to AI crawlers. Update the key in the CiteCue settings.', 'citecue-ai-auto-fix' ) . ' ' . esc_html__( 'Open settings', 'citecue-ai-auto-fix' ) . '

'; } @@ -183,10 +225,11 @@ public function notices() { * CiteCue learns the capability only from the connect exchange, so a site * that connected before this release injects enriched metadata while the * app still reports the channel as unable to — and tells the customer their - * "Live" fix is not reaching human visitors. One reconnect fixes it. Shown - * on the Plugins and CiteCue screens only: it is worth acting on, but it is - * not an error, and it has no business following an administrator around - * their whole dashboard. + * "Live" fix is not reaching human visitors. One reconnect fixes it. + * + * It is advice, not an error, so it can be turned off for good: the same + * state is on the settings screen's status card either way, and a message + * an administrator has read and decided against should not keep arriving. * * @return void */ @@ -194,10 +237,7 @@ private function seo_head_reconnect_notice() { if ( ! $this->plugin->settings->needs_seo_head_reconnect() ) { return; } - - $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; - $here = $screen ? (string) $screen->id : ''; - if ( 'plugins' !== $here && false === strpos( $here, 'citecue' ) ) { + if ( $this->notice_is_dismissed( 'seo_head_reconnect' ) ) { return; } @@ -211,11 +251,79 @@ private function seo_head_reconnect_notice() {

-

action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?>

+

+ action_button( 'citecue_connect_start', __( 'Reconnect to CiteCue', 'citecue-ai-auto-fix' ) ); ?> + +

current_admin_url() ), + 'citecue_dismiss_' . $notice + ); + } + + /** + * The admin URL currently being rendered, for links that come back here. + * + * @return string + */ + private function current_admin_url() { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + return ( $screen && 'plugins' === $screen->id ) ? admin_url( 'plugins.php' ) : $this->settings_url(); + } + + /** + * Records a dismissal and reloads the screen without the query arguments. + * + * @return void + */ + public function maybe_dismiss_notice() { + if ( ! isset( $_GET['citecue_dismiss'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked below, once there is something to check it against. + return; + } + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $notice = sanitize_key( wp_unslash( $_GET['citecue_dismiss'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- checked on the next line. + check_admin_referer( 'citecue_dismiss_' . $notice ); + + if ( 'seo_head_reconnect' !== $notice ) { + return; + } + + update_user_option( get_current_user_id(), self::DISMISSED_OPTION_PREFIX . $notice, time() ); + + $back = wp_get_referer(); + wp_safe_redirect( $back ? remove_query_arg( array( 'citecue_dismiss', '_wpnonce' ), $back ) : $this->settings_url() ); + exit; + } + /** * Completes a handshake when CiteCue redirects back with a one-time code. * diff --git a/readme.txt b/readme.txt index 496f2d9..549dca5 100644 --- a/readme.txt +++ b/readme.txt @@ -1,27 +1,37 @@ === CiteCue AI Auto-Fix === Contributors: citecue -Tags: ai, llms.txt, gptbot, ai-seo, woocommerce +Tags: ai, ai-crawlers, gptbot, ai-seo, woocommerce Requires at least: 5.8 Tested up to: 7.0 Requires PHP: 7.4 -Stable tag: 1.1.0 +Stable tag: 1.1.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html -Serve AI-optimized versions of your pages to AI bots and crawlers, enrich your live pages' SEO metadata, publish your llms.txt, and receive draft content from CiteCue. +Answers AI crawlers with a CiteCue-optimized version of the page they asked for, and fills only the metadata gaps your SEO plugin leaves behind. == Description == -CiteCue AI Auto-Fix connects your WordPress site to CiteCue: +CiteCue AI Auto-Fix is the WordPress end of CiteCue. It decides, per request, which version of a page WordPress returns — the optimized one to a recognised AI crawler, your normal page to everyone else — and it can do that only from inside WordPress, before the theme renders. -* **AI crawler middleware** — when an AI bot or crawler (GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User and more) requests a page, the plugin serves the CiteCue-optimized version of that page. Human visitors always see your normal site. Any miss, timeout or outage passes straight through to the normal page. -* **Enriched page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: any tag WordPress, your theme or your SEO plugin already outputs is left exactly as it is, so there is never a second title or canonical. -* **llms.txt** — publishes the llms.txt file CiteCue generates for your brand at your site root. +* **Per-request delivery to AI crawlers** — when GPTBot, ClaudeBot, PerplexityBot, ChatGPT-User or any other agent in the crawler registry requests a page, the plugin returns the CiteCue-optimized version of that URL. Human visitors always see your normal site, and optimized responses are never cached for regular traffic. Any miss, timeout or outage passes straight through to the normal page. +* **Gap-filling page metadata** — adds CiteCue's title, meta description, OpenGraph, canonical and structured-data tags to your live pages, so search engines and AI answer engines see them on the page a human sees. It fills gaps only: it reads what your theme, WordPress and your SEO plugin actually printed into `` and adds only what none of them emitted, so there is never a second title or canonical. +* **llms.txt** — serves the llms.txt file CiteCue maintains for your brand at your site root, refreshed from CiteCue rather than regenerated here. * **Content from CiteCue** — a signed endpoint through which CiteCue can push new brand-building content (content briefs, FAQ packs, gap-filling pages) into WordPress as drafts for your review. * **WooCommerce-aware** — cart, checkout, account pages and cart-modifying links are never intercepted, while product and shop pages are served optimized. Pushed content can also create or enrich WooCommerce products (draft by default, matched by SKU with explicit consent). This plugin requires a CiteCue account (citecue.com) and does nothing until you connect one. See "External services" below for exactly what is sent where. += What the plugin actually does = + +llms.txt is one of the four features above, and CiteCue writes that file — the plugin serves it. The rest of the code is about what happens on a live request: + +* **It serves a different representation per requester, safely.** Crawler matching runs against a registry that refreshes daily, so an agent launched last week is recognised without a plugin update. A logged-in user, a cart URL, a WooCommerce endpoint or a cart-modifying link is never intercepted. A circuit breaker, a per-minute lookup budget, negative caching and a stale-while-revalidate cache mean an outage at CiteCue costs a passthrough, never a broken page or a slow one. +* **It composes with your SEO plugin rather than replacing it.** The metadata layer detects what was actually printed into `` rather than looking for particular plugins, so it behaves correctly beside Yoast, Rank Math, a plugin nobody has heard of, or none at all. Every tag it adds carries a `data-citecue` attribute, so View Source tells you exactly which ones came from CiteCue. +* **Nothing from the API is trusted as markup.** Every returned tag is parsed, matched against an allowlist of shapes and rebuilt from escaped values, with structured data re-encoded so it cannot escape its own script element. +* **It never makes a visitor wait on a third party.** The render path reads cache only; a URL with nothing cached yet renders untouched and the fetch is queued to WP-Cron. +* **Content flows back in.** The signed `citecue/v1` endpoint is how CiteCue delivers new content into WordPress — as drafts, with replayed signatures rejected — so the loop from "this page is missing" to "this page exists" closes without anyone copying and pasting. + == Installation == 1. Install and activate the plugin from Plugins → Add New, or upload it under Plugins → Add New → Upload Plugin. @@ -108,6 +118,9 @@ Yes. Store pages (cart, checkout, account, all WooCommerce endpoints) are never == Upgrade Notice == += 1.1.1 = +Fixes a fatal error on sites that still have the old citecue/ folder installed alongside this plugin. Admin notices now appear only on the Plugins and CiteCue screens. + = 1.1.0 = Adds enriched page metadata for live pages. Existing connections need one reconnect before CiteCue knows this site can do it — Settings → CiteCue will ask. @@ -116,6 +129,15 @@ The plugin folder is now citecue-ai-auto-fix. If you installed 1.0.0 by uploadin == Changelog == += 1.1.1 = +* Admin notices are confined to the Plugins screen and the CiteCue settings screen. The rejected-key warning and the duplicate-install warning used to print on every screen in the dashboard; neither asks for anything that can be done anywhere else, and the settings screen states both a second time in its status card. +* The reconnect prompt can now be dismissed permanently, per user and per site. It is advice rather than an error, and an administrator who has read it and decided against it should not keep being told. On multisite the dismissal is scoped to the site it was made on, since the condition it reports on is per-site while WordPress stores user metadata network-wide. +* Fixed a fatal error on a site that still has the pre-WordPress.org copy in a citecue/ folder alongside this one. That copy is 1.0.0, which predates the duplicate-install guard and loads its classes unconditionally — and WordPress always includes citecue-ai-auto-fix/ first, so 1.0.0 was always the copy that redeclared them and took the site down. This copy now stands aside for it and says which folder to delete, so the site keeps running either way. +* Both duplicate-install warnings now also appear in Network Admin → Plugins. A network-activated copy can only be removed from there, and the hook they were on does not fire anywhere in Network Admin — so on multisite the warning was missing from the one screen where a super admin could act on it. +* Uninstalling from a network now clears dismissal records for every site, not just the one running the uninstall. WordPress stores user metadata in a single table shared by the whole network, so anything left there is left for good. +* Uninstall removes the dismissal records along with everything else. +* readme: shorter summary, and the description now says plainly what this plugin does that a static llms.txt generator does not. + = 1.1.0 = * New: enriched page metadata. CiteCue's title, meta description, OpenGraph, canonical and structured-data tags are added to your live pages, so search engines and AI answer engines see them, not just AI crawlers. Uses CiteCue's `/api/delivery/v2/seo-head` endpoint. * Fills gaps only: anything WordPress, your theme or your SEO plugin already prints is left untouched, so the plugin never emits a second title or canonical. Detection reads the real `` output rather than looking for particular plugins. diff --git a/tests/cases/test-admin-notices.php b/tests/cases/test-admin-notices.php new file mode 100644 index 0000000..b737293 --- /dev/null +++ b/tests/cases/test-admin-notices.php @@ -0,0 +1,316 @@ +admin = new Citecue_Admin( $this->plugin ); + $this->user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $this->user_id ); + } + + /** + * @return void + */ + public function tear_down() { + $GLOBALS['current_screen'] = null; + parent::tear_down(); + } + + /** + * Everything the notices print while pretending to be on one screen. + * + * @param string $screen_id Screen to render as, e.g. 'dashboard'. + * @return string + */ + private function notices_on( $screen_id ) { + set_current_screen( $screen_id ); + + ob_start(); + $this->admin->notices(); + return (string) ob_get_clean(); + } + + /** + * Puts the site in the state that raises the auth-failure notice. + * + * @return void + */ + private function reject_the_key() { + $this->configure_delivery(); + update_option( 'citecue_auth_failed', time() ); + } + + /** + * Puts the site in the state that raises the reconnect notice: connected, + * injecting metadata, and CiteCue never told. + * + * @return void + */ + private function owe_a_reconnect() { + $this->configure_delivery( + array( + 'seo_head_enabled' => true, + 'seo_head_reported' => false, + ) + ); + } + + /** + * @return void + */ + public function test_the_auth_failure_reaches_the_settings_screen() { + $this->reject_the_key(); + + $this->assertStringContainsString( 'the API key was rejected', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * The Plugins screen is where an administrator goes when a plugin needs + * attention, so it is in scope even though it is not the plugin's page. + * + * @return void + */ + public function test_the_auth_failure_reaches_the_plugins_screen() { + $this->reject_the_key(); + + $this->assertStringContainsString( 'the API key was rejected', $this->notices_on( 'plugins' ) ); + } + + /** + * The one that mattered: an error condition that persists until someone + * fixes it used to print on every admin screen there is. + * + * @dataProvider unrelated_screens + * @param string $screen_id Screen that is none of the plugin's business. + * @return void + */ + public function test_no_notice_follows_the_administrator_elsewhere( $screen_id ) { + $this->reject_the_key(); + $this->owe_a_reconnect(); + + $this->assertSame( '', $this->notices_on( $screen_id ) ); + } + + /** + * Screens the plugin has nothing to say on. + * + * @return array + */ + public function unrelated_screens() { + return array( + 'dashboard' => array( 'dashboard' ), + 'post editor' => array( 'post' ), + 'media' => array( 'upload' ), + 'comments' => array( 'edit-comments' ), + 'users' => array( 'users' ), + 'other plugin' => array( 'settings_page_some-other-plugin' ), + ); + } + + /** + * @return void + */ + public function test_the_reconnect_notice_reaches_the_plugin_screens() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( 'Reconnect to CiteCue', $this->notices_on( 'plugins' ) ); + } + + /** + * @return void + */ + public function test_the_reconnect_notice_offers_a_way_out() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( 'citecue_dismiss=seo_head_reconnect', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * Dismissed means dismissed: the condition is still true, and the notice + * still does not come back. + * + * @return void + */ + public function test_a_dismissed_reconnect_notice_stays_dismissed() { + $this->owe_a_reconnect(); + update_user_option( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); + + $html = $this->notices_on( 'settings_page_citecue' ); + + $this->assertTrue( $this->plugin->settings->needs_seo_head_reconnect() ); + $this->assertStringNotContainsString( 'Reconnect to CiteCue', $html ); + } + + /** + * One administrator's decision is not made on their colleagues' behalf. + * + * @return void + */ + public function test_a_dismissal_belongs_to_the_user_who_made_it() { + $this->owe_a_reconnect(); + update_user_option( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + + $this->assertStringContainsString( 'Reconnect to CiteCue', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * A subscriber who reaches an admin screen is told nothing. + * + * @return void + */ + public function test_a_user_who_cannot_manage_options_sees_nothing() { + $this->reject_the_key(); + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + + $this->assertSame( '', $this->notices_on( 'settings_page_citecue' ) ); + } + + /** + * The dismissal is a state change, so it is behind a nonce. + * + * @return void + */ + public function test_the_dismissal_link_carries_a_nonce() { + $this->owe_a_reconnect(); + + $this->assertStringContainsString( '_wpnonce', $this->notices_on( 'plugins' ) ); + } + + /** + * Requests a dismissal the way the link does. + * + * The handler redirects and exits, so the redirect is turned into an + * exception: what happened before it is the whole subject of these tests. + * A refusal throws too — wp_die() does, under the test suite — and that one + * is the caller's to see, so it is passed straight back out. + * + * @param string $notice Notice key to dismiss. + * @param string $nonce Nonce to present. + * @return void + * @throws WPDieException When the request is refused. + */ + private function request_dismissal( $notice, $nonce ) { + $_GET['citecue_dismiss'] = $notice; + $_REQUEST['_wpnonce'] = $nonce; + + add_filter( + 'wp_redirect', + static function () { + throw new Exception( 'redirected' ); + } + ); + + try { + $this->admin->maybe_dismiss_notice(); + } catch ( Exception $e ) { + if ( $e instanceof WPDieException ) { + throw $e; + } + } finally { + unset( $_GET['citecue_dismiss'], $_REQUEST['_wpnonce'] ); + } + } + + /** + * @return void + */ + public function test_a_signed_dismissal_is_recorded() { + $this->owe_a_reconnect(); + + $this->request_dismissal( 'seo_head_reconnect', wp_create_nonce( 'citecue_dismiss_seo_head_reconnect' ) ); + + $this->assertNotEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $this->user_id ) ); + } + + /** + * The usermeta table is shared across a whole multisite network, while the + * condition this notice reports comes from a per-site option — so the key + * has to carry this site's prefix, or dismissing it on one site in a + * network silences a still-true prompt on all the others. + * + * Asserting on the stored key rather than switching blogs, because that is + * the part a refactor back to update_user_meta() would quietly undo, and it + * is checkable on a single-site install. + * + * @return void + */ + public function test_a_dismissal_is_scoped_to_this_site() { + global $wpdb; + $this->owe_a_reconnect(); + + $this->request_dismissal( 'seo_head_reconnect', wp_create_nonce( 'citecue_dismiss_seo_head_reconnect' ) ); + + $this->assertNotEmpty( + get_user_meta( $this->user_id, $wpdb->get_blog_prefix() . Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', true ), + 'The dismissal should be stored under this blog’s prefix.' + ); + $this->assertEmpty( + get_user_meta( $this->user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', true ), + 'Nothing should be stored under a network-wide key.' + ); + } + + /** + * Without a valid nonce this is a link someone else can put in front of an + * administrator, so it has to die rather than write anything. + * + * @return void + */ + public function test_an_unsigned_dismissal_is_refused() { + $this->owe_a_reconnect(); + + $this->expectException( 'WPDieException' ); + try { + $this->request_dismissal( 'seo_head_reconnect', 'not-a-nonce' ); + } finally { + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $this->user_id ) ); + } + } + + /** + * The notice key is part of the nonce action, but it also reaches + * update_user_meta() — so only keys the plugin actually issues are honoured. + * + * @return void + */ + public function test_an_unknown_notice_key_writes_nothing() { + $this->request_dismissal( 'made_up', wp_create_nonce( 'citecue_dismiss_made_up' ) ); + + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'made_up', $this->user_id ) ); + } +} diff --git a/tests/cases/test-lifecycle.php b/tests/cases/test-lifecycle.php index c42b07c..1b4f6b6 100644 --- a/tests/cases/test-lifecycle.php +++ b/tests/cases/test-lifecycle.php @@ -193,6 +193,152 @@ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() $this->assertSame( '', $this->render_duplicate_notice_as( 'subscriber' ) ); } + /** + * Deleting a plugin directory happens on the Plugins screen, and the notice + * asks for nothing that can be done anywhere else — so that is the only + * screen it appears on. + * + * @return void + */ + public function test_the_duplicate_notice_stays_on_the_plugins_screen() { + $this->assertSame( '', $this->render_duplicate_notice_as( 'administrator', 'dashboard' ) ); + } + + /** + * A network-activated copy can only be deactivated from Network Admin → + * Plugins, and `admin_notices` does not fire anywhere in Network Admin — so + * scoping to that one hook hides the warning from the only screen where a + * super admin can act on it. + * + * @return void + */ + public function test_the_duplicate_notice_reaches_network_admin() { + $notice = $this->render_duplicate_notice_as( 'administrator', 'plugins-network', 'network_admin_notices' ); + + $this->assertStringContainsString( 'installed twice', $notice ); + } + + /** + * @return void + */ + public function test_the_legacy_notice_reaches_network_admin() { + $this->install_legacy_copy(); + + $notice = $this->render_duplicate_notice_as( 'administrator', 'plugins-network', 'network_admin_notices' ); + + $this->assertStringContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * Network Admin has its own dashboard and its own settings screens, and the + * notice has no more business on those than on the per-site ones. + * + * @return void + */ + public function test_the_duplicate_notice_stays_off_the_network_dashboard() { + $notice = $this->render_duplicate_notice_as( 'administrator', 'dashboard-network', 'network_admin_notices' ); + + $this->assertSame( '', $notice ); + } + + /** + * The one duplicate this plugin has actually shipped, and the one the guard + * above cannot cover. + * + * The citecue/ directory only ever held 1.0.0, which predates the guard and + * so loads its classes unconditionally. WordPress sorts active_plugins and + * '-' sorts before + * '/', so citecue-ai-auto-fix/ is always included first — meaning 1.0.0 is + * always the copy that redeclares and fatals, and it has no guard with + * which to stand down. This copy has to be the one that yields. + * + * @return void + */ + public function test_this_copy_yields_to_a_legacy_copy_that_cannot_yield() { + $this->install_legacy_copy(); + + $notice = $this->render_duplicate_notice_as( 'administrator' ); + + $this->assertStringContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * A deleted directory can outlive its active_plugins entry. Yielding to a + * copy that is not there any more would leave the site running neither. + * + * @return void + */ + public function test_a_stale_entry_for_a_deleted_copy_is_ignored() { + $this->install_legacy_copy( false ); + + $notice = $this->render_duplicate_notice_as( 'administrator' ); + + $this->assertStringNotContainsString( 'An older copy in citecue/', $notice ); + } + + /** + * Puts a pre-WordPress.org copy in active_plugins, optionally with the file + * on disk to match. + * + * Both halves are stated, including the absent one: "the entry is stale" + * means the file is not there, and a test that only assumes that is at the + * mercy of whatever a previous run left behind. The directory removed here + * can only ever be this helper's own leftover — WP_PLUGIN_DIR under the + * test suite is the vendored WordPress, which ships with no plugins at all. + * + * @param bool $on_disk Whether the plugin file also exists. + * @return void + */ + private function install_legacy_copy( $on_disk = true ) { + update_option( 'active_plugins', array( 'citecue/citecue.php' ) ); + + // Registered before the branch, so the cleanup runs either way. + $this->legacy_copy_path = WP_PLUGIN_DIR . '/citecue'; + $this->remove_legacy_copy(); + + if ( ! $on_disk ) { + $this->assertFileDoesNotExist( $this->legacy_copy_path . '/citecue.php' ); + return; + } + + mkdir( $this->legacy_copy_path, 0777, true ); + file_put_contents( $this->legacy_copy_path . '/citecue.php', "legacy_copy_path ) { + return; + } + + if ( file_exists( $this->legacy_copy_path . '/citecue.php' ) ) { + unlink( $this->legacy_copy_path . '/citecue.php' ); + } + if ( is_dir( $this->legacy_copy_path ) ) { + rmdir( $this->legacy_copy_path ); + } + } + + /** + * @return void + */ + public function tear_down() { + $this->remove_legacy_copy(); + $this->legacy_copy_path = ''; + parent::tear_down(); + } + /** * Loads the main file a second time — which is the state the duplicate * copy boots into — and renders what it hooked onto `admin_notices`. @@ -203,19 +349,37 @@ public function test_the_duplicate_notice_is_hidden_from_users_who_cannot_act() * null outside a genuine admin request. Isolating the hook keeps this a * test of the guard rather than of whatever else happens to be active. * - * @param string $role Role of the user viewing the admin screen. + * @param string $role Role of the user viewing the admin screen. + * @param string $screen_id Screen being viewed. + * @param string $hook Notice hook the screen fires: WordPress fires + * `admin_notices` and `network_admin_notices` in + * mutually exclusive branches, never both. * @return string Rendered notice markup. */ - private function render_duplicate_notice_as( $role ) { + private function render_duplicate_notice_as( $role, $screen_id = 'plugins', $hook = 'admin_notices' ) { remove_all_actions( 'admin_notices' ); + remove_all_actions( 'network_admin_notices' ); require dirname( __DIR__, 2 ) . '/citecue.php'; - wp_set_current_user( self::factory()->user->create( array( 'role' => $role ) ) ); + $user_id = self::factory()->user->create( array( 'role' => $role ) ); + // On multisite `activate_plugins` maps through `manage_network_plugins`, + // so a site administrator does not have it — which is correct, since a + // network-activated duplicate is only a super admin's to remove. The + // notice is gated on that capability, so the viewer has to hold it. + if ( is_multisite() && 'administrator' === $role ) { + grant_super_admin( $user_id ); + } + wp_set_current_user( $user_id ); + set_current_screen( $screen_id ); ob_start(); - do_action( 'admin_notices' ); - return ob_get_clean(); + do_action( $hook ); + $notice = ob_get_clean(); + + $GLOBALS['current_screen'] = null; + + return $notice; } /** @@ -241,6 +405,54 @@ public function test_uninstall_removes_plugin_options() { $this->assertFalse( wp_next_scheduled( Citecue_Plugin::CRON_HOOK ) ); } + /** + * Dismissals are one row per administrator, in a table nobody else cleans + * up — so they go with everything else rather than outliving the plugin. + * + * @return void + */ + public function test_uninstall_removes_dismissed_notices() { + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + update_user_option( $user_id, Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', time() ); + + $this->run_uninstall(); + + $this->assertEmpty( get_user_option( Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect', $user_id ) ); + } + + /** + * The usermeta table is shared by a whole network, and WordPress includes + * uninstall.php once. A dismissal made on any other site in the network is + * therefore still ours to remove — nothing else ever will, and unlike the + * per-site options tables, this one outlives every site that wrote to it. + * + * @group multisite + * @return void + */ + public function test_uninstall_removes_dismissals_from_every_site() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Requires a multisite install.' ); + } + + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + $other = self::factory()->blog->create(); + $key = Citecue_Admin::DISMISSED_OPTION_PREFIX . 'seo_head_reconnect'; + + switch_to_blog( $other ); + update_user_option( $user_id, $key, time() ); + restore_current_blog(); + update_user_option( $user_id, $key, time() ); + + $this->run_uninstall(); + + switch_to_blog( $other ); + $elsewhere = get_user_option( $key, $user_id ); + restore_current_blog(); + + $this->assertEmpty( $elsewhere, 'The other site’s dismissal should be gone too.' ); + $this->assertEmpty( get_user_option( $key, $user_id ) ); + } + /** * …but content pushed by CiteCue belongs to the site, so it stays. * diff --git a/uninstall.php b/uninstall.php index 3996fc5..e0bc0e3 100644 --- a/uninstall.php +++ b/uninstall.php @@ -20,6 +20,39 @@ delete_option( 'citecue_last_config_at' ); delete_option( 'citecue_install_verified' ); +/* + * One row per administrator who dismissed the reconnect notice, stored through + * update_user_option() — so the key carries the site's table prefix. + * + * Every site's prefix, not just this one's. WordPress includes this file once, + * for the site uninstalling the plugin, which is the right scope for the + * options above: those live in per-site tables that go away with the site. + * usermeta is one shared table for the whole network, so a key left behind + * there is left behind for good, on a table that outlives every site that ever + * wrote to it. + */ +global $wpdb; + +$citecue_dismissal_key = 'citecue_dismissed_seo_head_reconnect'; +$citecue_site_ids = array( null ); + +if ( is_multisite() ) { + // One indexed query returning ids. Uninstall happens once, so paying for + // the full list here is cheaper than orphaning rows nothing else cleans up. + $citecue_site_ids = get_sites( + array( + 'fields' => 'ids', + 'number' => 0, + ) + ); +} + +foreach ( $citecue_site_ids as $citecue_site_id ) { + delete_metadata( 'user', 0, $wpdb->get_blog_prefix( $citecue_site_id ) . $citecue_dismissal_key, '', true ); +} + +unset( $citecue_dismissal_key, $citecue_site_ids, $citecue_site_id ); + delete_transient( 'citecue_circuit' ); delete_transient( 'citecue_ingest_rate' ); delete_transient( 'citecue_connect_state' );