Skip to content

fix(content-gate): cascade taxonomy content rules to child terms (NPPD-1670)#283

Open
adekbadek wants to merge 1 commit into
releasefrom
nppd-1670-content-rules-handle-child-terms-for-hierarchical-taxonomies
Open

fix(content-gate): cascade taxonomy content rules to child terms (NPPD-1670)#283
adekbadek wants to merge 1 commit into
releasefrom
nppd-1670-content-rules-handle-child-terms-for-hierarchical-taxonomies

Conversation

@adekbadek

@adekbadek adekbadek commented Jun 11, 2026

Copy link
Copy Markdown
Member

All Submissions:

Changes proposed in this Pull Request:

Fixes NPPD-1670. Content gates with taxonomy-term content rules now cascade to a targeted term's child terms for hierarchical taxonomies (categories), matching WooCommerce Memberships.

Previously Content_Restriction_Control::get_post_gates() matched a post's term IDs against a rule's term IDs with a flat array_intersect, so a rule targeting a parent category did not gate posts assigned only to a child category. Restricted content leaked: a publisher reported no paywall appearing on child-category posts.

The fix adds a private expand_hierarchical_terms() helper that expands a hierarchical-taxonomy rule's target terms to include all descendants (via get_term_children()) before the intersect. Key properties:

  • Evaluation-time expansion, so child terms added after a rule is saved are covered automatically (matches Woo's dynamic behavior).
  • Symmetric for inclusion and exclusion rules.
  • No-op for non-hierarchical taxonomies (tags), and no public-contract change (private method only) — the cross-repo newspack-manager consumer is unaffected.

How to test the changes in this Pull Request:

  1. Create a category tree (parent → child → grandchild) and a post assigned only to the child or grandchild category (not the parent).
  2. Create a content gate with a content rule targeting the parent category, with registration or custom access active.
  3. Visit the child/grandchild post as a logged-out reader → the gate (paywall/regwall) now appears. Before this change it did not.
  4. Flip the rule to an exclusion rule on the parent → the child/grandchild post is no longer gated, while posts outside the subtree still are.
  5. Or run the unit tests: n test-php --filter test_content_rules (covers inclusion cascade, exclusion cascade, the one-directional guarantee, and non-hierarchical passthrough).

Rollout note

Because expansion is evaluation-time, on deploy every existing gate targeting a parent category recomputes its gated-post set with no migration step — this is a behavior-changing deploy, not a transparent fix. A per-site audit of the currently-live Access-Control sites (which gates expand and by how much) is tracked on NPPD-1670.

Other information:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your changes, as applicable?
  • Have you successfully run tests with your changes locally? (Test_Content_Gates: 56 tests / 167 assertions green; PHPCS clean.)

Copilot AI review requested due to automatic review settings June 11, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a content-gate evaluation gap where taxonomy-term rules didn’t cascade from a parent term to its descendants for hierarchical taxonomies (e.g., categories), which could allow restricted content to appear ungated on child-category posts. The change aligns Newspack’s behavior with WooCommerce Memberships by expanding hierarchical rule targets at evaluation time.

Changes:

  • Expand hierarchical taxonomy rule term IDs to include descendant terms before evaluating post matches.
  • Add unit tests covering inclusion cascade, exclusion cascade, one-directional behavior, and non-hierarchical taxonomy behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
plugins/newspack-plugin/includes/content-gate/class-content-restriction-control.php Expands hierarchical taxonomy rule targets (via get_term_children()) before intersecting with a post’s assigned term IDs.
plugins/newspack-plugin/tests/unit-tests/content-gate/content-gates.php Adds unit tests validating hierarchical descendant cascading and non-hierarchical passthrough behavior.

@adekbadek adekbadek marked this pull request as ready for review June 11, 2026 10:11
@adekbadek adekbadek requested a review from a team as a code owner June 11, 2026 10:11
…D-1670)

Content gates with taxonomy-term content rules now include a targeted
term's descendants for hierarchical taxonomies, matching the cascade
behavior of WooCommerce Memberships. Previously a rule targeting a parent
category did not gate posts assigned only to a child category, so
restricted content could leak.

Expansion happens at evaluation time via get_term_children(), so child
terms added after a rule is saved are covered automatically. Symmetric
for inclusion and exclusion rules; non-hierarchical taxonomies (tags) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adekbadek adekbadek force-pushed the nppd-1670-content-rules-handle-child-terms-for-hierarchical-taxonomies branch from 8da8d6b to fbdf5ea Compare June 11, 2026 13:00
@adekbadek adekbadek changed the base branch from main to release June 11, 2026 13:00
@adekbadek adekbadek marked this pull request as draft June 11, 2026 13:01
@adekbadek adekbadek marked this pull request as ready for review June 11, 2026 13:03
@dkoo dkoo self-assigned this Jun 11, 2026

@dkoo dkoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve with comments — no blockers. The fix is small, well-targeted, and the test coverage hits the cases that matter (parent rule + child-only post, exclusion symmetry, tag passthrough, the one-directional guarantee). Two things worth a close look before merge: (1) on deploy, every existing gate whose rule targets a parent category will silently widen — confirm the per-site audit promised in the rollout note runs and surfaces newly-captured posts; (2) expand_hierarchical_terms() is invoked inside a hot loop that runs on every restricted page render, so memoising the helper (and get_post_gates() per-post) costs nothing and avoids repeating get_term_children() per call. The other comments are cleanups: an empty-value early-continue mirroring the post_types guard, an optional typed signature on the new helper, and a small test-hygiene nit. There is a latent Premium Newsletters cron path (class-premium-newsletters.php:321) that converts an is_post_restricted() true into an ESP unsubscribe — no Newspack site uses it in production yet, but it's worth keeping in mind for whichever publisher adopts newsletter gating first.

