diff --git a/includes/class-newspack.php b/includes/class-newspack.php index 3bde777a1f..576f3d079f 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -97,6 +97,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/class-theme-manager.php'; include_once NEWSPACK_ABSPATH . 'includes/class-admin-plugins-screen.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation.php'; + include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-registration.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation-emails.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-data.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-sync.php'; diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index e053b60533..6d92ae95d0 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -150,12 +150,13 @@ public static function enqueue_scripts() { 'is_ras_enabled' => self::is_enabled(), ]; + $script_data = array_merge( $script_data, Reader_Registration::get_script_data() ); + if ( Recaptcha::can_use_captcha() ) { $recaptcha_version = Recaptcha::get_setting( 'version' ); $script_dependencies[] = Recaptcha::SCRIPT_HANDLE; - if ( 'v3' === $recaptcha_version ) { - $script_data['captcha_site_key'] = Recaptcha::get_site_key(); - } + $script_data['captcha_site_key'] = Recaptcha::get_site_key(); + $script_data['captcha_version'] = $recaptcha_version; } Newspack::load_common_assets(); diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php new file mode 100644 index 0000000000..22deac86f0 --- /dev/null +++ b/includes/reader-activation/class-reader-registration.php @@ -0,0 +1,394 @@ + \WP_REST_Server::CREATABLE, + 'callback' => [ __CLASS__, 'api_frontend_register_reader' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'npe' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'default' => '', + ], + 'email' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_key' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'first_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'last_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'g-recaptcha-response' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + ], + ] + ); + } + + /** + * Get registered frontend registration integrations. + * + * @return array Map of integration ID => label. + */ + public static function get_frontend_registration_integrations(): array { + /** + * Filters the list of integrations that can trigger frontend reader registration. + * + * @param array $integrations Map of integration ID => display label. + */ + $integrations = \apply_filters( 'newspack_frontend_registration_integrations', [] ); + + // Also include Integration subclasses that opt in. + foreach ( Integrations::get_available_integrations() as $integration ) { + if ( $integration->supports_frontend_registration() && ! isset( $integrations[ $integration->get_id() ] ) ) { + $integrations[ $integration->get_id() ] = $integration->get_name(); + } + } + + return $integrations; + } + + /** + * Generate an HMAC key for a frontend registration integration. + * + * The key is deterministic (safe for page caching) and unique per + * integration ID and site. It is not a secret — it is output to the + * page source — but it binds registration requests to a PHP-registered + * integration, preventing arbitrary callers. + * + * @param string $integration_id Integration identifier. + * @return string HMAC-SHA256 hex string. + */ + public static function get_frontend_registration_key( string $integration_id ): string { + $integration = Integrations::get_integration( $integration_id ); + if ( $integration && $integration->supports_frontend_registration() ) { + return $integration->get_registration_key(); + } + // Fallback for filter-only registrations. + return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); + } + + /** + * Get script data for frontend localization. + * + * Called by Reader_Activation::enqueue_scripts() to merge integration + * config into the newspack_ras_config object. + * + * @return array Script data to merge, or empty array if no integrations. + */ + public static function get_script_data(): array { + if ( ! Reader_Activation::is_enabled() ) { + return []; + } + + $frontend_integrations = self::get_frontend_registration_integrations(); + if ( empty( $frontend_integrations ) ) { + return []; + } + + $integrations_config = []; + foreach ( $frontend_integrations as $id => $label ) { + $integrations_config[ $id ] = [ + 'key' => self::get_frontend_registration_key( $id ), + 'label' => $label, + ]; + } + + return [ + 'frontend_registration_integrations' => $integrations_config, + 'frontend_registration_url' => \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ), + ]; + } + + /** + * Check and increment the per-IP rate limit for frontend registration. + * + * @return bool|\WP_Error True if under limit, WP_Error if exceeded. + */ + private static function check_registration_rate_limit(): bool|\WP_Error { + // @todo REMOTE_ADDR may be a proxy/load-balancer IP in some environments. + // On WordPress VIP/Atomic this is the real client IP. For other hosts, + // consider parsing forwarded headers or providing a filter to override IP resolution. + // See WooCommerce_Connection::get_client_ip() for a forwarded-header approach. + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ + $cache_key = 'newspack_reg_ip_' . md5( $ip ); + + /** + * Filters the maximum number of frontend registration attempts per IP per hour. + * + * @param int $limit Maximum attempts. Default 10. + * @param string $ip The client IP address. + */ + $limit = \apply_filters( 'newspack_frontend_registration_rate_limit', 10, $ip ); + + if ( \wp_using_ext_object_cache() ) { + $cache_group = 'newspack_rate_limit'; + \wp_cache_add( $cache_key, 0, $cache_group, HOUR_IN_SECONDS ); + $attempts = \wp_cache_incr( $cache_key, 1, $cache_group ); + } else { + $attempts = (int) \get_transient( $cache_key ); + \set_transient( $cache_key, $attempts + 1, HOUR_IN_SECONDS ); + $attempts++; + } + + if ( $attempts > $limit ) { + Logger::log( 'Frontend registration rate limit exceeded for IP ' . $ip ); + return new \WP_Error( + 'rate_limit_exceeded', + __( 'Too many registration attempts. Please try again later.', 'newspack-plugin' ), + [ 'status' => 429 ] + ); + } + + return true; + } + + /** + * REST API handler for frontend integration reader registration. + * + * Validation sequence: + * 1. Already logged in — return current reader data + * 2. Reader Activation is enabled + * 3. Integration ID is registered + * 4. Integration key matches HMAC + * 5. Honeypot field is empty + * 6. Per-IP rate limit + * 7. reCAPTCHA (when configured) + * 8. Email is valid + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + // Step 1: If caller is already logged in, return current reader data. + // This makes the API idempotent — integrations don't need to check + // authentication state before calling register(). + if ( \is_user_logged_in() ) { + $current_user = \wp_get_current_user(); + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'existing', + 'email' => $current_user->user_email, + ], + 200 + ); + } + + // Step 2: Check RAS is enabled. + if ( ! Reader_Activation::is_enabled() ) { + return new \WP_Error( + 'reader_activation_disabled', + __( 'Reader Activation is not enabled.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 3: Validate integration ID is registered. + $integration_id = $request->get_param( 'integration_id' ); + $integrations = self::get_frontend_registration_integrations(); + if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { + Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration', + __( 'Invalid integration.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Step 4: Validate integration key. + $integration_key = $request->get_param( 'integration_key' ); + $integration_instance = Integrations::get_integration( $integration_id ); + if ( $integration_instance && $integration_instance->supports_frontend_registration() ) { + $key_valid = $integration_instance->validate_registration_key( $integration_key ); + } else { + // Fallback for filter-only registrations. + $expected_key = self::get_frontend_registration_key( $integration_id ); + $key_valid = hash_equals( $expected_key, $integration_key ); + } + if ( ! $key_valid ) { + Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration_key', + __( 'Invalid integration key.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 5: Honeypot — the `email` field must be empty. Real email is in `npe`. + $honeypot = $request->get_param( 'email' ); + if ( ! empty( $honeypot ) ) { + // Return fake success to avoid revealing the honeypot to bots. + // @todo Consider returning the npe value instead of the honeypot value to make + // the fake response indistinguishable from a real one. + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $honeypot, + ], + 200 + ); + } + + // Step 6: Per-IP rate limit. Checked before reCAPTCHA to avoid + // triggering external verification calls for rate-limited IPs. + $rate_check = self::check_registration_rate_limit(); + if ( \is_wp_error( $rate_check ) ) { + return $rate_check; + } + + // Step 7: reCAPTCHA (when configured). + $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); + $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); + if ( $should_verify ) { + // Bridge: verify_captcha() reads from $_POST. + // @todo Refactor Recaptcha::verify_captcha() to accept an optional $token parameter, eliminating this $_POST mutation. + $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $captcha_result = Recaptcha::verify_captcha(); + unset( $_POST['g-recaptcha-response'] ); + if ( \is_wp_error( $captcha_result ) ) { + return new \WP_Error( + 'recaptcha_failed', + $captcha_result->get_error_message(), + [ 'status' => 403 ] + ); + } + } + + // Step 8: Validate email. + $email = $request->get_param( 'npe' ); + if ( empty( $email ) ) { + return new \WP_Error( + 'invalid_email', + __( 'A valid email address is required.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Build display name from profile fields. + $first_name = $request->get_param( 'first_name' ); + $last_name = $request->get_param( 'last_name' ); + $display_name = trim( $first_name . ' ' . $last_name ); + + // Build metadata. Normalize referer to a local path, matching process_auth_form(). + $referer = \wp_parse_url( \wp_get_referer() ); + $referer = is_array( $referer ) ? $referer : []; + $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; + $metadata = [ + 'registration_method' => 'integration-registration-' . $integration_id, + 'current_page_url' => $current_page_url, + ]; + + $result = Reader_Activation::register_reader( $email, $display_name, true, $metadata ); + + if ( \is_wp_error( $result ) ) { + // Race condition: concurrent requests for the same email can cause + // wp_insert_user() or wc_create_new_customer() to return an "existing + // user" error instead of register_reader() returning false. + $existing_user_codes = [ 'existing_user_email', 'existing_user_login', 'registration-error-email-exists' ]; + if ( array_intersect( $result->get_error_codes(), $existing_user_codes ) ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + return new \WP_Error( + 'registration_failed', + $result->get_error_message(), + [ 'status' => 500 ] + ); + } + + // @todo register_reader() returns false for both existing readers (sends magic link) + // and existing non-reader accounts (sends login reminder). This 409 treats both + // identically. Consider distinguishing these cases to avoid disclosing account type. + if ( false === $result ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + // Apply profile fields after creation. + if ( ! empty( $first_name ) || ! empty( $last_name ) ) { + \wp_update_user( + [ + 'ID' => $result, + 'first_name' => $first_name, + 'last_name' => $last_name, + ] + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $email, + ], + 201 + ); + } +} +Reader_Registration::init(); diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 82d655fd0b..55eb8896a7 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -126,6 +126,59 @@ public function get_description() { return $this->description; } + /** + * Whether this integration supports frontend reader registration. + * + * Integrations that return true will have their key output to the page + * and will be accepted by the frontend registration endpoint. + * + * @return bool + */ + public function supports_frontend_registration(): bool { + return false; + } + + /** + * Generate the registration key for this integration. + * + * The default implementation uses HMAC-SHA256 with the site's auth salt. + * Subclasses can override this to implement custom key schemes + * (e.g., asymmetric key pairs, time-bounded tokens). + * + * @return string The registration key. + */ + public function get_registration_key(): string { + return hash_hmac( 'sha256', $this->id, \wp_salt( 'auth' ) ); + } + + /** + * Validate a submitted registration key for this integration. + * + * The default implementation uses timing-safe comparison against + * the HMAC key. Subclasses can override this to implement custom + * validation (e.g., signature verification, token decryption). + * + * Note: The built-in JS client (newspackReaderActivation.register()) + * always sends the value from get_registration_key(). Integrations + * that override this method to accept a different value must provide + * their own client-side code to compute and submit the correct key. + * + * @param string $key The submitted key to validate. + * @return bool Whether the key is valid. + */ + public function validate_registration_key( string $key ): bool { + return hash_equals( $this->get_registration_key(), $key ); + } + + /** + * Initialize the integration, performing any necessary setup or validation. + * + * Currently only initializes settings fields, but can be extended by child classes for additional setup. + */ + public function init() { + $this->settings_fields = $this->register_settings_fields(); + } + /** * Register settings fields for this integration. * diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 0b16826b2d..3cac20d41a 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -437,6 +437,179 @@ function attachNewsletterFormListener() { } ); } +/** + * Acquire a reCAPTCHA v2 invisible token. + * + * Renders a temporary invisible widget, executes it, and resolves + * with the token. Cleans up the widget container after completion. + * + * @todo Consider adding an in-flight guard to coalesce concurrent calls, + * since each invocation renders a separate widget and the reCAPTCHA API + * may not handle multiple simultaneous invisible widgets well. + * + * @param {string} siteKey reCAPTCHA site key. + * @return {Promise} Resolves with the reCAPTCHA token. + */ +function acquireV2InvisibleToken( siteKey ) { + return new Promise( function ( resolve, reject ) { + const container = document.createElement( 'div' ); + container.style.display = 'none'; + document.body.appendChild( container ); + + let settled = false; + const timeout = setTimeout( function () { + if ( ! settled ) { + settled = true; + container.remove(); + reject( new Error( 'reCAPTCHA timed out.' ) ); + } + }, 30000 ); + + function settle( fn, value ) { + if ( settled ) { + return; + } + settled = true; + clearTimeout( timeout ); + container.remove(); + fn( value ); + } + + try { + const widgetId = window.grecaptcha.render( container, { + sitekey: siteKey, + size: 'invisible', + isolated: true, + callback( token ) { + settle( resolve, token ); + }, + 'error-callback'() { + settle( reject, new Error( 'reCAPTCHA challenge failed.' ) ); + }, + 'expired-callback'() { + settle( reject, new Error( 'reCAPTCHA token expired.' ) ); + }, + } ); + window.grecaptcha.execute( widgetId ); + } catch ( err ) { + settle( reject, err ); + } + } ); +} + +/** + * Register a reader via a frontend integration. + * + * @param {string} email Reader email address. + * @param {string} integrationId Registered integration ID. + * @param {Object} profileFields Optional profile fields: { first_name, last_name }. + * @return {Promise} Resolves with reader data on success, rejects with error on failure. + */ +function register( email, integrationId, profileFields = {} ) { + const config = newspack_ras_config?.frontend_registration_integrations || {}; + const integration = config[ integrationId ]; + + if ( ! integration ) { + return Promise.reject( new Error( 'Unknown integration: ' + integrationId ) ); + } + + if ( ! email || ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) { + return Promise.reject( new Error( 'Invalid email address.' ) ); + } + + if ( ! newspack_ras_config?.frontend_registration_url ) { + return Promise.reject( new Error( 'Registration is not available.' ) ); + } + + const body = { + npe: email, + integration_id: integrationId, + integration_key: integration.key, + first_name: profileFields.first_name || '', + last_name: profileFields.last_name || '', + }; + + // Acquire reCAPTCHA token if configured, using the appropriate version flow. + const captchaSiteKey = newspack_ras_config?.captcha_site_key; + const captchaVersion = newspack_ras_config?.captcha_version; + let captchaPromise; + + if ( captchaSiteKey ) { + if ( ! window.grecaptcha ) { + return Promise.reject( new Error( 'reCAPTCHA is configured but not loaded.' ) ); + } + if ( captchaVersion === 'v3' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + window.grecaptcha + .execute( captchaSiteKey, { + action: 'integration_registration', + } ) + .then( resolve ) + .catch( reject ); + } ); + } ); + } else if ( captchaVersion && captchaVersion.substring( 0, 2 ) === 'v2' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + acquireV2InvisibleToken( captchaSiteKey ).then( resolve ).catch( reject ); + } ); + } ); + } else { + captchaPromise = Promise.resolve( '' ); + } + } else { + captchaPromise = Promise.resolve( '' ); + } + + return captchaPromise + .then( function ( token ) { + if ( token ) { + body[ 'g-recaptcha-response' ] = token; + } + return fetch( newspack_ras_config.frontend_registration_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify( body ), + } ); + } ) + .then( function ( response ) { + return response.json().then( function ( data ) { + if ( ! response.ok ) { + const error = new Error( data.message || 'Registration failed.' ); + error.code = data.code; + throw error; + } + return data; + } ); + } ) + .then( function ( data ) { + const readerEmail = data.email || email; + const reader = { + ...( store.get( 'reader' ) || {} ), + email: readerEmail, + authenticated: true, + }; + store.set( 'reader', reader, false ); + emit( EVENTS.reader, reader ); + dispatchActivity( 'reader_registered', { + email: readerEmail, + integration_id: integrationId, + status: data.status || 'created', + } ); + return data; + } ) + .catch( function ( error ) { + dispatchActivity( 'reader_registration_failed', { + email, + integration_id: integrationId, + error: error.code || 'network_error', + } ); + throw error; + } ); +} + const readerActivation = { store, overlays, @@ -461,6 +634,7 @@ const readerActivation = { setPendingCheckout, getPendingCheckout, debugLog, + register, ...( newspack_ras_config.is_ras_enabled && { openAuthModal } ), }; diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php new file mode 100644 index 0000000000..021aedc0fa --- /dev/null +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -0,0 +1,760 @@ +server = $wp_rest_server; + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + // Ensure routes are registered — Reader_Activation::init() may have run + // before IS_TEST_ENV was defined, skipping the rest_api_init hook. + add_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); + do_action( 'rest_api_init' ); + wp_set_current_user( 0 ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + remove_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); + $user = get_user_by( 'email', self::$reader_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + // Reset rate limit state. + delete_transient( 'newspack_reg_ip_' . md5( '127.0.0.1' ) ); + wp_cache_delete( 'newspack_reg_ip_' . md5( '127.0.0.1' ), 'newspack_rate_limit' ); + // Clean up any $_POST pollution. + unset( $_POST['g-recaptcha-response'] ); + parent::tear_down(); + } + + /** + * Helper to make a registration request. + * + * @param array $body Request body. + * @return WP_REST_Response + */ + private function do_register_request( $body = [] ) { + $request = new WP_REST_Request( 'POST', self::$route ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + return $this->server->dispatch( $request ); + } + + /** + * Test successful reader registration. + */ + public function test_register_new_reader() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'created', $data['status'] ); + $this->assertEquals( self::$reader_email, $data['email'] ); + $this->assertInstanceOf( 'WP_User', get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test duplicate email returns 409. + */ + public function test_register_duplicate_email() { + // Create the user directly to avoid register_reader()'s RAS-enabled check. + self::factory()->user->create( + [ + 'user_email' => self::$reader_email, + 'role' => 'subscriber', + ] + ); + wp_set_current_user( 0 ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + } + + /** + * Test missing email returns 400. + */ + public function test_register_missing_email() { + $response = $this->do_register_request( + [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test invalid email returns 400. + */ + public function test_register_invalid_email() { + $response = $this->do_register_request( + [ + 'npe' => 'not-an-email', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test missing integration ID returns 400. + */ + public function test_register_missing_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_key' => 'anything', + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test unregistered integration ID returns 400. + */ + public function test_register_unknown_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => 'unknown-tool', + 'integration_key' => self::generate_key( 'unknown-tool' ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test wrong integration key returns 403. + */ + public function test_register_wrong_integration_key() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => 'wrong-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + } + + /** + * Test honeypot field triggers fake success. + */ + public function test_honeypot_returns_fake_success() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'email' => 'bot-filled@spam.com', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + // Verify user was NOT actually created. + $this->assertFalse( get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test logged-in user returns current reader data. + */ + public function test_register_while_logged_in() { + $admin_id = self::factory()->user->create( + [ + 'role' => 'administrator', + 'user_email' => 'admin@test.com', + ] + ); + wp_set_current_user( $admin_id ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'existing', $data['status'] ); + $this->assertEquals( 'admin@test.com', $data['email'] ); + + wp_delete_user( $admin_id ); + } + + /** + * Test registration with profile fields. + */ + public function test_register_with_profile_fields() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'first_name' => 'Jane', + 'last_name' => 'Doe', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertEquals( 'Jane', $user->first_name ); + $this->assertEquals( 'Doe', $user->last_name ); + $this->assertStringContainsString( 'Jane', $user->display_name ); + } + + /** + * Test registration stores the integration-based registration method. + */ + public function test_register_stores_registration_method() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertEquals( + 'integration-registration-' . self::$integration_id, + get_user_meta( $user->ID, Reader_Activation::REGISTRATION_METHOD, true ) + ); + } + + /** + * Test RAS disabled returns 403. + * + * Skipped in the test environment because Reader_Activation::is_enabled() + * short-circuits to true when IS_TEST_ENV is defined, bypassing the filter. + */ + public function test_register_when_ras_disabled() { + if ( defined( 'IS_TEST_ENV' ) && IS_TEST_ENV ) { + $this->markTestSkipped( 'is_enabled() always returns true when IS_TEST_ENV is defined.' ); + } + + add_filter( 'newspack_reader_activation_enabled', '__return_false' ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_activation_disabled', $data['code'] ); + + remove_filter( 'newspack_reader_activation_enabled', '__return_false' ); + } + + /** + * Test per-IP rate limiting returns 429. + */ + public function test_rate_limit_exceeded() { + // Lower limit to 2 for testing. + $set_limit = function() { + return 2; + }; + add_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + $base_body = [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ]; + + // First two requests should succeed or return non-429 errors. + // Reset current user between requests since successful registration authenticates the reader. + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate1@test.com' ] ) ); + wp_set_current_user( 0 ); + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate2@test.com' ] ) ); + wp_set_current_user( 0 ); + + // Third request should be rate-limited. + $response = $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate3@test.com' ] ) ); + $this->assertEquals( 429, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rate_limit_exceeded', $data['code'] ); + + remove_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + // Clean up created users. + foreach ( [ 'rate1@test.com', 'rate2@test.com' ] as $email ) { + $user = get_user_by( 'email', $email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + } + + /** + * Test that a race condition WP_Error with "existing user" code maps to 409. + * + * Simulates a race where another request creates the user between + * register_reader()'s exists check and its wp_insert_user() call. + */ + public function test_race_condition_existing_user_returns_409() { + $race_email = 'race-test@test.com'; + + // This filter fires inside canonize_user_data(), after the exists check + // but before wp_insert_user(). Creating the user here simulates a race. + $create_user_during_insert = function( $user_data ) use ( $race_email ) { + if ( ! empty( $user_data['user_email'] ) && $user_data['user_email'] === $race_email ) { + wp_insert_user( + [ + 'user_login' => 'race-user', + 'user_email' => $race_email, + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ] + ); + } + return $user_data; + }; + add_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $response = $this->do_register_request( + [ + 'npe' => $race_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + + remove_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $user = get_user_by( 'email', $race_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that current_page_url is normalized from the HTTP referer. + * + * The endpoint should parse the referer, extract the path, and rebuild + * it with home_url() — matching the process_auth_form() convention. + */ + public function test_current_page_url_normalization() { + $test_email = 'referer-test@test.com'; + + // Set a referer with query params and fragment that should be stripped. + $_SERVER['HTTP_REFERER'] = home_url( '/sample-page/?foo=bar&baz=1#section' ); + + $response = $this->do_register_request( + [ + 'npe' => $test_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', $test_email ); + $this->assertInstanceOf( 'WP_User', $user ); + + $registration_page = get_user_meta( $user->ID, Reader_Activation::REGISTRATION_PAGE, true ); + // Should be normalized to just the path on the home URL, no query params. + $this->assertEquals( home_url( '/sample-page/' ), $registration_page ); + + unset( $_SERVER['HTTP_REFERER'] ); + wp_delete_user( $user->ID ); + } + + /** + * Test that the reCAPTCHA verify filter controls the verification attempt. + * + * When the filter returns true, the endpoint enters the verification block + * and calls verify_captcha(). In the test environment reCAPTCHA is not + * configured, so verify_captcha() short-circuits to true (passes). + * This test confirms the filter is respected and the $_POST bridge + * sets and cleans up the token correctly. + */ + public function test_recaptcha_filter_forces_verification() { + $captcha_email = 'captcha-test@test.com'; + $token_value = 'test-recaptcha-token'; + + // Force reCAPTCHA verification on, regardless of configuration. + $force_verify = function() { + return true; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $response = $this->do_register_request( + [ + 'npe' => $captcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'g-recaptcha-response' => $token_value, + ] + ); + // verify_captcha() returns true when not configured, so registration succeeds. + $this->assertEquals( 201, $response->get_status() ); + // Verify $_POST was cleaned up after the bridge. + $this->assertArrayNotHasKey( 'g-recaptcha-response', $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + + remove_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $user = get_user_by( 'email', $captcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that the reCAPTCHA verify filter can disable verification. + * + * When forced off, registration should succeed even if reCAPTCHA + * would otherwise be required. + */ + public function test_recaptcha_filter_disables_verification() { + $disable_verify = function() { + return false; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $recaptcha_email = 'captcha-disabled@test.com'; + $response = $this->do_register_request( + [ + 'npe' => $recaptcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + remove_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $user = get_user_by( 'email', $recaptcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that the integration registry returns filtered integrations. + */ + public function test_get_frontend_registration_integrations() { + // The test integration is registered via the filter in set_up(). + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertArrayHasKey( self::$integration_id, $integrations ); + $this->assertEquals( 'Test Integration', $integrations[ self::$integration_id ] ); + } + + /** + * Test that the integration registry is empty without the filter. + */ + public function test_get_frontend_registration_integrations_empty_without_filter() { + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertEmpty( $integrations ); + + // Re-add for tear_down consistency. + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + } + + /** + * Test that integration key generation is deterministic and unique per ID. + */ + public function test_integration_key_determinism_and_uniqueness() { + $key_a_first = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_a_second = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_b = Reader_Registration::get_frontend_registration_key( 'integration-b' ); + + // Same ID produces the same key. + $this->assertEquals( $key_a_first, $key_a_second ); + // Different IDs produce different keys. + $this->assertNotEquals( $key_a_first, $key_b ); + // Keys are 64-character hex strings (SHA-256). + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_a_first ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_b ); + } + + /** + * Test registration via an Integration subclass with default HMAC key. + */ + public function test_register_via_integration_subclass() { + $integration = new Test_Frontend_Integration( 'subclass-test', 'Subclass Test' ); + Integrations::register( $integration ); + + $response = $this->do_register_request( + [ + 'npe' => 'subclass@test.com', + 'integration_id' => 'subclass-test', + 'integration_key' => $integration->get_registration_key(), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + + $user = get_user_by( 'email', 'subclass@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that a custom key validation override is used. + */ + public function test_custom_key_validation() { + $integration = new Test_Custom_Key_Integration( 'custom-key-test', 'Custom Key Test' ); + Integrations::register( $integration ); + + // The public key (from get_registration_key) should NOT validate. + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-public-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + + // The secret key should validate via the custom validate_registration_key(). + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-secret-key', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', 'custom@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that Integration subclass is included in get_frontend_registration_integrations(). + */ + public function test_integration_subclass_in_registry() { + $integration = new Test_Frontend_Integration( 'registry-test', 'Registry Test' ); + Integrations::register( $integration ); + + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertArrayHasKey( 'registry-test', $integrations ); + $this->assertEquals( 'Registry Test', $integrations['registry-test'] ); + } +}