From f32d26ef479c257a6811ccadfe7aa82784b88c05 Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Wed, 20 Nov 2019 10:40:45 -0500 Subject: [PATCH] Organize email & authentication code (#101) * Split Auth functionality out to new file * Move email-related code into the email file * Use `assertFalse` for boolean assertions * Add `can_manage_pledge` to check user or token against a given pledge Pulled out of e9763f6678e99e2987fbb87a59179d0bfa922708 * Remove duplicate test --- .../wporg-5ftf/includes/authentication.php | 186 ++++++++++++++ plugins/wporg-5ftf/includes/email.php | 240 ++++++++---------- plugins/wporg-5ftf/includes/pledge-form.php | 96 +------ plugins/wporg-5ftf/includes/pledge-meta.php | 6 +- plugins/wporg-5ftf/includes/pledge.php | 29 +-- plugins/wporg-5ftf/index.php | 1 + .../tests/{test-email.php => test-auth.php} | 76 ++++-- 7 files changed, 360 insertions(+), 274 deletions(-) create mode 100644 plugins/wporg-5ftf/includes/authentication.php rename plugins/wporg-5ftf/tests/{test-email.php => test-auth.php} (81%) diff --git a/plugins/wporg-5ftf/includes/authentication.php b/plugins/wporg-5ftf/includes/authentication.php new file mode 100644 index 0000000..d86f32c --- /dev/null +++ b/plugins/wporg-5ftf/includes/authentication.php @@ -0,0 +1,186 @@ + wp_generate_password( TOKEN_LENGTH, false ), + // todo Ideally should encrypt at rest, see https://core.trac.wordpress.org/ticket/24783. + 'expiration' => time() + ( 2 * HOUR_IN_SECONDS ), + 'use_once' => $use_once, + ); + + /* + * Tying the token to a specific pledge is important for security, otherwise companies could get a valid token + * for their pledge, and use it to edit other company's pledges. + * + * Similarly, tying it to specific actions is also important, to protect against CSRF attacks. + * + * This function intentionally requires the caller to pass in a pledge ID and action, so that it can guarantee + * that each token will be unique across pledges and actions. + */ + update_post_meta( $pledge_id, TOKEN_PREFIX . $action, $auth_token ); + + $auth_url = add_query_arg( + array( + 'action' => $action, + 'pledge_id' => $pledge_id, + 'auth_token' => $auth_token['value'], + ), + get_permalink( $action_page_id ) + ); + + // todo include a "this lnk will expire in 10 hours and after its used once" message too? + // probably, but what's the best way to do that DRYly? + + return $auth_url; +} + +/** + * Verify whether or not a given authentication token is valid. + * + * These tokens are more secure than WordPress' imitation nonces because they cannot be reused[1], and expire + * in a shorter timeframe. Like WP nonces, though, they must be tied to a specific action and post object in order + * to prevent misuse. + * + * [1] In some cases, tokens can be reused, when that is explicitly required for their flow. For an example, see + * the documentation in `get_authentication_url()`. + * + * @param int $pledge_id + * @param string $action + * @param string $unverified_token + * + * @return bool + */ +function is_valid_authentication_token( $pledge_id, $action, $unverified_token ) { + $verified = false; + $valid_token = get_post_meta( $pledge_id, TOKEN_PREFIX . $action, true ); + + /* + * Later on we'll compare the value to user input, and the user could input null/false/etc, so let's guarantee + * that the thing we're comparing against is really what we expect it to be. + */ + if ( ! is_array( $valid_token ) || ! array_key_exists( 'value', $valid_token ) || ! array_key_exists( 'expiration', $valid_token ) ) { + return false; + } + + if ( ! is_string( $valid_token['value'] ) || TOKEN_LENGTH !== strlen( $valid_token['value'] ) ) { + return false; + } + + if ( ! is_string( $unverified_token ) || TOKEN_LENGTH !== strlen( $unverified_token ) ) { + return false; + } + + if ( $valid_token && $valid_token['expiration'] > time() && hash_equals( $valid_token['value'], $unverified_token ) ) { + $verified = true; + + // Tokens should not be reusable -- to increase security -- unless explicitly required to fulfill their purpose. + if ( false !== $valid_token['use_once'] ) { + delete_post_meta( $pledge_id, TOKEN_PREFIX . $action ); + } + } + + return $verified; +} + +/** + * Checks user capabilties or auth token to see if this user can edit the given pledge. + * + * @param int $requested_pledge_id The pledge to edit. + * @param string $auth_token The supplied auth token to check. + * + * @return true|WP_Error + */ +function can_manage_pledge( $requested_pledge_id, $auth_token = '' ) { + // A valid token superceeds other auth methods. + if ( true === is_valid_authentication_token( $requested_pledge_id, 'manage_pledge', $auth_token ) ) { + return true; + } else if ( is_user_logged_in() ) { + if ( current_user_can( 'manage_options' ) ) { + return true; + } + return new \WP_Error( + 'invalid_token', + sprintf( + __( 'You don\'t have permissions to edit this page. Request an edit link.', 'wporg-5ftf' ), + get_permalink( $requested_pledge_id ) + ) + ); + } + + return new \WP_Error( + 'invalid_token', + sprintf( + __( 'Your link has expired, please obtain a new one.', 'wporg-5ftf' ), + get_permalink( $requested_pledge_id ) + ) + ); +} + diff --git a/plugins/wporg-5ftf/includes/email.php b/plugins/wporg-5ftf/includes/email.php index 9a4c1d9..277f2a8 100644 --- a/plugins/wporg-5ftf/includes/email.php +++ b/plugins/wporg-5ftf/includes/email.php @@ -1,58 +1,16 @@ wp_generate_password( TOKEN_LENGTH, false ), - // todo Ideally should encrypt at rest, see https://core.trac.wordpress.org/ticket/24783. - 'expiration' => time() + ( 2 * HOUR_IN_SECONDS ), - 'use_once' => $use_once, - ); - - /* - * Tying the token to a specific pledge is important for security, otherwise companies could get a valid token - * for their pledge, and use it to edit other company's pledges. - * - * Similarly, tying it to specific actions is also important, to protect against CSRF attacks. - * - * This function intentionally requires the caller to pass in a pledge ID and action, so that it can guarantee - * that each token will be unique across pledges and actions. - */ - update_post_meta( $pledge_id, TOKEN_PREFIX . $action, $auth_token ); - - $auth_url = add_query_arg( - array( - 'action' => $action, - 'pledge_id' => $pledge_id, - 'auth_token' => $auth_token['value'], - ), - get_permalink( $action_page_id ) - ); - - // todo include a "this lnk will expire in 10 hours and after its used once" message too? - // probably, but what's the best way to do that DRYly? - - return $auth_url; -} - -/** - * Verify whether or not a given authentication token is valid. - * - * These tokens are more secure than WordPress' imitation nonces because they cannot be reused[1], and expire - * in a shorter timeframe. Like WP nonces, though, they must be tied to a specific action and post object in order - * to prevent misuse. - * - * [1] In some cases, tokens can be reused, when that is explicitly required for their flow. For an example, see - * the documentation in `get_authentication_url()`. - * - * @param int $pledge_id - * @param string $action - * @param string $unverified_token + * @param int $pledge_id The ID of the pledge. + * @param int $action_page_id The ID of the page that the user will be taken back to, in order to process their + * confirmation request. * * @return bool */ -function is_valid_authentication_token( $pledge_id, $action, $unverified_token ) { - $verified = false; - $valid_token = get_post_meta( $pledge_id, TOKEN_PREFIX . $action, true ); +function send_pledge_confirmation_email( $pledge_id, $action_page_id ) { + $pledge = get_post( $pledge_id ); + + $message = sprintf( + "Thanks for pledging your organization's time to contribute to the WordPress open source project! Please confirm this email address in order to publish your pledge:\n\n%s", + Auth\get_authentication_url( $pledge_id, 'confirm_pledge_email', $action_page_id ) + ); + + return send_email( + $pledge->{'5ftf_org-pledge-email'}, + 'Please confirm your email address', + $message, + $pledge_id + ); +} + +/** + * Send contributors an email to confirm their participation. + * + * @param int $pledge_id + * @param int|null $contributor_id Optional. Send to a specific contributor instead of all. + */ +function send_contributor_confirmation_emails( $pledge_id, $contributor_id = null ) { + $pledge = get_post( $pledge_id ); + $subject = "Confirm your {$pledge->post_title} sponsorship"; /* - * Later on we'll compare the value to user input, and the user could input null/false/etc, so let's guarantee - * that the thing we're comparing against is really what we expect it to be. + * Only fetch unconfirmed ones, because we might be resending confirmation emails, and we shouldn't resend to + * confirmed contributors. */ - if ( ! is_array( $valid_token ) || ! array_key_exists( 'value', $valid_token ) || ! array_key_exists( 'expiration', $valid_token ) ) { - return false; + $unconfirmed_contributors = Contributor\get_pledge_contributors( $pledge->ID, 'pending', $contributor_id ); + + foreach ( $unconfirmed_contributors as $contributor ) { + $user = get_user_by( 'login', $contributor->post_title ); + + /* + * Their first name is ideal, but their username is the best fallback because `nickname`, `display_name`, + * etc are too formal. + */ + $name = $user->first_name ? $user->first_name : '@' . $user->user_nicename; + + /* + * This uses w.org login accounts instead of `Auth\get_authentication_url()`, because the reasons for using + * tokens for pledges don't apply to contributors, accounts are more secure, and they provide a better UX + * because there's no expiration. + */ + $message = + "Howdy $name, {$pledge->post_title} has created a Five for the Future pledge on WordPress.org and listed you as one of the contributors that they sponsor to contribute to the WordPress open source project. You can view their pledge at:\n\n" . + + get_permalink( $pledge_id ) . "\n\n" . + + "To confirm that they're sponsoring your contributions, please review your pledges at:\n\n" . + + get_permalink( get_page_by_path( 'my-pledges' ) ) . "\n\n" . + + "Please also update your WordPress.org profile to include the number of hours per week that you contribute, and the teams that you contribute to:\n\n" . + + "https://profiles.wordpress.org/me/profile/edit/group/5/\n\n" . + + "If {$pledge->post_title} isn't sponsoring your contributions, then you can ignore this email, and you won't be listed on their pledge."; + + $user = get_user_by( 'login', $contributor->post_title ); + send_email( $user->user_email, $subject, $message, $pledge_id ); } - - if ( ! is_string( $valid_token['value'] ) || TOKEN_LENGTH !== strlen( $valid_token['value'] ) ) { - return false; - } - - if ( ! is_string( $unverified_token ) || TOKEN_LENGTH !== strlen( $unverified_token ) ) { - return false; - } - - if ( $valid_token && $valid_token['expiration'] > time() && hash_equals( $valid_token['value'], $unverified_token ) ) { - $verified = true; - - // Tokens should not be reusable -- to increase security -- unless explicitly required to fulfill their purpose. - if ( false !== $valid_token['use_once'] ) { - delete_post_meta( $pledge_id, TOKEN_PREFIX . $action ); - } - } - - return $verified; +} + +/** + * Email the pledge admin a temporary link they can use to manage their pledge. + * + * @param int $pledge_id + * + * @return true|WP_Error + */ +function send_manage_pledge_link( $pledge_id ) { + $admin_email = get_post( $pledge_id )->{ META_PREFIX . 'org-pledge-email' }; + + if ( ! is_email( $admin_email ) ) { + return new WP_Error( 'invalid_email', 'Invalid email address.' ); + } + + $subject = __( 'Updating your Pledge', 'wporg-5ftf' ); + $message = + 'Howdy, please open this link to update your pledge:' . "\n\n" . + + Auth\get_authentication_url( + $pledge_id, + 'manage_pledge', + get_page_by_path( 'manage-pledge' )->ID, + // The token needs to be reused so that the admin can view the form, submit it, and view the result. + false + ); + + $result = send_email( $admin_email, $subject, $message, $pledge_id ); + + if ( ! $result ) { + $result = new WP_Error( 'email_failed', 'Email failed to send' ); + } + + return $result; } diff --git a/plugins/wporg-5ftf/includes/pledge-form.php b/plugins/wporg-5ftf/includes/pledge-form.php index fc6422e..c5eca80 100755 --- a/plugins/wporg-5ftf/includes/pledge-form.php +++ b/plugins/wporg-5ftf/includes/pledge-form.php @@ -6,7 +6,7 @@ namespace WordPressDotOrg\FiveForTheFuture\PledgeForm; use WordPressDotOrg\FiveForTheFuture; -use WordPressDotOrg\FiveForTheFuture\{ Pledge, PledgeMeta, Contributor, Email }; +use WordPressDotOrg\FiveForTheFuture\{ Auth, Contributor, Email, Pledge, PledgeMeta }; use WP_Error, WP_User; defined( 'WPINC' ) || die(); @@ -49,7 +49,7 @@ function render_form_new() { $pledge_id = filter_input( INPUT_GET, 'pledge_id', FILTER_VALIDATE_INT ); $complete = true; - Pledge\send_pledge_confirmation_email( $pledge_id, get_post()->ID ); + Email\send_pledge_confirmation_email( $pledge_id, get_post()->ID ); } ob_start(); @@ -130,7 +130,7 @@ function process_pledge_confirmation_email( $pledge_id, $action, $unverified_tok return true; } - $email_confirmed = Email\is_valid_authentication_token( $pledge_id, $action, $unverified_token ); + $email_confirmed = Auth\is_valid_authentication_token( $pledge_id, $action, $unverified_token ); if ( $email_confirmed ) { update_post_meta( $pledge_id, $meta_key, true ); @@ -138,62 +138,12 @@ function process_pledge_confirmation_email( $pledge_id, $action, $unverified_tok 'ID' => $pledge_id, 'post_status' => 'publish', ) ); - send_contributor_confirmation_emails( $pledge_id ); + Email\send_contributor_confirmation_emails( $pledge_id ); } return $email_confirmed; } -/** - * Send contributors an email to confirm their participation. - * - * @param int $pledge_id - * @param int|null $contributor_id Optional. Send to a specific contributor instead of all. - */ -function send_contributor_confirmation_emails( $pledge_id, $contributor_id = null ) { - $pledge = get_post( $pledge_id ); - $subject = "Confirm your {$pledge->post_title} sponsorship"; - - /* - * Only fetch unconfirmed ones, because we might be resending confirmation emails, and we shouldn't resend to - * confirmed contributors. - */ - $unconfirmed_contributors = Contributor\get_pledge_contributors( $pledge->ID, 'pending', $contributor_id ); - - foreach ( $unconfirmed_contributors as $contributor ) { - $user = get_user_by( 'login', $contributor->post_title ); - - /* - * Their first name is ideal, but their username is the best fallback because `nickname`, `display_name`, - * etc are too formal. - */ - $name = $user->first_name ? $user->first_name : '@' . $user->user_nicename; - - /* - * This uses w.org login accounts instead of `Email\get_authentication_url()`, because the reasons for using - * tokens for pledges don't apply to contributors, accounts are more secure, and they provide a better UX - * because there's no expiration. - */ - $message = - "Howdy $name, {$pledge->post_title} has created a Five for the Future pledge on WordPress.org and listed you as one of the contributors that they sponsor to contribute to the WordPress open source project. You can view their pledge at:\n\n" . - - get_permalink( $pledge_id ) . "\n\n" . - - "To confirm that they're sponsoring your contributions, please review your pledges at:\n\n" . - - get_permalink( get_page_by_path( 'my-pledges' ) ) . "\n\n" . - - "Please also update your WordPress.org profile to include the number of hours per week that you contribute, and the teams that you contribute to:\n\n" . - - "https://profiles.wordpress.org/me/profile/edit/group/5/\n\n" . - - "If {$pledge->post_title} isn't sponsoring your contributions, then you can ignore this email, and you won't be listed on their pledge."; - - $user = get_user_by( 'login', $contributor->post_title ); - Email\send_email( $user->user_email, $subject, $message, $pledge_id ); - } -} - /** * Render the form(s) for managing existing pledges. * @@ -262,7 +212,7 @@ function process_manage_link_request() { if ( $valid_admin_email && $valid_admin_email === $unverified_admin_email ) { $verified_pledge_id = $unverified_pledge_id; // The addresses will only match is the pledge ID is valid. - $message_sent = send_manage_pledge_link( $verified_pledge_id ); + $message_sent = Email\send_manage_pledge_link( $verified_pledge_id ); if ( $message_sent ) { $result = __( "Thanks! We've emailed you a link you can open in order to update your pledge.", 'wporg-5ftf' ); @@ -281,42 +231,6 @@ function process_manage_link_request() { return $result; } - -/** - * Email the pledge admin a temporary link they can use to manage their pledge. - * - * @param int $pledge_id - * - * @return true|WP_Error - */ -function send_manage_pledge_link( $pledge_id ) { - $admin_email = get_post( $pledge_id )->{ PledgeMeta\META_PREFIX . 'org-pledge-email' }; - - if ( ! is_email( $admin_email ) ) { - return new WP_Error( 'invalid_email', 'Invalid email address.' ); - } - - $subject = __( 'Updating your Pledge', 'wporg-5ftf' ); - $message = - 'Howdy, please open this link to update your pledge:' . "\n\n" . - - Email\get_authentication_url( - $pledge_id, - 'manage_pledge', - get_page_by_path( 'manage-pledge' )->ID, - // The token needs to be reused so that the admin can view the form, submit it, and view the result. - false - ); - - $result = Email\send_email( $admin_email, $subject, $message, $pledge_id ); - - if ( ! $result ) { - $result = new WP_Error( 'email_failed', 'Email failed to send' ); - } - - return $result; -} - /** * Process a submission from the Manage Existing Pledge form. * diff --git a/plugins/wporg-5ftf/includes/pledge-meta.php b/plugins/wporg-5ftf/includes/pledge-meta.php index 240aca8..4a8b53f 100755 --- a/plugins/wporg-5ftf/includes/pledge-meta.php +++ b/plugins/wporg-5ftf/includes/pledge-meta.php @@ -6,7 +6,7 @@ namespace WordPressDotOrg\FiveForTheFuture\PledgeMeta; use WordPressDotOrg\FiveForTheFuture; -use WordPressDotOrg\FiveForTheFuture\{ Pledge, PledgeForm, Contributor, XProfile }; +use WordPressDotOrg\FiveForTheFuture\{ Contributor, Email, Pledge, PledgeForm, XProfile }; use WP_Post, WP_Error; defined( 'WPINC' ) || die(); @@ -274,14 +274,14 @@ function save_pledge( $pledge_id, $pledge ) { save_pledge_meta( $pledge_id, $submitted_meta ); if ( filter_input( INPUT_POST, 'resend-pledge-confirmation' ) ) { - Pledge\send_pledge_confirmation_email( + Email\send_pledge_confirmation_email( filter_input( INPUT_GET, 'resend-pledge-id', FILTER_VALIDATE_INT ), get_page_by_path( 'for-organizations' )->ID ); } if ( filter_input( INPUT_POST, 'resend-contributor-confirmation' ) ) { - PledgeForm\send_contributor_confirmation_emails( + Email\send_contributor_confirmation_emails( $pledge_id, filter_input( INPUT_GET, 'resend-contributor-id', FILTER_VALIDATE_INT ) ); diff --git a/plugins/wporg-5ftf/includes/pledge.php b/plugins/wporg-5ftf/includes/pledge.php index 750a33e..1acf7ef 100755 --- a/plugins/wporg-5ftf/includes/pledge.php +++ b/plugins/wporg-5ftf/includes/pledge.php @@ -7,7 +7,7 @@ namespace WordPressDotOrg\FiveForTheFuture\Pledge; use WordPressDotOrg\FiveForTheFuture; -use WordPressDotOrg\FiveForTheFuture\{ Contributor, Email }; +use WordPressDotOrg\FiveForTheFuture\{ Auth, Contributor, Email }; use WP_Error, WP_Query; use const WordPressDotOrg\FiveForTheFuture\PledgeMeta\META_PREFIX; @@ -189,37 +189,12 @@ function create_new_pledge( $name ) { // The pledge's meta data is saved at this point via `save_pledge_meta()`, which is a `save_post` callback. if ( ! is_wp_error( $pledge_id ) ) { - send_pledge_confirmation_email( $pledge_id, get_post()->ID ); + Email\send_pledge_confirmation_email( $pledge_id, get_post()->ID ); } return $pledge_id; } -/** - * Email pledge manager to confirm their email address. - * - * @param int $pledge_id The ID of the pledge. - * @param int $action_page_id The ID of the page that the user will be taken back to, in order to process their - * confirmation request. - * - * @return bool - */ -function send_pledge_confirmation_email( $pledge_id, $action_page_id ) { - $pledge = get_post( $pledge_id ); - - $message = sprintf( - "Thanks for pledging your organization's time to contribute to the WordPress open source project! Please confirm this email address in order to publish your pledge:\n\n%s", - Email\get_authentication_url( $pledge_id, 'confirm_pledge_email', $action_page_id ) - ); - - return Email\send_email( - $pledge->{'5ftf_org-pledge-email'}, - 'Please confirm your email address', - $message, - $pledge_id - ); -} - /** * Filter query for archive & search pages to ensure we're only showing the expected data. * diff --git a/plugins/wporg-5ftf/index.php b/plugins/wporg-5ftf/index.php index a69b2da..070ed41 100755 --- a/plugins/wporg-5ftf/index.php +++ b/plugins/wporg-5ftf/index.php @@ -25,6 +25,7 @@ add_action( 'plugins_loaded', __NAMESPACE__ . '\load' ); function load() { $running_unit_tests = isset( $_SERVER['_'] ) && false !== strpos( $_SERVER['_'], 'phpunit' ); + require_once get_includes_path() . 'authentication.php'; require_once get_includes_path() . 'contributor.php'; require_once get_includes_path() . 'email.php'; require_once get_includes_path() . 'pledge.php'; diff --git a/plugins/wporg-5ftf/tests/test-email.php b/plugins/wporg-5ftf/tests/test-auth.php similarity index 81% rename from plugins/wporg-5ftf/tests/test-email.php rename to plugins/wporg-5ftf/tests/test-auth.php index b64a265..2a9c595 100644 --- a/plugins/wporg-5ftf/tests/test-email.php +++ b/plugins/wporg-5ftf/tests/test-auth.php @@ -1,12 +1,12 @@ assertSame( false, $verified ); + $this->assertFalse( $verified ); } /** @@ -130,7 +130,7 @@ class Test_Email extends WP_UnitTestCase { self::$token['value'] ); - $this->assertSame( false, $verified ); + $this->assertFalse( $verified ); } /** @@ -142,16 +142,7 @@ class Test_Email extends WP_UnitTestCase { $second_verification = is_valid_authentication_token( self::$pledge->ID, self::$action, self::$token['value'] ); $this->assertSame( true, $first_verification ); - $this->assertSame( false, $second_verification ); - } - - /** - * @covers ::is_valid_authentication_token - */ - public function test_valid_tokens_are_rejected_for_other_pages() { - $verified = is_valid_authentication_token( self::$page->ID, self::$action, self::$token['value'] ); - - $this->assertSame( false, $verified ); + $this->assertFalse( $second_verification ); } /** @@ -169,7 +160,7 @@ class Test_Email extends WP_UnitTestCase { self::$token['value'] ); - $this->assertSame( false, $verified ); + $this->assertFalse( $verified ); } /** @@ -192,7 +183,7 @@ class Test_Email extends WP_UnitTestCase { $this->assertSame( 'array', gettype( $new_token ) ); $this->assertArrayHasKey( 'value', $new_token ); $this->assertNotSame( $new_token['value'], self::$token['value'] ); - $this->assertSame( false, $verified ); + $this->assertFalse( $verified ); } /** @@ -224,6 +215,55 @@ class Test_Email extends WP_UnitTestCase { $verified = is_valid_authentication_token( self::$pledge->ID, $action, $token['value'] ); - $this->assertSame( false, $verified ); + $this->assertFalse( $verified ); + } + + /** + * @covers ::can_manage_pledge + */ + public function test_user_with_token_can_manage_pledge() { + $action = 'manage_pledge'; + $token = self::_get_token( self::$pledge->ID, $action, self::$page->ID, false ); + + $result = can_manage_pledge( self::$pledge->ID, $token['value'] ); + $this->assertTrue( $result ); + } + + /** + * @covers ::can_manage_pledge + */ + public function test_user_without_token_cant_manage_pledge() { + $result = can_manage_pledge( self::$pledge->ID, '' ); + $this->assertWPError( $result ); + } + + /** + * @covers ::can_manage_pledge + */ + public function test_logged_in_admin_can_manage_pledge() { + $user = self::factory()->user->create( + array( + 'role' => 'administrator', + ) + ); + wp_set_current_user( $user ); + + $result = can_manage_pledge( self::$pledge->ID ); + $this->assertTrue( $result ); + } + + /** + * @covers ::can_manage_pledge + */ + public function test_logged_in_subscriber_cant_manage_pledge() { + $user = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $user ); + + $result = can_manage_pledge( self::$pledge->ID ); + $this->assertWPError( $result ); } }