Before deploy, run the per-site audit promised in the rollout note: enumerate every active gate's content-rule taxonomy terms, expand them through get_term_children(), and list any posts newly captured by the cascade. (Newsletter-list CPTs feed an ESP unsubscribe path that isn't in production use today, but worth including the list in the audit so it's ready the day a publisher turns newsletter gating on.)

Notes on code outside this PR’s changed lines (can’t be posted inline, so collected here):

  • plugins/newspack-plugin/includes/content-gate/class-content-restriction-control.php:184Suggestion: Early-continue when $content_rule['value'] is empty — The post_types branch a few lines above already short-circuits an empty rule value. The taxonomy branch does not: an empty inclusion rule still runs get_taxonomy() + wp_get_post_terms() + the new expand_hierarchical_terms(), then array_intersect() against [], then falls through continue 2 — same outcome, more work. php if ( empty( $content_rule['value'] ) ) { continue 2; } placed just before the get_taxonomy() call makes the intent explicit and avoids the unnecessary lookups — meaningful on the Premium Newsletters per-user-per-list cron path. Consider: Early-continue in get_post_gates() when $content_rule['value'] is empty (mirroring the post_types empty-value guard at line 175), so the taxonomy branch skips get_taxonomy() + wp_get_post_terms() for a rule that can never match.

*
* @return int[] De-duplicated term IDs including descendants.
*/
private static function expand_hierarchical_terms( $term_ids, $taxonomy ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Memoise hierarchical expansion within a request — is_post_restricted() is invoked many times per singular page render — directly from Content_Gate::is_post_restricted() (metering, gifting, comment filters, restricted-excerpt rendering, the gate output path) and once per restricted list per queued user inside the Premium Newsletters cron (class-premium-newsletters.php:321). Each call now fans out into wp_get_post_terms() + get_term_children() per taxonomy rule term.

get_term_children() is option-cached at the hierarchy layer, but it still calls get_term() per descendant to recurse — deep category trees aren't free. The existing $post_gate_id_map only memoises a positive restriction result inside is_post_restricted(); it does not short-circuit get_post_gates() and never memoises the negative case.

Two cheap wins:

  1. A static $memo = [] keyed by $taxonomy->name . ':' . $term_id inside expand_hierarchical_terms() — eliminates redundant recursion across rules and across calls.
  2. A request-scoped [$post_id => $gates] static cache around get_post_gates() — mirrors the $post_gate_id_map pattern further down and is the bigger lever for the cron loop.

Neither needs invalidation logic — both are request-scoped.

Consider: Memoise expand_hierarchical_terms() per (taxonomy, term_id) for the request lifetime; ideally also memoise get_post_gates() per $post_id (mirroring the $post_gate_id_map pattern in is_post_restricted()), so the cron loop in Premium_Newsletters::check_access() does not repeat the term-tree walk per user per list.

// that term's descendants, mirroring WooCommerce Memberships' cascade.
// The helper also casts the rule's term IDs to integers, matching the
// integer IDs from wp_get_post_terms() on the other side of the intersect.
$target_terms = self::expand_hierarchical_terms( $content_rule['value'], $taxonomy );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Acknowledge the int-cast as a quiet behaviour fix — The inline comment frames array_map( 'intval', (array) $term_ids ) as a defensive mirror of wp_get_post_terms( ..., 'fields' => 'ids' ). In practice the test suite stores some rule IDs as strings (e.g. (string) $page_id in the specific_posts tests), and array_intersect() falls back to string-equivalence — so the new intval is actually doing real normalisation work, not just defence. It's almost certainly correct and harmless, but worth either reflecting in the comment or adding a one-line test that stores a stringified term ID in a category rule's value and confirms it still gates.

*
* @return int[] De-duplicated term IDs including descendants.
*/
private static function expand_hierarchical_terms( $term_ids, $taxonomy ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Optional: type the new helper signature — Non-blocking — typed signatures aren't enforced repo-wide yet. The docblock already says int[] and \WP_Taxonomy, but the signature is untyped:

private static function expand_hierarchical_terms( $term_ids, $taxonomy ) {

If you want to pin it:

private static function expand_hierarchical_terms( array $term_ids, \WP_Taxonomy $taxonomy ): array {

The function already dereferences $taxonomy->hierarchical / $taxonomy->name, so requiring the object (not the slug) is the right contract — typing the signature makes that explicit and lets static analysis catch a future caller that passes a slug. Take it or leave it.

$child_post = $this->factory->post->create( [ 'post_category' => [ $child_cat ] ] );
$grandchild_post = $this->factory->post->create( [ 'post_category' => [ $grandchild_cat ] ] );
$other_post = $this->factory->post->create( [ 'post_category' => [ $other_cat ] ] );
$this->post_ids = array_merge( $this->post_ids, [ $parent_post, $child_post, $grandchild_post, $other_post ] );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Call reset_restriction_cache() between scenarios in test_content_rules_hierarchical_child_terms — The test runs three sequential rule configurations (inclusion targeting parent, exclusion targeting parent, inclusion targeting child) and calls get_post_gates() between them. Today get_post_gates() does not consult $post_gate_id_map so there's no false cache hit. Other multi-scenario tests in this file (institutional-access for example) do call reset_restriction_cache() between scenarios — adding the call here matches the convention and future-proofs the test if the caching strategy in get_post_gates() ever changes (see the memoise suggestion).

@github-actions github-actions Bot added the [Status] Approved Pull request has been approved label Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Status] Approved Pull request has been approved

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants