diff --git a/src/Common/Integrations/Harbor/PUE.php b/src/Common/Integrations/Harbor/PUE.php index 6fad00412..e8aeb7cde 100644 --- a/src/Common/Integrations/Harbor/PUE.php +++ b/src/Common/Integrations/Harbor/PUE.php @@ -44,6 +44,7 @@ protected function do_register(): void { add_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ], 10, 3 ); add_filter( 'tribe_settings_save_field_value', [ $this, 'prevent_storing_unified_license_key' ], 10, 2 ); add_filter( 'tribe_license_fields', [ $this, 'readonly_and_disable_harbor_managed_license_fields' ], 30 ); + add_filter( 'stellarwp/uplink/tec/license_field_html', [ $this, 'filter_stellarwp_uplink_tec_license_field_html' ], 10, 2 ); } /** @@ -62,6 +63,7 @@ public function unregister(): void { remove_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ] ); remove_filter( 'tribe_settings_save_field_value', [ $this, 'prevent_storing_unified_license_key' ] ); remove_filter( 'tribe_license_fields', [ $this, 'readonly_and_disable_harbor_managed_license_fields' ], 30 ); + remove_filter( 'stellarwp/uplink/tec/license_field_html', [ $this, 'filter_stellarwp_uplink_tec_license_field_html' ] ); } /** @@ -105,6 +107,55 @@ public function readonly_and_disable_harbor_managed_license_fields( array $field return $fields; } + /** + * Disable Harbor-managed Uplink license inputs (Seating, Event Tickets Plus). + * + * Those fields are rendered as HTML, not as PUE `license_key` fields, so the + * tribe_license_fields attribute lock above cannot reach the text input. + * + * @since TBD + * + * @param string $html Field HTML. + * @param string $slug Plugin slug. + * + * @return string + */ + public function filter_stellarwp_uplink_tec_license_field_html( string $html, string $slug ): string { + if ( ! $this->harbor->is_license_field_managed_by_harbor( $slug ) ) { + return $html; + } + + return $this->disable_uplink_license_input( $html ); + } + + /** + * Add readonly and disabled attributes to the Uplink license text input. + * + * @since TBD + * + * @param string $html Field HTML. + * + * @return string + */ + private function disable_uplink_license_input( string $html ): string { + if ( '' === $html || ! str_contains( $html, 'stellarwp-uplink__settings-field' ) ) { + return $html; + } + + $processor = new \WP_HTML_Tag_Processor( $html ); + + while ( $processor->next_tag( 'input' ) ) { + if ( ! $processor->has_class( 'stellarwp-uplink__settings-field' ) ) { + continue; + } + + $processor->set_attribute( 'readonly', 'readonly' ); + $processor->set_attribute( 'disabled', 'disabled' ); + } + + return $processor->get_updated_html(); + } + /** * Short-circuit legacy PUE key validation for Harbor unified licenses. * @@ -134,11 +185,7 @@ public function filter_tec_common_pue_pre_validate_key( ?array $response, string if ( $this->harbor->is_license_field_managed_by_harbor( $slug ) ) { return [ 'status' => 1, - 'message' => sprintf( - /* translators: URL to the Liquid Web License Manager */ - __( 'Licensed via Unified License Manager', 'tribe-common' ), - esc_url( lw_harbor_get_license_page_url() ), - ), + 'message' => $this->harbor->get_harbor_managed_license_message(), ]; } diff --git a/src/Common/Libraries/Harbor.php b/src/Common/Libraries/Harbor.php index 1bc227007..3a156b0b0 100644 --- a/src/Common/Libraries/Harbor.php +++ b/src/Common/Libraries/Harbor.php @@ -11,11 +11,15 @@ use TEC\Common\Integrations\Harbor\EventAggregator; use TEC\Common\Integrations\Harbor\PUE; use TEC\Common\Integrations\Harbor\PUE_Resolver; +use TEC\Common\StellarWP\Uplink\API\Validation_Response; use TEC\Common\StellarWP\Uplink\API\V3\Auth\Contracts\Auth_Url; +use TEC\Common\StellarWP\Uplink\Resources\Plugin as Uplink_Plugin; +use TEC\Common\StellarWP\Uplink\Resources\Resource as Uplink_Resource; use TEC\Common\Integrations\Uplink\Auth_URL_Decorator; use Tribe__Dependency as Dependency; use Tribe__Main as Common; use function TEC\Common\StellarWP\Uplink\get_plugins; +use function TEC\Common\StellarWP\Uplink\get_resource; use function lw_harbor_has_unified_license_key; use function lw_harbor_get_unified_license_key; use function lw_harbor_is_feature_enabled; @@ -24,6 +28,22 @@ /** * Controller for setting up the Harbor library. * + * Unified license keys (LWSW-) are handled on two independent validation pipelines. + * This controller is always registered, even when Harbor does not fully load + * (no premium plugin). When Harbor is loaded, Integrations\Harbor\PUE takes over + * the PUE pipeline; this class still owns the Uplink pipeline. + * + * 1. PUE - Tribe__PUE__Checker::validate_key() + * Products such as Events Calendar Pro, Filter Bar, Promoter, Community. + * AJAX action: pue-validate-key_{slug}. + * Filter: tec_common_pue_pre_validate_key (before the remote request). + * This class only handles the Harbor-not-loaded case. + * + * 2. Uplink - StellarWP Uplink Client::validate_license() + * Products: Seating, Event Tickets Plus. + * AJAX action: pue-validate-key-uplink-tec. + * Filter: stellarwp/uplink/tec/client_validate_license (after the remote request). + * * @since 6.11.0 * * @package TEC\Common\Libraries\Harbor @@ -90,10 +110,16 @@ public function do_register(): void { add_filter( 'lw-harbor/legacy_licenses', [ $this,'register_legacy_licenses' ] ); add_filter( 'lw_harbor/premium_plugin_exists', [ $this, 'register_premium_plugin_exists' ] ); - // Runs even when Harbor does not fully load (no premium plugin), so unified keys - // pasted into free-plugin PUE fields still get a clear guidance message. + + // PUE pipeline: Tribe__PUE__Checker. Runs even when Harbor does not fully load + // so a unified key pasted into a free-plugin PUE field still gets a guidance message. + // When Harbor is loaded this callback bails; Integrations\Harbor\PUE owns the filter then. add_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ], 10, 3 ); + // Uplink pipeline: Client::validate_license() for Seating / Event Tickets Plus. + // Those products never enter Tribe__PUE__Checker. This class always owns this filter. + add_filter( 'stellarwp/uplink/tec/client_validate_license', [ $this, 'filter_stellarwp_uplink_tec_client_validate_license' ], 10, 2 ); + Harbor_Provider::init(); // Uplink is being initialized in init with prio 8 - so we want to decorate it with our own decorator later. @@ -118,6 +144,7 @@ public function unregister(): void { remove_filter( 'lw-harbor/legacy_licenses', [ $this,'register_legacy_licenses' ] ); remove_filter( 'lw_harbor/premium_plugin_exists', [ $this, 'register_premium_plugin_exists' ] ); remove_filter( 'tec_common_pue_pre_validate_key', [ $this, 'filter_tec_common_pue_pre_validate_key' ] ); + remove_filter( 'stellarwp/uplink/tec/client_validate_license', [ $this, 'filter_stellarwp_uplink_tec_client_validate_license' ] ); remove_action( 'init', [ $this, 'decorate_uplinks_auth_url' ] ); } @@ -133,11 +160,13 @@ public function decorate_uplinks_auth_url(): void { } /** - * Reject unified license keys when Harbor is not loaded. + * PUE pipeline: reject unified license keys when Harbor is not loaded. + * + * Hook: `tec_common_pue_pre_validate_key` (before Tribe__PUE__Checker hits the remote API). * - * Without a premium plugin, Harbor never fires `lw_harbor/loaded` and the PUE - * Harbor integration is not registered — so this callback is the only guard that - * stops a unified key from being sent through legacy PUE validation. + * Without a premium plugin, Harbor never fires `lw_harbor/loaded` and + * Integrations\Harbor\PUE is not registered — so this callback only + * prevents a unified key from being sent through legacy PUE validation. * * @since TBD * @@ -163,17 +192,173 @@ public function filter_tec_common_pue_pre_validate_key( ?array $response, string return [ 'status' => 0, - 'message' => sprintf( - /* translators: %s: My account page link. */ - __( - 'This is a unified license key. To activate it, install The Events Calendar Pro or Event Tickets Pro, then add your license in the Unified License Manager. You can download the plugin from your account.', - 'tribe-common' - ), - esc_url( $this->get_portal_url() ) - ), + 'message' => $this->get_unified_license_key_requires_premium_message(), ]; } + /** + * Uplink pipeline: handle unified license keys validated through StellarWP Uplink. + * + * Hook: `stellarwp/uplink/tec/client_validate_license` (after Client::validate_license()). + * + * Seating and Event Tickets Plus use `pue-validate-key-uplink-tec` and never + * enter Tribe__PUE__Checker::validate_key(). + * + * Only the Licenses UI AJAX is rewritten. Uplink also uses this method for + * plugin update checks, which must keep the catalog/Herald payload. + * + * @since TBD + * + * @param Validation_Response $results License validation results. + * @param array $args License validation arguments. + * + * @return Validation_Response + */ + public function filter_stellarwp_uplink_tec_client_validate_license( $results, array $args ) { + if ( ! $results instanceof Validation_Response ) { + return $results; + } + + // Uplink also calls validate_license() during plugin update checks. + // Only rewrite the Licenses UI AJAX so catalog/Herald update payloads stay intact. + if ( ! $this->is_uplink_license_field_validation_request() ) { + return $results; + } + + $plugin = $args['plugin'] ?? ''; + if ( ! is_string( $plugin ) || '' === $plugin ) { + return $results; + } + + $key = $args['key'] ?? ''; + $key = is_string( $key ) ? $key : ''; + + if ( $this->is_license_field_managed_by_harbor( $plugin ) ) { + return $this->make_uplink_validation_response( + $key, + $plugin, + true, + $this->get_harbor_managed_license_message() + ); + } + + if ( ! $this->is_unified_license_key( $key ) ) { + return $results; + } + + $message = did_action( 'lw_harbor/loaded' ) + ? $this->get_unified_license_key_entry_error_message() + : $this->get_unified_license_key_requires_premium_message(); + + return $this->make_uplink_validation_response( $key, $plugin, false, $message ); + } + + /** + * Uplink pipeline: whether the current request is license-field AJAX validation. + * + * Distinguishes Licenses UI (`pue-validate-key-uplink-*`) from plugin update checks, + * which share Client::validate_license() and must not be rewritten. + * + * @since TBD + * + * @return bool + */ + private function is_uplink_license_field_validation_request(): bool { + $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + return str_starts_with( $action, 'pue-validate-key-uplink-' ); + } + + /** + * Uplink pipeline: build a synthetic validation response for the Licenses UI. + * + * Uses the real Uplink resource when it is registered so a valid result is + * not persisted as a "new" per-product key. Falls back to a stub resource + * for message rendering when the collection is unavailable. + * + * @since TBD + * + * @param string $key License key being validated. + * @param string $plugin Uplink plugin slug. + * @param bool $is_valid Whether the synthetic result is valid. + * @param string $message Message shown in the license field (may include HTML). + * + * @return Validation_Response + */ + private function make_uplink_validation_response( string $key, string $plugin, bool $is_valid, string $message ): Validation_Response { + $resource = $this->get_uplink_resource_for_validation( $plugin ); + + if ( $is_valid ) { + $stored_key = $resource->get_license_key(); + if ( is_string( $stored_key ) && '' !== $stored_key ) { + $key = $stored_key; + } + } + + $payload = [ + 'plugin' => $plugin, + 'slug' => $plugin, + ]; + + if ( $is_valid ) { + $payload['api_message'] = $message; + } else { + $payload['api_invalid'] = 1; + $payload['api_inline_invalid_message'] = $message; + } + + return new Validation_Response( $key, 'local', (object) $payload, $resource ); + } + + /** + * Uplink pipeline: get the resource used to render a synthetic validation message. + * + * @since TBD + * + * @param string $plugin Uplink plugin slug. + * + * @return Uplink_Resource + */ + private function get_uplink_resource_for_validation( string $plugin ): Uplink_Resource { + try { + if ( function_exists( '\TEC\Common\StellarWP\Uplink\get_resource' ) ) { + $resource = get_resource( $plugin ); + if ( $resource instanceof Uplink_Resource ) { + return $resource; + } + } + } catch ( \Throwable $exception ) { + // Uplink collection is not ready; a stub is enough for message rendering. + unset( $exception ); + } + + return new Uplink_Plugin( + $plugin, + $plugin, + '0.0.0', + $plugin . '.php', + \stdClass::class + ); + } + + /** + * Guidance shown when a unified key is entered but Harbor cannot load yet. + * + * @since TBD + * + * @return string + */ + public function get_unified_license_key_requires_premium_message(): string { + return sprintf( + /* translators: %s: My account page link. */ + __( + 'This is a unified license key. To activate it, install The Events Calendar Pro or Event Tickets Plus, then add your license in the Unified License Manager. You can download the plugin from your account.', + 'tribe-common' + ), + esc_url( $this->get_portal_url() ) + ); + } + /** * Get the premium plugin existence callbacks. * @@ -376,6 +561,21 @@ public function get_unified_license_key_entry_error_message(): string { ); } + /** + * Success message shown on Harbor-managed per-product license fields. + * + * @since TBD + * + * @return string + */ + public function get_harbor_managed_license_message(): string { + return sprintf( + /* translators: URL to the Liquid Web License Manager */ + __( 'Licensed via Unified License Manager', 'tribe-common' ), + esc_url( lw_harbor_get_license_page_url() ) + ); + } + /** * Get the unified license key if the feature is enabled. * diff --git a/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php b/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php index a127a7ec0..aa8d44100 100644 --- a/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php +++ b/tests/integration/Tribe/Common/Integrations/Harbor/PUE_Test.php @@ -54,6 +54,65 @@ public function reattach_airplane_mode(): void { $this->airplane_mode_callback = null; } + /** + * Simulate Uplink's license-field AJAX so Harbor rewrites the validation result. + * + * @param callable $callback Callback to run with the AJAX action set. + * + * @return mixed + */ + private function with_uplink_license_ajax_request( callable $callback ) { + $previous = $_REQUEST['action'] ?? null; + $_REQUEST['action'] = 'pue-validate-key-uplink-tec'; + + try { + return $callback(); + } finally { + if ( null === $previous ) { + unset( $_REQUEST['action'] ); + } else { + $_REQUEST['action'] = $previous; + } + } + } + + /** + * Run Uplink license-field AJAX validation for a pasted unified key. + * + * @param string $plugin_slug Uplink plugin slug. + * + * @return \TEC\Common\StellarWP\Uplink\API\Validation_Response + */ + private function filter_uplink_unified_key_on_license_ajax( string $plugin_slug ) { + $resource = new Uplink_Plugin( + $plugin_slug, + $plugin_slug, + '1.0.0', + $plugin_slug . '/' . $plugin_slug . '.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'LWSW-PASTED-INTO-UPLINK-FIELD', + 'local', + (object) [ 'api_message' => 'would-have-been-remote' ], + $resource + ); + + return $this->with_uplink_license_ajax_request( + static function () use ( $results, $plugin_slug ) { + return apply_filters( + 'stellarwp/uplink/tec/client_validate_license', + $results, + [ + 'plugin' => $plugin_slug, + 'key' => 'LWSW-PASTED-INTO-UPLINK-FIELD', + ] + ); + } + ); + } + /** * @test */ @@ -236,6 +295,57 @@ public function it_should_pass_http_request_through_for_unlicensed_product(): vo $this->assertFalse( $response ); } + /** + * An LWSW- key on a Uplink field Harbor is not managing must not keep the remote result. + * + * @test + * @dataProvider uplink_plugin_slug_provider + */ + public function it_should_reject_unified_key_on_uplink_when_product_is_not_harbor_managed( string $plugin_slug ): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'events-calendar-pro' ] ); + + $filtered = $this->filter_uplink_unified_key_on_license_ajax( $plugin_slug ); + + $this->assertFalse( $filtered->is_valid() ); + $this->assertSame( 'invalid', $filtered->get_result() ); + $this->assertStringContainsString( + 'unified license key', + strtolower( wp_strip_all_tags( $filtered->get_message()->get() ) ) + ); + } + + /** + * Harbor-managed Uplink fields must show as licensed via Unified License Manager. + * + * @test + * @dataProvider uplink_plugin_slug_provider + */ + public function it_should_mark_uplink_valid_when_product_is_harbor_managed( string $plugin_slug ): void { + $harbor_features = [ + 'tec-seating' => 'seating', + 'event-tickets-plus' => 'event-tickets-plus', + ]; + + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ $harbor_features[ $plugin_slug ] ] ); + + $filtered = $this->filter_uplink_unified_key_on_license_ajax( $plugin_slug ); + + $this->assertTrue( $filtered->is_valid() ); + $this->assertStringContainsString( + 'unified license manager', + strtolower( wp_strip_all_tags( $filtered->get_message()->get() ) ) + ); + } + + public function uplink_plugin_slug_provider(): array { + return [ + 'seating' => [ 'tec-seating' ], + 'event tickets plus' => [ 'event-tickets-plus' ], + ]; + } + /** * @test */ @@ -528,4 +638,89 @@ public function it_should_disable_harbor_managed_legacy_license_fields(): void { $this->assertArrayNotHasKey( 'disabled', $fields['pue_install_key_tribe_filterbar']['attributes'] ); } + + /** + * Uplink fields (Seating, Event Tickets Plus) are HTML, not PUE license_key + * fields. Harbor-managed ones must still be locked against editing. + * + * @test + * @dataProvider uplink_license_field_html_provider + */ + public function it_should_disable_harbor_managed_uplink_license_fields( + string $slug, + array $licensed_features, + bool $expect_disabled + ): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( $licensed_features ); + + $html = ''; + + $filtered = apply_filters( 'stellarwp/uplink/tec/license_field_html', $html, $slug ); + + if ( $expect_disabled ) { + $this->assertStringContainsString( 'readonly="readonly"', $filtered ); + $this->assertStringContainsString( 'disabled="disabled"', $filtered ); + $this->assertStringContainsString( 'stellarwp-uplink__settings-field', $filtered ); + + return; + } + + $this->assertSame( $html, $filtered ); + } + + public function uplink_license_field_html_provider(): array { + return [ + 'seating when harbor-managed is disabled' => [ + 'tec-seating', + [ 'seating' ], + true, + ], + 'event tickets plus when harbor-managed is disabled' => [ + 'event-tickets-plus', + [ 'event-tickets-plus' ], + true, + ], + 'seating when not harbor-managed stays editable' => [ + 'tec-seating', + [ 'events-calendar-pro' ], + false, + ], + 'event tickets plus when not harbor-managed stays editable' => [ + 'event-tickets-plus', + [ 'events-calendar-pro' ], + false, + ], + ]; + } + + /** + * @test + */ + public function it_should_disable_only_uplink_license_text_inputs(): void { + // Test empty HTML. + $this->assertSame( '', $this->call_disable_uplink_license_input( '' ) ); + + // Test tooltip HTML. Should not be modified. + $tooltip = '

A valid license key is required for support and updates

'; + $this->assertSame( $tooltip, $this->call_disable_uplink_license_input( $tooltip ) ); + + // Test license input HTML. The input in the HTML should be disabled and readonly. + $license_input = ''; + $filtered = $this->call_disable_uplink_license_input( $license_input ); + + $this->assertStringContainsString( 'readonly="readonly"', $filtered ); + $this->assertStringContainsString( 'disabled="disabled"', $filtered ); + $this->assertStringContainsString( 'stellarwp-uplink__settings-field', $filtered ); + } + + /** + * Invoke the private HTML helper without going through Harbor field management. + */ + private function call_disable_uplink_license_input( string $html ): string { + $method = new \ReflectionMethod( PUE::class, 'disable_uplink_license_input' ); + $method->setAccessible( true ); + + return $method->invoke( new PUE( tribe(), tribe( Harbor::class ) ), $html ); + } } diff --git a/tests/integration/Tribe/Common/Libraries/Harbor_Test.php b/tests/integration/Tribe/Common/Libraries/Harbor_Test.php index 1cb1f3476..54ce723e0 100644 --- a/tests/integration/Tribe/Common/Libraries/Harbor_Test.php +++ b/tests/integration/Tribe/Common/Libraries/Harbor_Test.php @@ -23,6 +23,28 @@ public function ensure_harbor_registered(): void { tribe( Harbor::class ); } + /** + * Simulate Uplink's license-field AJAX so Harbor rewrites the validation result. + * + * @param callable $callback Callback to run with the AJAX action set. + * + * @return mixed + */ + private function with_uplink_license_ajax_request( callable $callback ) { + $previous = $_REQUEST['action'] ?? null; + $_REQUEST['action'] = 'pue-validate-key-uplink-tec'; + + try { + return $callback(); + } finally { + if ( null === $previous ) { + unset( $_REQUEST['action'] ); + } else { + $_REQUEST['action'] = $previous; + } + } + } + /** * @test * @dataProvider tec_to_harbor_slug_provider @@ -107,7 +129,7 @@ public function it_should_reject_unified_key_via_pre_validate_when_harbor_is_not $this->assertSame( 0, $result['status'] ); $this->assertStringContainsString( 'unified license key', strtolower( $result['message'] ) ); $this->assertStringContainsString( 'The Events Calendar Pro', $result['message'] ); - $this->assertStringContainsString( 'Event Tickets Pro', $result['message'] ); + $this->assertStringContainsString( 'Event Tickets Plus', $result['message'] ); $this->assertStringContainsString( 'Unified License Manager', $result['message'] ); } @@ -166,6 +188,233 @@ public function it_should_pass_through_unified_key_via_pre_validate_when_harbor_ $this->assertNull( $result ); } + /** + * Uplink AJAX never hits tec_common_pue_pre_validate_key; without a premium plugin + * Harbor still must reject unified keys on Uplink's client_validate_license filter. + * + * @test + */ + public function it_should_reject_unified_key_via_uplink_when_harbor_is_not_loaded(): void { + global $wp_actions; + + $previous_loaded = $wp_actions['lw_harbor/loaded'] ?? null; + unset( $wp_actions['lw_harbor/loaded'] ); + + $resource = new \TEC\Common\StellarWP\Uplink\Resources\Plugin( + 'tec-seating', + 'Seating', + '1.0.0', + 'event-tickets/event-tickets.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'LWSW-PASTED-WITHOUT-PREMIUM', + 'local', + (object) [ 'api_message' => 'remote' ], + $resource + ); + + $filtered = $this->with_uplink_license_ajax_request( + static function () use ( $results ) { + return tribe( Harbor::class )->filter_stellarwp_uplink_tec_client_validate_license( + $results, + [ + 'plugin' => 'tec-seating', + 'key' => 'LWSW-PASTED-WITHOUT-PREMIUM', + ] + ); + } + ); + + if ( null !== $previous_loaded ) { + $wp_actions['lw_harbor/loaded'] = $previous_loaded; + } + + $this->assertNotSame( $results, $filtered ); + $this->assertFalse( $filtered->is_valid() ); + $message = $filtered->get_message()->get(); + $this->assertStringContainsString( 'unified license key', strtolower( wp_strip_all_tags( $message ) ) ); + $this->assertStringContainsString( 'The Events Calendar Pro', $message ); + $this->assertStringContainsString( 'Event Tickets Plus', $message ); + } + + /** + * @test + */ + public function it_should_pass_legacy_key_via_uplink_when_harbor_is_not_loaded(): void { + global $wp_actions; + + $previous_loaded = $wp_actions['lw_harbor/loaded'] ?? null; + unset( $wp_actions['lw_harbor/loaded'] ); + + $resource = new \TEC\Common\StellarWP\Uplink\Resources\Plugin( + 'tec-seating', + 'Seating', + '1.0.0', + 'event-tickets/event-tickets.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'legacy-product-key', + 'local', + (object) [ 'api_message' => 'remote' ], + $resource + ); + + $filtered = $this->with_uplink_license_ajax_request( + static function () use ( $results ) { + return tribe( Harbor::class )->filter_stellarwp_uplink_tec_client_validate_license( + $results, + [ + 'plugin' => 'tec-seating', + 'key' => 'legacy-product-key', + ] + ); + } + ); + + if ( null !== $previous_loaded ) { + $wp_actions['lw_harbor/loaded'] = $previous_loaded; + } + + $this->assertSame( $results, $filtered ); + } + + /** + * Once Harbor is loaded, unmanaged Uplink fields still reject unified keys + * with a link to the Unified License Manager — not a remote invalid message. + * + * @test + */ + public function it_should_reject_unified_key_via_uplink_when_harbor_is_loaded_and_product_is_not_managed(): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'events-calendar-pro' ] ); + + $resource = new \TEC\Common\StellarWP\Uplink\Resources\Plugin( + 'tec-seating', + 'Seating', + '1.0.0', + 'event-tickets/event-tickets.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'LWSW-PASTED-INTO-UPLINK-FIELD', + 'local', + (object) [ 'api_message' => 'would-have-been-remote' ], + $resource + ); + + $filtered = $this->with_uplink_license_ajax_request( + static function () use ( $results ) { + return tribe( Harbor::class )->filter_stellarwp_uplink_tec_client_validate_license( + $results, + [ + 'plugin' => 'tec-seating', + 'key' => 'LWSW-PASTED-INTO-UPLINK-FIELD', + ] + ); + } + ); + + $this->assertNotSame( $results, $filtered ); + $this->assertFalse( $filtered->is_valid() ); + $message = $filtered->get_message()->get(); + $this->assertStringContainsString( 'unified license key', strtolower( wp_strip_all_tags( $message ) ) ); + $this->assertStringContainsString( 'Unified License Manager', $message ); + $this->assertStringNotContainsString( 'The Events Calendar Pro', $message ); + } + + /** + * Harbor-managed Uplink fields must not surface the remote invalid-key message. + * + * @test + */ + public function it_should_mark_uplink_valid_when_product_is_harbor_managed(): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'event-tickets-plus' ] ); + + $resource = new \TEC\Common\StellarWP\Uplink\Resources\Plugin( + 'event-tickets-plus', + 'Event Tickets Plus', + '1.0.0', + 'event-tickets-plus/event-tickets-plus.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'LWSW-PASTED-INTO-UPLINK-FIELD', + 'local', + (object) [ 'api_invalid' => 1, 'api_inline_invalid_message' => 'would-have-been-remote' ], + $resource + ); + + $filtered = $this->with_uplink_license_ajax_request( + static function () use ( $results ) { + return tribe( Harbor::class )->filter_stellarwp_uplink_tec_client_validate_license( + $results, + [ + 'plugin' => 'event-tickets-plus', + 'key' => 'LWSW-PASTED-INTO-UPLINK-FIELD', + ] + ); + } + ); + + $this->assertNotSame( $results, $filtered ); + $this->assertTrue( $filtered->is_valid() ); + $this->assertStringContainsString( + 'Unified License Manager', + wp_strip_all_tags( $filtered->get_message()->get() ) + ); + } + + /** + * Uplink update checks share Client::validate_license(). Rewriting that + * result would drop version/download_url and hide automatic updates. + * + * @test + */ + public function it_should_not_rewrite_uplink_validation_during_update_checks(): void { + $this->seed_unified_license_key(); + $this->seed_harbor_catalog_for_tec( [ 'event-tickets-plus' ] ); + + $resource = new \TEC\Common\StellarWP\Uplink\Resources\Plugin( + 'event-tickets-plus', + 'Event Tickets Plus', + '1.0.0', + 'event-tickets-plus/event-tickets-plus.php', + \stdClass::class + ); + + $results = new \TEC\Common\StellarWP\Uplink\API\Validation_Response( + 'LWSW-UPDATE-CHECK', + 'local', + (object) [ + 'version' => '6.0.0', + 'download_url' => 'https://herald.example/event-tickets-plus.zip', + ], + $resource + ); + + $filtered = tribe( Harbor::class )->filter_stellarwp_uplink_tec_client_validate_license( + $results, + [ + 'plugin' => 'event-tickets-plus', + 'key' => 'LWSW-UPDATE-CHECK', + ] + ); + + $this->assertSame( $results, $filtered ); + $this->assertSame( '6.0.0', $filtered->get_raw_response()->version ); + $this->assertSame( + 'https://herald.example/event-tickets-plus.zip', + $filtered->get_raw_response()->download_url + ); + } + /** * @test */ @@ -213,6 +462,16 @@ public function it_should_include_license_manager_link_in_unified_key_entry_erro $this->assertStringContainsString( 'Unified License Manager', $message ); } + /** + * @test + */ + public function it_should_include_license_manager_link_in_harbor_managed_license_message(): void { + $message = tribe( Harbor::class )->get_harbor_managed_license_message(); + + $this->assertStringContainsString( '