diff --git a/admin/class-ajax.php b/admin/class-ajax.php
index 47ad43b83..9e7e342a1 100644
--- a/admin/class-ajax.php
+++ b/admin/class-ajax.php
@@ -322,7 +322,7 @@ function ( $a, $b ) {
*
* @allowed bool True if allowed, false if not
*/
- $ignore_permission = apply_filters( 'edac_ignore_permission', true );
+ $ignore_permission = apply_filters( 'edac_ignore_permission', edac_user_can_ignore() );
$severity_map = [
1 => [
diff --git a/admin/class-enqueue-admin.php b/admin/class-enqueue-admin.php
index fcc888026..e090a7079 100644
--- a/admin/class-enqueue-admin.php
+++ b/admin/class-enqueue-admin.php
@@ -225,6 +225,8 @@ public static function maybe_enqueue_sidebar_script() {
'edacApiUrl' => esc_url_raw( rest_url() . 'accessibility-checker/v1' ),
'settingsUrl' => esc_url_raw( admin_url( 'admin.php?page=accessibility_checker_settings' ) ),
'canManageSettings' => current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ),
+ 'canDismiss' => edac_user_can_ignore(),
+ 'canDismissGlobally' => edac_user_can_ignore_globally(),
'readabilityHelpUrl' => esc_url_raw( edac_link_wrapper( 'https://a11ychecker.com/help3265', 'wordpress-general', 'content-analysis-sidebar', false ) ),
'dismissReasons' => IgnoreUI::get_reasons(),
'simplifiedSummaryPrompt' => get_option( 'edac_simplified_summary_prompt', 'none' ),
diff --git a/includes/classes/Capabilities/CapabilityChecker.php b/includes/classes/Capabilities/CapabilityChecker.php
new file mode 100644
index 000000000..ae3cf1448
--- /dev/null
+++ b/includes/classes/Capabilities/CapabilityChecker.php
@@ -0,0 +1,47 @@
+capabilities = is_array( $capabilities ) ? array_values( $capabilities ) : [ $capabilities ];
+
+ if ( [] === $this->capabilities ) {
+ throw new \InvalidArgumentException( 'SyncCapability requires at least one capability.' );
+ }
+
+ $this->option_name = $option_name;
+ $this->default_roles = $default_roles;
+ $this->version = $version;
+ }
+
+ /**
+ * Wire up the bypass filter, live sync on option save, and the
+ * version-gated migration. Call once, typically from plugin bootstrap.
+ *
+ * @return void
+ */
+ public function register(): void {
+ add_filter( 'map_meta_cap', [ $this, 'bypass_for_admins' ], 10, 3 );
+
+ add_action(
+ "add_option_{$this->option_name}",
+ function ( $option, $value ) {
+ $this->sync( $value );
+ },
+ 10,
+ 2
+ );
+ add_action(
+ "update_option_{$this->option_name}",
+ function ( $old_value, $value ) {
+ $this->sync( $value );
+ },
+ 10,
+ 2
+ );
+ // Whatever deleted the option (typically an uninstall routine, gated
+ // behind the "delete data" preference) intends for the roles it
+ // granted to lose these capabilities too - without this, sync()
+ // would only ever run again on the next add_option/update_option,
+ // leaving the capabilities stuck on whichever roles had them at
+ // deletion time indefinitely.
+ add_action(
+ "delete_option_{$this->option_name}",
+ function () {
+ $this->sync( [] );
+ }
+ );
+
+ // init, not admin_init: admin_menu (where menu capability checks happen)
+ // and rest_api_init (where REST permission_callbacks are registered) both
+ // fire before admin_init on their respective request types, so migrating
+ // on admin_init would leave the very first request after a version bump
+ // building a menu, or serving a REST request, against pre-migration
+ // capabilities. init fires early enough on every request type - admin,
+ // front-end, REST, and cron alike - to have already run by the time any
+ // of those capability checks happen.
+ add_action( 'init', [ $this, 'maybe_migrate' ] );
+ }
+
+ /**
+ * Whether the current user has one of this instance's capabilities.
+ * Defaults to the first (or only) capability in the bundle so existing
+ * single-capability callers can keep calling user_can() with no argument.
+ *
+ * @param string|null $capability Which capability to check; defaults to the first in the bundle.
+ * @return bool
+ */
+ public function user_can( ?string $capability = null ): bool {
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown -- Custom capability, synced by this class.
+ return current_user_can( $capability ?? $this->capabilities[0] );
+ }
+
+ /**
+ * A REST route permission_callback closure for one of this instance's
+ * capabilities, so routes can pass this directly instead of wrapping
+ * current_user_can() in their own inline closure.
+ *
+ * @param string|null $capability Which capability to check; defaults to the first in the bundle.
+ * @return callable
+ */
+ public function permission_callback( ?string $capability = null ): callable {
+ return function () use ( $capability ) {
+ return $this->user_can( $capability );
+ };
+ }
+
+ /**
+ * Map_meta_cap callback: manage_options users always pass a check
+ * against any capability in this bundle, regardless of role sync.
+ *
+ * @param array $caps Required primitive capabilities.
+ * @param string $cap Capability being checked.
+ * @param int $user_id User ID.
+ * @return array
+ */
+ public function bypass_for_admins( $caps, $cap, $user_id ) {
+ if ( in_array( $cap, $this->capabilities, true ) && user_can( $user_id, 'manage_options' ) ) {
+ return [];
+ }
+ return $caps;
+ }
+
+ /**
+ * Add or remove one capability on one role. The generic primitive
+ * sync() is built on. Deliberately private: calling it directly for a
+ * single capability out of a multi-capability bundle would grant/revoke
+ * that one capability while leaving the rest of the bundle untouched
+ * for that role, breaking the "these capabilities always travel
+ * together" guarantee this class exists to provide. Always go through
+ * sync() (or the option it's wired to) so every capability in the
+ * bundle stays in lockstep.
+ *
+ * @param string $role_slug Role slug, e.g. 'editor'.
+ * @param string $capability Capability string.
+ * @param bool $should_have Whether the role should have this capability.
+ * @return void
+ */
+ private function sync_role_capability( string $role_slug, string $capability, bool $should_have ): void {
+ $role = wp_roles()->get_role( $role_slug );
+
+ if ( ! $role ) {
+ return;
+ }
+
+ if ( $should_have ) {
+ $role->add_cap( $capability );
+ } else {
+ $role->remove_cap( $capability );
+ }
+ }
+
+ /**
+ * Add or remove every capability in this bundle on every role so each
+ * capability matches exactly the role list passed in.
+ *
+ * @param mixed $roles Role slugs that should have the capabilities.
+ * @return void
+ */
+ public function sync( $roles ): void {
+ $roles = is_array( $roles ) ? $roles : [];
+
+ foreach ( array_keys( wp_roles()->role_objects ) as $role_slug ) {
+ $should_have = in_array( $role_slug, $roles, true );
+
+ foreach ( $this->capabilities as $capability ) {
+ $this->sync_role_capability( $role_slug, $capability, $should_have );
+ }
+ }
+ }
+
+ /**
+ * Name of the option this bundle's migration-version marker is stored
+ * under. Includes a hash of the capability list, not just option_name,
+ * so two different SyncCapability instances that happen to point at the
+ * same option (e.g. a future feature layered onto an existing option)
+ * can never collide on one shared version counter and silently skip
+ * each other's migration.
+ *
+ * @return string
+ */
+ private function version_option_name(): string {
+ $capabilities = $this->capabilities;
+ sort( $capabilities );
+
+ return 'edac_capability_version_' . $this->option_name . '_' . md5( implode( '|', $capabilities ) );
+ }
+
+ /**
+ * Run the sync once per migration version. Covers two cases: a site
+ * that already had option_name set before this bundle existed (needs an
+ * initial sync), and a site whose stored version predates a
+ * default_roles/capabilities change (needs a re-sync even though it
+ * already ran an earlier version's migration).
+ *
+ * Versioned per (option, capability set) pair, not per capability,
+ * since every capability in the bundle is always granted together and
+ * shares one migration.
+ *
+ * @return void
+ */
+ public function maybe_migrate(): void {
+ $version_option = $this->version_option_name();
+ $stored_version = (int) get_option( $version_option, 0 );
+
+ if ( $stored_version >= $this->version ) {
+ return;
+ }
+
+ $this->sync( get_option( $this->option_name, $this->default_roles ) );
+ update_option( $version_option, $this->version );
+ }
+}
diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php
index 1de2368f5..eabaf53d8 100644
--- a/includes/classes/class-rest-api.php
+++ b/includes/classes/class-rest-api.php
@@ -340,6 +340,21 @@ function () use ( $ns, $version ) {
return false;
}
+ if ( ! edac_user_can_ignore() ) {
+ return false;
+ }
+
+ // A largeBatch request from a user who can ignore globally doesn't
+ // need edit_post on the URL's representative issue - dismiss_issue()
+ // already re-verifies (or, for this exact capability, deliberately
+ // bypasses) per-post permission for every affected row once inside
+ // the handler. Gating on the one representative post here would
+ // block the very requests this capability exists to allow, whenever
+ // that post happens not to be one the user personally owns.
+ if ( $request->get_param( 'largeBatch' ) && edac_user_can_ignore_globally() ) {
+ return true;
+ }
+
$table_name = edac_get_valid_table_name( $wpdb->prefix . 'accessibility_checker' );
if ( ! $table_name ) {
return false;
@@ -1204,6 +1219,14 @@ private function get_wcag_url_and_title_from_number( $wcag_number ) {
public function dismiss_issue( $request ) {
global $wpdb;
+ if ( ! edac_user_can_ignore() ) {
+ return new \WP_Error(
+ 'rest_forbidden',
+ __( 'Sorry, you are not allowed to dismiss issues.', 'accessibility-checker' ),
+ [ 'status' => rest_authorization_required_code() ]
+ );
+ }
+
$issue_id = (int) $request['issue_id'];
$action = $request->get_param( 'action' );
$reason = $request->get_param( 'reason' ) ?? '';
@@ -1211,6 +1234,20 @@ public function dismiss_issue( $request ) {
$ignore_global = $request->get_param( 'ignore_global' ) ?? 0;
$large_batch = $request->get_param( 'largeBatch' ) ?? false;
+ // largeBatch is what actually performs the global action (updating every
+ // row that shares the object, not just $issue_id) - the per-post
+ // edit_post loop below only proves the user can edit each affected post,
+ // it doesn't prove they're allowed to take a global action at all. That
+ // requires the separate, larger-blast-radius capability.
+ $can_ignore_globally = edac_user_can_ignore_globally();
+ if ( $large_batch && ! $can_ignore_globally ) {
+ return new \WP_Error(
+ 'rest_forbidden',
+ __( 'Sorry, you are not allowed to dismiss issues globally.', 'accessibility-checker' ),
+ [ 'status' => rest_authorization_required_code() ]
+ );
+ }
+
$table_name = $wpdb->prefix . 'accessibility_checker';
$site_id = get_current_blog_id();
@@ -1266,14 +1303,22 @@ public function dismiss_issue( $request ) {
);
}
- foreach ( $issue_rows as $issue_row ) {
- $post_id = isset( $issue_row['postid'] ) ? (int) $issue_row['postid'] : 0;
- if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
- return new \WP_Error(
- 'rest_forbidden',
- __( 'Sorry, you are not allowed to dismiss one or more issues in this batch.', 'accessibility-checker' ),
- [ 'status' => rest_authorization_required_code() ]
- );
+ // A user with edac_ignore_issues_globally is already trusted for
+ // this exact "affects posts you may not own" action (enforced
+ // above), so the per-post edit_post lookups below - one
+ // current_user_can() call per affected post - would be pure
+ // overhead for them. Kept as a fallback check for any caller that
+ // somehow reaches this branch without that capability.
+ if ( ! $can_ignore_globally ) {
+ foreach ( $issue_rows as $issue_row ) {
+ $post_id = isset( $issue_row['postid'] ) ? (int) $issue_row['postid'] : 0;
+ if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
+ return new \WP_Error(
+ 'rest_forbidden',
+ __( 'Sorry, you are not allowed to dismiss one or more issues in this batch.', 'accessibility-checker' ),
+ [ 'status' => rest_authorization_required_code() ]
+ );
+ }
}
}
diff --git a/includes/options-page.php b/includes/options-page.php
index a0ac85a33..eda6d2e31 100644
--- a/includes/options-page.php
+++ b/includes/options-page.php
@@ -10,28 +10,83 @@
use EDAC\Admin\Settings;
use EDAC\Inc\Accessibility_Statement;
use EqualizeDigital\AccessibilityChecker\Admin\AdminPage\FixesPage;
+use EqualizeDigital\AccessibilityChecker\Capabilities\CapabilityChecker;
+use EqualizeDigital\AccessibilityChecker\Capabilities\SyncCapability;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+// The ignore-permissions capability bundle - all three are synced together
+// onto whichever roles are configured in edacp_ignore_user_roles (set on the
+// pro plugin's settings page). There is deliberately no separate settings
+// control for the two newer capabilities; granting a role "can ignore
+// issues" grants all three at once.
+defined( 'EDAC_CAPABILITY_IGNORE_ISSUES' ) || define( 'EDAC_CAPABILITY_IGNORE_ISSUES', 'edac_ignore_issues' );
+// Larger blast radius than a per-post ignore: suppresses an issue across
+// every post sharing a rule+object, not just the one being viewed.
+defined( 'EDAC_CAPABILITY_IGNORE_ISSUES_GLOBALLY' ) || define( 'EDAC_CAPABILITY_IGNORE_ISSUES_GLOBALLY', 'edac_ignore_issues_globally' );
+// Gates getting into pro's Issues Explorer app at all, independent of
+// whether the user can also ignore issues once inside it.
+defined( 'EDAC_CAPABILITY_ISSUES_EXPLORER_ACCESS' ) || define( 'EDAC_CAPABILITY_ISSUES_EXPLORER_ACCESS', 'edac_issues_explorer_access' );
+
/**
- * Check if user can ignore or can manage options
+ * The ignore-permissions capability bundle (edac_ignore_issues,
+ * edac_ignore_issues_globally, edac_issues_explorer_access), synced onto the
+ * roles listed in the edacp_ignore_user_roles option, with a manage_options
+ * bypass. Consumers should check individual capabilities via
+ * CapabilityChecker (or the edac_user_can_*() helpers below) rather than
+ * calling into this instance directly.
+ *
+ * @return SyncCapability
+ */
+function edac_ignore_capability(): SyncCapability {
+ static $capability = null;
+
+ if ( null === $capability ) {
+ $capability = new SyncCapability(
+ [
+ EDAC_CAPABILITY_IGNORE_ISSUES,
+ EDAC_CAPABILITY_IGNORE_ISSUES_GLOBALLY,
+ EDAC_CAPABILITY_ISSUES_EXPLORER_ACCESS,
+ ],
+ 'edacp_ignore_user_roles',
+ [ 'administrator' ],
+ 2 // Bumped from 1: adds the two new bundled capabilities for roles already granted ignore access.
+ );
+ $capability->register();
+ }
+
+ return $capability;
+}
+edac_ignore_capability();
+
+/**
+ * Check if user can ignore issues (per-post) or can manage options.
*
* @return bool
*/
function edac_user_can_ignore() {
+ return CapabilityChecker::user_can( EDAC_CAPABILITY_IGNORE_ISSUES );
+}
- if ( current_user_can( 'manage_options' ) ) {
- return true;
- }
-
- $user = wp_get_current_user();
- $user_roles = ( isset( $user->roles ) ) ? $user->roles : [];
- $ignore_user_roles = get_option( 'edacp_ignore_user_roles' );
- $interset = ( $user_roles && $ignore_user_roles ) ? array_intersect( $user_roles, $ignore_user_roles ) : false;
+/**
+ * Check if user can globally ignore an issue (suppress it across every post
+ * sharing a rule+object) or can manage options.
+ *
+ * @return bool
+ */
+function edac_user_can_ignore_globally() {
+ return CapabilityChecker::user_can( EDAC_CAPABILITY_IGNORE_ISSUES_GLOBALLY );
+}
- return ( $interset );
+/**
+ * Check if user can access the (pro) Issues Explorer, or can manage options.
+ *
+ * @return bool
+ */
+function edac_user_can_access_issues_explorer() {
+ return CapabilityChecker::user_can( EDAC_CAPABILITY_ISSUES_EXPLORER_ACCESS );
}
/**
diff --git a/src/issueModal/components/DismissPanel.js b/src/issueModal/components/DismissPanel.js
index c51a835ce..251ae2654 100644
--- a/src/issueModal/components/DismissPanel.js
+++ b/src/issueModal/components/DismissPanel.js
@@ -18,14 +18,19 @@ import { getDismissReasonOptions } from '../../sidebar/utils/dismissHelpers';
/**
* Dismiss Panel Component
*
- * @param {Object} props - Component props.
- * @param {Object} props.issue - The issue object.
- * @param {boolean} props.isOpen - Whether the panel is open.
- * @param {Function} props.onToggle - Callback when panel is toggled.
- * @param {Function} props.onIgnore - Callback when issue is dismissed/restored.
- * @param {Function} props.onCloseModal - Callback to close the parent modal.
- * @param {boolean} props.forceGlobal - When true, the primary dismiss action targets all pages (global dismiss).
- * @param {boolean} props.isPro - Whether the current UI is running in Pro.
+ * @param {Object} props - Component props.
+ * @param {Object} props.issue - The issue object.
+ * @param {boolean} props.isOpen - Whether the panel is open.
+ * @param {Function} props.onToggle - Callback when panel is toggled.
+ * @param {Function} props.onIgnore - Callback when issue is dismissed/restored.
+ * @param {Function} props.onCloseModal - Callback to close the parent modal.
+ * @param {boolean} props.forceGlobal - When true, the primary dismiss action targets all pages (global dismiss).
+ * @param {boolean} props.isPro - Whether the current UI is running in Pro.
+ * @param {boolean} props.canDismiss - Whether the current user is allowed to dismiss/reopen issues.
+ * @param {boolean} props.canDismissGlobally - Whether the current user is allowed to dismiss/reopen an issue across every
+ * page it appears on. Must come from a real capability check (edac_user_can_ignore_globally()),
+ * not just isPro - showing this control to a user who lacks the capability
+ * only leads to the underlying REST call being rejected server-side.
*/
const DismissPanel = ( {
issue,
@@ -35,6 +40,8 @@ const DismissPanel = ( {
onCloseModal,
forceGlobal = false,
isPro = typeof window !== 'undefined' && ( window.edac_editor_app?.pro === '1' || window.edac_script_vars?.pro === '1' ),
+ canDismiss = true,
+ canDismissGlobally = false,
} ) => {
const panelRef = useRef( null );
const [ comment, setComment ] = useState( issue?.ignre_comment ? decodeEntities( issue.ignre_comment ) : '' );
@@ -44,8 +51,8 @@ const DismissPanel = ( {
const [ successNotice, setSuccessNotice ] = useState( null );
const [ isIgnored, setIsIgnored ] = useState( issue?.ignre === '1' || issue?.ignre === 1 );
const isGloballyDismissed = issue?.ignre_global === 1 || issue?.ignre_global === '1';
- const canDismissGlobally = isPro;
- const dismissGlobally = canDismissGlobally && forceGlobal;
+ const canUseGlobalDismiss = isPro && canDismissGlobally;
+ const dismissGlobally = canUseGlobalDismiss && forceGlobal;
const dismissReasonOptions = getDismissReasonOptions();
const dismissReasonLabel = dismissReasonOptions.find( ( option ) => option.value === issue?.ignre_reason )?.label;
const handleToggleIgnore = async ( ignore, isGlobal = false ) => {
@@ -122,6 +129,177 @@ const DismissPanel = ( {
panelTitle = __( 'Dismiss Issue', 'accessibility-checker' );
}
+ let dismissBody;
+ if ( isIgnored ) {
+ dismissBody = (
+ <>
+ { ( issue?.user || issue?.ignre_user_name || issue?.ignre_date || isGloballyDismissed ) && (
+
- { __( 'Reason for dismissal:', 'accessibility-checker' ) }
-
-
- { issue.ignre_comment }
-
-
- ) }
- { isGloballyDismissed ? (
-
-
-
- ) : (
-
-
-
- ) }
- >
- ) : (
-
- ) }
+ { dismissBody }
diff --git a/src/issueModal/components/IssueDetailsModal.js b/src/issueModal/components/IssueDetailsModal.js
index 5c390c7be..e33da53f4 100644
--- a/src/issueModal/components/IssueDetailsModal.js
+++ b/src/issueModal/components/IssueDetailsModal.js
@@ -406,6 +406,8 @@ export const IssueDetailsModal = ( { issue, rule, onClose, isOpen, focusSection,
onToggle={ () => setIsDismissPanelOpen( ! isDismissPanelOpen ) }
onIgnore={ onIgnore }
onCloseModal={ onClose }
+ canDismiss={ window.edac_sidebar_app?.canDismiss !== false }
+ canDismissGlobally={ window.edac_sidebar_app?.canDismissGlobally === true }
/>
diff --git a/tests/jest/issueModal/DismissPanel.test.js b/tests/jest/issueModal/DismissPanel.test.js
index 2d7320063..589edb000 100644
--- a/tests/jest/issueModal/DismissPanel.test.js
+++ b/tests/jest/issueModal/DismissPanel.test.js
@@ -94,9 +94,9 @@ describe( 'DismissPanel', () => {
unmount();
} );
- test( 'keeps global undo available when an issue was globally dismissed', async () => {
+ test( 'keeps global undo available for a user who can globally dismiss', async () => {
const { toggleIssueDismiss } = require( '../../../src/issueModal/api' );
- window.edac_editor_app.pro = '0';
+ window.edac_editor_app.pro = '1';
const { container, unmount } = renderReact(
{
onToggle={ jest.fn() }
onIgnore={ jest.fn() }
onCloseModal={ jest.fn() }
- isPro={ false }
+ isPro={ true }
+ canDismissGlobally={ true }
/>,
);
@@ -129,4 +130,73 @@ describe( 'DismissPanel', () => {
unmount();
} );
+
+ test( 'hides global dismiss controls for a Pro user without canDismissGlobally', () => {
+ window.edac_editor_app.pro = '1';
+
+ const { container, unmount } = renderReact(
+ ,
+ );
+
+ expect( container.querySelector( 'button[aria-label="More dismiss options"]' ) ).toBeNull();
+ expect( container.textContent ).not.toContain( 'Dismiss Globally' );
+
+ unmount();
+ } );
+
+ test( 'hides the global undo for a Pro user without canDismissGlobally, even on an already-globally-dismissed issue', () => {
+ window.edac_editor_app.pro = '1';
+
+ const { container, unmount } = renderReact(
+ ,
+ );
+
+ expect( container.textContent ).not.toContain( 'Remove Global Dismissal' );
+
+ unmount();
+ } );
+
+ test( 'shows global dismiss controls for a Pro user with canDismissGlobally', () => {
+ window.edac_editor_app.pro = '1';
+
+ const { container, unmount } = renderReact(
+ ,
+ );
+
+ expect( container.textContent ).toContain( 'Dismiss Globally' );
+
+ unmount();
+ } );
} );
diff --git a/tests/phpunit/includes/IgnoreCapabilityTest.php b/tests/phpunit/includes/IgnoreCapabilityTest.php
new file mode 100644
index 000000000..5e4bddf45
--- /dev/null
+++ b/tests/phpunit/includes/IgnoreCapabilityTest.php
@@ -0,0 +1,171 @@
+role_objects as $role ) {
+ $role->remove_cap( 'edac_ignore_issues' );
+ $role->remove_cap( 'edac_ignore_issues_globally' );
+ $role->remove_cap( 'edac_issues_explorer_access' );
+ }
+ parent::tearDown();
+ }
+
+ /**
+ * Syncing should add the capability only to the roles passed in, and
+ * remove it from roles not included.
+ *
+ * @return void
+ */
+ public function test_sync_adds_and_removes_capability_by_role() {
+ wp_roles()->get_role( 'editor' )->add_cap( 'edac_ignore_issues' );
+
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( 'edac_ignore_issues' ), 'Editor should have lost the capability.' );
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( 'edac_ignore_issues' ), 'Author should have gained the capability.' );
+ }
+
+ /**
+ * A user in a role that was granted the capability should pass
+ * edac_user_can_ignore().
+ *
+ * @return void
+ */
+ public function test_user_can_ignore_true_for_synced_role() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'author' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertTrue( edac_user_can_ignore() );
+ }
+
+ /**
+ * A user in a role that was not granted the capability should fail
+ * edac_user_can_ignore().
+ *
+ * @return void
+ */
+ public function test_user_can_ignore_false_for_unsynced_role() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertFalse( edac_user_can_ignore() );
+ }
+
+ /**
+ * Manage_options users must always pass the check, even if their role
+ * was left out of edacp_ignore_user_roles (e.g. an admin restricted
+ * even the administrator role by mistake).
+ *
+ * @return void
+ */
+ public function test_manage_options_user_always_can_ignore() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertFalse( wp_roles()->get_role( 'administrator' )->has_cap( 'edac_ignore_issues' ), 'Precondition: administrator role itself was not synced.' );
+ $this->assertTrue( edac_user_can_ignore(), 'manage_options users must always be able to ignore/dismiss.' );
+ }
+
+ /**
+ * Saving the edacp_ignore_user_roles option should sync the capability
+ * automatically via the update_option/add_option hooks.
+ *
+ * @return void
+ */
+ public function test_saving_option_triggers_sync() {
+ delete_option( 'edacp_ignore_user_roles' );
+ add_option( 'edacp_ignore_user_roles', [ 'author' ] );
+
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( 'edac_ignore_issues' ) );
+
+ update_option( 'edacp_ignore_user_roles', [ 'editor' ] );
+
+ $this->assertFalse( wp_roles()->get_role( 'author' )->has_cap( 'edac_ignore_issues' ) );
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( 'edac_ignore_issues' ) );
+ }
+
+ /**
+ * Syncing the ignore-roles option should grant all three bundled
+ * capabilities together - there is no separate setting for global-ignore
+ * or Issues Explorer access, so a role given ignore permission gets all
+ * three at once.
+ *
+ * @return void
+ */
+ public function test_sync_grants_all_three_bundled_capabilities() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $author = wp_roles()->get_role( 'author' );
+ $this->assertTrue( $author->has_cap( 'edac_ignore_issues' ) );
+ $this->assertTrue( $author->has_cap( 'edac_ignore_issues_globally' ) );
+ $this->assertTrue( $author->has_cap( 'edac_issues_explorer_access' ) );
+ }
+
+ /**
+ * A user in a role that was granted the bundle should pass both new
+ * helper functions, mirroring edac_user_can_ignore().
+ *
+ * @return void
+ */
+ public function test_new_helper_functions_true_for_synced_role() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'author' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertTrue( edac_user_can_ignore_globally() );
+ $this->assertTrue( edac_user_can_access_issues_explorer() );
+ }
+
+ /**
+ * A user in a role that was not granted the bundle should fail both new
+ * helper functions.
+ *
+ * @return void
+ */
+ public function test_new_helper_functions_false_for_unsynced_role() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertFalse( edac_user_can_ignore_globally() );
+ $this->assertFalse( edac_user_can_access_issues_explorer() );
+ }
+
+ /**
+ * Manage_options users must always pass the new helper functions too,
+ * same as edac_user_can_ignore().
+ *
+ * @return void
+ */
+ public function test_manage_options_user_always_passes_new_helpers() {
+ edac_ignore_capability()->sync( [ 'author' ] );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertTrue( edac_user_can_ignore_globally() );
+ $this->assertTrue( edac_user_can_access_issues_explorer() );
+ }
+}
diff --git a/tests/phpunit/includes/classes/Capabilities/CapabilityCheckerTest.php b/tests/phpunit/includes/classes/Capabilities/CapabilityCheckerTest.php
new file mode 100644
index 000000000..650856f6a
--- /dev/null
+++ b/tests/phpunit/includes/classes/Capabilities/CapabilityCheckerTest.php
@@ -0,0 +1,99 @@
+role_objects as $role ) {
+ $role->remove_cap( self::TEST_CAP );
+ }
+ wp_set_current_user( 0 );
+ parent::tearDown();
+ }
+
+ /**
+ * User_can() should reflect a plain role-level capability grant, with no
+ * SyncCapability instance involved at all.
+ *
+ * @return void
+ */
+ public function test_user_can_reflects_role_grant() {
+ wp_roles()->get_role( 'editor' )->add_cap( self::TEST_CAP );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertTrue( CapabilityChecker::user_can( self::TEST_CAP ) );
+ }
+
+ /**
+ * User_can() should return false for a user whose role lacks the
+ * capability.
+ *
+ * @return void
+ */
+ public function test_user_can_false_without_grant() {
+ $user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ wp_set_current_user( $user_id );
+
+ $this->assertFalse( CapabilityChecker::user_can( self::TEST_CAP ) );
+ }
+
+ /**
+ * User_can() should accept an explicit user ID instead of relying on the
+ * current user.
+ *
+ * @return void
+ */
+ public function test_user_can_checks_explicit_user_id() {
+ wp_roles()->get_role( 'editor' )->add_cap( self::TEST_CAP );
+ $user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+
+ $this->assertTrue( CapabilityChecker::user_can( self::TEST_CAP, $user_id ) );
+ }
+
+ /**
+ * Permission_callback() should return a callable proxying user_can(),
+ * suitable for a REST route's permission_callback directly.
+ *
+ * @return void
+ */
+ public function test_permission_callback_proxies_user_can() {
+ wp_roles()->get_role( 'editor' )->add_cap( self::TEST_CAP );
+
+ $callback = CapabilityChecker::permission_callback( self::TEST_CAP );
+ $this->assertIsCallable( $callback );
+
+ $user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
+ wp_set_current_user( $user_id );
+ $this->assertTrue( $callback() );
+
+ $subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ wp_set_current_user( $subscriber_id );
+ $this->assertFalse( $callback() );
+ }
+}
diff --git a/tests/phpunit/includes/classes/Capabilities/SyncCapabilityTest.php b/tests/phpunit/includes/classes/Capabilities/SyncCapabilityTest.php
new file mode 100644
index 000000000..a247c4541
--- /dev/null
+++ b/tests/phpunit/includes/classes/Capabilities/SyncCapabilityTest.php
@@ -0,0 +1,351 @@
+role_objects as $role ) {
+ $role->remove_cap( self::TEST_CAP );
+ $role->remove_cap( self::TEST_CAP_2 );
+ }
+ delete_option( self::TEST_OPTION );
+ // Version markers are keyed by option name + a hash of the capability
+ // set, so different tests in this file produce different keys -
+ // delete anything matching the option prefix rather than one exact key.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->query(
+ $wpdb->prepare(
+ "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
+ $wpdb->esc_like( 'edac_capability_version_' . self::TEST_OPTION ) . '%'
+ )
+ );
+ wp_set_current_user( 0 );
+ parent::tearDown();
+ }
+
+ /**
+ * Sync() should add the capability only to the roles passed in, and
+ * remove it from roles not included.
+ *
+ * @return void
+ */
+ public function test_sync_adds_and_removes_capability_by_role() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+
+ wp_roles()->get_role( 'editor' )->add_cap( self::TEST_CAP );
+
+ $capability->sync( [ 'author' ] );
+
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+ }
+
+ /**
+ * Register() should wire live sync to the option's add/update hooks.
+ *
+ * @return void
+ */
+ public function test_register_syncs_on_option_save() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->register();
+
+ add_option( self::TEST_OPTION, [ 'author' ] );
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+
+ update_option( self::TEST_OPTION, [ 'editor' ] );
+ $this->assertFalse( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ }
+
+ /**
+ * Deleting the option (e.g. an uninstall routine's opt-in data cleanup)
+ * should revoke the capability from every role it was synced onto - the
+ * option's own removal is the strongest possible signal that a stale
+ * grant shouldn't be left behind, and there is no other hook left that
+ * would ever catch this since add_option/update_option only fire again
+ * on a future save.
+ *
+ * @return void
+ */
+ public function test_deleting_option_revokes_capability_from_all_roles() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->register();
+
+ add_option( self::TEST_OPTION, [ 'editor', 'author' ] );
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+
+ delete_option( self::TEST_OPTION );
+
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ $this->assertFalse( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+ }
+
+ /**
+ * Manage_options users must always pass user_can(), regardless of
+ * whether their role was synced.
+ *
+ * @return void
+ */
+ public function test_manage_options_bypasses_sync() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->register();
+ $capability->sync( [ 'author' ] );
+
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ $this->assertFalse( wp_roles()->get_role( 'administrator' )->has_cap( self::TEST_CAP ), 'Precondition: administrator role itself was not synced.' );
+ $this->assertTrue( $capability->user_can() );
+ }
+
+ /**
+ * Permission_callback() should return a callable proxying user_can(),
+ * suitable for a REST route's permission_callback directly.
+ *
+ * @return void
+ */
+ public function test_permission_callback_proxies_user_can() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->sync( [ 'author' ] );
+
+ $callback = $capability->permission_callback();
+ $this->assertIsCallable( $callback );
+
+ $author_id = self::factory()->user->create( [ 'role' => 'author' ] );
+ wp_set_current_user( $author_id );
+ $this->assertTrue( $callback() );
+
+ $subscriber_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ wp_set_current_user( $subscriber_id );
+ $this->assertFalse( $callback() );
+ }
+
+ /**
+ * Maybe_migrate() should run the initial sync from default_roles when
+ * the option was never set and no migration has run yet.
+ *
+ * @return void
+ */
+ public function test_migration_runs_once_for_unset_option() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION, [ 'editor' ] );
+
+ $capability->maybe_migrate();
+
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ }
+
+ /**
+ * Maybe_migrate() should not re-run (and shouldn't clobber roles synced
+ * some other way since) once it has already run for the current version.
+ *
+ * @return void
+ */
+ public function test_migration_does_not_rerun_for_same_version() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION, [ 'editor' ] );
+ $capability->maybe_migrate();
+
+ // Simulate the site's config changing after the one-time migration ran.
+ $capability->sync( [ 'author' ] );
+
+ $capability->maybe_migrate();
+
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ), 'Migration should not have re-applied default_roles.' );
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+ }
+
+ /**
+ * Bumping the version should force maybe_migrate() to re-sync even
+ * though an earlier version's migration already ran once.
+ *
+ * @return void
+ */
+ public function test_version_bump_forces_remigration() {
+ $v1 = new SyncCapability( self::TEST_CAP, self::TEST_OPTION, [ 'editor' ], 1 );
+ $v1->maybe_migrate();
+
+ // Site never saved the option, so it's still on default_roles from v1.
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+
+ $v2 = new SyncCapability( self::TEST_CAP, self::TEST_OPTION, [ 'author' ], 2 );
+ $v2->maybe_migrate();
+
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ), 'v2 default_roles should have been applied.' );
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ), 'v1 default_roles should no longer apply after the v2 re-sync.' );
+ }
+
+ /**
+ * A single string capability (the pre-bundle constructor signature)
+ * must still work unchanged, since existing single-capability callers
+ * pass a string, not an array.
+ *
+ * @return void
+ */
+ public function test_single_string_capability_still_works() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->sync( [ 'author' ] );
+
+ $this->assertTrue( wp_roles()->get_role( 'author' )->has_cap( self::TEST_CAP ) );
+
+ $author_id = self::factory()->user->create( [ 'role' => 'author' ] );
+ wp_set_current_user( $author_id );
+ $this->assertTrue( $capability->user_can() );
+ }
+
+ /**
+ * Passing an array of capabilities should sync all of them together onto
+ * the same roles - the "bundle" case, e.g. ignore/global-ignore/explorer.
+ *
+ * @return void
+ */
+ public function test_bundle_syncs_multiple_capabilities_together() {
+ $capability = new SyncCapability( [ self::TEST_CAP, self::TEST_CAP_2 ], self::TEST_OPTION );
+ $capability->sync( [ 'author' ] );
+
+ $author = wp_roles()->get_role( 'author' );
+ $this->assertTrue( $author->has_cap( self::TEST_CAP ) );
+ $this->assertTrue( $author->has_cap( self::TEST_CAP_2 ) );
+
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP_2 ) );
+ }
+
+ /**
+ * User_can()/permission_callback() must accept an explicit capability
+ * argument so bundle consumers can check one specific capability instead
+ * of only ever getting the first one in the bundle.
+ *
+ * @return void
+ */
+ public function test_bundle_user_can_checks_the_capability_passed_in() {
+ $capability = new SyncCapability( [ self::TEST_CAP, self::TEST_CAP_2 ], self::TEST_OPTION );
+ $capability->sync( [ 'author' ] );
+
+ $author_id = self::factory()->user->create( [ 'role' => 'author' ] );
+ wp_set_current_user( $author_id );
+
+ $this->assertTrue( $capability->user_can( self::TEST_CAP ) );
+ $this->assertTrue( $capability->user_can( self::TEST_CAP_2 ) );
+
+ $callback = $capability->permission_callback( self::TEST_CAP_2 );
+ $this->assertTrue( $callback() );
+ }
+
+ /**
+ * Manage_options bypass must apply to every capability in the bundle,
+ * not just the first one.
+ *
+ * @return void
+ */
+ public function test_manage_options_bypasses_every_capability_in_bundle() {
+ $capability = new SyncCapability( [ self::TEST_CAP, self::TEST_CAP_2 ], self::TEST_OPTION );
+ $capability->register();
+ $capability->sync( [ 'author' ] );
+
+ $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] );
+ wp_set_current_user( $admin_id );
+
+ $this->assertTrue( $capability->user_can( self::TEST_CAP ) );
+ $this->assertTrue( $capability->user_can( self::TEST_CAP_2 ) );
+ }
+
+ /**
+ * The constructor should reject an empty capabilities array rather than
+ * silently falling through to user_can()'s no-argument default checking
+ * an undefined (null) capability.
+ *
+ * @return void
+ */
+ public function test_constructor_throws_on_empty_capabilities_array() {
+ $this->expectException( \InvalidArgumentException::class );
+
+ new SyncCapability( [], self::TEST_OPTION );
+ }
+
+ /**
+ * Register() must hook maybe_migrate() to init, not admin_init - admin_menu
+ * and rest_api_init both fire before admin_init on their respective request
+ * types, so a migration gated on admin_init would miss the first request
+ * after a version bump for menu builds and REST-only requests entirely.
+ *
+ * @return void
+ */
+ public function test_register_hooks_migration_to_init_not_admin_init() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+ $capability->register();
+
+ $this->assertNotFalse(
+ has_action( 'init', [ $capability, 'maybe_migrate' ] ),
+ 'maybe_migrate() should be hooked to init.'
+ );
+ $this->assertFalse(
+ has_action( 'admin_init', [ $capability, 'maybe_migrate' ] ),
+ 'maybe_migrate() should not be hooked to admin_init.'
+ );
+ }
+
+ /**
+ * Sync_role_capability() is the generic (role, capability) primitive
+ * sync() is built on. It's private (calling it directly for one
+ * capability out of a bundle would desync the rest of the bundle for
+ * that role), so this test reaches it via reflection rather than a
+ * public call - it's still worth covering in isolation from sync()'s
+ * roles-loop.
+ *
+ * @return void
+ */
+ public function test_sync_role_capability_generic_primitive() {
+ $capability = new SyncCapability( self::TEST_CAP, self::TEST_OPTION );
+
+ $method = new ReflectionMethod( SyncCapability::class, 'sync_role_capability' );
+ $method->setAccessible( true );
+
+ $method->invoke( $capability, 'editor', self::TEST_CAP, true );
+ $this->assertTrue( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+
+ $method->invoke( $capability, 'editor', self::TEST_CAP, false );
+ $this->assertFalse( wp_roles()->get_role( 'editor' )->has_cap( self::TEST_CAP ) );
+ }
+}
diff --git a/tests/phpunit/includes/classes/RestApiEndpointsTest.php b/tests/phpunit/includes/classes/RestApiEndpointsTest.php
index 5bfd4242f..b3907aa64 100644
--- a/tests/phpunit/includes/classes/RestApiEndpointsTest.php
+++ b/tests/phpunit/includes/classes/RestApiEndpointsTest.php
@@ -89,6 +89,15 @@ public static function wpSetUpBeforeClass( $factory ) {
// Give limited user edit_posts but not edit_others_posts so they cannot edit this post.
$user = new WP_User( self::$limited_id );
$user->add_cap( 'edit_posts' );
+ // Also grant edac_ignore_issues so dismiss tests exercise edit_post
+ // authorization specifically, independent of the ignore capability
+ // (covered separately in IgnoreCapabilityTest and
+ // test_single_issue_dismiss_forbidden_without_ignore_capability).
+ $user->add_cap( 'edac_ignore_issues' );
+ // Deliberately NOT edac_ignore_issues_globally here - it's granted
+ // per-test below where a largeBatch test specifically needs it, since
+ // holding it now bypasses the per-post edit_post loop entirely
+ // (see dismiss_issue()'s $can_ignore_globally short-circuit).
self::$post_id = $factory->post->create(
[
@@ -589,6 +598,64 @@ public function test_single_issue_dismiss_authorized_user() {
$this->assertSame( 'This is intentional', $updated_issue['ignre_comment'] );
}
+ /**
+ * Test: A user who can edit_post but lacks the edac_ignore_issues
+ * capability is forbidden from dismissing, even though edit_post alone
+ * used to be sufficient.
+ *
+ * @return void
+ */
+ public function test_single_issue_dismiss_forbidden_without_ignore_capability() {
+ global $wpdb;
+
+ $this->assertNotNull( $this->server );
+
+ // User can edit their own post, but was never granted edac_ignore_issues.
+ $user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ ( new WP_User( $user_id ) )->add_cap( 'edit_posts' );
+ wp_set_current_user( $user_id );
+
+ $own_post_id = self::factory()->post->create(
+ [
+ 'post_type' => 'post',
+ 'post_status' => 'draft',
+ 'post_author' => $user_id,
+ 'post_title' => 'Test Ignore Capability Post',
+ 'post_content' => 'Test content',
+ ]
+ );
+
+ $table_name = $wpdb->prefix . 'accessibility_checker';
+ $site_id = get_current_blog_id();
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->insert(
+ $table_name,
+ [
+ 'postid' => $own_post_id,
+ 'siteid' => $site_id,
+ 'type' => 'error',
+ 'rule' => 'single-no-ignore-cap-test',
+ 'ruletype' => 'error',
+ 'object' => 'single-no-ignore-cap-test',
+ 'recordcheck' => 1,
+ 'user' => $user_id,
+ 'ignre' => 0,
+ 'ignre_global' => 0,
+ ],
+ [ '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ]
+ );
+ $issue_id = $wpdb->insert_id;
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ $request = new \WP_REST_Request( 'POST', '/accessibility-checker/v1/dismiss-issue/' . $issue_id );
+ $request->set_param( 'action', 'dismiss' );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 403, $response->get_status(), 'A user without edac_ignore_issues should not be able to dismiss even their own editable post.' );
+ }
+
/**
* Test: Single issue dismissed by unauthorized user fails with 403.
*
@@ -651,11 +718,11 @@ public function test_single_issue_dismiss_unauthorized_user() {
}
/**
- * Test: Large batch dismissed by user with edit permission on all posts succeeds.
+ * Test: Large batch dismissed by a user with edac_ignore_issues_globally
+ * succeeds.
*
- * Verifies that when a user has edit_post capability for all posts
- * in a large batch, the endpoint dismisses all issues with one bulk
- * UPDATE query and returns success.
+ * Verifies that a user holding the global-ignore capability can dismiss
+ * an entire batch with one bulk UPDATE query and a 200 response.
*
* @return void
*/
@@ -664,6 +731,8 @@ public function test_large_batch_dismiss_authorized_on_all() {
$this->assertNotNull( $this->server );
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
// Create posts for batch test (limited user owns all).
// Use 'draft' so the limited user (who only has edit_posts, not edit_published_posts)
// can edit them — WordPress requires edit_published_posts for published posts.
@@ -748,6 +817,80 @@ public function test_large_batch_dismiss_authorized_on_all() {
}
}
+ /**
+ * Test: A user who can edit_post on every affected post, and has
+ * edac_ignore_issues, but was never granted edac_ignore_issues_globally,
+ * must still be forbidden from a largeBatch dismiss - per-post edit
+ * permission is not a substitute for the global-ignore capability, since
+ * largeBatch updates every row sharing the object regardless of which
+ * single issue_id the request named.
+ *
+ * @return void
+ */
+ public function test_large_batch_dismiss_forbidden_without_global_ignore_capability() {
+ global $wpdb;
+
+ $this->assertNotNull( $this->server );
+
+ // User can edit their own post and has edac_ignore_issues, but was
+ // never granted edac_ignore_issues_globally.
+ $user_id = self::factory()->user->create( [ 'role' => 'subscriber' ] );
+ $user = new WP_User( $user_id );
+ $user->add_cap( 'edit_posts' );
+ $user->add_cap( 'edac_ignore_issues' );
+
+ $post_id = self::factory()->post->create(
+ [
+ 'post_type' => 'post',
+ 'post_status' => 'draft',
+ 'post_author' => $user_id,
+ 'post_title' => 'No Global Ignore Batch Post',
+ 'post_content' => 'No Global Ignore Batch Content',
+ ]
+ );
+
+ $table_name = $wpdb->prefix . 'accessibility_checker';
+ $site_id = get_current_blog_id();
+ $batch_object = 'batch-no-global-cap-test-' . wp_generate_uuid4();
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->insert(
+ $table_name,
+ [
+ 'postid' => $post_id,
+ 'siteid' => $site_id,
+ 'type' => 'error',
+ 'rule' => 'batch-no-global-cap-test',
+ 'ruletype' => 'error',
+ 'object' => $batch_object,
+ 'recordcheck' => 1,
+ 'user' => $user_id,
+ 'ignre' => 0,
+ 'ignre_global' => 0,
+ ],
+ [ '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ]
+ );
+ $issue_id = $wpdb->insert_id;
+
+ wp_set_current_user( $user_id );
+
+ $request = new \WP_REST_Request( 'POST', '/accessibility-checker/v1/dismiss-issue/' . $issue_id );
+ $request->set_param( 'action', 'dismiss' );
+ $request->set_param( 'largeBatch', true );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 403, $response->get_status(), 'largeBatch dismiss without edac_ignore_issues_globally should return 403 even when the user can edit every affected post.' );
+
+ $updated_issue = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT ignre FROM %i WHERE id = %d', $table_name, $issue_id ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ $this->assertSame( '0', $updated_issue['ignre'], 'Issue should not have been dismissed.' );
+ }
+
/**
* Test: Large batch dismiss only affects rows sharing the same rule, not just the same object.
*
@@ -762,6 +905,11 @@ public function test_large_batch_dismiss_only_affects_matching_rule() {
$this->assertNotNull( $this->server );
+ // largeBatch itself requires edac_ignore_issues_globally now (PRO-1239) -
+ // unrelated to the rule+object scoping this test targets, but a
+ // prerequisite to reach the code path at all.
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
$post_1 = self::factory()->post->create(
[
'post_type' => 'post',
@@ -905,6 +1053,9 @@ public function test_large_batch_reopen_only_affects_matching_rule() {
$this->assertNotNull( $this->server );
+ // largeBatch itself requires edac_ignore_issues_globally now (PRO-1239).
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
$post_1 = self::factory()->post->create(
[
'post_type' => 'post',
@@ -1030,13 +1181,19 @@ public function test_large_batch_reopen_only_affects_matching_rule() {
}
/**
- * Test: Large batch dismiss succeeds for a user who cannot edit another post that shares
- * only the object (different rule), because the rule filter excludes that post's issue
- * from the batch entirely.
+ * Test: Large batch dismiss does not touch a row that shares only the object
+ * (different rule) on a post the actor can't individually edit, even though
+ * PRO-1239 makes edac_ignore_issues_globally bypass the per-post edit_post
+ * loop entirely once granted.
*
- * Before this fix, the batch query matched on object alone, so this same setup would have
- * required the limited user to also have edit_post on the admin-owned post and would 403
- * without it — even though that post's issue is an unrelated rule violation.
+ * Before the PRO-1264 fix, the batch query matched on object alone, so this
+ * unrelated-rule row would have been silently touched too. After PRO-1239,
+ * this test can no longer prove "succeeds despite lacking edit_post on that
+ * post" (edac_ignore_issues_globally is required just to reach this code
+ * path, and once granted it bypasses per-post checks for every row in
+ * scope regardless of rule) - but the rule filter excluding that row from
+ * the batch in the first place remains independently meaningful and is
+ * what this test now verifies.
*
* @return void
*/
@@ -1045,6 +1202,8 @@ public function test_large_batch_dismiss_succeeds_when_unrelated_rule_row_is_on_
$this->assertNotNull( $this->server );
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
$limited_post = self::factory()->post->create(
[
'post_type' => 'post',
@@ -1089,7 +1248,7 @@ public function test_large_batch_dismiss_succeeds_when_unrelated_rule_row_is_on_
);
$issue_id = $wpdb->insert_id;
- // Admin-owned post's issue: same object, DIFFERENT rule. Limited user cannot edit this post.
+ // Admin-owned post's issue: same object, DIFFERENT rule.
$wpdb->insert(
$table_name,
[
@@ -1120,7 +1279,7 @@ public function test_large_batch_dismiss_succeeds_when_unrelated_rule_row_is_on_
$this->assertSame(
200,
$response->get_status(),
- 'Large batch dismiss should succeed even though an unrelated-rule issue on an unauthorized post shares the object.'
+ 'Large batch dismiss should succeed for the authorized user.'
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Need fresh data for assertions.
@@ -1130,29 +1289,32 @@ public function test_large_batch_dismiss_succeeds_when_unrelated_rule_row_is_on_
$this->assertSame(
'0',
$unrelated_ignre,
- 'The unrelated-rule issue on the unauthorized post must stay open -- the batch succeeding must be because it was excluded by the rule filter, not because permissions were skipped.'
+ 'A row sharing only the object (different rule) must NOT be dismissed, even though edac_ignore_issues_globally would otherwise bypass the per-post edit_post check for it.'
);
}
/**
* Test: Large batch dismissed by user with partial authorization fails before bulk query.
*
- * Verifies that when a user can edit only SOME posts in a large batch,
- * the endpoint returns rest_forbidden BEFORE executing the bulk UPDATE query,
- * ensuring no data is modified when permission checks fail.
+ * Verifies that a user with edac_ignore_issues_globally can dismiss a
+ * batch that includes a post they do NOT personally have edit_post on -
+ * the whole point of the capability is to bypass that per-post
+ * ownership check, not just to unlock largeBatch requests in general.
*
* @return void
*/
- public function test_large_batch_dismiss_authorized_on_some() {
+ public function test_large_batch_dismiss_bypasses_per_post_check_with_global_capability() {
global $wpdb;
$this->assertNotNull( $this->server );
- // Create posts: one owned by limited_id, one by admin_id.
- // Use 'draft' for the limited user's post so they can edit it with only edit_posts
- // (WordPress requires edit_published_posts to edit published posts, which limited_id lacks).
- // This correctly models partial authorization: the limited user CAN edit their draft post
- // but CANNOT edit the admin-owned published post.
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
+ // Create posts: one owned by limited_id, one by admin_id. limited_id
+ // only has edit_posts (not edit_others_posts), so without the
+ // global-ignore bypass they could edit_post on the first but not the
+ // second - that's exactly the distinction this test proves no longer
+ // matters once edac_ignore_issues_globally is granted.
$limited_post = self::factory()->post->create(
[
'post_type' => 'post',
@@ -1178,7 +1340,7 @@ public function test_large_batch_dismiss_authorized_on_some() {
$batch_object = 'batch-partial-authorized-test-' . wp_generate_uuid4();
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
- // Create first issue on limited_id's post (limited user CAN edit).
+ // Create first issue on limited_id's own post.
// Both rows share the same rule AND object so they land in the same batch.
$wpdb->insert(
$table_name,
@@ -1198,7 +1360,9 @@ public function test_large_batch_dismiss_authorized_on_some() {
);
$first_issue_id = $wpdb->insert_id;
- // Create second issue on admin_id's post (limited user CANNOT edit).
+ // Create second issue on admin_id's post - limited_id has no
+ // edit_post on this one, which is exactly what the global capability
+ // should bypass.
$wpdb->insert(
$table_name,
[
@@ -1226,26 +1390,116 @@ public function test_large_batch_dismiss_authorized_on_some() {
$response = $this->server->dispatch( $request );
- // Verify response is 403 Forbidden.
- $this->assertSame( 403, $response->get_status(), 'Large batch dismiss with partial authorization should return 403.' );
+ // Verify response is successful despite limited_id lacking edit_post
+ // on the admin-owned post - the global capability is the gate now.
+ $this->assertSame( 200, $response->get_status(), 'Large batch dismiss with edac_ignore_issues_globally should succeed even across posts the user cannot individually edit.' );
- // Verify NO issues were updated (permission check failed before bulk query).
+ // Verify BOTH issues were updated, including the one on the post
+ // limited_id doesn't own.
$updated_issues = $wpdb->get_results(
$wpdb->prepare( 'SELECT id, ignre FROM %i WHERE object = %s', $table_name, $batch_object ),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $this->assertCount( 2, $updated_issues, 'Both issues in the batch should be updated.' );
foreach ( $updated_issues as $issue ) {
- $this->assertSame( '0', $issue['ignre'], 'No issues should be updated when permission check fails.' );
+ $this->assertSame( '1', $issue['ignre'], 'Both issues should be dismissed, including the one on the post the user cannot individually edit.' );
}
+
+ // The route's own permission_callback does a separate edit_post lookup
+ // against whichever post the URL's issue_id resolves to, before the
+ // handler above (and its per-post bypass) ever runs - $first_issue_id
+ // resolves to limited_id's own post, so this assertion alone can't
+ // prove that lookup also respects the global capability. See
+ // test_large_batch_dismiss_permission_callback_bypasses_edit_post_on_representative_post()
+ // for the case where the URL's own post isn't one the user can edit.
}
/**
- * Test: Large batch dismissed by user with no authorization fails.
+ * Test: the dismiss-issue route's permission_callback must not require
+ * edit_post on the URL's own representative post when the request is a
+ * largeBatch global-ignore - that check runs before dismiss_issue() (and
+ * its per-post bypass) is ever reached, so gating it on ownership of one
+ * specific post would block exactly the requests edac_ignore_issues_globally
+ * exists to allow, any time that one post isn't personally owned by the
+ * caller.
*
- * Verifies that when a user cannot edit ANY posts in a large batch,
- * the endpoint returns rest_forbidden immediately and no data is modified.
+ * @return void
+ */
+ public function test_large_batch_dismiss_permission_callback_bypasses_edit_post_on_representative_post() {
+ global $wpdb;
+
+ $this->assertNotNull( $this->server );
+
+ ( new WP_User( self::$limited_id ) )->add_cap( 'edac_ignore_issues_globally' );
+
+ // Post owned by admin - limited_id has no edit_post on this one.
+ $admin_post = self::factory()->post->create(
+ [
+ 'post_type' => 'post',
+ 'post_status' => 'publish',
+ 'post_author' => self::$admin_id,
+ 'post_title' => 'Admin-Only Representative Post',
+ 'post_content' => 'Admin-Only Representative Content',
+ ]
+ );
+
+ $table_name = $wpdb->prefix . 'accessibility_checker';
+ $site_id = get_current_blog_id();
+ $batch_object = 'batch-representative-post-test-' . wp_generate_uuid4();
+
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->insert(
+ $table_name,
+ [
+ 'postid' => $admin_post,
+ 'siteid' => $site_id,
+ 'type' => 'error',
+ 'rule' => 'batch-representative-post',
+ 'ruletype' => 'error',
+ 'object' => $batch_object,
+ 'recordcheck' => 1,
+ 'user' => self::$admin_id,
+ 'ignre' => 0,
+ 'ignre_global' => 0,
+ ],
+ [ '%d', '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%d' ]
+ );
+ // The URL's issue_id resolves to $admin_post - the post limited_id
+ // cannot edit - which is exactly what the permission_callback's own
+ // edit_post lookup would otherwise gate on.
+ $issue_id = $wpdb->insert_id;
+
+ wp_set_current_user( self::$limited_id );
+
+ $request = new \WP_REST_Request( 'POST', '/accessibility-checker/v1/dismiss-issue/' . $issue_id );
+ $request->set_param( 'action', 'dismiss' );
+ $request->set_param( 'largeBatch', true );
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame(
+ 200,
+ $response->get_status(),
+ 'largeBatch dismiss with edac_ignore_issues_globally should not be blocked by the permission_callback\'s edit_post check on the URL\'s own representative post.'
+ );
+
+ $updated_issue = $wpdb->get_row(
+ $wpdb->prepare( 'SELECT ignre FROM %i WHERE id = %d', $table_name, $issue_id ),
+ ARRAY_A
+ );
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+
+ $this->assertSame( '1', $updated_issue['ignre'], 'Issue should have been dismissed.' );
+ }
+
+ /**
+ * Test: Large batch dismissed by a user without edac_ignore_issues_globally
+ * fails, even though every affected post happens to belong to someone
+ * else (a scenario that would also fail the per-post edit_post loop, if
+ * that loop were ever reached - it isn't here, since the capability gate
+ * runs first for every largeBatch request).
*
* @return void
*/