diff --git a/includes/content-gate/class-block-visibility.php b/includes/content-gate/class-block-visibility.php new file mode 100644 index 0000000000..85c650fa58 --- /dev/null +++ b/includes/content-gate/class-block-visibility.php @@ -0,0 +1,338 @@ + [ + 'type' => 'string', + 'default' => 'visible', + ], + 'newspackAccessControlMode' => [ + 'type' => 'string', + 'default' => 'gate', + ], + 'newspackAccessControlGateIds' => [ + 'type' => 'array', + 'default' => [], + 'items' => [ + 'type' => 'integer', + ], + ], + 'newspackAccessControlRules' => [ + 'type' => 'object', + 'default' => [], + ], + ] + ); + return $args; + } + + /** + * Enqueue block editor assets. + */ + public static function enqueue_block_editor_assets() { + if ( ! current_user_can( 'edit_others_posts' ) ) { + return; + } + + $available_post_types = array_column( + Content_Restriction_Control::get_available_post_types(), + 'value' + ); + // get_post_type() returns false in the Site Editor / widget screens where + // no post is in context — in_array( false, [...], true ) is false, so the + // asset is correctly suppressed. This mirrors the guard in Content_Gate. + if ( ! in_array( get_post_type(), $available_post_types, true ) ) { + return; + } + + $asset_file = dirname( NEWSPACK_PLUGIN_FILE ) . '/dist/content-gate-block-visibility.asset.php'; + if ( ! file_exists( $asset_file ) ) { + return; + } + $asset = require $asset_file; + + wp_enqueue_script( + 'newspack-content-gate-block-visibility', + Newspack::plugin_url() . '/dist/content-gate-block-visibility.js', + $asset['dependencies'], + $asset['version'], + true + ); + + wp_localize_script( + 'newspack-content-gate-block-visibility', + 'newspackBlockVisibility', + [ + 'available_access_rules' => array_map( + function( $rule ) { + unset( $rule['callback'] ); + return $rule; + }, + Access_Rules::get_access_rules() + ), + 'available_gates' => array_values( + array_map( + function( $gate ) { + return [ + 'id' => $gate['id'], + 'title' => $gate['title'], + ]; + }, + Content_Gate::get_gates( Content_Gate::GATE_CPT, 'publish' ) + ) + ), + ] + ); + } + + /** + * Per-request cache: keyed by "{user_id}:{md5(rules)}" or "gate:{user_id}:{md5(gate_ids)}". + * + * @var bool[] + */ + private static $rules_match_cache = []; + + /** + * Reset the per-request cache. Used in unit tests only. + */ + public static function reset_cache_for_tests() { + self::$rules_match_cache = []; + } + + /** + * Public wrapper for tests. Calls evaluate_rules_for_user(). + * + * @param array $rules Rules array. + * @param int $user_id User ID. + * @return bool + */ + public static function evaluate_rules_for_user_public( $rules, $user_id ) { + return self::evaluate_rules_for_user( $rules, $user_id ); + } + + /** + * Evaluate whether a user matches the block's custom access rules (with caching). + * + * @param array $rules Parsed newspackAccessControlRules attribute. + * @param int $user_id User ID (0 for logged-out). + * @return bool True if user matches (should be treated as "matching reader"). + */ + private static function evaluate_rules_for_user( $rules, $user_id ) { + $cache_key = $user_id . ':' . md5( wp_json_encode( $rules ) ); + if ( isset( self::$rules_match_cache[ $cache_key ] ) ) { + return self::$rules_match_cache[ $cache_key ]; + } + + $result = self::compute_rules_match( $rules, $user_id ); + self::$rules_match_cache[ $cache_key ] = $result; + return $result; + } + + /** + * Evaluate whether a user matches any of the given gate's access rules (with caching). + * + * Deleted or unpublished gates are silently skipped. If every gate in the list + * is deleted/unpublished the result is true (pass-through — no active restriction). + * + * @param int[] $gate_ids Array of np_content_gate post IDs. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function evaluate_gate_rules_for_user( $gate_ids, $user_id ) { + $cache_key = 'gate:' . $user_id . ':' . md5( wp_json_encode( $gate_ids ) ); + if ( isset( self::$rules_match_cache[ $cache_key ] ) ) { + return self::$rules_match_cache[ $cache_key ]; + } + + $result = self::compute_gate_rules_match( $gate_ids, $user_id ); + self::$rules_match_cache[ $cache_key ] = $result; + return $result; + } + + /** + * Compute whether a user matches the access rules of any of the given gates (uncached). + * + * @param int[] $gate_ids Array of np_content_gate post IDs. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function compute_gate_rules_match( $gate_ids, $user_id ) { + $has_active_gate = false; + + foreach ( $gate_ids as $gate_id ) { + $gate = Content_Gate::get_gate( $gate_id ); + + // Deleted gate: Content_Gate::get_gate() returns WP_Error when the post + // doesn't exist. Unpublished gates have status !== 'publish'. Both are + // skipped so only currently-active gates impose restrictions. + if ( \is_wp_error( $gate ) || 'publish' !== $gate['status'] ) { + continue; + } + + $has_active_gate = true; + + $rules = [ + 'registration' => $gate['registration'], + 'custom_access' => $gate['custom_access'], + ]; + + // OR logic: the user passes if they satisfy any single active gate's rules. + if ( self::compute_rules_match( $rules, $user_id ) ) { + return true; + } + } + + // All gates were deleted or unpublished → no active restriction → pass-through. + return ! $has_active_gate; + } + + /** + * Compute whether a user matches the block's access rules (uncached). + * + * @param array $rules Parsed newspackAccessControlRules attribute. + * @param int $user_id User ID (0 for logged-out). + * @return bool + */ + private static function compute_rules_match( $rules, $user_id ) { + $registration = $rules['registration'] ?? []; + $custom_access = $rules['custom_access'] ?? []; + + $registration_passes = true; + if ( ! empty( $registration['active'] ) ) { + if ( ! $user_id ) { + $registration_passes = false; + } elseif ( ! empty( $registration['require_verification'] ) ) { + $registration_passes = (bool) get_user_meta( $user_id, Reader_Activation::EMAIL_VERIFIED, true ); + } + } + + $access_passes = true; + if ( ! empty( $custom_access['active'] ) && ! empty( $custom_access['access_rules'] ) ) { + $access_passes = Access_Rules::evaluate_rules( $custom_access['access_rules'], $user_id ); + } + + // AND logic: both must pass when both are configured. + return $registration_passes && $access_passes; + } +} +Block_Visibility::init(); diff --git a/includes/content-gate/class-content-gate.php b/includes/content-gate/class-content-gate.php index fa2a4f756c..0d88ee2d72 100644 --- a/includes/content-gate/class-content-gate.php +++ b/includes/content-gate/class-content-gate.php @@ -110,6 +110,7 @@ public static function init() { include __DIR__ . '/class-institution.php'; include __DIR__ . '/class-user-gate-access.php'; include __DIR__ . '/class-premium-newsletters.php'; + include __DIR__ . '/class-block-visibility.php'; } /** diff --git a/src/content-gate/editor/block-visibility.test.ts b/src/content-gate/editor/block-visibility.test.ts new file mode 100644 index 0000000000..da1b14a3b0 --- /dev/null +++ b/src/content-gate/editor/block-visibility.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for block-visibility attribute registration filter. + */ + +/** + * Capture callbacks registered via addFilter, keyed by namespace. + */ +const registeredFilters: Record< string, ( settings: any, name: string ) => any > = {}; + +jest.mock( '@wordpress/hooks', () => ( { + addFilter: jest.fn( ( _hook: string, namespace: string, callback: ( settings: any, name: string ) => any ) => { + registeredFilters[ namespace ] = callback; + } ), +} ) ); + +jest.mock( '@wordpress/compose', () => ( { + createHigherOrderComponent: jest.fn( ( fn: any ) => fn ), +} ) ); +jest.mock( '@wordpress/block-editor', () => ( { InspectorControls: () => null } ) ); +jest.mock( '@wordpress/components', () => ( {} ) ); +jest.mock( '@wordpress/i18n', () => ( { __: ( s: string ) => s } ) ); +jest.mock( '@wordpress/element', () => ( { + useState: jest.fn( ( v: any ) => [ v, jest.fn() ] ), + useEffect: jest.fn(), +} ) ); +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +// Importing the module triggers the addFilter side effects. +require( './block-visibility' ); + +const attributeFilter = registeredFilters[ 'newspack-plugin/block-visibility/attributes' ]; + +describe( 'block-visibility attribute registration', () => { + it( 'adds attributes to core/group', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'adds attributes to core/stack', () => { + const result = attributeFilter( { attributes: {} }, 'core/stack' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'adds attributes to core/row', () => { + const result = attributeFilter( { attributes: {} }, 'core/row' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlRules' ); + } ); + + it( 'does not modify non-target blocks', () => { + const settings = { attributes: { align: { type: 'string' } } }; + const result = attributeFilter( settings, 'core/paragraph' ); + expect( result ).toBe( settings ); + } ); + + it( 'newspackAccessControlVisibility defaults to visible', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes.newspackAccessControlVisibility.default ).toBe( 'visible' ); + } ); + + it( 'newspackAccessControlRules defaults to empty object', () => { + const result = attributeFilter( { attributes: {} }, 'core/group' ); + expect( result.attributes.newspackAccessControlRules.default ).toEqual( {} ); + } ); + + it( 'preserves existing attributes on target blocks', () => { + const result = attributeFilter( { attributes: { align: { type: 'string' } } }, 'core/group' ); + expect( result.attributes ).toHaveProperty( 'align' ); + expect( result.attributes ).toHaveProperty( 'newspackAccessControlVisibility' ); + } ); +} ); diff --git a/src/content-gate/editor/block-visibility.tsx b/src/content-gate/editor/block-visibility.tsx new file mode 100644 index 0000000000..40fef862b1 --- /dev/null +++ b/src/content-gate/editor/block-visibility.tsx @@ -0,0 +1,419 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { InspectorControls } from '@wordpress/block-editor'; +import { + CheckboxControl, + FormTokenField, + PanelBody, + PanelRow, + TextControl, + ToggleControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControl as ToggleGroupControl, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalToggleGroupControlOption as ToggleGroupControlOption, +} from '@wordpress/components'; +import { useState, useEffect } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './editor.scss'; + +/** + * Target block types that receive access control attributes. + */ +const TARGET_BLOCKS = [ 'core/group', 'core/stack', 'core/row' ]; + +/** + * Register custom attributes on target block types. + */ +addFilter( 'blocks.registerBlockType', 'newspack-plugin/block-visibility/attributes', ( settings: BlockSettings, name: string ) => { + if ( ! TARGET_BLOCKS.includes( name ) ) { + return settings; + } + return { + ...settings, + attributes: { + ...settings.attributes, + newspackAccessControlVisibility: { + type: 'string', + default: 'visible', + }, + newspackAccessControlMode: { + type: 'string', + default: 'gate', + }, + newspackAccessControlGateIds: { + type: 'array', + default: [], + items: { type: 'integer' }, + }, + newspackAccessControlRules: { + type: 'object', + default: {}, + }, + }, + }; +} ); + +/** + * Available access rules from localized data. + */ +const availableAccessRules: Record< string, AccessRuleConfig > = window.newspackBlockVisibility?.available_access_rules ?? {}; + +/** + * Available gates from localized data. + */ +const availableGates: GateOption[] = window.newspackBlockVisibility?.available_gates ?? []; + +/** + * Whether any rules are currently active on the block. + */ +function hasActiveRules( rules: BlockVisibilityRules, mode: string, gateIds: number[] ): boolean { + if ( 'gate' === mode ) { + return gateIds.length > 0; + } + return !! rules?.registration?.active || !! rules?.custom_access?.active; +} + +/** ToggleGroupControl for the two standard visibility options. */ +const VisibilityControl = ( { + label, + help, + value, + onChange, + disabled, +}: { + label: string; + help: string; + value: string; + onChange: ( value: string ) => void; + disabled: boolean; +} ) => ( + + onChange( String( v ?? 'visible' ) ) } + isBlock + __next40pxDefaultSize + > + + + + +); + +/** + * Gate selector: a FormTokenField that lets the editor link one or more gates. + * A reader needs to satisfy any one of the selected gates' rules to match. + */ +const GateControls = ( { gateIds, onChange }: { gateIds: number[]; onChange: ( ids: number[] ) => void } ) => { + const selectedLabels = availableGates.filter( g => gateIds.includes( g.id ) ).map( g => g.title ); + + return ( + + g.title ) } + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( availableGates.filter( g => labels.includes( g.title ) ).map( g => g.id ) ); + } } + __experimentalExpandOnFocus + __next40pxDefaultSize + /> + + ); +}; + +/** + * Rules whose options must be fetched dynamically. + */ +const DYNAMIC_OPTION_RULES: Record< string, { path: string; mapItem: ( item: DynamicOptionItem ) => AccessRuleOption } > = { + institution: { + path: '/wp/v2/np_institution?per_page=100&context=edit', + mapItem: ( item: DynamicOptionItem ) => ( { value: item.id, label: item.title.raw } ), + }, +}; + +/** + * Value control for a single access rule. + * Renders FormTokenField for rules with options, TextControl for free-text rules. + */ +const AccessRuleValueControl = ( { + slug, + config, + value, + onChange, +}: { + slug: string; + config: AccessRuleConfig; + value: ActiveRule[ 'value' ]; + onChange: ( value: ActiveRule[ 'value' ] ) => void; +} ) => { + const dynamicConfig = DYNAMIC_OPTION_RULES[ slug ]; + const staticOptions: AccessRuleOption[] = config.options ?? []; + + const [ options, setOptions ] = useState< AccessRuleOption[] >( staticOptions ); + + useEffect( () => { + if ( ! dynamicConfig ) { + return; + } + let cancelled = false; + apiFetch< DynamicOptionItem[] >( { path: dynamicConfig.path } ) + .then( items => { + if ( ! cancelled ) { + setOptions( items.map( dynamicConfig.mapItem ) ); + } + } ) + .catch( () => {} ); + return () => { + cancelled = true; + }; + }, [ slug ] ); // eslint-disable-line react-hooks/exhaustive-deps + + if ( options.length > 0 ) { + // Map stored IDs to labels for display; silently drop IDs with no matching option. + const valueArr = Array.isArray( value ) ? value : []; + const selectedLabels = options.filter( o => valueArr.some( v => String( v ) === String( o.value ) ) ).map( o => o.label ); + + return ( + o.label ) } + onChange={ ( tokens: ( string | { value: string } )[] ) => { + const labels = tokens.map( t => ( typeof t === 'string' ? t : t.value ) ); + onChange( options.filter( o => labels.includes( o.label ) ).map( o => o.value ) ); + } } + __experimentalExpandOnFocus + __next40pxDefaultSize + /> + ); + } + + return ( + void } + __next40pxDefaultSize + /> + ); +}; + +/** One toggle + value control per available access rule. */ +const AccessRulesControls = ( { activeRules, onChange }: { activeRules: ActiveRule[]; onChange: ( rules: ActiveRule[] ) => void } ) => { + const handleToggle = ( slug: string, defaultValue: ActiveRule[ 'value' ] ) => { + const has = activeRules.some( r => r.slug === slug ); + if ( has ) { + onChange( activeRules.filter( r => r.slug !== slug ) ); + } else { + onChange( [ ...activeRules, { slug, value: defaultValue } ] ); + } + }; + + const handleValueChange = ( slug: string, value: ActiveRule[ 'value' ] ) => { + onChange( activeRules.map( r => ( r.slug === slug ? { ...r, value } : r ) ) ); + }; + + return ( + <> + { Object.entries( availableAccessRules ).map( ( [ slug, config ] ) => { + const activeRule = activeRules.find( r => r.slug === slug ); + return ( + +
+ handleToggle( slug, config.default ) } + /> + { activeRule && ! config.is_boolean && ( + handleValueChange( slug, v ) } + /> + ) } +
+
+ ); + } ) } + + ); +}; + +/** Registration section: logged-in toggle + optional verification sub-toggle. */ +const RegistrationControls = ( { + registration, + onChange, +}: { + registration: RegistrationRule; + onChange: ( registration: RegistrationRule ) => void; +} ) => ( + +
+ onChange( { ...registration, active } ) } + /> + { registration.active && ( + onChange( { ...registration, require_verification } ) } + /> + ) } +
+
+); + +/** + * Inspector panel for block access control. + */ +const BlockVisibilityPanel = ( { attributes, setAttributes }: BlockEditProps ) => { + const rules: BlockVisibilityRules = attributes.newspackAccessControlRules ?? {}; + const visibility: string = attributes.newspackAccessControlVisibility ?? 'visible'; + const mode: string = attributes.newspackAccessControlMode ?? 'gate'; + const gateIds: number[] = attributes.newspackAccessControlGateIds ?? []; + + const registration: RegistrationRule = rules.registration ?? { active: false }; + const customAccess: CustomAccessRule = rules.custom_access ?? { active: false, access_rules: [] }; + // Flatten grouped OR rules for display: [[rule]] → [rule] + const activeRules: ActiveRule[] = customAccess.access_rules.map( group => group[ 0 ] ).filter( Boolean ); + + const rulesActive = hasActiveRules( rules, mode, gateIds ); + + const updateRules = ( updates: Partial< BlockVisibilityRules > ) => { + const newRules: BlockVisibilityRules = { ...rules, ...updates }; + const stillActive = hasActiveRules( newRules, mode, gateIds ); + setAttributes( { + newspackAccessControlRules: newRules, + // Reset visibility to 'visible' when all custom rules are cleared. + ...( ! stillActive ? { newspackAccessControlVisibility: 'visible' } : {} ), + } ); + }; + + const setRegistration = ( newRegistration: RegistrationRule ) => { + updateRules( { + registration: { + ...newRegistration, + // Ensure require_verification is cleared when registration is turned off. + require_verification: newRegistration.active ? newRegistration.require_verification : false, + }, + } ); + }; + + const setAccessRules = ( flatRules: ActiveRule[] ) => { + const grouped: ActiveRule[][] = flatRules.map( rule => [ rule ] ); + updateRules( { + custom_access: { + ...customAccess, + active: grouped.length > 0, + access_rules: grouped, + }, + } ); + }; + + return ( + + +

{ __( 'Control visibility of this block using gates or custom rules.', 'newspack-plugin' ) }

+ + { /* Mode toggle: Gate (default) or Custom */ } + + setAttributes( { newspackAccessControlMode: String( v ?? 'gate' ) } ) } + isBlock + __next40pxDefaultSize + > + + + + + + { 'gate' === mode && ( + { + setAttributes( { + newspackAccessControlGateIds: ids, + // Reset visibility when the last gate is removed. + ...( ids.length === 0 ? { newspackAccessControlVisibility: 'visible' } : {} ), + } ); + } } + /> + ) } + + { 'custom' === mode && ( + <> + { /* Registration toggle */ } + + + { /* Access rule toggles */ } + + + ) } + + setAttributes( { newspackAccessControlVisibility: v } ) } + disabled={ ! rulesActive } + /> +
+
+ ); +}; + +/** + * Inject the Inspector panel into target block editors. + */ +addFilter( + 'editor.BlockEdit', + 'newspack-plugin/block-visibility/inspector', + createHigherOrderComponent( BlockEdit => { + const WithBlockVisibilityPanel = ( props: BlockEditProps ) => { + if ( ! TARGET_BLOCKS.includes( props.name ) ) { + return ; + } + return ( + <> + + + + ); + }; + return WithBlockVisibilityPanel; + }, 'withBlockVisibilityPanel' ) +); diff --git a/src/content-gate/editor/editor.scss b/src/content-gate/editor/editor.scss index 62ef38c11d..d92168c968 100644 --- a/src/content-gate/editor/editor.scss +++ b/src/content-gate/editor/editor.scss @@ -56,3 +56,29 @@ .post-type-np_gate_layout .editor-post-title { display: none; } + +/** + * Access control block visibility panel. + */ +.newspack-access-control-block-visibility-panel { + .components-base-control, + .components-form-token-field { + width: 100%; + } + .components-toggle-control { + margin-top: 8px; + width: 100%; + .components-base-control__field { + margin-bottom: 4px; + } + .components-form-toggle { + order: 2; + } + .components-base-control__help { + margin-top: 0; + } + .components-toggle-control__help { + margin-inline-start: auto; + } + } +} diff --git a/src/content-gate/editor/index.d.ts b/src/content-gate/editor/index.d.ts new file mode 100644 index 0000000000..16220969fa --- /dev/null +++ b/src/content-gate/editor/index.d.ts @@ -0,0 +1,67 @@ +declare module '@wordpress/block-editor'; + +/** + * Types. + */ +type BlockSettings = { + attributes: Record< string, unknown >; + name: string; +}; +type DynamicOptionItem = { + id: string | number; + title: { + raw: string; + }; +}; +type AccessRuleOption = { + value: string | number; + label: string; +}; +type AccessRuleConfig = { + name: string; + description: string; + default: string | Array< string | number >; + is_boolean?: boolean; + placeholder?: string; + options?: AccessRuleOption[]; +}; +type ActiveRule = { + slug: string; + value: string | Array< string | number > | null; +}; +type RegistrationRule = { + active: boolean; + require_verification?: boolean; +}; +type CustomAccessRule = { + active: boolean; + access_rules: ActiveRule[][]; +}; +type BlockVisibilityRules = { + registration?: RegistrationRule; + custom_access?: CustomAccessRule; +}; +type GateOption = { + id: number; + title: string; +}; +type BlockVisibilityAttributes = { + newspackAccessControlRules: BlockVisibilityRules; + newspackAccessControlVisibility: string; + newspackAccessControlMode: string; + newspackAccessControlGateIds: number[]; + [ key: string ]: unknown; +}; +type BlockEditProps = { + name: string; + attributes: BlockVisibilityAttributes; + setAttributes: ( attrs: Partial< BlockVisibilityAttributes > ) => void; + [ key: string ]: unknown; +}; + +interface Window { + newspackBlockVisibility: { + available_access_rules: Record< string, AccessRuleConfig >; + available_gates: GateOption[]; + }; +} diff --git a/tests/unit-tests/content-gate/class-block-visibility.php b/tests/unit-tests/content-gate/class-block-visibility.php new file mode 100644 index 0000000000..e463fe0b84 --- /dev/null +++ b/tests/unit-tests/content-gate/class-block-visibility.php @@ -0,0 +1,654 @@ +test_user_id = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + + // Register a simple test rule: passes only for our test user. + // Guard against duplicate registration: Access_Rules::$rules is static and + // persists across test methods within the same PHP process. + $registered = \Newspack\Access_Rules::get_registered_rules(); + if ( ! isset( $registered['test_rule'] ) ) { + \Newspack\Access_Rules::register_rule( + [ + 'id' => 'test_rule', + 'name' => 'Test Rule', + 'callback' => function( $user_id, $value ) { + return intval( $user_id ) === intval( $value ); + }, + ] + ); + } + } + + /** + * Tear down test environment. + */ + public function tear_down() { + Block_Visibility::reset_cache_for_tests(); + wp_set_current_user( 0 ); + parent::tear_down(); + } + + /** + * Test that the Block_Visibility class exists. + */ + public function test_class_exists() { + $this->assertTrue( class_exists( 'Newspack\Block_Visibility' ) ); + } + + /** + * Test that the render_block filter is registered. + */ + public function test_render_block_filter_registered() { + $this->assertNotFalse( + has_filter( 'render_block', [ 'Newspack\Block_Visibility', 'filter_render_block' ] ) + ); + } + + /** + * Test that the enqueue_block_editor_assets action is registered. + */ + public function test_enqueue_block_editor_assets_action_registered() { + $this->assertNotFalse( + has_action( 'enqueue_block_editor_assets', [ 'Newspack\Block_Visibility', 'enqueue_block_editor_assets' ] ) + ); + } + + /** + * Test that the register_block_type_args filter is registered. + */ + public function test_register_block_type_args_filter_registered() { + $this->assertNotFalse( + has_filter( 'register_block_type_args', [ 'Newspack\Block_Visibility', 'register_block_type_args' ] ) + ); + } + + /** + * Helper to build a mock block array. + * + * @param string $name Block name. + * @param array $attrs Block attributes. + * @return array + */ + private function make_block( $name, $attrs = [] ) { + return [ + 'blockName' => $name, + 'attrs' => $attrs, + 'innerHTML' => '
content
', + ]; + } + + /** + * Test that non-target blocks pass through unchanged. + */ + public function test_non_target_block_passes_through() { + $result = Block_Visibility::filter_render_block( '

hello

', $this->make_block( 'core/paragraph' ) ); + $this->assertSame( '

hello

', $result ); + } + + /** + * Test that a target block with no attrs passes through unchanged. + */ + public function test_target_block_with_no_rules_passes_through() { + $result = Block_Visibility::filter_render_block( '
hi
', $this->make_block( 'core/group', [] ) ); + $this->assertSame( '
hi
', $result ); + } + + /** + * Test that a target block with an empty rules object passes through unchanged. + */ + public function test_target_block_with_empty_rules_object_passes_through() { + $result = Block_Visibility::filter_render_block( + '
hi
', + $this->make_block( 'core/group', [ 'newspackAccessControlRules' => [] ] ) + ); + $this->assertSame( '
hi
', $result ); + } + + /** + * Test that a target block with only inactive rules passes through unchanged. + */ + public function test_target_block_with_inactive_rules_passes_through() { + $result = Block_Visibility::filter_render_block( + '
hi
', + $this->make_block( + 'core/group', + [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => false ], + 'custom_access' => [ + 'active' => false, + 'access_rules' => [], + ], + ], + ] + ) + ); + $this->assertSame( '
hi
', $result ); + } + + /** + * Test that a target block with active rules passes through unchanged when is_admin() is true. + */ + public function test_target_block_with_rules_passes_through_in_admin() { + set_current_screen( 'dashboard' ); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlRules' => [ + 'registration' => [ 'active' => true ], + ], + ] + ); + $result = Block_Visibility::filter_render_block( '
admin view
', $block ); + $this->assertSame( '
admin view
', $result ); + unset( $GLOBALS['current_screen'] ); + } + + /** + * Registration: logged-out user does not match. + */ + public function test_registration_logged_out_does_not_match() { + wp_set_current_user( 0 ); + $rules = [ 'registration' => [ 'active' => true ] ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) ); + } + + /** + * Registration: logged-in user matches. + */ + public function test_registration_logged_in_matches() { + $rules = [ 'registration' => [ 'active' => true ] ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Registration + require_verification: unverified user does not match. + */ + public function test_registration_unverified_does_not_match() { + $rules = [ + 'registration' => [ + 'active' => true, + 'require_verification' => true, + ], + ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Registration + require_verification: verified user matches. + */ + public function test_registration_verified_matches() { + update_user_meta( $this->test_user_id, \Newspack\Reader_Activation::EMAIL_VERIFIED, true ); + $rules = [ + 'registration' => [ + 'active' => true, + 'require_verification' => true, + ], + ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Custom access rule: matching user passes. + */ + public function test_access_rule_matching_user_passes() { + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], + ], + ]; + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + } + + /** + * Custom access rule: non-matching user fails. + */ + public function test_access_rule_non_matching_user_fails() { + $other_user = $this->factory->user->create( [ 'role' => 'subscriber' ] ); + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], + ], + ]; + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, $other_user ) ); + } + + /** + * AND logic: registration + access rules — both must pass. + */ + public function test_and_logic_both_must_pass() { + $rules = [ + 'registration' => [ 'active' => true ], + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], + ], + ]; + // Logged-in user who matches the access rule: passes. + $this->assertTrue( Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ) ); + + // Logged-out user: fails (registration not met). + Block_Visibility::reset_cache_for_tests(); + $this->assertFalse( Block_Visibility::evaluate_rules_for_user_public( $rules, 0 ) ); + } + + /** + * Helper: build a block with both control attributes. + * + * @param string $block_name Block type name. + * @param array $rules newspackAccessControlRules value. + * @param string $visibility 'visible' or 'hidden'. + * @return array + */ + private function make_block_with_rules( $block_name, $rules, $visibility = 'visible' ) { + return $this->make_block( + $block_name, + [ + 'newspackAccessControlMode' => 'custom', + 'newspackAccessControlRules' => $rules, + 'newspackAccessControlVisibility' => $visibility, + ] + ); + } + + /** + * "visible" mode: matching user sees the block. + */ + public function test_visible_mode_matching_user_sees_block() { + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
secret
', $block ); + $this->assertSame( '
secret
', $result ); + } + + /** + * "visible" mode: non-matching user does not see the block. + */ + public function test_visible_mode_non_matching_user_hidden() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
secret
', $block ); + $this->assertSame( '', $result ); + } + + /** + * "hidden" mode: matching user does not see the block. + */ + public function test_hidden_mode_matching_user_hidden() { + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' ); + $result = Block_Visibility::filter_render_block( '
members only
', $block ); + $this->assertSame( '', $result ); + } + + /** + * "hidden" mode: non-matching user sees the block. + */ + public function test_hidden_mode_non_matching_user_sees_block() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'hidden' ); + $result = Block_Visibility::filter_render_block( '
non-member content
', $block ); + $this->assertSame( '
non-member content
', $result ); + } + + /** + * All three target block types are evaluated. + */ + public function test_all_target_block_types_evaluated() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $rules = [ 'registration' => [ 'active' => true ] ]; + foreach ( [ 'core/group', 'core/stack', 'core/row' ] as $block_name ) { + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block_with_rules( $block_name, $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '', $result, "Expected empty for $block_name" ); + } + } + + /** + * Missing visibility attribute defaults to "visible". + */ + public function test_missing_visibility_attribute_defaults_to_visible() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'custom', + 'newspackAccessControlRules' => [ 'registration' => [ 'active' => true ] ], + // newspackAccessControlVisibility intentionally omitted. + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + // Logged-out user: rules don't match, so hidden under default "visible" mode. + $this->assertSame( '', $result ); + } + + /** + * A user who can edit the post sees restricted blocks on the front end. + */ + public function test_editor_bypasses_access_rules_on_front_end() { + $editor_id = $this->factory->user->create( [ 'role' => 'editor' ] ); + $post_id = $this->factory->post->create(); + $GLOBALS['post'] = get_post( $post_id ); + + wp_set_current_user( $editor_id ); + Block_Visibility::reset_cache_for_tests(); + + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
restricted
', $block ); + + $this->assertSame( '
restricted
', $result ); + + unset( $GLOBALS['post'] ); + } + + /** + * A user who cannot edit the post is still subject to access rules. + */ + public function test_non_editor_still_restricted_on_front_end() { + $post_id = $this->factory->post->create(); + $GLOBALS['post'] = get_post( $post_id ); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $rules = [ 'registration' => [ 'active' => true ] ]; + $block = $this->make_block_with_rules( 'core/group', $rules, 'visible' ); + $result = Block_Visibility::filter_render_block( '
restricted
', $block ); + + $this->assertSame( '', $result ); + + unset( $GLOBALS['post'] ); + } + + /** + * Core/group block has both visibility attributes registered server-side. + */ + public function test_group_block_has_visibility_attribute_registered() { + $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'core/group' ); + $this->assertArrayHasKey( 'newspackAccessControlVisibility', $block_type->attributes ); + $this->assertArrayHasKey( 'newspackAccessControlRules', $block_type->attributes ); + } + + /** + * Caching: second call returns cached result without re-evaluation. + */ + public function test_result_is_cached() { + $call_count = 0; + $counting_rule_id = 'counting_rule_' . uniqid(); + \Newspack\Access_Rules::register_rule( + [ + 'id' => $counting_rule_id, + 'name' => 'Counting Rule', + 'callback' => function( $user_id, $value ) use ( &$call_count ) { + $call_count++; + return true; + }, + ] + ); + $rules = [ + 'custom_access' => [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => $counting_rule_id, + 'value' => null, + ], + ], + ], + ], + ]; + Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ); + Block_Visibility::evaluate_rules_for_user_public( $rules, $this->test_user_id ); + // Callback fired only once despite two calls with identical rules + user. + $this->assertSame( 1, $call_count ); + } + + // ----------------------------------------------------------------------- + // Gate mode tests + // ----------------------------------------------------------------------- + + /** + * Helper: create a published gate post and optionally set its registration meta. + * + * @param bool $registration_active Whether to activate the registration rule. + * @param string $status Post status. Default 'publish'. + * @return int Gate post ID. + */ + private function make_gate( $registration_active = true, $status = 'publish' ) { + $gate_id = $this->factory->post->create( + [ + 'post_type' => \Newspack\Content_Gate::GATE_CPT, + 'post_status' => $status, + ] + ); + if ( $registration_active ) { + update_post_meta( $gate_id, 'registration', [ 'active' => true ] ); + } + return $gate_id; + } + + /** + * Gate mode with no gates selected passes through regardless of user. + */ + public function test_gate_mode_no_gates_passes_through() { + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: user matching an active gate's rules sees the block. + */ + public function test_gate_mode_matching_user_sees_block() { + $gate_id = $this->make_gate(); + + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
members
', $block ); + $this->assertSame( '
members
', $result ); + } + + /** + * Gate mode: user not matching an active gate's rules does not see the block. + */ + public function test_gate_mode_non_matching_user_hidden() { + $gate_id = $this->make_gate(); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
members
', $block ); + $this->assertSame( '', $result ); + } + + /** + * Gate mode: an unpublished (draft) gate is skipped — results in pass-through. + */ + public function test_gate_mode_unpublished_gate_passes_through() { + $gate_id = $this->make_gate( true, 'draft' ); + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: a permanently deleted gate is skipped — results in pass-through. + */ + public function test_gate_mode_deleted_gate_passes_through() { + $gate_id = $this->make_gate(); + wp_delete_post( $gate_id, true ); // Force-delete. + + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '
x
', $result ); + } + + /** + * Gate mode: a deleted gate alongside an active gate; only the active gate is evaluated. + */ + public function test_gate_mode_deleted_gate_does_not_affect_active_gate() { + $active_gate_id = $this->make_gate(); + $deleted_gate_id = $this->make_gate(); + wp_delete_post( $deleted_gate_id, true ); + + // Logged-out user does not satisfy the active gate's registration rule. + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $active_gate_id, $deleted_gate_id ], + ] + ); + $result = Block_Visibility::filter_render_block( '
x
', $block ); + $this->assertSame( '', $result ); + } + + /** + * Gate mode: OR logic — user matching any one of multiple active gates sees the block. + */ + public function test_gate_mode_or_logic_any_matching_gate_passes() { + // Gate A: requires custom access rule that only matches test_user_id. + $gate_a = $this->make_gate( false ); // No registration rule. + update_post_meta( + $gate_a, + 'custom_access', + [ + 'active' => true, + 'access_rules' => [ + [ + [ + 'slug' => 'test_rule', + 'value' => $this->test_user_id, + ], + ], + ], + ] + ); + + // Gate B: requires registration (logged-in only). + $gate_b = $this->make_gate( true ); + + // A logged-out user matches neither gate. + wp_set_current_user( 0 ); + Block_Visibility::reset_cache_for_tests(); + $block = $this->make_block( + 'core/group', + [ + 'newspackAccessControlMode' => 'gate', + 'newspackAccessControlGateIds' => [ $gate_a, $gate_b ], + ] + ); + $this->assertSame( '', Block_Visibility::filter_render_block( '
x
', $block ) ); + + // The test user matches Gate A (custom rule), so they see the block. + wp_set_current_user( $this->test_user_id ); + Block_Visibility::reset_cache_for_tests(); + $this->assertSame( '
x
', Block_Visibility::filter_render_block( '
x
', $block ) ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 7ec9d016da..4161fe7baa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -67,6 +67,7 @@ const entry = { 'content-gate-editor-memberships': path.join( __dirname, 'src', 'content-gate', 'editor', 'memberships.js' ), 'content-gate-editor-metering': path.join( __dirname, 'src', 'content-gate', 'editor', 'metering-settings.js' ), 'content-gate-block-patterns': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-patterns.js' ), + 'content-gate-block-visibility': path.join( __dirname, 'src', 'content-gate', 'editor', 'block-visibility.tsx' ), 'content-gate-post-settings': path.join( __dirname, 'src', 'content-gate', 'editor', 'post-settings.js' ), 'content-banner': path.join( __dirname, 'src', 'content-gate', 'content-banner.js' ), wizards: path.join( __dirname, 'src', 'wizards', 'index.tsx' ),