Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use WP_Session_Tokens;
use WP_Term;
use WP_User;
use WP_User_Query;

const GUEST_ROLE = 'guest-author';
const POSTS_PARAM = 'authorship';
Expand All @@ -45,6 +46,7 @@ function bootstrap(): void {
add_action( 'rest_api_init', __NAMESPACE__ . '\\register_rest_api_fields' );
add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\\enqueue_assets' );
add_action( 'pre_get_posts', __NAMESPACE__ . '\\action_pre_get_posts', 9999 );
add_action( 'pre_user_query', __NAMESPACE__ . '\\action_pre_user_query', 9999 );
add_action( 'wp', __NAMESPACE__ . '\\action_wp' );
add_action( 'wp_insert_post', [ $insert_post_handler, 'action_wp_insert_post' ], 10, 3 );
add_action( 'set_user_role', __NAMESPACE__ . '\\action_set_user_role', 10, 2 );
Expand Down Expand Up @@ -828,6 +830,77 @@ function action_pre_get_posts( WP_Query $query ): void {
}, 999, 2 );
}

/**
* Makes the has_published_posts arg of WP_User_Query use attributed authors for supported post types.
*
* Replaces the clause core adds, which only checks the post_author field.
*
* @param WP_User_Query $query The user query.
*/
function action_pre_user_query( WP_User_Query $query ): void {
global $wpdb;

$has_published_posts = $query->get( 'has_published_posts' );
$blog_id = absint( $query->get( 'blog_id' ) );

// Core skips the clause in both of these cases.
if ( ! $has_published_posts || ! $blog_id ) {
return;
}

if ( true === $has_published_posts ) {
$post_types = get_post_types( [
'public' => true,
] );
} else {
$post_types = (array) $has_published_posts;
}

$supported_post_types = array_intersect( $post_types, get_supported_post_types() );
$unsupported_post_types = array_diff( $post_types, $supported_post_types );

$prepare_in = function ( array $values ) use ( $wpdb ): string {
return implode( ', ', array_map( function ( $value ) use ( $wpdb ): string {
return $wpdb->prepare( '%s', $value );
}, $values ) );
};

$prefix = $wpdb->get_blog_prefix( $blog_id );
$posts_table = $prefix . 'posts';
$term_relationships_table = $prefix . 'term_relationships';
$term_taxonomy_table = $prefix . 'term_taxonomy';
$terms_table = $prefix . 'terms';

// The clause added by core, which must match exactly.
$core_sql = " $wpdb->users.ID IN ( SELECT DISTINCT $posts_table.post_author FROM $posts_table WHERE $posts_table.post_status = 'publish' AND $posts_table.post_type IN ( " . $prepare_in( $post_types ) . ' ) )';

// Post types that Authorship doesn't handle still use the post_author field.
$unsupported_sql = empty( $unsupported_post_types )
? '1=0'
: " $wpdb->users.ID IN ( SELECT DISTINCT $posts_table.post_author FROM $posts_table WHERE $posts_table.post_status = 'publish' AND $posts_table.post_type IN ( " . $prepare_in( $unsupported_post_types ) . ' ) )';

// Author term slugs are user IDs.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
$supported_sql = empty( $supported_post_types )
? '1=0'
: $wpdb->prepare(
" $wpdb->users.ID IN (
SELECT DISTINCT CAST( t.slug AS UNSIGNED )
FROM {$posts_table} p
INNER JOIN {$term_relationships_table} tr ON p.ID = tr.object_id
INNER JOIN {$term_taxonomy_table} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
INNER JOIN {$terms_table} t ON tt.term_id = t.term_id
WHERE tt.taxonomy = %s
AND p.post_status = 'publish'
AND p.post_type IN ( " . $prepare_in( $supported_post_types ) . ' )
)',
TAXONOMY
);
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared

$query->query_where = str_replace( $core_sql, " ( ( $unsupported_sql ) OR ( $supported_sql ) )", $query->query_where );
}

/**
* Filters the list of recipients for comment moderation emails.
*
Expand Down
7 changes: 7 additions & 0 deletions tests/phpunit/includes/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@
require_once $_plugin_dir . '/plugin.php';
} );

tests_add_filter( 'init', function (): void {
register_post_type( 'test_cpt_no_author', [
'public' => true,
'supports' => [ 'title', 'editor' ],
] );
} );

require_once $_tests_dir . '/includes/bootstrap.php';
require_once __DIR__ . '/testcase.php';
require_once __DIR__ . '/email-testcase.php';
Expand Down
162 changes: 162 additions & 0 deletions tests/phpunit/test-wp-user-query.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php
/**
* WP_User_Query tests.
*
* @package authorship
*/

