Skip to content

REST associate endpoint silently succeeds when a published shadow post has no resolvable term #68

Description

@jeremyfelt

Summary

In handle_rest_associate() (includes/taxonomy.php), the published-post branch associates the connected post using API\get_term_id( $post_id ) as the term:

wp_set_object_terms( $associated_post_id, API\get_term_id( $post_id ), API\get_taxonomy_slug( $post_id ), true );
// ...
return rest_ensure_response( [ 'success' => true, 'message' => '', 'posts' => $associated_posts->posts ] );

API\get_term_id() returns 0 when it cannot resolve a shadow term for the post. WordPress core's wp_set_object_terms() explicitly skips a non-existent integer term ID (wp-includes/taxonomy.php: "Skip if a non-existent term ID is passed."). So when get_term_id() returns 0, the call is a no-op — but the handler still returns success: true (with an empty posts array). The caller is told the association succeeded when nothing happened.

Note: this is distinct from the $append data-loss bug fixed in #67. Before that fix, this same condition was worse — wp_set_object_terms() with $append = false and an empty resolved term set would delete all of the connected post's existing shadow terms. #67 stops the data loss; this issue is the remaining silent false-success.

Conditions that trigger it

get_term_id() returns 0 for a published shadow post in these cases:

  1. Missing term. The shadow term was deleted directly (e.g. an editor with the override_shadow_terms capability removed it from the term admin UI) and the post has not been re-saved, so sync.php's recovery branch hasn't recreated it. The post is still publish, but no term exists.
  2. Term name no longer matches the post title. get_term_id() looks the term up with get_term_by( 'name', $post->post_title, $taxonomy ). The slug stays stable, but if a privileged user renames the term (its name) so it diverges from the current post title, the name lookup fails and returns 0 even though a term still exists.
  3. Title/name normalization mismatch. Edge cases where the stored term name and the post title differ after sanitization/encoding so the exact-name lookup misses.

In all three, the REST associate request returns HTTP 200 / success: true while creating no term relationship.

Suggested fix (for discussion)

Guard the published branch on a resolved term before writing, e.g.:

  • If get_term_id( $post_id ) === 0, either return success: false with an explanatory message, or recreate the missing term (mirroring sync.php's recovery branch) before associating.

Decision needed: should the endpoint fail loudly, or self-heal by recreating the term?

How to test

PHPUnit regression test (mirrors the harness already added in tests/test-multi-association.php):

public function test_rest_associate_reports_failure_when_published_term_missing(): void {
    $editor_id = $this->factory()->user->create( array( 'role' => 'editor' ) );
    wp_set_current_user( $editor_id );

    $acme_id    = $this->create_post( 'example', 'Acme' );   // publish -> term created
    $article_id = $this->create_post( 'post', 'Article' );

    // Simulate the missing-term condition: delete the term but leave the post published
    // and un-saved (so sync's recovery branch never runs).
    $acme_term = get_term_by( 'slug', 'acme', 'example_connect' );
    wp_delete_term( $acme_term->term_id, 'example_connect' );

    $request = new WP_REST_Request( 'POST', '/shadow-terms/v1/associate' );
    $request->set_param( 'postId', $acme_id );
    $request->set_param( 'associatedPostId', $article_id );
    $response = $this->rest_server->dispatch( $request );

    // Current (buggy) behavior: success === true, but the article has no terms.
    $data  = $response->get_data();
    $terms = wp_get_object_terms( $article_id, 'example_connect' );

    // Desired behavior: the response should NOT claim success while doing nothing.
    $this->assertFalse(
        $data['success'] && empty( $terms ),
        'associate must not report success when no term was actually attached.'
    );
}

Manual repro:

  1. Create and publish an example post "Acme" (a shadow term acme is created).
  2. In the shadow taxonomy admin UI (as a user with override_shadow_terms), delete the acme term. Do not re-save the Acme post.
  3. POST to /wp-json/shadow-terms/v1/associate with postId = Acme's ID and associatedPostId = any post.
  4. Observe: response is 200 { "success": true, "posts": [] }, but the target post gains no example_connect term — the association silently did not happen.

Metadata

Metadata

Assignees

No one assigned

    Labels

    developmentFront or back-end development is needed

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions