From 64f58c4ae4b498bf57e0fa500d485550acf15029 Mon Sep 17 00:00:00 2001 From: Gabriela Paiz Date: Tue, 3 Nov 2026 11:54:15 +0000 Subject: [PATCH 1/3] DID-10021: reproduce the guest retry lockout and the missing redirect escape Two plugin-side failures trap an Android shopper whose liveness step cannot open the camera inside the embedded flow: - rest_create_session() creates a new Didit session on every retry and check_rate_limit() runs before any reuse, so a guest gets three sessions and then a 429 for the rest of the hour. - The plugin only offers modal and embedded display modes, so there is no documented way to continue on the hosted page when the frame cannot reach the camera. Add failing regression tests for both, plus a guard that the bundled SDK keeps delegating camera and microphone to its modal and embedded frames. --- .github/workflows/tests.yml | 3 + tests/test-checkout-retry.php | 139 ++++++++++++++++++++++++++++++ tests/test-iframe-permissions.php | 43 +++++++++ tests/test-redirect-fallback.php | 52 +++++++++++ tests/wp-stubs.php | 21 ++++- 5 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tests/test-checkout-retry.php create mode 100644 tests/test-iframe-permissions.php create mode 100644 tests/test-redirect-fallback.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d3a04e5..841d3cd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,3 +18,6 @@ jobs: - run: php tests/test-shortcode-assets.php - run: php tests/test-webhook-signature.php - run: php tests/test-verification-authorization.php + - run: php tests/test-checkout-retry.php + - run: php tests/test-redirect-fallback.php + - run: php tests/test-iframe-permissions.php diff --git a/tests/test-checkout-retry.php b/tests/test-checkout-retry.php new file mode 100644 index 0000000..a7d0708 --- /dev/null +++ b/tests/test-checkout-retry.php @@ -0,0 +1,139 @@ +setAccessible(true); + } + return $method->invoke(Didit_Verify::init(), $session_id); +} + +function store($session_id, array $record) +{ + $method = new ReflectionMethod(Didit_Verify::class, 'store_session'); + if (PHP_VERSION_ID < 80100) { + $method->setAccessible(true); + } + $method->invoke(Didit_Verify::init(), $session_id, $record); +} + +function start_attempt() +{ + return Didit_Verify::init()->rest_create_session(new Didit_Test_Request('{}')); +} + +function reset_attempts($owner_seed, $ip) +{ + $GLOBALS['didit_test_transients'] = []; + $GLOBALS['didit_test_created'] = []; + $_COOKIE['didit_verification_owner'] = str_repeat($owner_seed, 64); + $_SERVER['REMOTE_ADDR'] = $ip; +} + +$plugin = Didit_Verify::init(); +$GLOBALS['didit_test_options']['didit_api_key'] = 'test-only-key'; +$GLOBALS['didit_test_options']['didit_workflow_id'] = 'test-workflow'; +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'checkout'; + +// A guest at checkout: no WordPress user, no WooCommerce order yet. +$GLOBALS['didit_test_current_user'] = 0; +reset_attempts('c', '198.51.100.23'); + +$GLOBALS['didit_test_post_response'] = function () { + $n = count($GLOBALS['didit_test_created']) + 1; + $id = sprintf('%08x-1111-4111-8111-%012x', $n, $n); + $GLOBALS['didit_test_created'][] = $id; + return ['response' => ['code' => 201], 'body' => json_encode([ + 'session_id' => $id, + 'status' => 'Not Started', + 'url' => 'https://verify.didit.me/session/' . $id, + ])]; +}; + +// First attempt: the shopper clicks Verify and the flow opens. +$first = start_attempt(); +ok(is_array($first) && !empty($first['url']), 'the checkout button opens a verification session'); +$session_id = $first['sessionId'] ?? ''; +ok(1 === count($GLOBALS['didit_test_created']), 'the first attempt creates one Didit session'); + +// The liveness step fails; the shopper clicks Verify again. +$second = start_attempt(); +ok(is_array($second) && ($second['sessionId'] ?? '') === $session_id, + 'a retry reopens the session that is already running for this browser'); +ok(1 === count($GLOBALS['didit_test_created']), 'a retry does not create a duplicate Didit session'); + +// Android shoppers retry more than once; Try Again inside the frame does not help. +$third = start_attempt(); +$fourth = start_attempt(); +ok(!is_wp_error($fourth), 'guest retries are not refused after a few failed attempts'); +ok(($third['sessionId'] ?? '') === $session_id && !is_wp_error($fourth) && ($fourth['sessionId'] ?? '') === $session_id, + 'every retry reopens the same running session'); + +// A finished session must not be reopened for a new verification. +reset_attempts('d', '198.51.100.25'); +$open = start_attempt(); +$open_id = $open['sessionId'] ?? ''; +$record = stored($open_id); +$record['status'] = 'Approved'; +store($open_id, $record); +$renewed = start_attempt(); +ok(is_array($renewed) && ($renewed['sessionId'] ?? '') !== $open_id, + 'an approved session is not reused for a new verification'); + +// Creation stays limited: a client that keeps ending up in a closed session still hits the cap. +reset_attempts('e', '198.51.100.24'); +$GLOBALS['didit_test_post_response'] = function () { + $n = count($GLOBALS['didit_test_created']) + 1; + $id = sprintf('%08x-2222-4222-8222-%012x', $n, $n); + $GLOBALS['didit_test_created'][] = $id; + return ['response' => ['code' => 201], 'body' => json_encode([ + 'session_id' => $id, + 'status' => 'Not Started', + 'url' => 'https://verify.didit.me/session/' . $id, + ])]; +}; + +$last = null; +for ($i = 1; $i <= 4; $i++) { + $last = start_attempt(); + if (is_array($last) && !empty($last['sessionId'])) { + // Every attempt ends in a session that cannot be resumed. + $closed = stored($last['sessionId']); + $closed['status'] = 'Declined'; + store($last['sessionId'], $closed); + } +} +ok(is_wp_error($last) && 'rate_limit' === $last->get_error_code(), 'the guest creation limit still stops a runaway loop'); + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/test-iframe-permissions.php b/tests/test-iframe-permissions.php new file mode 100644 index 0000000..4023379 --- /dev/null +++ b/tests/test-iframe-permissions.php @@ -0,0 +1,43 @@ + $allow) { + $directives = array_map('trim', explode(';', $allow)); + ok(in_array('camera', $directives, true), sprintf('frame %d delegates camera', $index + 1)); + ok(in_array('microphone', $directives, true), sprintf('frame %d delegates microphone', $index + 1)); +} + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/test-redirect-fallback.php b/tests/test-redirect-fallback.php new file mode 100644 index 0000000..e04c800 --- /dev/null +++ b/tests/test-redirect-fallback.php @@ -0,0 +1,52 @@ +field_display_mode(); +$settings = ob_get_clean(); +ok(false !== strpos($settings, 'value="redirect"'), 'Display Mode offers the documented redirect option'); + +// 2. The chosen mode reaches the browser integration. +$GLOBALS['didit_test_options']['didit_display_mode'] = 'redirect'; +$plugin->render_shortcode([]); +$config = $GLOBALS['didit_test_localized_scripts']['didit-verify']['diditConfig'] ?? []; +ok('redirect' === ($config['displayMode'] ?? ''), 'the redirect mode reaches the frontend config'); + +// 3. In redirect mode the flow must run on the hosted page, not inside a frame. +$script = (string) file_get_contents(__DIR__ . '/../assets/js/didit-verify.js'); +ok((bool) preg_match('/displayMode\s*===\s*["\']redirect["\']/', $script) + && (bool) preg_match('/location\.(assign|replace|href)/', $script), + 'the frontend continues on the hosted page in redirect mode'); + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed > 0 ? 1 : 0); diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 49e748d..7f9e000 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -3,13 +3,15 @@ * The smallest slice of WordPress the webhook handler touches, so the signature * verification can be exercised without a WordPress install. * - * Only functions reached by rest_webhook() (and by loading didit-verify.php) are - * stubbed; anything else is deliberately left undefined so an accidental new - * dependency in the handler surfaces as a fatal error instead of passing silently. + * Only functions reached by rest_webhook() and rest_create_session() (and by + * loading didit-verify.php) are stubbed; anything else is deliberately left + * undefined so an accidental new dependency in a handler surfaces as a fatal + * error instead of passing silently. */ define('ABSPATH', __DIR__ . '/'); define('MINUTE_IN_SECONDS', 60); +define('HOUR_IN_SECONDS', 3600); define('DAY_IN_SECONDS', 86400); // Options the handler reads. Tests overwrite entries directly. @@ -43,6 +45,7 @@ function absint($value) { return abs((int) $value); } function sanitize_text_field($value) { return trim(strip_tags((string) $value)); } function current_time() { return '2026-03-31 12:00:00'; } function shortcode_atts($pairs, $atts) { return array_merge($pairs, (array) $atts); } +function selected($a, $b, $echo = true) { $out = ((string) $a === (string) $b) ? ' selected="selected"' : ''; if ($echo) { echo $out; } return $out; } function apply_filters($hook, $value) { return $value; } function rest_url($path = '') { return 'https://example.test/wp-json/' . ltrim($path, '/'); } function wp_create_nonce($action) { return 'test-nonce-' . $action; } @@ -138,6 +141,8 @@ public function get_header($name) { } $GLOBALS['didit_test_transients'] = []; +$GLOBALS['didit_test_api_posts'] = []; +$GLOBALS['didit_test_post_response'] = null; $GLOBALS['didit_test_current_user'] = 42; function get_transient($key) { return $GLOBALS['didit_test_transients'][$key] ?? false; } function set_transient($key, $value, $expiry) { $GLOBALS['didit_test_transients'][$key] = $value; } @@ -146,7 +151,17 @@ function get_current_user_id() { return $GLOBALS['didit_test_current_user']; } function is_user_logged_in() { return get_current_user_id() > 0; } function is_wp_error($value) { return $value instanceof WP_Error; } function wp_remote_get($url, $args) { $GLOBALS['didit_test_api_calls'][] = $url; return $GLOBALS['didit_test_api_response']; } +function wp_remote_post($url, $args = []) { + $GLOBALS['didit_test_api_posts'][] = ['url' => $url, 'args' => $args]; + $handler = $GLOBALS['didit_test_post_response'] ?? null; + if (is_callable($handler)) { + return $handler($url, $args); + } + return ['response' => ['code' => 500], 'body' => '{}']; +} function wp_remote_retrieve_response_code($response) { return $response['response']['code']; } function wp_remote_retrieve_body($response) { return $response['body']; } +function wp_json_encode($data) { return json_encode($data); } +function wp_unslash($value) { return is_string($value) ? stripslashes($value) : $value; } function update_option($key, $value, $autoload = null) { $GLOBALS['didit_test_options'][$key] = $value; } From 92a32f69afc5fda496bbcb62b039841c747fb61e Mon Sep 17 00:00:00 2001 From: Gabriela Paiz Date: Tue, 3 Nov 2026 12:03:46 +0000 Subject: [PATCH 2/3] DID-10021: WordPress plugin 0.3.4: liveness camera fails on Android (dampfelix.ch), works on iPhone --- README.md | 14 +++- assets/js/didit-verify.js | 92 ++++++++++++++++++++++++++- didit-verify.php | 106 +++++++++++++++++++++++++------ languages/didit-verify.pot | 14 +++- readme.txt | 16 ++++- tests/test-redirect-fallback.php | 8 +++ 6 files changed, 220 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a86628a..5a81c6e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Identity verification for WordPress & WooCommerce using the [Didit SDK](https:// | **Shortcode** | `[didit_verify]` — drop a verification button on any page | | **WooCommerce** | Require identity verification at checkout | | **Two modes** | **UniLink** (no backend, paste a URL) or **API** (unique sessions per user) | -| **Display** | Modal (popup overlay) or Embedded (inline) | +| **Display** | Modal (popup overlay), Embedded (inline) or Redirect (hosted page) | | **Customizable** | Button colors, text, padding, radius — all configurable with live preview. Checkout section title and messages editable too | | **Translatable** | All customer-facing text is translatable; German (`de_DE`, `de_DE_formal`) included | | **Product scope** | Require verification for all products, only selected products, or all except selected | @@ -82,7 +82,7 @@ Configure how the verification UI appears: | Setting | Description | |---------|-------------| -| **Display Mode** | Modal (popup overlay) or Embedded (inline where shortcode is placed) | +| **Display Mode** | Modal (popup overlay), Embedded (inline where shortcode is placed) or Redirect (hosted verification page) | | **Close Button** | Show or hide the X button on the modal | | **Exit Confirmation** | "Are you sure?" dialog when closing the modal | | **Auto-close** | Automatically close the modal when verification completes | @@ -126,6 +126,14 @@ This renders a verification button styled with your Button Appearance settings. [didit_verify mode="embedded"] ``` +**Redirect instead of embedding:** + +``` +[didit_verify mode="redirect"] +``` + +Redirect opens the hosted verification page in the browser. Use it when the camera cannot start inside a frame — the liveness step on some Android Chrome devices, for example — and set the **Callback URL** under Session so shoppers come back to the shop. On return the plugin confirms the result on the server and keeps the checkout pointing at the same session. + ### 7. Status & content gating **Show verification status** anywhere: @@ -276,7 +284,7 @@ All data is sanitized server-side before being sent to the Didit API. |-----------|---------|-------------| | `text` | Admin setting | Button label | | `success_text` | Admin setting | Label after verification | -| `mode` | Admin setting | `modal` or `embedded` — override display mode | +| `mode` | Admin setting | `modal`, `embedded` or `redirect` — override display mode | ### `[didit_status]` diff --git a/assets/js/didit-verify.js b/assets/js/didit-verify.js index 9d6590e..325bdd2 100644 --- a/assets/js/didit-verify.js +++ b/assets/js/didit-verify.js @@ -9,6 +9,7 @@ var activeButton = null; var pendingResult = null; + var RESUME_KEY = "diditPendingSessionId"; function feedback(btn, message, isError) { var node = btn.parentNode.querySelector('.didit-feedback'); @@ -33,6 +34,80 @@ } } + function rememberSession(sessionId) { + try { window.sessionStorage.setItem(RESUME_KEY, sessionId); } catch (e) { /* storage may be blocked */ } + } + + function forgetSession() { + try { window.sessionStorage.removeItem(RESUME_KEY); } catch (e) { /* storage may be blocked */ } + if (window.history && window.history.replaceState) { + try { + var current = new URL(window.location.href); + if (current.searchParams.has("verificationSessionId")) { + current.searchParams.delete("verificationSessionId"); + window.history.replaceState({}, "", current.toString()); + } + } catch (e) { /* older browser, the parameter is harmless */ } + } + } + + function returnedSessionId() { + var id = ""; + try { + id = new URL(window.location.href).searchParams.get("verificationSessionId") || ""; + } catch (e) { id = ""; } + if (!id) { + try { id = window.sessionStorage.getItem(RESUME_KEY) || ""; } catch (e) { id = ""; } + } + return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id) ? id : ""; + } + + // The shopper comes back from the hosted page: confirm what Didit decided and keep + // the checkout pointing at the same session, so the order carries the result. + function resumeReturnedSession() { + var sessionId = returnedSessionId(); + if (!sessionId) return; + var btn = document.querySelector(".didit-verify-btn"); + if (!btn || btn.classList.contains("didit-verified")) { + forgetSession(); + return; + } + + var body = { type: "completed", sessionId: sessionId }; + if (btn.dataset.orderId) { + body.order_id = btn.dataset.orderId; + body.order_key = btn.dataset.orderKey || ""; + } + activeButton = btn; + btn.disabled = true; + + fetch(cfg.restUrl.replace("/session", "/verify"), { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", "X-WP-Nonce": cfg.nonce }, + body: JSON.stringify(body) + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) throw new Error(data.message || "Error " + response.status); + return data; + }); + }).then(function (data) { + forgetSession(); + if (data.status === "Approved") { + btn.textContent = btn.dataset.success || "Verified"; + btn.classList.add("didit-verified"); + btn.disabled = true; + setCheckoutSession(data.sessionId || sessionId); + feedback(btn, i18n.approved || "Identity verified. You can continue.", false); + } else { + resetBtn(btn); + } + }).catch(function () { + forgetSession(); + resetBtn(btn); + }); + } + function confirmResult(result, btn) { btn.textContent = i18n.confirming || 'Confirming verification…'; btn.disabled = true; @@ -209,7 +284,7 @@ }) .then(function (data) { if (!data.url) throw new Error(i18n.noUrl || "No verification URL returned"); - startSdk(data.url, btn); + startSdk(data.url, btn, data.sessionId); }) .catch(function (err) { feedback(btn, err.message, true); @@ -223,7 +298,14 @@ btn.disabled = false; } - function startSdk(url, btn) { + function startSdk(url, btn, sessionId) { + if (btn.dataset.display === "redirect" || cfg.displayMode === "redirect") { + // The hosted page is the only place a blocked camera can recover the flow. + if (sessionId) rememberSession(sessionId); + window.location.assign(url); + return; + } + var config = { showCloseButton: cfg.showCloseButton, showExitConfirmation: cfg.showExitConfirmation, @@ -243,4 +325,10 @@ DiditSdk.shared.startVerification({ url: url, configuration: config }); btn.textContent = btn.dataset.text || "Verify Identity"; } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", resumeReturnedSession); + } else { + resumeReturnedSession(); + } })(); diff --git a/didit-verify.php b/didit-verify.php index e95db79..2d31d1a 100644 --- a/didit-verify.php +++ b/didit-verify.php @@ -3,7 +3,7 @@ * Plugin Name: Didit Verify * Plugin URI: https://github.com/didit-protocol/plugin-wordpress * Description: Identity verification for WordPress & WooCommerce using the Didit SDK. - * Version: 0.3.4 + * Version: 0.3.5 * Author: Didit * Author URI: https://didit.me * License: GPL-2.0-or-later @@ -18,7 +18,7 @@ exit; } -define('DIDIT_VERIFY_VERSION', '0.3.4'); +define('DIDIT_VERIFY_VERSION', '0.3.5'); define('DIDIT_VERIFY_URL', plugin_dir_url(__FILE__)); define('DIDIT_API_URL', 'https://verification.didit.me/v3/session/'); @@ -377,13 +377,18 @@ public function field_display_mode() ' +

