diff --git a/phpunit.xml b/phpunit.xml index 557c77f2..10e90ab1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -10,4 +10,7 @@ tests/Alley/WP/ + + + diff --git a/src/alley/wp/alleyvate/class-feature.php b/src/alley/wp/alleyvate/class-feature.php index 01031862..ab3d3f00 100644 --- a/src/alley/wp/alleyvate/class-feature.php +++ b/src/alley/wp/alleyvate/class-feature.php @@ -62,6 +62,18 @@ public function filtered_boot(): void { */ $load = apply_filters( "alleyvate_load_{$this->handle}", $load ); + /** + * Filters whether to load the given Alleyvate feature. + * + * The dynamic portion of the hook name, `$this->$this->handle`, refers to the + * machine name for the feature. Filtering in this instance is based on the + * secondary parameter, environment,which should be used to determine whether + * to load or not. + * + * @param bool $load Whether to load the feature. Default true. + */ + $load = apply_filters( "alleyvate_load_{$this->handle}_in_environment", $load, wp_get_environment_type() ); + if ( $load ) { $this->booted = true; $this->origin->boot(); diff --git a/src/alley/wp/alleyvate/features/class-disable-alley-authors.php b/src/alley/wp/alleyvate/features/class-disable-alley-authors.php new file mode 100644 index 00000000..e9c2f8d0 --- /dev/null +++ b/src/alley/wp/alleyvate/features/class-disable-alley-authors.php @@ -0,0 +1,173 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * @package wp-alleyvate + */ + +namespace Alley\WP\Alleyvate\Features; + +use Alley\WP\Types\Feature; + +/** + * Removes the impact of Alley accounts on the frontend of client websites by: + * - Ensuring Alley users do not have author archive pages. + * - Ensuring Byline Manager and Co-Authors Plus profiles linked to Alley users do not have author archive pages. + * - Filtering Alley account usernames to display as "Staff" on the frontend. + */ +final class Disable_Alley_Authors implements Feature { + + /** + * Add an early hook to decide if this feature should load or not, based on the environment. + */ + public function __construct() { + add_filter( 'alleyvate_load_disable_alley_authors_in_environment', [ self::class, 'restrict_to_environment' ], 999, 2 ); + } + + /** + * Accepts whether or not the feature should load, as well as the current environment, + * to allow for disabling this feature on certain environments. + * + * @param bool $load Whether or not to load the feature. + * @param string $environment The loaded environment. + * @return bool + */ + public static function restrict_to_environment( $load, $environment ): bool { + if ( ! $load ) { + return $load; + } + + $allowed_environments = apply_filters( 'alleyvate_disable_alley_authors_environments', [ 'production' ] ); + + return in_array( $environment, $allowed_environments, true ); + } + + /** + * Boot the feature. + */ + public function boot(): void { + add_action( 'template_include', [ self::class, 'disable_staff_archives' ], 999 ); + add_filter( 'get_the_author_display_name', [ self::class, 'filter__get_the_author_display_name' ], 999, 2 ); + add_filter( 'author_link', [ self::class, 'filter__author_link' ], 999, 2 ); + } + + /** + * Filters the author archive URL. + * + * @param string $link The author link. + * @param int $author_id The author ID. + * @return string + */ + public static function filter__author_link( $link, $author_id ): string { + if ( ! is_singular() ) { + return $link; + } + + $author = get_user_by( 'ID', $author_id ); + if ( ! self::is_staff_author( $author->user_email ) ) { + return $link; + } + + return get_home_url(); + } + + /** + * Action fired once the post data has been set up. Passes the post and query by + * reference, so we can filter the objects directly. + * + * @param string $display_name The current post we are filtering. + * @param int $author_id The author ID. + * @return string + */ + public static function filter__get_the_author_display_name( $display_name, $author_id ): string { + $author = get_user_by( 'ID', $author_id ); + + if ( ! self::is_staff_author( $author->user_email ) ) { + return $display_name; + } + + return __( 'Staff', 'alley' ); + } + + /** + * Disable author archives for Alley--and other "general staff"--accounts. + * + * @param string $template The template currently being included. + * @return string The template to ultimately include. + */ + public static function disable_staff_archives( string $template ): string { + global $wp_query; + // If this isn't an author archive, skip it. + if ( ! is_author() ) { + return $template; + } + + $author = $wp_query->get_queried_object(); + + if ( ! self::is_staff_author( $author->user_email ) ) { + return $template; + } + $wp_query->set_404(); + status_header( 404 ); + nocache_headers(); + return get_404_template(); + } + + /** + * Generate an array of authors in the database that are to be defined as "Staff" + * authors and not attributable authors. + * + * @param string $email The email address to compare against. + * @return int[] + */ + public static function is_staff_author( string $email ): bool { + /** + * Filters which domains to use for defining staff users in the user database. + * + * @param string[] $domains The array of domains. Defaults to alley domains. + */ + $domains = apply_filters( + 'alleyvate_staff_author_domains', + [ + 'alley.com', + 'alley.co', + 'local.test', + ] + ); + + $domains = array_map( + /** + * Force domains to take the format of an email domain, to avoid + * false positives like `alley.com@example.com` which is a valid + * email. + * + * @param string $domain The domain to filter. + * @return string The filtered domain value. + */ + function ( $domain ) { + if ( trim( $domain, '@' ) !== $domain ) { + return $domain; + } + + return '@' . $domain; + }, + $domains + ); + + return (bool) array_reduce( + $domains, + function ( $carry, $domain ) use ( $email ) { + if ( ! $carry ) { + $carry = str_contains( $email, $domain ); + } + return $carry; + }, + false + ); + } +} diff --git a/src/alley/wp/alleyvate/load.php b/src/alley/wp/alleyvate/load.php index 84ee4da0..8ed38246 100644 --- a/src/alley/wp/alleyvate/load.php +++ b/src/alley/wp/alleyvate/load.php @@ -109,6 +109,10 @@ function load(): void { 'disable_block_editor_rest_api_preload_paths', new Features\Disable_Block_Editor_Rest_Api_Preload_Paths(), ), + new Feature( + 'disable_alley_authors', + new Features\Disable_Alley_Authors(), + ), ); $plugin->boot(); diff --git a/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php b/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php new file mode 100644 index 00000000..5dc5cc83 --- /dev/null +++ b/tests/Alley/WP/Alleyvate/Features/DisableAlleyAuthorsTest.php @@ -0,0 +1,342 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * @package wp-alleyvate + */ + +declare( strict_types=1 ); + +namespace Alley\WP\Alleyvate\Features; + +use Alley\WP\Alleyvate\Feature; + +use Mantle\Database\Model\User; +use Mantle\Testkit\Test_Case; +use Mantle\Testing\Concerns\Refresh_Database; + +/** + * Tests for confirming Alley usernames authors do not appear on the frontend as authors. + */ +final class DisableAlleyAuthorsTest extends Test_Case { + use Refresh_Database; + + /** + * Feature instance. + * + * @var Disable_Alley_Authors + */ + private Disable_Alley_Authors $feature; + + /** + * Alley User test account. + * + * @var Mantle\Database\Model\User + */ + private User $alley_account; + + /** + * Non-Alley User test account. + * + * @var Mantle\Database\Model\User + */ + private User $non_alley_account; + + /** + * Determine if Byline Manager is installed and available. + * + * @var bool + */ + private bool $byline_manager_installed; + + /** + * Determine if Co-Authors Plus is installed and available. + * + * @var bool + */ + private bool $co_authors_plus_installed; + + /** + * Set up the test. + */ + protected function setUp(): void { + parent::setUp(); + + $this->feature = new Disable_Alley_Authors(); + + $this->alley_account = $this->factory()->user->as_models()->create_and_get( + [ + 'user_email' => 'user@alley.com', + 'role' => 'administrator', + ] + ); + + $this->non_alley_account = $this->factory()->user->as_models()->create_and_get( + [ + 'user_email' => 'user@example.com', + 'role' => 'editor', + ] + ); + + $this->byline_manager_installed = class_exists( 'Byline_Manager\Core_Author_Block' ); + $this->co_authors_plus_installed = class_exists( 'CoAuthors_Plus' ); + } + + /** + * Ensure Alley users (as identified by an email address at one of Alley's domains) do + * not have author archive pages (they should 404) + * + * @test + */ + public function test_ensure_alley_users_do_not_have_author_archive_pages() { + $this->feature->boot(); + + $this->factory()->post->create( [ 'post_author' => $this->alley_account->ID ] ); + $this->factory()->post->create( [ 'post_author' => $this->non_alley_account->ID ] ); + + $this->get( $this->alley_account->permalink() ) + ->assertStatus( 404 ); + + $this->get( $this->non_alley_account->permalink() ) + ->assertOk(); + } + + /** + * Ensure Co-Authors Plus profiles linked to Alley users do not have author archives + */ + public function test_ensure_co_authors_plus_profiles_linked_to_alley_users_do_not_have_author_archive() { + $this->markTestIncomplete(); + } + + /** + * Ensure Byline Manager profiles linked to Alley users do not have author archives + */ + public function test_ensure_byline_manager_profiles_linked_to_alley_users_do_not_have_author_archive() { + $this->markTestIncomplete(); + } + + /** + * Filter author names for traditional authors data so filtered users don't appear as + * their actual names, but rather a generic "Staff" name. + * + * @test + */ + public function test_alley_author_names_appear_as_generic_staff_name() { + $this->feature->boot(); + + $post = $this->factory()->post + ->as_models() + ->create_and_get( [ 'post_author' => $this->alley_account->ID ] ); + + $this->get( $post->permalink() ) + ->assertOk() + ->assertDontSee( '>' . $this->alley_account->name . '<' ) + ->assertSee( '>Staff<' ); + } + + /** + * Filter author URLs for traditional authors data so filtered users don't get author + * links. + * + * @test + */ + public function test_alley_author_urls_do_not_render() { + $this->feature->boot(); + + $post = $this->factory()->post + ->as_models() + ->create_and_get( [ 'post_author' => $this->alley_account->ID ] ); + + $this->get( $post->permalink() ) + ->assertOk() + ->assertDontSee( '/author/' . $this->alley_account->slug ); + } + + /** + * Filter author names for Co-Authors Plus so filtered users appear as "Staff" instead of + * their display name. + */ + public function test_alley_author_names_appear_as_generic_staff_name_in_co_authors_plus() { + $this->markTestIncomplete(); + } + + /** + * Filter author names for Byline Manager so filtered users appear as "Staff" instead of + * their display name. + */ + public function test_alley_author_names_appear_as_generic_staff_name_in_byline_manager() { + $this->markTestIncomplete(); + } + + /** + * Data Provider for testing emails. + * + * @return array; + */ + public static function emailProvider(): array { + return [ + [ 'user1@alley.com', true ], + [ 'user1@alley.co', true ], + [ 'user1@example.com', false ], + [ 'user1@example.co', false ], + [ 'alley.com@example.co', false ], + [ 'alley.co@example.co', false ], + ]; + } + + /** + * Generate a list of user accounts by email domain, defaulting to include Alley domains. + * + * @param string $email The email address to test. + * @param bool $expected_result Whether or not the comparison should work. + * @test + * @dataProvider emailProvider + */ + public function test_user_array_generated_by_email_domain( string $email, bool $expected_result ) { + $this->assertSame( + $expected_result, + Disable_Alley_Authors::is_staff_author( $email ), + sprintf( + 'Email %s was expected to be %s but returned %s.', + $email, + ( $expected_result ) ? 'true' : 'false', + ( ! $expected_result ) ? 'true' : 'false', + ), + ); + } + + /** + * Allow the list of domains for this feature to be filtered. To test this, we take our + * list of test emails and invert the expected results. + * + * @param string $email The email address to test. + * @param bool $expected_result Whether or not the comparison should work. + * @test + * @dataProvider emailProvider + */ + public function test_email_domains_is_filterable( string $email, bool $expected_result ) { + // Define the set of domains to be non-alley domains. + $filter = fn() => [ 'example.com', 'example.co' ]; + add_filter( 'alleyvate_staff_author_domains', $filter ); + + /* + * The filter above should provide us with the exact opposite results as defined + * in the dataProvider so we invert that expectation here. + */ + $expected_result = ! $expected_result; + + // Now that we've inverted expectations, compare against reality. + $this->assertSame( + $expected_result, + Disable_Alley_Authors::is_staff_author( $email ), + sprintf( + 'Email %s was expected to be %s but returned %s.', + $email, + ( $expected_result ) ? 'true' : 'false', + ( ! $expected_result ) ? 'true' : 'false', + ), + ); + + remove_filter( 'alleyvate_staff_author_domains', $filter ); + } + + /** + * The environment list should be filterable, with production being the default. + */ + public function test_environment_list_is_filterable() { + // Temporarily enable feature loading, but disable for all environments. + remove_filter( 'alleyvate_load_feature', '__return_false' ); + + // Allow this feature for our test environment. + $filter = fn() => [ 'development' ]; + add_filter( 'alleyvate_disable_alley_authors_environments', $filter ); + + $feature = new Feature( 'disable_alley_authors', $this->feature ); + $feature->filtered_boot(); + + $debug_information = $feature->add_debug_information( [] ); + + /* + * We can use the debug information to confirm that our feature + * was actually enabled during this process. + */ + $this->assertSame( 'Enabled', $debug_information['wp-alleyvate']['fields'][0]['value'] ); + + // Clean up. + remove_filter( 'alleyvate_disable_alley_authors_environments', $filter ); + add_filter( 'alleyvate_load_feature', '__return_false' ); + } + + /** + * Add a filter to conditionally enable/disable features by environment, which passes the + * feature and the environment name, using defaults from the feature (with the typical case + * of a feature being enabled on all environments) so this can be filtered a high level. + */ + public function test_high_level_enable_disable_filter_exists_to_allow_enabling_feature_by_environment() { + + // Temporarily enable feature loading, but disable for all environments. + remove_filter( 'alleyvate_load_feature', '__return_false' ); + + $filter = function ( $load, $environment ): bool { + if ( 'production' === $environment ) { + return true; + } + + return false; + }; + + add_filter( 'alleyvate_load_example_feature_in_environment', $filter, 10, 2 ); + + /** + * A test dummy feature that increments a counter whenever booted. + */ + $dummy = new class() implements \Alley\WP\Types\Feature { + /** + * Counter to count how many times the boot method is run. + * + * @var int + */ + public static $counter = 0; + + /** + * Boot of test dummy feature. + */ + public function boot(): void { + self::$counter++; + } + + /** + * Get the counter. + * + * @return int + */ + public function getCounter(): int { + return self::$counter; + } + }; + + $feature = new Feature( 'example_feature', $dummy ); + $feature->filtered_boot(); + + $this->assertSame( 0, $dummy->getCounter() ); + + // Remove test filter. + remove_filter( 'alleyvate_load_example_feature_in_environment', $filter ); + + // Force feature to load. + add_filter( 'alleyvate_load_example_feature_in_environment', '__return_true' ); + + $feature->filtered_boot(); + + $this->assertSame( 1, $dummy->getCounter() ); + + // Remove customizations of filters. + remove_filter( 'alleyvate_load_example_feature_in_environment', '__return_true' ); + add_filter( 'alleyvate_load_feature', '__return_false' ); + } +}