declare( strict_types=1 );

namespace Authorship\Tests;

use WP_User_Query;

use const Authorship\GUEST_ROLE;
use const Authorship\POSTS_PARAM;

class TestWPUserQuery extends TestCase {
public function setUp(): void {
parent::setUp();

$factory = self::factory()->post;

// Attributed to Editor, owned by Admin.
$factory->create( [
'post_author' => self::$users['admin']->ID,
'post_status' => 'publish',
POSTS_PARAM => [
self::$users['editor']->ID,
],
] );

// Attributed to Editor, owned by nobody.
$factory->create( [
'post_status' => 'publish',
POSTS_PARAM => [
self::$users['editor']->ID,
],
] );

// Attributed to Guest, owned by Author.
$factory->create( [
'post_author' => self::$users['author']->ID,
'post_status' => 'publish',
POSTS_PARAM => [
self::$users[ GUEST_ROLE ]->ID,
],
] );

// Attributed to Contributor, but not published.
$factory->create( [
'post_author' => self::$users['admin']->ID,
'post_status' => 'draft',
POSTS_PARAM => [
self::$users['contributor']->ID,
],
] );

// Owned by Author, in a post type that Authorship doesn't support.
$factory->create( [
'post_author' => self::$users['author']->ID,
'post_status' => 'publish',
'post_type' => 'test_cpt_no_author',
] );

// Owned by Admin, in a post type that Authorship doesn't support.
$factory->create( [
'post_author' => self::$users['admin']->ID,
'post_status' => 'publish',
'post_type' => 'test_cpt_no_author',
] );
}

/**
* @param bool|string[] $has_published_posts
* @return int[]
*/
private static function get_user_ids( $has_published_posts ): array {
$query = new WP_User_Query( [
'fields' => 'ID',
'orderby' => 'ID',
'order' => 'ASC',
'has_published_posts' => $has_published_posts,
] );

return array_map( 'intval', $query->get_results() );
}

public function testQueryForSupportedPostTypeReturnsAttributedAuthors(): void {
$this->assertSame(
[
self::$users['editor']->ID,
self::$users[ GUEST_ROLE ]->ID,
],
self::get_user_ids( [ 'post' ] )
);
}

public function testQueryForUnsupportedPostTypeReturnsPostAuthors(): void {
$this->assertSame(
[
self::$users['admin']->ID,
self::$users['author']->ID,
],
self::get_user_ids( [ 'test_cpt_no_author' ] )
);
}

public function testQueryForMixedPostTypesReturnsBoth(): void {
$this->assertSame(
[
self::$users['admin']->ID,
self::$users['editor']->ID,
self::$users['author']->ID,
self::$users[ GUEST_ROLE ]->ID,
],
self::get_user_ids( [ 'post', 'test_cpt_no_author' ] )
);
}

public function testQueryForAllPublicPostTypesReturnsBoth(): void {
$this->assertSame(
[
self::$users['admin']->ID,
self::$users['editor']->ID,
self::$users['author']->ID,
self::$users[ GUEST_ROLE ]->ID,
],
self::get_user_ids( true )
);
}

public function testTermsFromOtherTaxonomiesAreIgnored(): void {
$subscriber_id = self::$users['subscriber']->ID;

// A tag whose slug matches a user ID.
self::factory()->post->create( [
'post_status' => 'publish',
'tags_input' => [ (string) $subscriber_id ],
POSTS_PARAM => [
self::$users['editor']->ID,
],
] );

$this->assertNotContains( $subscriber_id, self::get_user_ids( [ 'post' ] ) );
}

public function testAuthorTermNamesAreIgnored(): void {
$editor_id = self::$users['editor']->ID;
$subscriber_id = self::$users['subscriber']->ID;

// Author term names follow the display name, which can look like another user's ID.
wp_update_user( [
'ID' => $editor_id,
'display_name' => (string) $subscriber_id,
] );

$user_ids = self::get_user_ids( [ 'post' ] );

$this->assertContains( $editor_id, $user_ids );
$this->assertNotContains( $subscriber_id, $user_ids );
}
}