%s

%s

', selected($v, 'modal', false), esc_html__('Modal (popup) — opens over the page', 'didit-verify'), selected($v, 'embedded', false), esc_html__('Embedded (inline) — appears where the shortcode is placed', 'didit-verify'), - esc_html__('Modal opens a centered overlay. Embedded renders the verification directly on the page where you placed [didit_verify] or the WooCommerce checkout section.', 'didit-verify') + selected($v, 'redirect', false), + esc_html__('Redirect — opens the hosted verification page in the browser', 'didit-verify'), + esc_html__('Modal opens a centered overlay. Embedded renders the verification directly on the page where you placed [didit_verify] or the WooCommerce checkout section.', 'didit-verify'), + esc_html__('Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit\'s page; set the Callback URL under Session so they come back to the shop.', 'didit-verify') ); } @@ -886,6 +891,16 @@ private function validate_order_context($input) public function rest_create_session($request) { + $input = $request->get_json_params(); + $order = $this->validate_order_context($input); + + // Continuing the session this browser or order already has costs nothing, so a retry + // must not spend the visitor's creation budget or leave a duplicate session behind. + $resume = $this->resumable_session($order); + if ($resume) { + return rest_ensure_response($resume); + } + $rate_error = $this->check_rate_limit(); if (is_wp_error($rate_error)) { return $rate_error; @@ -921,17 +936,6 @@ public function rest_create_session($request) $body['language'] = $language; } - $input = $request->get_json_params(); - - $order = $this->validate_order_context($input); - if ($order) { - $existing_url = $order->get_meta('_didit_session_url'); - $existing_status = $this->confirmed_order_status($order); - if ($existing_url && $this->stored_session((string) $order->get_meta('_didit_session_id')) && !in_array($existing_status, ['Approved', 'Declined', 'Expired', 'KYC Expired'], true)) { - return rest_ensure_response(['url' => $existing_url]); - } - } - if (!empty($input['contact_details']) && is_array($input['contact_details'])) { $body['contact_details'] = $this->sanitize_contact_details($input['contact_details']); } @@ -1040,7 +1044,9 @@ public function rest_create_session($request) 'status' => 'Not Started', 'event_timestamp' => 0, 'consumed_order_id' => 0, + 'url' => esc_url_raw($url), ]); + update_option($this->resume_key($owner), $session_id, false); if ($user_id) { update_user_meta($user_id, '_didit_pending_session_id', $session_id); } @@ -1084,6 +1090,53 @@ private function store_session($session_id, array $record) update_option($this->session_key($session_id), $record, false); } + private function resume_key(string $owner): string + { + return 'didit_resume_' . $owner; + } + + private function session_is_open(array $record): bool + { + return !in_array( + $this->normalize_status((string) ($record['status'] ?? '')), + ['Approved', 'Declined', 'Expired', 'KYC Expired'], + true + ); + } + + /** + * The session this visitor can continue, if any. A retry returns it instead of + * creating another session, which is what kept Android shoppers locked out. + */ + private function resumable_session($order = null) + { + $candidates = []; + $owner = $this->verification_owner(); + if ('' !== $owner) { + $candidates[] = (string) get_option($this->resume_key($owner), ''); + } + if ($order) { + $candidates[] = (string) $order->get_meta('_didit_session_id'); + } + + foreach (array_unique(array_filter($candidates)) as $session_id) { + $record = $this->stored_session($session_id); + if (!is_array($record) || !$this->session_belongs_to_request($record, $order) || !$this->session_is_open($record)) { + continue; + } + $url = (string) ($record['url'] ?? ''); + if ('' === $url && $order) { + $url = (string) $order->get_meta('_didit_session_url'); + } + if ('' === $url) { + continue; + } + return ['url' => $url, 'sessionId' => $session_id]; + } + + return null; + } + private function verification_owner(bool $create = false): string { $cookie = $_COOKIE['didit_verification_owner'] ?? ''; @@ -1998,6 +2051,9 @@ public function render_shortcode($atts) $is_embedded = ('embedded' === $display); $container_id = 'didit-embed-' . $instance; $container_attr = $is_embedded ? sprintf(' data-container="%s"', esc_attr($container_id)) : ''; + if ('redirect' === $display) { + $container_attr .= ' data-display="redirect"'; + } $embed_html = $is_embedded ? sprintf('
', esc_attr($container_id)) @@ -2173,6 +2229,17 @@ public function wc_product_save_verification_field($product) $product->update_meta_data('_didit_verify', isset($_POST['_didit_verify']) ? 'yes' : 'no'); } + /** + * Extra attributes the frontend needs to render this button outside a frame. + */ + private function button_attributes(bool $is_embedded): string + { + if ($is_embedded) { + return ' data-container="didit-wc-embed"'; + } + return 'redirect' === get_option('didit_display_mode', 'modal') ? ' data-display="redirect"' : ''; + } + public function wc_checkout_field() { if ('checkout' !== $this->wc_mode() || !$this->wc_cart_requires_verification()) { @@ -2182,6 +2249,7 @@ public function wc_checkout_field() enqueue_sdk_assets(); $is_embedded = ('embedded' === get_option('didit_display_mode', 'modal')); + $button_attrs = $this->button_attributes($is_embedded); $btn_text = $this->btn_text(); $btn_success = $this->btn_success_text(); ?> @@ -2191,8 +2259,7 @@ public function wc_checkout_field() wc_checkout_text()); ?>

@@ -2317,6 +2384,7 @@ private function wc_render_post_purchase_box($order): string $this->enqueue_sdk_assets(); $is_embedded = ('embedded' === get_option('didit_display_mode', 'modal')); + $button_attrs = $this->button_attributes($is_embedded); $btn_text = $this->btn_text(); $btn_success = $this->btn_success_text(); @@ -2333,7 +2401,7 @@ private function wc_render_post_purchase_box($order): string esc_attr($btn_success), absint($order->get_id()), esc_attr($order->get_order_key()), - $is_embedded ? ' data-container="didit-wc-embed"' : '', + $button_attrs, esc_html($btn_text), $is_embedded ? '
' : '' ); @@ -2475,6 +2543,7 @@ public function wc_block_checkout_field($block_content) $this->enqueue_sdk_assets(); $is_embedded = ('embedded' === get_option('didit_display_mode', 'modal')); + $button_attrs = $this->button_attributes($is_embedded); $btn_text = $this->btn_text(); $btn_success = $this->btn_success_text(); @@ -2486,8 +2555,7 @@ public function wc_block_checkout_field($block_content) wc_checkout_text()); ?>

diff --git a/languages/didit-verify.pot b/languages/didit-verify.pot index 6f9e270..a0b7ed7 100644 --- a/languages/didit-verify.pot +++ b/languages/didit-verify.pot @@ -270,18 +270,26 @@ msgstr "" msgid "Language for the verification UI shown to the user. Auto-detect uses the browser language." msgstr "" -#: didit-verify.php:383 +#: didit-verify.php:385 msgid "Modal (popup) — opens over the page" msgstr "" -#: didit-verify.php:385 +#: didit-verify.php:387 msgid "Embedded (inline) — appears where the shortcode is placed" msgstr "" -#: didit-verify.php:386 +#: didit-verify.php:389 +msgid "Redirect — opens the hosted verification page in the browser" +msgstr "" + +#: didit-verify.php:390 msgid "Modal opens a centered overlay. Embedded renders the verification directly on the page where you placed [didit_verify] or the WooCommerce checkout section." msgstr "" +#: didit-verify.php:391 +msgid "Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit's page; set the Callback URL under Session so they come back to the shop." +msgstr "" + #: didit-verify.php:396 msgid "Show close (X) button on the verification modal" msgstr "" diff --git a/readme.txt b/readme.txt index 3504d03..bf8e139 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: identity verification, kyc, woocommerce, age verification, id check Requires at least: 6.0 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 0.3.4 +Stable tag: 0.3.5 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -30,6 +30,7 @@ After updating from 0.3.1 or earlier, users must verify again before protected c * **Modal** — opens a centered overlay on top of the page * **Embedded** — renders the verification inline where the shortcode is placed +* **Redirect** — opens the hosted verification page in the browser, for devices whose camera cannot start inside a frame * Configurable close button, exit confirmation dialog, and auto-close on completion * Debug logging for SDK events in the browser console @@ -195,12 +196,16 @@ Yes. Go to **Settings → Didit Verify → WooCommerce → Product Scope** and c Select products on the product edit page under **Product data → Advanced → Didit verification**. Mixed carts require verification whenever at least one product in the cart requires it. The scope applies to both checkout and after-purchase modes. -= Can I switch between modal and embedded display? = += Can I switch between modal, embedded and redirect display? = -Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. Choose Modal (popup overlay) or Embedded (inline where the shortcode is). You can also override per shortcode: +Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. Choose Modal (popup overlay), Embedded (inline where the shortcode is) or Redirect (the hosted verification page in the browser). You can also override per shortcode: `[didit_verify mode="embedded"]` += When should I use Redirect display? = + +When the camera cannot start inside a frame, for example the liveness step on some Android Chrome devices. Redirect opens Didit's hosted page in the browser, so the camera runs at the top level. Set the **Callback URL** under Session to the page shoppers should return to, such as the checkout page; the plugin then confirms the result and keeps the checkout session. + == Screenshots == 1. Settings page — configure mode, credentials, display options, and button appearance. @@ -210,6 +215,11 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C == Changelog == += 0.3.5 = +* Fix: retrying verification no longer creates another session or spends the visitor's hourly limit. The plugin reopens the session this browser (or this WooCommerce order) is already in, so a shopper can keep retrying until the flow completes. Approved, declined and expired sessions still start a new verification, and the creation limit still stops a runaway loop. +* New Redirect display mode: the verification runs on Didit's hosted page instead of inside a frame, the way out of camera or liveness failures inside an iframe on Android Chrome. Set the Callback URL so the shopper returns to the shop; the plugin then confirms the result and keeps the checkout session. +* When a shopper returns from the hosted page, the plugin confirms the result on the server and marks the checkout as soon as the session is approved. + = 0.3.4 = * Security: revoke protected-content access when the approved verification is declined, expires, or returns to review, even if another verification has started. * Keep a newer approved verification unaffected by updates to an older session. diff --git a/tests/test-redirect-fallback.php b/tests/test-redirect-fallback.php index e04c800..c77b1f6 100644 --- a/tests/test-redirect-fallback.php +++ b/tests/test-redirect-fallback.php @@ -48,5 +48,13 @@ function ok($condition, $label) && (bool) preg_match('/location\.(assign|replace|href)/', $script), 'the frontend continues on the hosted page in redirect mode'); +// 4. The WooCommerce checkout button - the screen this customer is stuck on - asks for it too. +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'checkout'; +ob_start(); +$plugin->wc_checkout_field(); +$checkout = ob_get_clean(); +ok(false !== strpos($checkout, 'data-display="redirect"') && false === strpos($checkout, 'didit-embed-container'), + 'the WooCommerce checkout button uses the redirect mode and renders no frame'); + echo "\n{$passed} passed, {$failed} failed\n"; exit($failed > 0 ? 1 : 0); From 4f564482d3444e317b5b3fd74efc2cad7ac77635 Mon Sep 17 00:00:00 2001 From: Gabriela Paiz Date: Tue, 3 Nov 2026 12:18:23 +0000 Subject: [PATCH 3/3] DID-10021: WordPress plugin 0.3.4: liveness camera fails on Android (dampfelix.ch), works on iPhone --- README.md | 2 +- assets/js/didit-verify.js | 59 +++++++++--------------- didit-verify.php | 60 ++++++++++++++++++++---- languages/didit-verify.pot | 2 +- readme.txt | 6 +-- tests/test-checkout-retry.php | 78 +++++++++++++++++++++++++++++++- tests/test-redirect-fallback.php | 14 +++++- tests/wp-stubs.php | 9 +++- 8 files changed, 174 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 5a81c6e..9973926 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ This renders a verification button styled with your Button Appearance settings. [didit_verify mode="redirect"] ``` -Redirect opens the hosted verification page in the browser. Use it when the camera cannot start inside a frame — the liveness step on some Android Chrome devices, for example — and set the **Callback URL** under Session so shoppers come back to the shop. On return the plugin confirms the result on the server and keeps the checkout pointing at the same session. +Redirect opens the hosted verification page in the browser. Use it when the camera cannot start inside a frame — the liveness step on some Android Chrome devices, for example — and shoppers come back to the page where they started. On return the plugin confirms the result on the server and keeps the checkout pointing at the same session. ### 7. Status & content gating diff --git a/assets/js/didit-verify.js b/assets/js/didit-verify.js index 325bdd2..1ddd6a4 100644 --- a/assets/js/didit-verify.js +++ b/assets/js/didit-verify.js @@ -44,13 +44,26 @@ try { var current = new URL(window.location.href); if (current.searchParams.has("verificationSessionId")) { - current.searchParams.delete("verificationSessionId"); - window.history.replaceState({}, "", current.toString()); + window.history.replaceState({}, "", pageUrl()); } } catch (e) { /* older browser, the parameter is harmless */ } } } + // This page without the parameters Didit appends on the way back. + function pageUrl() { + try { + var current = new URL(window.location.href); + if (current.searchParams.has("verificationSessionId")) { + current.searchParams.delete("verificationSessionId"); + current.searchParams.delete("status"); + } + return current.toString(); + } catch (e) { + return window.location.href; + } + } + function returnedSessionId() { var id = ""; try { @@ -72,40 +85,8 @@ forgetSession(); return; } - - var body = { type: "completed", sessionId: sessionId }; - if (btn.dataset.orderId) { - body.order_id = btn.dataset.orderId; - body.order_key = btn.dataset.orderKey || ""; - } activeButton = btn; - btn.disabled = true; - - fetch(cfg.restUrl.replace("/session", "/verify"), { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json", "X-WP-Nonce": cfg.nonce }, - body: JSON.stringify(body) - }).then(function (response) { - return response.json().then(function (data) { - if (!response.ok) throw new Error(data.message || "Error " + response.status); - return data; - }); - }).then(function (data) { - forgetSession(); - if (data.status === "Approved") { - btn.textContent = btn.dataset.success || "Verified"; - btn.classList.add("didit-verified"); - btn.disabled = true; - setCheckoutSession(data.sessionId || sessionId); - feedback(btn, i18n.approved || "Identity verified. You can continue.", false); - } else { - resetBtn(btn); - } - }).catch(function () { - forgetSession(); - resetBtn(btn); - }); + confirmResult({ type: "completed", session: { sessionId: sessionId } }, btn).then(forgetSession, forgetSession); } function confirmResult(result, btn) { @@ -139,7 +120,7 @@ pendingResult = null; setCheckoutSession(data.sessionId); feedback(btn, i18n.approved || 'Identity verified. You can continue.', false); - } else if (data.status === 'Declined' || data.status === 'Expired' || data.status === 'KYC Expired') { + } else if (data.status === 'Declined' || data.status === 'Abandoned' || data.status === 'Expired' || data.status === 'KYC Expired') { pendingResult = null; resetBtn(btn); feedback(btn, data.status === 'Declined' ? (i18n.declined || 'Verification declined. Please try again.') : (i18n.expired || 'Verification expired. Please start again.'), true); @@ -265,6 +246,9 @@ requestBody.order_id = btn.dataset.orderId; requestBody.order_key = btn.dataset.orderKey || ""; } + if (btn.dataset.display === "redirect") { + requestBody.return_url = pageUrl(); + } fetch(cfg.restUrl, { method: "POST", @@ -299,7 +283,8 @@ } function startSdk(url, btn, sessionId) { - if (btn.dataset.display === "redirect" || cfg.displayMode === "redirect") { + // The server marks every redirect button, so a shortcode's own mode always wins. + if (btn.dataset.display === "redirect") { // The hosted page is the only place a blocked camera can recover the flow. if (sessionId) rememberSession(sessionId); window.location.assign(url); diff --git a/didit-verify.php b/didit-verify.php index 2d31d1a..6155f87 100644 --- a/didit-verify.php +++ b/didit-verify.php @@ -388,7 +388,7 @@ public function field_display_mode() selected($v, 'redirect', false), esc_html__('Redirect — opens the hosted verification page in the browser', 'didit-verify'), esc_html__('Modal opens a centered overlay. Embedded renders the verification directly on the page where you placed [didit_verify] or the WooCommerce checkout section.', 'didit-verify'), - esc_html__('Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit\'s page; set the Callback URL under Session so they come back to the shop.', 'didit-verify') + esc_html__('Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit\'s page and returns to the page where they started.', 'didit-verify') ); } @@ -924,6 +924,11 @@ public function rest_create_session($request) if ($vendor_data) { $body['vendor_data'] = $vendor_data; } + // A redirected shopper comes back to the page they left, where the plugin can pick up the result. + $return_url = $this->same_site_url($input['return_url'] ?? ''); + if ($return_url) { + $callback_url = $return_url; + } if ($callback_url) { $body['callback'] = $callback_url; } @@ -1070,6 +1075,21 @@ public function rest_create_session($request) return rest_ensure_response(['url' => $url, 'sessionId' => $session_id]); } + private function same_site_url($url): string + { + if (!is_string($url) || '' === $url) { + return ''; + } + $url = esc_url_raw($url); + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); + $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST)); + $site_host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); + if (!in_array($scheme, ['http', 'https'], true) || '' === $host || $host !== $site_host) { + return ''; + } + return $url; + } + private function valid_session_id($session_id): bool { return is_string($session_id) && 1 === preg_match('/^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/i', $session_id); @@ -1095,13 +1115,9 @@ private function resume_key(string $owner): string return 'didit_resume_' . $owner; } - private function session_is_open(array $record): bool + private function status_is_open(string $status): bool { - return !in_array( - $this->normalize_status((string) ($record['status'] ?? '')), - ['Approved', 'Declined', 'Expired', 'KYC Expired'], - true - ); + return in_array($this->normalize_status($status), ['Not Started', 'In Progress', 'In Review'], true); } /** @@ -1121,7 +1137,11 @@ private function resumable_session($order = null) foreach (array_unique(array_filter($candidates)) as $session_id) { $record = $this->stored_session($session_id); - if (!is_array($record) || !$this->session_belongs_to_request($record, $order) || !$this->session_is_open($record)) { + if (!is_array($record) || !$this->session_belongs_to_request($record, $order) || !$this->status_is_open((string) ($record['status'] ?? ''))) { + continue; + } + // A guest session carries no account, so after logging in the visitor needs one that does. + if (!$order && (int) $record['user_id'] !== get_current_user_id()) { continue; } $url = (string) ($record['url'] ?? ''); @@ -1131,6 +1151,16 @@ private function resumable_session($order = null) if ('' === $url) { continue; } + // The stored status only moves when a webhook arrives, so ask Didit before reopening: + // an abandoned or expired session would otherwise be handed back on every retry. + $status = $this->fetch_decision_status($session_id); + if (is_wp_error($status)) { + continue; + } + if (!$this->status_is_open($status)) { + $this->apply_confirmed_status($session_id, $record, $status, time()); + continue; + } return ['url' => $url, 'sessionId' => $session_id]; } @@ -1189,6 +1219,19 @@ private function confirm_session($session_id, $order = null) if (!$this->session_belongs_to_request($record, $order)) { return new WP_Error('didit_session_mismatch', __('Start a new verification from this page.', 'didit-verify'), ['status' => 403]); } + $status = $this->fetch_decision_status($session_id); + if (is_wp_error($status)) { + return $status; + } + $this->apply_confirmed_status($session_id, $record, $status, time()); + return $status; + } + + /** + * The status Didit currently holds for a session, read from the decision endpoint. + */ + private function fetch_decision_status($session_id) + { $api_key = get_option('didit_api_key', ''); if (!$api_key) { return new WP_Error('didit_not_configured', __('API Session mode is required to confirm verification.', 'didit-verify'), ['status' => 503]); @@ -1205,7 +1248,6 @@ private function confirm_session($session_id, $order = null) if (!$status || ($data['session_id'] ?? '') !== $session_id) { return new WP_Error('didit_invalid_decision', __('Unable to confirm verification. Please try again.', 'didit-verify'), ['status' => 502]); } - $this->apply_confirmed_status($session_id, $record, $status, time()); return $status; } diff --git a/languages/didit-verify.pot b/languages/didit-verify.pot index a0b7ed7..80e4079 100644 --- a/languages/didit-verify.pot +++ b/languages/didit-verify.pot @@ -287,7 +287,7 @@ msgid "Modal opens a centered overlay. Embedded renders the verification directl msgstr "" #: didit-verify.php:391 -msgid "Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit's page; set the Callback URL under Session so they come back to the shop." +msgid "Choose Redirect when the camera cannot open inside a frame, for example the liveness step on Android Chrome. The shopper finishes on Didit's page and returns to the page where they started." msgstr "" #: didit-verify.php:396 diff --git a/readme.txt b/readme.txt index bf8e139..9eb6d33 100644 --- a/readme.txt +++ b/readme.txt @@ -204,7 +204,7 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C = When should I use Redirect display? = -When the camera cannot start inside a frame, for example the liveness step on some Android Chrome devices. Redirect opens Didit's hosted page in the browser, so the camera runs at the top level. Set the **Callback URL** under Session to the page shoppers should return to, such as the checkout page; the plugin then confirms the result and keeps the checkout session. +When the camera cannot start inside a frame, for example the liveness step on some Android Chrome devices. Redirect opens Didit's hosted page in the browser, so the camera runs at the top level. Shoppers return to the page where they started; the plugin then confirms the result and keeps the checkout session. == Screenshots == @@ -216,8 +216,8 @@ When the camera cannot start inside a frame, for example the liveness step on so == Changelog == = 0.3.5 = -* Fix: retrying verification no longer creates another session or spends the visitor's hourly limit. The plugin reopens the session this browser (or this WooCommerce order) is already in, so a shopper can keep retrying until the flow completes. Approved, declined and expired sessions still start a new verification, and the creation limit still stops a runaway loop. -* New Redirect display mode: the verification runs on Didit's hosted page instead of inside a frame, the way out of camera or liveness failures inside an iframe on Android Chrome. Set the Callback URL so the shopper returns to the shop; the plugin then confirms the result and keeps the checkout session. +* Fix: retrying verification no longer creates another session or spends the visitor's hourly limit. The plugin reopens the session this browser (or this WooCommerce order) is already in, so a shopper can keep retrying until the flow completes. Before reopening a session the plugin asks Didit for its current status, so approved, declined, abandoned and expired sessions start a new verification, and the creation limit still stops a runaway loop. +* New Redirect display mode: the verification runs on Didit's hosted page instead of inside a frame, the way out of camera or liveness failures inside an iframe on Android Chrome. The shopper returns to the page where they started; the plugin then confirms the result and keeps the checkout session. * When a shopper returns from the hosted page, the plugin confirms the result on the server and marks the checkout as soon as the session is approved. = 0.3.4 = diff --git a/tests/test-checkout-retry.php b/tests/test-checkout-retry.php index a7d0708..0d114f2 100644 --- a/tests/test-checkout-retry.php +++ b/tests/test-checkout-retry.php @@ -47,11 +47,25 @@ function store($session_id, array $record) $method->invoke(Didit_Verify::init(), $session_id, $record); } -function start_attempt() +function start_attempt(array $input = []) { - return Didit_Verify::init()->rest_create_session(new Didit_Test_Request('{}')); + return Didit_Verify::init()->rest_create_session(new Didit_Test_Request($input ? json_encode($input) : '{}')); } +// What Didit currently says about each session, as its decision endpoint reports it. +$GLOBALS['didit_remote_status'] = []; +$GLOBALS['didit_test_api_response'] = function ($url) { + if (!empty($GLOBALS['didit_decision_unavailable'])) { + return ['response' => ['code' => 503], 'body' => '{}']; + } + preg_match('#/session/([^/]+)/decision/#', $url, $m); + $id = rawurldecode($m[1] ?? ''); + return ['response' => ['code' => 200], 'body' => json_encode([ + 'session_id' => $id, + 'status' => $GLOBALS['didit_remote_status'][$id] ?? 'Not Started', + ])]; +}; + function reset_attempts($owner_seed, $ip) { $GLOBALS['didit_test_transients'] = []; @@ -110,6 +124,66 @@ function reset_attempts($owner_seed, $ip) ok(is_array($renewed) && ($renewed['sessionId'] ?? '') !== $open_id, 'an approved session is not reused for a new verification'); +// Didit ended the session but the webhook never reached the shop (this shop's webhooks returned 401). +foreach (['Abandoned', 'Expired'] as $i => $final) { + reset_attempts((string) ($i + 1), '198.51.100.3' . $i); + $dead = start_attempt(); + $dead_id = $dead['sessionId'] ?? ''; + $GLOBALS['didit_remote_status'][$dead_id] = $final; + $after = start_attempt(); + ok(is_array($after) && !empty($after['sessionId']) && $after['sessionId'] !== $dead_id, + "a session Didit reports as {$final} is not reopened, even without its webhook"); + $dead_record = stored($dead_id); + ok($final === ($dead_record['status'] ?? ''), "the {$final} status Didit reported is recorded"); + $again = start_attempt(); + ok(($again['sessionId'] ?? '') === ($after['sessionId'] ?? 'x'), "the replacement for an {$final} session is the one reopened next"); +} + +// A webhook already recorded the session as abandoned. +reset_attempts('5', '198.51.100.40'); +$abandoned = start_attempt(); +$abandoned_id = $abandoned['sessionId'] ?? ''; +$record = stored($abandoned_id); +$record['status'] = 'Abandoned'; +store($abandoned_id, $record); +$calls = count($GLOBALS['didit_test_api_calls'] ?? []); +$fresh = start_attempt(); +ok(is_array($fresh) && ($fresh['sessionId'] ?? '') !== $abandoned_id, 'a session recorded as Abandoned is not reopened'); +ok(count($GLOBALS['didit_test_api_calls'] ?? []) === $calls, 'a session already recorded as finished is not looked up again'); + +// When Didit cannot say whether the session is still open, start a new one rather than guess. +reset_attempts('6', '198.51.100.41'); +$unknown = start_attempt(); +$GLOBALS['didit_decision_unavailable'] = true; +$retry = start_attempt(); +$GLOBALS['didit_decision_unavailable'] = false; +ok(is_array($retry) && ($retry['sessionId'] ?? '') !== ($unknown['sessionId'] ?? ''), + 'a session whose status cannot be confirmed is not reopened'); + +// A guest session carries no account; after logging in the shopper needs one tied to their user. +reset_attempts('7', '198.51.100.42'); +$guest = start_attempt(); +$GLOBALS['didit_test_current_user'] = 42; +$member = start_attempt(); +ok(is_array($member) && ($member['sessionId'] ?? '') !== ($guest['sessionId'] ?? ''), + 'a guest session is not reopened once the shopper has logged in'); +ok(42 === (int) (stored($member['sessionId'] ?? '')['user_id'] ?? 0), 'the new session belongs to the logged-in shopper'); +$GLOBALS['didit_test_current_user'] = 0; + +// Redirect returns the shopper to the page they left, never to another site. +reset_attempts('8', '198.51.100.43'); +$GLOBALS['didit_test_api_posts'] = []; +start_attempt(['return_url' => 'https://example.test/checkout/?step=2']); +$sent = json_decode($GLOBALS['didit_test_api_posts'][0]['args']['body'] ?? '{}', true); +ok('https://example.test/checkout/?step=2' === ($sent['callback'] ?? ''), 'a redirected session returns to the page it started from'); +reset_attempts('9', '198.51.100.44'); +$GLOBALS['didit_test_options']['didit_callback_url'] = 'https://example.test/verification-done'; +$GLOBALS['didit_test_api_posts'] = []; +start_attempt(['return_url' => 'https://attacker.example/phish']); +$sent = json_decode($GLOBALS['didit_test_api_posts'][0]['args']['body'] ?? '{}', true); +ok('https://example.test/verification-done' === ($sent['callback'] ?? ''), 'a return address on another site is ignored'); +unset($GLOBALS['didit_test_options']['didit_callback_url']); + // Creation stays limited: a client that keeps ending up in a closed session still hits the cap. reset_attempts('e', '198.51.100.24'); $GLOBALS['didit_test_post_response'] = function () { diff --git a/tests/test-redirect-fallback.php b/tests/test-redirect-fallback.php index c77b1f6..e3cc496 100644 --- a/tests/test-redirect-fallback.php +++ b/tests/test-redirect-fallback.php @@ -44,9 +44,19 @@ function ok($condition, $label) // 3. In redirect mode the flow must run on the hosted page, not inside a frame. $script = (string) file_get_contents(__DIR__ . '/../assets/js/didit-verify.js'); -ok((bool) preg_match('/displayMode\s*===\s*["\']redirect["\']/', $script) +ok((bool) preg_match('/dataset\.display\s*===\s*["\']redirect["\']/', $script) && (bool) preg_match('/location\.(assign|replace|href)/', $script), - 'the frontend continues on the hosted page in redirect mode'); + 'the frontend continues on the hosted page for a redirect button'); +ok(!preg_match('/cfg\.displayMode/', $script), 'the site-wide mode does not override the mode a button was rendered with'); + +// The per-shortcode override documented in the readme works both ways. +$GLOBALS['didit_test_options']['didit_display_mode'] = 'redirect'; +ok(false === strpos($plugin->render_shortcode(['mode' => 'modal']), 'data-display="redirect"'), + 'a shortcode with mode="modal" stays in place when the site uses Redirect'); +$GLOBALS['didit_test_options']['didit_display_mode'] = 'modal'; +ok(false !== strpos($plugin->render_shortcode(['mode' => 'redirect']), 'data-display="redirect"'), + 'a shortcode with mode="redirect" redirects when the site uses Modal'); +$GLOBALS['didit_test_options']['didit_display_mode'] = 'redirect'; // 4. The WooCommerce checkout button - the screen this customer is stuck on - asks for it too. $GLOBALS['didit_test_options']['didit_wc_mode'] = 'checkout'; diff --git a/tests/wp-stubs.php b/tests/wp-stubs.php index 7f9e000..90f4bc5 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -47,6 +47,8 @@ function current_time() { return '2026-03-31 12:00:00'; } function shortcode_atts($pairs, $atts) { return array_merge($pairs, (array) $atts); } function selected($a, $b, $echo = true) { $out = ((string) $a === (string) $b) ? ' selected="selected"' : ''; if ($echo) { echo $out; } return $out; } function apply_filters($hook, $value) { return $value; } +function home_url($path = '') { return 'https://example.test/' . ltrim($path, '/'); } +function wp_parse_url($url, $component = -1) { return parse_url($url, $component); } function rest_url($path = '') { return 'https://example.test/wp-json/' . ltrim($path, '/'); } function wp_create_nonce($action) { return 'test-nonce-' . $action; } @@ -149,8 +151,13 @@ function set_transient($key, $value, $expiry) { $GLOBALS['didit_test_transients' function get_user_meta($id, $key, $single = true) { return $GLOBALS['didit_test_user_meta'][$id . '|' . $key] ?? ''; } function get_current_user_id() { return $GLOBALS['didit_test_current_user']; } function is_user_logged_in() { return get_current_user_id() > 0; } +function wp_get_current_user() { return (object) ['ID' => get_current_user_id(), 'user_email' => 'user' . get_current_user_id() . '@example.test']; } function is_wp_error($value) { return $value instanceof WP_Error; } -function wp_remote_get($url, $args) { $GLOBALS['didit_test_api_calls'][] = $url; return $GLOBALS['didit_test_api_response']; } +function wp_remote_get($url, $args) { + $GLOBALS['didit_test_api_calls'][] = $url; + $response = $GLOBALS['didit_test_api_response'] ?? null; + return is_callable($response) ? $response($url, $args) : $response; +} function wp_remote_post($url, $args = []) { $GLOBALS['didit_test_api_posts'][] = ['url' => $url, 'args' => $args]; $handler = $GLOBALS['didit_test_post_response'] ?? null;