WordPress: Whitelist, Blacklist and the Unit Testing Rabbit Hole

posted in: Uncategorized | 1

It started simple enough, a WordPress issue was brought to my attention that affected both the TruBox server and the OpenETC server. It was behaviour that I was certain had changed, but none-the-less the behaviour was correct. Well, now it was, but it was messing us up.

The behaviour was this, when WordPress multisite domains are white-listed (called Limited Email Registrations) or black-listed (called Banned Email Domains) they apply to not only new registrations on the sign-up page, but also when super-admins and site admins tried to add users to either the network or individual sub-sites. And site admins wished to manually add users from any domain to their sites.

Note: there is a large thank you list/references at the end of this blog. I owe a huge debt of gratitude to the developers on that list for taking the time from their development work to write things up. Particularly, these folks write “next step” stuff. By that I mean, once you are ready to move beyond plugins and copying and pasting functions from Stack Overflow to your functions.php files these guys will take you to the next step.

Following on the box above, I give an additionally tip of my hat to Alan Levine and Tom Woodward. These two have been role models and teachers in how to reason about solving problems with WordPress in educational contexts. Go check out their blogs. Right now, I’ll wait.

So, in solving this user account creation problem, I first dug into how the rejection of email domains works in WordPress. The magic happens (for this problem) in the wpmu_validate_user_signup function. Specifically, as a user sign-up is being processed, it adds an error code and error message to an WP_Error object for each occurring issue with the username and email, including one for the black list and/or one for the white list infractions.

The wpmu_validate_user_signup function works like this: it takes in a username and email, does a bunch of validity checks and then returns an array containing sanitized versions of the username and email along with a WP_Error object. If there are any errors in the error object the registration is aborted and the error message(s) displayed to the user.

Luckily, the wpmu_validate_user_signup function includes a filter hook. It is this hook that will let us intercept the return array with the WP_Error object and filter out the white list and black list errors for super admins and site admins and let them add users from other domains. It looks like this.

Note: one of the downsides of this approach is that the error code and error messages are strings. So the constants in what I’ve written below will need to be changed for other languages or other changes/differences in those strings.

First we lay down some constants for the targeted error code and the two corresponding error messages to reduce typing and keep the code cleaner.

/**
 * WP_Error Object error code.
 *
 * @const string $target_error_code
 */
define( 'TARGET_ERROR_CODE', 'user_email' );

/**
 * WP_Error Object message(s) that we are targeting.
 *
 * @const string $black_list_msg
 */
define( 'BLACK_LIST_MSG', 'You cannot use that email address to signup. We are having problems with them blocking some of our email. Please use another email provider.' );

/**
 * WP_Error Object message(s) that we are targeting.
 *
 * @const string $black_list_msg
 */
define( 'WHITE_LIST_MSG', 'Sorry, that email address is not allowed!' );

We then make sure that the user is both logged in and is either site admin or super admin. We do the latter by checking a capability, in this case promote_users.

/**
 * Tests if the current user is logged in and has admin/super admin privileges.
 *
 * @return bool
 */
function user_can_add(): bool {
	if ( is_user_logged_in() && current_user_can( 'promote-users' ) ) {
		return true;
	} else {
		return false;
	}
}

Then we want to check and see if we even need to bother processing the error object. If it doesn’t have either the black list error, white list error or both then we don’t need to process it at all. We’ll simply return what was there to begin with.

Notes: I have pulled the WP_Error object out of the return array prior to this function. Also, I’m using class methods of the WP_Error object to extract the array of error messages.

/**
 * Tests if an actual error object is passed, if it is empty and
 * if it contains a black or white list error message. Returns true
 * if error object needs processing
 *
 * @param \WP_Error $original_error Error object passed in the wpmu_validate_user_signup results.
 *
 * @return bool
 */
function e_needs_processing( \WP_Error $original_error ): bool {
	if ( ! empty( $original_error->errors ) // Is not empty.
		&& ( // and contains a black/white list error.
			in_array( BLACK_LIST_MSG, $original_error->get_error_messages( TARGET_ERROR_CODE ), true )
			|| in_array( WHITE_LIST_MSG, $original_error->get_error_messages( TARGET_ERROR_CODE ), true )
		)
	) {
		return true;
	} else {
		return false;
	}
}

Now for the actual meat of this. There are a couple of different approaches one could take, but I chose an approach that creates a new, empty WP_Error object and copies over errors that we need to keep but not the ones that we don’t. There are probably some philosophical reasons I chose this approach that you could coax out of me for the cost of a pint, but there was a compelling practical reason as well: the range and functionality of the class methods in the WP_Error object simply made this approach easier.

In this next function I first extract the error object from the results array and then call the above two ‘check’ functions. If the checks don’t pass, we drop out of this function returning the original results array. Then a new WP_Error object is created followed by a nested pair of foreach loops. In the first one we cycle through all of the error_codes, in the second we cycle through all of the error messages within each of the error_codes.

