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 ) && ( +
+ + { ( issue?.ignre_global === 1 || issue?.ignre_global === '1' ) && ( + <> +
{ __( 'Scope:', 'accessibility-checker' ) }
+
{ __( 'All pages', 'accessibility-checker' ) }
+ + ) } + { ( issue?.ignre_user_name || issue?.user ) && ( + <> +
{ __( 'By:', 'accessibility-checker' ) }
+
{ decodeEntities( issue.ignre_user_name || issue.user ) }
+ + ) } + { issue?.ignre_date && ( + <> +
{ __( 'On:', 'accessibility-checker' ) }
+
{ decodeEntities( issue.ignre_date ) }
+ + ) } +
+ ) } + { issue?.ignre_comment && ( +
+

+ { __( 'Reason for dismissal:', 'accessibility-checker' ) } +

+
+ { issue.ignre_comment } +
+
+ ) } + { canUseGlobalDismiss && isGloballyDismissed && ( +
+ +
+ ) } + { canDismiss && ! isGloballyDismissed && ( +
+ +
+ ) } + + ); + } else if ( canDismiss ) { + dismissBody = ( +
{ + e.preventDefault(); + handleToggleIgnore( true, dismissGlobally ); + } } + > + + +
+ + { canUseGlobalDismiss && ( + ( + + +
+ ) } + /> + ) } + + + ); + } else { + dismissBody = ( + + { __( 'You do not have permission to dismiss issues.', 'accessibility-checker' ) } + + ); + } + return (
@@ -148,164 +326,7 @@ const DismissPanel = ( { { error } ) } - { isIgnored ? ( - <> - { ( issue?.user || issue?.ignre_user_name || issue?.ignre_date || isGloballyDismissed ) && ( -
- - { ( issue?.ignre_global === 1 || issue?.ignre_global === '1' ) && ( - <> -
{ __( 'Scope:', 'accessibility-checker' ) }
-
{ __( 'All pages', 'accessibility-checker' ) }
- - ) } - { ( issue?.ignre_user_name || issue?.user ) && ( - <> -
{ __( 'By:', 'accessibility-checker' ) }
-
{ decodeEntities( issue.ignre_user_name || issue.user ) }
- - ) } - { issue?.ignre_date && ( - <> -
{ __( 'On:', 'accessibility-checker' ) }
-
{ decodeEntities( issue.ignre_date ) }
- - ) } -
- ) } - { issue?.ignre_comment && ( -
-

- { __( 'Reason for dismissal:', 'accessibility-checker' ) } -

-
- { issue.ignre_comment } -
-
- ) } - { isGloballyDismissed ? ( -
- -
- ) : ( -
- -
- ) } - - ) : ( -
{ - e.preventDefault(); - handleToggleIgnore( true, dismissGlobally ); - } } - > - - -
- - { canDismissGlobally && ( - ( - - -
- ) } - /> - ) } -
- - ) } + { 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 */