fix(content-gate): cascade taxonomy content rules to child terms (NPPD-1670)#283
Conversation
There was a problem hiding this comment.
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. |
…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>
8da8d6b to
fbdf5ea
Compare
dkoo
left a comment
There was a problem hiding this comment.
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:184— Suggestion: Early-continue when$content_rule['value']is empty — Thepost_typesbranch a few lines above already short-circuits an empty rule value. The taxonomy branch does not: an empty inclusion rule still runsget_taxonomy()+wp_get_post_terms()+ the newexpand_hierarchical_terms(), thenarray_intersect()against[], then falls throughcontinue 2— same outcome, more work.php if ( empty( $content_rule['value'] ) ) { continue 2; }placed just before theget_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 inget_post_gates()when$content_rule['value']is empty (mirroring thepost_typesempty-value guard at line 175), so the taxonomy branch skipsget_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 ) { |
There was a problem hiding this comment.
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:
- A
static $memo = []keyed by$taxonomy->name . ':' . $term_idinsideexpand_hierarchical_terms()— eliminates redundant recursion across rules and across calls. - A request-scoped
[$post_id => $gates]static cache aroundget_post_gates()— mirrors the$post_gate_id_mappattern 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 ); |
There was a problem hiding this comment.
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 ) { |
There was a problem hiding this comment.
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 ] ); |
There was a problem hiding this comment.
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).
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 flatarray_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 (viaget_term_children()) before the intersect. Key properties:newspack-managerconsumer is unaffected.How to test the changes in this Pull Request:
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:
Test_Content_Gates: 56 tests / 167 assertions green; PHPCS clean.)