If the error code is not the one we are looking for (‘user_email’) then we add the code and message to the new WP_Error object and move on to the next. If it is the error code we are looking for and not either of the error messages we are looking for then we likewise add the code and message to the WP_Error object. There is also a parallel array that holds data entries for each error_code, we slip those into the new object as needed. This gives us a complete error object minus the two troublesome error messages.

Finally, in the results array we replace the original WP_Error object with the new one we created and return the filtered results from the function.

/**
 * Checks user permission, existence of WP_Error object messages to remove,
 * builds new array, returns $results object with new array in place of the
 * original.
 *
 * @param result $result Return of wpmu_validate_user_signup.
 *
 * @return result $result
 */
function on_loaded( $result ) {
	// get error object from wpmu_validate_user_signup result.
	$original_error = $result['errors'];

	// return original array if auth conditions not met,
	// or black/white list messages don't exist.
	if ( ! user_can_add() || ! e_needs_processing( $original_error ) ) {
		return $result;
	}
	
	// create a new (empty) WP_Error() object for holding transferred entries.
	$new_error = new WP_Error();
	
	// Run through error codes and messages keep all but white/black list errors.
	foreach ( $original_error->get_error_codes() as $code ) {
		foreach ( $original_error->get_error_messages( $code ) as $message ) {
			if ( TARGET_ERROR_CODE !== $code ) {
				$new_error->add( $code, $message );
			} else {
				// Don't add the white/black list messages.
				if ( BLACK_LIST_MSG !== $message && WHITE_LIST_MSG !== $message ) {
					$new_error->add( $code, $message );
				}
			}
		}
		// add data entries for original to new error object.
		if ( ! is_null( $original_error->get_error_data( $code ) ) ) {
			$new_error->add_data( $original_error->get_error_data( $code ), $code );
		}
	}
	// Put the new error object back in $result and return.
	$result['errors'] = $new_error;
	return $result;
};

The last little bit of tidy up is tying this into WordPress. As mentioned above there is a filter hook in the wpmu_validate_user_signup function. Let’s look at how we make use of this. At the very end of the wpmu_validate_user_signup function we find the following:

/**
	 * Filters the validated user registration details.
	 *
	 * This does not allow you to override the username or email of the user during
	 * registration. The values are solely used for validation and error handling.
	 *
	 * @since MU (3.0.0)
	 *
	 * @param array $result {
	 *     The array of user name, email and the error messages.
	 *
	 *     @type string   $user_name     Sanitized and unique username.
	 *     @type string   $orig_username Original username.
	 *     @type string   $user_email    User email address.
	 *     @type WP_Error $errors        WP_Error object containing any errors found.
	 * }
	 */
	return apply_filters( 'wpmu_validate_user_signup', $result );

This is what gives the hook a place and a name. (The name in this case is the same as the function wpmu_validate_user_signup) We receive the $results array from this function and it expects one in return from us. The developers have also thoughtfully provided us with the array structure in the hook comments.

So we tie into it like this.

/**
 * Hooking into WordPress
 */
function truucde_init() {
	add_filter( 'wpmu_validate_user_signup', 'TruUcde\on_loaded', 10 );
};

I didn’t need to put the add_filter statement inside a function, but it was an easy way to initialize my code which I intend to deploy as an mu-plugin, so I wanted to keep it single-page, single file. In the add_filter parameters I am identifying the filter hook by name and then pointing to my primary function as the callback. WordPress will add this to its filter list and if the wpmu_validate_user_signup function is ever called, then my function gets called at the end of the process.

For the sake of completeness, this is the end bit of my php file that calls the truucde_init function.

truucde_init();

So where’s this rabbit hole you referred to?

Yeah, I know. I promised a rabbit hole. Hang on tight Alice. I try to almost never do something I already know how to do. (Keep your, “yeah, it shows” sniggers to yourselves.) By that I mean that I try to expand my knowledge base and skill set in each new project.

The above version of this was more or less my first planned approach but my third coded approach. I found an approach on the interwebs that busted everything down to arrays and used array procedures to remove the target error code and messages from the arrays. I tinkered with this and soon came to the conclusion that my life was enhanced by keeping the WP_Error class intact and using its class methods, so I abandoned this approach.

Then I thought I would take a stab at an object-oriented, class-based approach. It seems that most people initialize a class-based plugin from an initialization function in an entry-point file. I needed to keep it all in one file and couldn’t get initialization working properly. Also, to be perfectly honest, my class looked pretty much my like my final result wrapped in a class declaration, so I simplified.

The benefit of this is that my initial planned approach, which involved one big long rambling function, was split into smaller functions which are much easier to test.

Test?

It is not my intention from here on to discuss the testing process in detail, merely my flight down the rabbit hole. I may at some point dig into testing in further posts. I did find the interweb quite helpful in setting up, but less so in how to approach testing specific types of things.

The past few months I’ve been working with Javascript and concomitant unit testing. I thought this project, being small and relatively simple, would provide a great opportunity to explore unit testing in PHP and WordPress. (Seeing any rabbits yet?)

As far as I can tell, the most popular testing framework for the PHP/Wordpress pairing is PhpUnit. In order to install PhpUnit you need to use something called Composer, which is a package manager for PHP.

Got PhpUnit installed and then started looking on the interwebs to see how people were unit testing their WordPress stuff. It seems that the WordPress command line interface, WP-CLI, can be used to ‘scaffold’ some testing-related things for your plugin. I found this documentation unduly succinct and searched further afield for some more detail and advice.

A good many of the posts I encountered began by distinguishing unit testing from integration testing. The aim of unit testing is to test your code in isolation. More about isolation in a bit. The aim of integration testing is to test the integration of your code with other code that it may rely on, such as the WordPress code base. The consensus was that both are valuable processes.

Why this matters is that while called the WordPress Unit Testing Scaffold actually provides for integration testing. A couple of things that almost all posts on testing assert (you testers will see what I did there) is that testing makes your code better. Not only in the obvious way of uncovering bugs and errors, but in a couple of valuable educative ways. The first is that you often wind up refactoring and simplifying your code to facilitate the testing process. The second is you either need a deep understanding of how code functions to properly test it, or you will get one along the way.

I’m reminded of something a colleague said years back, “programmers never wanted a “goto” statement, but they would love a “came from” statement’.

Unit Testing

So, I set about unit testing my code. The basic paradigm is you set small segments, let’s call them units, of your code to test. Functions, methods and classes are popular units. So you set a function to its task with known inputs and an expected output. If the expected output matches the actual output the test succeeds.

You will probably notice from my code above that almost every function draws from WordPress somehow, so how do we isolate code under test? It’s at this point you discover the wonderful world of mocks, spies, and test doubles. These are similar, but let’s carry the discussion on with mocks. What a mock does is pretend to be the outside code and you tell it what you want it to return.

So, for example, I use the WordPress function is_user_logged_in() in my code above. The is_user_logged_in() function will return ‘true’ or ‘false’. So in unit testing we establish a mock, we tell it to pretend to be is_user_logged_in() and what to return. Let’s say ‘true’ for a couple of tests and then ‘false’ for a couple.

As I dug deeper in to this I discovered additional packages that are specifically designed for mocking WordPress functions. The two main ones are WP_Mock and Brain Monkey. I found Brain Monkey’s functionality more appealing, but at the end of the day the more recent updates and simplicity of WP_Mock headed me in that direction.

Once I caught the swing of things my plugin was nicely unit tested. One of the things I found most interesting was the need to plot and test all of the possible routes through the code. I dusted off memories of my first logic course to develop a fairly complex truth table. While the truth table sat stinking on one monitor, I refactored my code on the other to break out the two ‘check’ functions I reference above. It cut my testing work immeasurable (well it can probably be measured somehow, but it’s a figure of speach).

Integration testing

Then I turned to integration testing. I ran the WP-CLI command:

$ wp scaffold plugin truucde

I created a database and ran the install script. See, what the WP scaffold does is spins up a clean WordPress environment for each of your tests, so you need a test database as well.

Then as part of test setup you can create users, subsites, add options, etc. from your test code using WordPress functions. It is very cool.

A short while later my code was both Unit tested and Integration tested and ready to go.

Thanks

Okay, this is already too long, I probably need to take a detailed look at the testing in another post, time to deliver some thanks and wrap up.

I mentioned Alan Levine and Tom Woodward above, veterans in solving WordPress problems in education. They both write in a walkthough style as they unfurl the specific challenges and how they solve them with code. Also, they are all round great guys.

Another three WordPress bloggers that I rely on quite a bit are: Tom McFarlin, Carl Alexander and Igor Benic. All three of these guys are excellent developers and give back to the community by writing on a range of WordPress development topics. They also tackle the harder stuff for once you are ready to move on from introductory development. There is oodles to be learned from them.

There is another resource I wish to single out. It is valuable as a front-to-back WordPress plugin creation tutorial. Too often novice developers are fed little bits and pieces (my testing odyssey comes to mind), but in this tutorial the Codetab folks track a plugin case study from inception through to testing. I particularly found their “types of testing problems” approach valuable. https://www.codetab.org/tutorial/wordpress-plugin-development/introduction/

Additionally, each of the following posts provided value and guidance during my first trip through testing:
https://gist.github.com/benlk/d1ac0240ec7c44abd393
https://taylorlovett.com/2014/07/04/wp_unittestcase-the-hidden-api/
https://www.slideshare.net/ptahdunbar/automated-testing-in-wordpress-really
https://miya0001.github.io/wp-unit-docs/
https://premium.wpmudev.org/blog/unit-testing-wordpress-plugins-phpunit/
https://www.codetab.org/tutorial/wordpress-plugin-development/unit-test/plugin-unit-testing/
http://planetozh.com/blog/wp-content/uploads/2015/01/WordPress-Plugin-Unit-Tests.pdf
https://premium.wpmudev.org/blog/unit-testing-wordpress-plugins-phpunit/

And that’s a wrap folks.

  1. cogdog

    This is amazingly thorough and valuable Troy, so don’t you dare sell your documentation skills short. I’m leaving with ideas seeded to look into unit testing, and I know where to “goto” to get started.

Leave a Reply

Your email address will not be published. Required fields are marked *