diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..21a90da --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +jobs: + php: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Requires PHP: 7.4 in the plugin header, plus current releases. + php: ["7.4", "8.2", "8.4"] + container: php:${{ matrix.php }}-cli + steps: + - uses: actions/checkout@v4 + + - name: Lint PHP + run: | + set -e + for f in $(find . -name '*.php' -not -path './.git/*'); do + php -l "$f" + done + + - name: Webhook signature tests + run: php tests/test-webhook-signature.php + + - name: Verification callback tests + run: php tests/test-verify-endpoint.php + + - name: Shortcode asset tests + run: php tests/test-shortcode-assets.php diff --git a/README.md b/README.md index 583f164..6424617 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Open and complete the WordPress setup wizard. 2. Paste your UniLink URL (from [Didit Console](https://business.didit.me) → Workflow → Copy Link) 3. Save +> UniLink sessions are started from a shared URL, so the site cannot confirm who completed them. In UniLink mode results are shown in the button only: `[didit_gate]` stays locked, `[didit_status]` does not change and WooCommerce orders are not updated from the browser. Use API mode for any of those. + #### API mode (recommended for production) 1. Set **Mode** to `API Session` @@ -290,7 +292,7 @@ wordpress-plugin/ ├── languages/ # POT template + German translations (de_DE, de_DE_formal) ├── uninstall.php # Cleans up options on plugin deletion ├── readme.txt # WordPress.org plugin directory format -├── tests/ # Webhook signature tests (plain PHP, no dependencies) +├── tests/ # Webhook, completion callback and shortcode tests (plain PHP, no dependencies; run in CI) ├── docker-compose.yml # Local dev (WordPress + MySQL) └── README.md ``` @@ -301,7 +303,8 @@ wordpress-plugin/ ``` User clicks button → JS calls DiditSdk.startVerification({ url }) → Modal opens -→ User completes verification → onComplete fires → Button shows "Verified" +→ User completes verification → onComplete fires → Button shows the SDK result +(nothing is saved on the site) ``` ### API Flow @@ -313,8 +316,13 @@ User clicks button → PHP checks: nonce ✓ → login ✓ → rate limit ✓ → PHP calls Didit API with API key (server-side) → returns { url } → JS calls DiditSdk.startVerification({ url }) → modal opens -→ User completes → onComplete fires → button shows "Verified" -→ JS sends POST /wp-json/didit/v1/verify → saves result to user meta +→ PHP records the session id on the user as pending (trusted session → user binding); + the session behind the user's current status stays bound until a new decision lands +→ User completes → onComplete fires +→ JS sends POST /wp-json/didit/v1/verify { sessionId } +→ PHP reads GET /v3/session/{id}/decision/ with the API key, checks the session + belongs to this user (or to the order being verified), saves Didit's status +→ Button shows the status the site saved ``` ### WooCommerce Flow @@ -390,7 +398,8 @@ When a user completes verification, the plugin saves these fields to WordPress u | Meta key | Value | Description | |----------|-------|-------------| | `_didit_verified` | `1` | User is verified | -| `_didit_session_id` | UUID | Didit session ID | +| `_didit_session_id` | UUID | Didit session the stored status came from | +| `_didit_pending_session_id` | UUID (multi-value) | Sessions created since, still waiting for a decision | | `_didit_status` | `Approved` / `Pending` / `Declined` | Verification result | | `_didit_verified_at` | datetime | When verification was completed | @@ -436,7 +445,7 @@ document.addEventListener('didit:complete', function (e) { | Method | Endpoint | Description | Auth | |--------|----------|-------------|------| | `POST` | `/wp-json/didit/v1/session` | Create a verification session | CSRF nonce + login, or order key (after-purchase) | -| `POST` | `/wp-json/didit/v1/verify` | Save verification result to user/order meta | Login, or order key (after-purchase) | +| `POST` | `/wp-json/didit/v1/verify` | Confirm a completion with Didit and save its decision to user/order meta (API mode only) | Login, or order key (after-purchase) | | `POST` | `/wp-json/didit/v1/webhook` | Didit webhook receiver (`status.updated`) | HMAC-SHA256 signature (`X-Signature-V2` → `X-Signature` → `X-Signature-Simple`) + timestamp | ## Uninstall @@ -451,10 +460,12 @@ docker compose exec wordpress wp plugin install woocommerce --activate --allow-r ## Tests -Webhook signature verification is covered by a dependency-free PHP test suite: +Webhook signature verification, the browser completion callback and shortcode asset loading are covered by dependency-free PHP test suites: ```bash php tests/test-webhook-signature.php +php tests/test-verify-endpoint.php +php tests/test-shortcode-assets.php # or, without a local PHP: docker run --rm -v "$PWD":/app -w /app php:8.2-cli php tests/test-webhook-signature.php ``` diff --git a/assets/js/didit-verify.js b/assets/js/didit-verify.js index 99cc0ba..48ab5ce 100644 --- a/assets/js/didit-verify.js +++ b/assets/js/didit-verify.js @@ -7,20 +7,18 @@ var DiditSdk = window.DiditSDK.DiditSdk; var i18n = cfg.i18n || {}; - DiditSdk.shared.onComplete = function (result) { - var status = result.session ? result.session.status : ""; - + function setButtonState(type, status) { document.querySelectorAll(".didit-verify-btn").forEach(function (btn) { btn.classList.remove("didit-verified", "didit-declined", "didit-in-review"); - if (result.type === "completed" && status === "Approved") { + if (type === "completed" && status === "Approved") { btn.textContent = btn.dataset.success || "Verified"; btn.classList.add("didit-verified"); btn.disabled = true; - } else if (result.type === "completed" && status === "Declined") { + } else if (type === "completed" && status === "Declined") { btn.textContent = btn.dataset.text || "Verify Identity"; btn.disabled = false; - } else if (result.type === "completed") { + } else if (type === "completed") { btn.textContent = i18n.inReview || "Verification In Review"; btn.classList.add("didit-in-review"); btn.disabled = true; @@ -29,6 +27,10 @@ btn.disabled = false; } }); + } + + DiditSdk.shared.onComplete = function (result) { + var status = result.session ? result.session.status : ""; var hidden = document.getElementById("didit_session_id"); if (hidden && result.session && result.session.sessionId) { @@ -43,11 +45,19 @@ } catch (e) {} } - if (cfg.restUrl && cfg.nonce) { + // In API mode the site confirms a completion with Didit before saving it, so + // the button shows the status the site stored rather than the one the SDK + // reported. UniLink results cannot be saved, so they are not sent. + var confirmWithSite = cfg.mode === "api" && cfg.restUrl && cfg.nonce; + + if (!confirmWithSite || result.type !== "completed") { + setButtonState(result.type, status); + } + + if (confirmWithSite) { var verifyBody = { type: result.type, - sessionId: result.session ? result.session.sessionId : "", - status: result.session ? result.session.status : "" + sessionId: result.session ? result.session.sessionId : "" }; var orderBtn = document.querySelector(".didit-verify-btn[data-order-id]"); if (orderBtn) { @@ -62,7 +72,27 @@ "X-WP-Nonce": cfg.nonce }, body: JSON.stringify(verifyBody) - }).catch(function () {}); + }) + .then(function (r) { + return r.json().then(function (d) { + return { response: r, data: d }; + }); + }) + .then(function (res) { + if (result.type !== "completed") return; + if (res.response.ok) { + setButtonState("completed", res.data.status); + } else if (res.response.status === 401) { + // Anonymous visitor outside an order: nothing is stored on the site. + setButtonState(result.type, status); + } else { + setButtonState("error", ""); + alert((i18n.verificationError || "Verification error:") + " " + (res.data.message || "Error " + res.response.status)); + } + }) + .catch(function () { + if (result.type === "completed") setButtonState("error", ""); + }); } document.dispatchEvent(new CustomEvent("didit:complete", { detail: result })); diff --git a/didit-verify.php b/didit-verify.php index 4a21289..a7236d1 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.1 + * Version: 0.3.2 * Author: Didit * Author URI: https://didit.me * License: GPL-2.0-or-later @@ -18,12 +18,17 @@ exit; } -define('DIDIT_VERIFY_VERSION', '0.3.1'); +define('DIDIT_VERIFY_VERSION', '0.3.2'); define('DIDIT_VERIFY_URL', plugin_dir_url(__FILE__)); define('DIDIT_API_URL', 'https://verification.didit.me/v3/session/'); +define('DIDIT_DECISION_URL', 'https://verification.didit.me/v3/session/%s/decision/'); final class Didit_Verify { + /** Multi-value user meta: sessions created for the user whose decision is still pending. */ + const PENDING_SESSION_META = '_didit_pending_session_id'; + /** Oldest pending bindings are dropped beyond this many per user. */ + const PENDING_SESSION_LIMIT = 10; private $block_session_id = ''; private $sdk_styles_enqueued = false; @@ -192,6 +197,7 @@ public function field_mode() selected($v, 'api', false), esc_html__('API Session — recommended for production', 'didit-verify'), esc_html__('UniLink uses a fixed URL. API mode creates a unique session per user (requires Workflow ID + API Key).', 'didit-verify') + . ' ' . esc_html__('UniLink results cannot be confirmed by this site, so [didit_gate], [didit_status] and WooCommerce order verification require API mode.', 'didit-verify') ); } @@ -983,8 +989,19 @@ public function rest_create_session($request) return new WP_Error('api_error', __('No verification URL returned.', 'didit-verify'), ['status' => 500]); } + $created_session_id = sanitize_text_field($data['session_id'] ?? ''); + if ($created_session_id && is_user_logged_in()) { + // Trusted session -> user binding, recorded server-side so the completion + // callback and a simple-signature webhook can both resolve the user without + // relying on the browser callback having succeeded first. It is kept apart + // from `_didit_session_id`, which stays on the session whose decision the + // user's current status came from, so starting a new session cannot detach + // the user from a decision that is later revoked. + $this->bind_pending_session(get_current_user_id(), $created_session_id); + } + if ($order) { - $order->update_meta_data('_didit_session_id', sanitize_text_field($data['session_id'] ?? '')); + $order->update_meta_data('_didit_session_id', $created_session_id); $order->update_meta_data('_didit_session_url', esc_url_raw($url)); if (!$order->get_meta('_didit_status')) { $order->update_meta_data('_didit_status', 'Not Started'); @@ -992,7 +1009,7 @@ public function rest_create_session($request) $order->add_order_note(sprintf( /* translators: %s: Didit session id. */ __('Didit: verification session created (%s).', 'didit-verify'), - sanitize_text_field($data['session_id'] ?? '') + $created_session_id )); $order->save(); } @@ -1002,6 +1019,17 @@ public function rest_create_session($request) return rest_ensure_response(['url' => $url]); } + /** + * Browser completion callback: `POST /didit/v1/verify`. + * + * The SDK posts this from the visitor's browser when the flow closes, so the + * whole body is caller-controlled. The `status` it carries is therefore never + * stored: the verified state that [didit_gate] and the WooCommerce order hold + * are keyed on is read back from Didit's own decision endpoint, and only for + * a session that was created for this user or this order. The signed webhook + * remains the authoritative source; this path just lets the site reflect the + * result immediately without trusting the client for it. + */ public function rest_save_verification($request) { $user_id = get_current_user_id(); @@ -1014,26 +1042,56 @@ public function rest_save_verification($request) $type = sanitize_text_field($input['type'] ?? ''); $session_id = sanitize_text_field($input['sessionId'] ?? ''); - $status = sanitize_text_field($input['status'] ?? ''); if ('completed' === $type) { - if ($user_id) { - update_user_meta($user_id, '_didit_session_id', $session_id); - update_user_meta($user_id, '_didit_status', $status); - update_user_meta($user_id, '_didit_verified_at', current_time('mysql')); - - if ('Approved' === $status) { - update_user_meta($user_id, '_didit_verified', 1); - } else { - delete_user_meta($user_id, '_didit_verified'); - } + if (!$session_id || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $session_id)) { + return new WP_Error('invalid_session', __('Invalid session id.', 'didit-verify'), ['status' => 400]); + } + + // A UniLink session is started from a shared URL, so nothing on this site + // ties it to the visitor and its result cannot be stored without trusting + // the browser. Say so instead of failing later with a misleading error. + if ('api' !== get_option('didit_mode', 'unilink')) { + return new WP_Error( + 'unilink_not_verifiable', + __('This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them.', 'didit-verify'), + ['status' => 409] + ); + } + + $decision = $this->fetch_session_decision($session_id); + if (!$decision) { + return new WP_Error( + 'decision_unavailable', + __('Could not confirm the verification result with Didit. It will be applied when the webhook arrives.', 'didit-verify'), + ['status' => 502] + ); } - if ($order && $session_id && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id)) { + // Authoritative: what Didit says about this session, not what the browser said. + $status = sanitize_text_field($decision['status']); + + // The user and the order are authorized independently: a session started + // as a guest for an order still belongs to that order after the customer + // logs in, but it must not change that logged-in user's own status. + $user_owns = $user_id && $this->session_belongs_to_user($decision, $session_id, $user_id); + $order_matches = $order && hash_equals((string) $order->get_meta('_didit_session_id'), $session_id); + + if (!$user_owns && !$order_matches) { + return new WP_Error('session_mismatch', __('This verification session does not belong to you.', 'didit-verify'), ['status' => 403]); + } + + if ($user_owns) { + $this->apply_decision_to_user($user_id, $session_id, $status); + } + + if ($order_matches) { $this->wc_apply_verification_to_order($order, $status, 'browser'); } - do_action('didit_verification_completed', $user_id, $session_id, $status); + do_action('didit_verification_completed', $user_owns ? $user_id : 0, $session_id, $status); + + return rest_ensure_response(['saved' => true, 'status' => $status]); } elseif ('cancelled' === $type) { do_action('didit_verification_cancelled', $user_id, $session_id); } @@ -1041,6 +1099,119 @@ public function rest_save_verification($request) return rest_ensure_response(['saved' => true]); } + /** + * Read a session's decision from Didit with the site API key. + * + * @return array|null Decoded decision with a non-empty `status`, or null when + * the session is unknown, the key is missing or the call failed. + */ + private function fetch_session_decision($session_id) + { + $api_key = get_option('didit_api_key'); + if (empty($api_key)) { + return null; + } + + $response = wp_remote_get(sprintf(DIDIT_DECISION_URL, rawurlencode($session_id)), [ + 'headers' => ['x-api-key' => $api_key], + 'timeout' => 15, + ]); + if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) { + return null; + } + + $decision = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($decision) || empty($decision['status']) || !is_string($decision['status'])) { + return null; + } + return $decision; + } + + /** + * Whether a session was created by this site for the given user. + * + * rest_create_session() records the session id in the user's meta (see + * session_bound_to_user()) and stamps `metadata.wp_user_id` (which the caller + * cannot override) and, in the default vendor-data mode, `vendor_data = wp-{id}`; + * any one of them ties the session to the user. `metadata` is sent JSON-encoded + * at creation, so it is accepted back as an object or as that string. + */ + private function session_belongs_to_user(array $decision, $session_id, $user_id) + { + if ($this->session_bound_to_user($user_id, $session_id)) { + return true; + } + $meta = $decision['metadata'] ?? null; + if (is_string($meta)) { + $meta = json_decode($meta, true); + } + if (is_array($meta) && absint($meta['wp_user_id'] ?? 0) === absint($user_id)) { + return true; + } + return ('wp-' . absint($user_id)) === (string) ($decision['vendor_data'] ?? ''); + } + + /** + * Record a session this site just created for a user, without touching the + * session that currently backs the user's stored status. + * + * Two bindings exist per user: `_didit_session_id` names the session whose + * decision `_didit_status` / `_didit_verified` came from, and the multi-value + * `_didit_pending_session_id` lists sessions created since whose decision has not + * been applied yet. A webhook or callback for either one is accepted, so a later + * session cannot hide the revocation of the one that granted access, and a + * decision for the new session still finds the user. Pending bindings are capped + * so a user who keeps starting sessions does not grow the list without bound. + */ + private function bind_pending_session($user_id, $session_id) + { + $pending = array_map('strval', (array) get_user_meta($user_id, self::PENDING_SESSION_META, false)); + if (in_array((string) $session_id, $pending, true)) { + return; + } + add_user_meta($user_id, self::PENDING_SESSION_META, $session_id, false); + + $excess = count($pending) + 1 - self::PENDING_SESSION_LIMIT; + for ($i = 0; $i < $excess; $i++) { + // get_user_meta() returns values in insertion order: drop the oldest first. + delete_user_meta($user_id, self::PENDING_SESSION_META, $pending[$i]); + } + } + + /** + * Whether this site recorded the session for the user at creation or on a + * decision: the granting session or any pending one. + */ + private function session_bound_to_user($user_id, $session_id) + { + if (hash_equals((string) get_user_meta($user_id, '_didit_session_id', true), (string) $session_id)) { + return true; + } + foreach ((array) get_user_meta($user_id, self::PENDING_SESSION_META, false) as $pending) { + if (hash_equals((string) $pending, (string) $session_id)) { + return true; + } + } + return false; + } + + /** + * Store an authoritative decision for a session bound to the user. The session + * becomes the one the user's status is keyed on and leaves the pending list. + */ + private function apply_decision_to_user($user_id, $session_id, $status) + { + update_user_meta($user_id, '_didit_session_id', $session_id); + update_user_meta($user_id, '_didit_status', $status); + update_user_meta($user_id, '_didit_verified_at', current_time('mysql')); + if ('Approved' === $status) { + update_user_meta($user_id, '_didit_verified', 1); + } else { + delete_user_meta($user_id, '_didit_verified'); + } + delete_user_meta($user_id, self::PENDING_SESSION_META, $session_id); + } + /** * Rebuild the canonical JSON form Didit signs for X-Signature-V2. * @@ -1171,9 +1342,10 @@ private static function verify_webhook_signature($request, $raw, array $payload, /** * Resolve the WordPress user a session belongs to from stored state. * - * The session id is written to user meta when the verification is saved, so this - * mapping is local and trustworthy - unlike the payload's `metadata.wp_user_id` and - * `vendor_data`, which X-Signature-Simple does not authenticate. + * The session id is written to user meta when the session is created and again + * when a decision is saved, so this mapping is local and trustworthy - unlike the + * payload's `metadata.wp_user_id` and `vendor_data`, which X-Signature-Simple does + * not authenticate. Both the granting session and pending sessions resolve. */ private function user_id_for_session($session_id) { @@ -1182,8 +1354,11 @@ private function user_id_for_session($session_id) } $users = get_users([ - 'meta_key' => '_didit_session_id', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => $session_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'relation' => 'OR', + ['key' => '_didit_session_id', 'value' => $session_id], + ['key' => self::PENDING_SESSION_META, 'value' => $session_id], + ], 'number' => 1, 'fields' => 'ID', ]); @@ -1272,14 +1447,7 @@ public function rest_webhook($request) $wp_user_id = $this->user_id_for_session($session_id); } if ($wp_user_id && get_userdata($wp_user_id)) { - update_user_meta($wp_user_id, '_didit_session_id', $session_id); - update_user_meta($wp_user_id, '_didit_status', $status); - update_user_meta($wp_user_id, '_didit_verified_at', current_time('mysql')); - if ('Approved' === $status) { - update_user_meta($wp_user_id, '_didit_verified', 1); - } else { - delete_user_meta($wp_user_id, '_didit_verified'); - } + $this->apply_decision_to_user($wp_user_id, $session_id, $status); } if (in_array($status, ['Approved', 'Declined'], true)) { @@ -1901,6 +2069,18 @@ public function render_gate_shortcode($atts, $content = null) ); } + if ('api' !== get_option('didit_mode', 'unilink')) { + // A UniLink completion can never unlock this gate, so offering the button + // would show "Verified" while the content stays locked. + return sprintf( + '

%s

%s
', + esc_html($a['message']), + current_user_can('manage_options') + ? '

' . esc_html__('Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings.', 'didit-verify') . '

' + : '' + ); + } + return sprintf( '

%s

%s
', esc_html($a['message']), diff --git a/languages/didit-verify-de_DE.mo b/languages/didit-verify-de_DE.mo index 0286516..e615e61 100644 Binary files a/languages/didit-verify-de_DE.mo and b/languages/didit-verify-de_DE.mo differ diff --git a/languages/didit-verify-de_DE.po b/languages/didit-verify-de_DE.po index c6861ad..8ea9bef 100644 --- a/languages/didit-verify-de_DE.po +++ b/languages/didit-verify-de_DE.po @@ -141,3 +141,22 @@ msgstr "Deine Bestellung wartet auf die Identitätsprüfung. Das dauert nur weni msgid "Verify your identity now" msgstr "Jetzt Identität verifizieren" +#: didit-verify.php:1041 +msgid "Invalid session id." +msgstr "Ungültige Sitzungs-ID." + +#: didit-verify.php:1050 +msgid "This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them." +msgstr "Diese Website nutzt den UniLink-Modus, der Verifizierungsergebnisse nicht bestätigen kann. Stelle Didit Verify auf den API-Session-Modus um, damit sie gespeichert werden." + +#: didit-verify.php:1059 +msgid "Could not confirm the verification result with Didit. It will be applied when the webhook arrives." +msgstr "Das Verifizierungsergebnis konnte nicht bei Didit bestätigt werden. Es wird übernommen, sobald der Webhook eintrifft." + +#: didit-verify.php:1074 +msgid "This verification session does not belong to you." +msgstr "Diese Verifizierungssitzung gehört nicht zu dir." + +#: didit-verify.php:2022 +msgid "Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings." +msgstr "Didit Verify ist im UniLink-Modus, der diesen Inhalt nicht freischalten kann. Stelle in den Einstellungen auf den API-Session-Modus um." diff --git a/languages/didit-verify-de_DE_formal.mo b/languages/didit-verify-de_DE_formal.mo index 28189c5..24936ef 100644 Binary files a/languages/didit-verify-de_DE_formal.mo and b/languages/didit-verify-de_DE_formal.mo differ diff --git a/languages/didit-verify-de_DE_formal.po b/languages/didit-verify-de_DE_formal.po index 0e2a60d..3138f17 100644 --- a/languages/didit-verify-de_DE_formal.po +++ b/languages/didit-verify-de_DE_formal.po @@ -141,3 +141,22 @@ msgstr "Ihre Bestellung wartet auf die Identitätsprüfung. Das dauert nur wenig msgid "Verify your identity now" msgstr "Jetzt Identität verifizieren" +#: didit-verify.php:1041 +msgid "Invalid session id." +msgstr "Ungültige Sitzungs-ID." + +#: didit-verify.php:1050 +msgid "This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them." +msgstr "Diese Website nutzt den UniLink-Modus, der Verifizierungsergebnisse nicht bestätigen kann. Stellen Sie Didit Verify auf den API-Session-Modus um, damit sie gespeichert werden." + +#: didit-verify.php:1059 +msgid "Could not confirm the verification result with Didit. It will be applied when the webhook arrives." +msgstr "Das Verifizierungsergebnis konnte nicht bei Didit bestätigt werden. Es wird übernommen, sobald der Webhook eintrifft." + +#: didit-verify.php:1074 +msgid "This verification session does not belong to you." +msgstr "Diese Verifizierungssitzung gehört nicht zu Ihnen." + +#: didit-verify.php:2022 +msgid "Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings." +msgstr "Didit Verify ist im UniLink-Modus, der diesen Inhalt nicht freischalten kann. Stellen Sie in den Einstellungen auf den API-Session-Modus um." diff --git a/languages/didit-verify.pot b/languages/didit-verify.pot index 22f12aa..bb16b70 100644 --- a/languages/didit-verify.pot +++ b/languages/didit-verify.pot @@ -669,3 +669,26 @@ msgstr "" msgid "Didit: verification reminder %1$d of %2$d sent to %3$s." msgstr "" +#: didit-verify.php:1041 +msgid "Invalid session id." +msgstr "" + +#: didit-verify.php:1050 +msgid "This site uses UniLink mode, which cannot confirm verification results. Switch Didit Verify to API Session mode to save them." +msgstr "" + +#: didit-verify.php:1059 +msgid "Could not confirm the verification result with Didit. It will be applied when the webhook arrives." +msgstr "" + +#: didit-verify.php:1074 +msgid "This verification session does not belong to you." +msgstr "" + +#: didit-verify.php:2022 +msgid "Didit Verify is in UniLink mode, which cannot unlock this content. Switch to API Session mode in Settings." +msgstr "" + +#: didit-verify.php:196 +msgid "UniLink results cannot be confirmed by this site, so [didit_gate], [didit_status] and WooCommerce order verification require API mode." +msgstr "" diff --git a/readme.txt b/readme.txt index 53edb5e..52b51cc 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: 6.9 Requires PHP: 7.4 -Stable tag: 0.3.1 +Stable tag: 0.3.2 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -106,6 +106,8 @@ Sign up at [business.didit.me](https://business.didit.me), create a verification UniLink uses a single shared URL — quick to set up but every visitor uses the same session link. API mode creates a unique verification session per user with full tracking, and keeps your API key secure on the server. +UniLink results cannot be confirmed by your site, so `[didit_gate]`, `[didit_status]` and WooCommerce order verification require API mode. + = Does it work without WooCommerce? = Yes. Use the `[didit_verify]` shortcode on any page. WooCommerce integration is optional. @@ -203,6 +205,14 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C == Changelog == += 0.3.2 = +* Security: the browser completion callback (`POST /didit/v1/verify`) no longer trusts the `status` sent by the client. The plugin now reads the session decision from Didit with the site API key and only applies it when the session was created for the calling user (or the order being verified). Previously any logged-in user could mark their own account as verified, unlocking `[didit_gate]` content, by posting a forged completion. +* The completion callback applies an order decision through the order key and the session stored on the order, even when the customer started the session as a guest and logged in afterwards. The logged-in user's own status only changes for a session created for that user. +* The session id is now linked to the WordPress user when the session is created, so a webhook signed only with `X-Signature-Simple` still finds the user if the browser callback never arrived. Sessions created since the last decision are tracked in `_didit_pending_session_id` (up to ten per user), while `_didit_session_id` keeps naming the session the stored status came from, so starting a new session never hides a later decline of the one that granted access. +* UniLink mode no longer tries to save results it cannot confirm: `[didit_gate]` shows administrators how to switch to API mode instead of a button that could not unlock it, and the verification button in API mode shows the status the site saved. +* Tests now run in GitHub Actions on PHP 7.4, 8.2 and 8.4. +* Site owners: update, then review users with `_didit_status = Approved` whose `_didit_session_id` has no matching session in the Didit Console if you suspect abuse. + = 0.3.1 = * Page builders that render shortcode content outside the post content (for example Oxygen) no longer show an unstyled verification button or embedded container. The plugin stylesheet and the button appearance CSS are now always queued in the header, while the SDK script is still enqueued when the shortcode itself renders. * Webhook receiver now verifies `X-Signature-V2` first, falls back to the legacy `X-Signature`, and finally to `X-Signature-Simple`. A delivery is accepted as soon as one variant verifies, so a reverse proxy, CDN rule or security plugin that strips a single `X-*` header no longer causes a 401 "Missing or stale webhook signature". @@ -269,6 +279,9 @@ Yes. Go to **Settings → Didit Verify → Display Options → Display Mode**. C == Upgrade Notice == += 0.3.2 = +Security fix: logged-in users could mark themselves verified. Update now. `[didit_gate]`, `[didit_status]` and WooCommerce order verification now require API mode. + = 0.3.0 = Customizable checkout copy, German translations, and per-product verification scope for WooCommerce. diff --git a/tests/test-verify-endpoint.php b/tests/test-verify-endpoint.php new file mode 100644 index 0000000..c8742c8 --- /dev/null +++ b/tests/test-verify-endpoint.php @@ -0,0 +1,315 @@ + '']; + $GLOBALS['didit_test_current_user_can'] = []; + $GLOBALS['didit_test_orders'] = []; + $GLOBALS['didit_test_options']['didit_api_key'] = 'test-api-key'; + $GLOBALS['didit_test_options']['didit_workflow_id'] = 'test-workflow'; + $GLOBALS['didit_test_options']['didit_mode'] = 'api'; + $GLOBALS['didit_test_options']['didit_wc_mode'] = 'after_purchase'; +} + +function queue_decision(array $decision, $code = 200) +{ + $GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => $code], + 'body' => json_encode($decision), + ]; +} + +function completion_request($session_id, $status, array $extra = []) +{ + return new Didit_Test_Request(json_encode([ + 'type' => 'completed', + 'sessionId' => $session_id, + 'status' => $status, + ] + $extra)); +} + +function user_meta($user_id, $key) +{ + return $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] ?? null; +} + +$plugin = Didit_Verify::init(); +$USER = 7; + +echo "Forged completion without any Didit session\n"; +reset_state($USER); +// No decision is queued: an unknown session id gets no 200 from Didit. +$GLOBALS['didit_test_http_responses'][] = ['response' => ['code' => 404], 'body' => '{"detail":"Not found."}']; +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000001', 'Approved')); +ok($result instanceof WP_Error, 'a completion Didit does not know about is rejected'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); +ok('Approved' !== user_meta($USER, '_didit_status'), 'the caller-supplied "Approved" is not stored'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] stays locked'); +ok(empty($GLOBALS['didit_test_actions']), 'didit_verification_completed does not fire for a forged completion'); + +echo "\nOwn session, caller claims Approved, Didit says Declined\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000002', + 'status' => 'Declined', + 'vendor_data' => 'wp-7', + 'metadata' => ['wp_user_id' => 7], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000002', 'Approved')); +ok(!($result instanceof WP_Error), 'the completion is accepted'); +ok('Declined' === user_meta($USER, '_didit_status'), 'the stored status is the one Didit reported'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] stays locked'); +$request = $GLOBALS['didit_test_http_requests'][0] ?? null; +ok($request && 'https://verification.didit.me/v3/session/00000000-0000-4000-8000-000000000002/decision/' === $request['url'], + 'the decision is read from the v3 decision endpoint for the claimed session'); +ok($request && 'test-api-key' === ($request['args']['headers']['x-api-key'] ?? null), 'the decision read is authenticated with the site API key'); + +echo "\nSomeone else's approved session\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000003', + 'status' => 'Approved', + 'vendor_data' => 'wp-8', + 'metadata' => ['wp_user_id' => 8], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000003', 'Approved')); +ok($result instanceof WP_Error && 403 === ($result->get_error_data()['status'] ?? 0), 'a session created for another user is refused with 403'); +ok(null === user_meta($USER, '_didit_verified') && null === user_meta($USER, '_didit_status'), 'nothing is stored for the caller'); + +echo "\nDecision endpoint unavailable\n"; +reset_state($USER); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000004', 'Approved')); +ok($result instanceof WP_Error && 502 === ($result->get_error_data()['status'] ?? 0), 'a failed decision read answers 502 instead of trusting the browser'); +ok(null === user_meta($USER, '_didit_verified'), 'the caller is not marked verified'); + +echo "\nOwn session approved by Didit\n"; +reset_state($USER); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000005', + 'status' => 'Approved', + 'vendor_data' => 'user@example.test', + // Session creation sends metadata JSON-encoded; the decision may hand it back as a string. + 'metadata' => json_encode(['wp_user_id' => 7, 'wp_email' => 'user@example.test']), +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000005', 'Declined')); +ok(!($result instanceof WP_Error), 'the completion is accepted'); +ok('Approved' === user_meta($USER, '_didit_status'), 'the stored status is Approved as Didit reported'); +ok(1 === user_meta($USER, '_didit_verified'), 'the caller is marked verified'); +ok('00000000-0000-4000-8000-000000000005' === user_meta($USER, '_didit_session_id'), 'the session mapping is stored'); +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] unlocks'); +ok(1 === count($GLOBALS['didit_test_actions']) && 'didit_verification_completed' === $GLOBALS['didit_test_actions'][0][0] + && 'Approved' === $GLOBALS['didit_test_actions'][0][3], 'didit_verification_completed fires with the Didit status'); + +echo "\nMalformed session id never reaches the API\n"; +reset_state($USER); +queue_decision(['status' => 'Approved', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request('../../v3/sessions/', 'Approved')); +ok($result instanceof WP_Error && 400 === ($result->get_error_data()['status'] ?? 0), 'a malformed session id is a 400'); +ok(empty($GLOBALS['didit_test_http_requests']), 'no request is made for it'); + +echo "\nCancelled callback\n"; +reset_state($USER); +$result = $plugin->rest_save_verification(new Didit_Test_Request(json_encode(['type' => 'cancelled', 'sessionId' => 'x']))); +ok(!($result instanceof WP_Error), 'a cancellation is acknowledged'); +ok(null === user_meta($USER, '_didit_status'), 'a cancellation stores nothing'); +ok(empty($GLOBALS['didit_test_http_requests']), 'a cancellation reads no decision'); + +echo "\nUniLink mode cannot confirm a completion\n"; +reset_state($USER); +$GLOBALS['didit_test_options']['didit_mode'] = 'unilink'; +queue_decision(['status' => 'Approved', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000006', 'Approved')); +ok($result instanceof WP_Error && 'unilink_not_verifiable' === $result->get_error_code() + && 409 === ($result->get_error_data()['status'] ?? 0), 'a UniLink completion is refused with an actionable 409'); +ok(false !== strpos($result->message, 'API Session mode'), 'the error tells the site how to fix it'); +ok(empty($GLOBALS['didit_test_http_requests']), 'no decision is read for it'); +ok(null === user_meta($USER, '_didit_status'), 'nothing is stored'); +$gate = $plugin->render_gate_shortcode([], 'SECRET'); +ok(false === strpos($gate, 'SECRET') && false === strpos($gate, 'didit-verify-btn'), + '[didit_gate] in UniLink mode stays locked and offers no button that could never unlock it'); +ok(false === strpos($gate, 'UniLink mode'), 'visitors do not see the configuration hint'); +$GLOBALS['didit_test_current_user_can'] = ['manage_options' => true]; +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'API Session mode'), 'administrators see how to fix the gate'); + +echo "\nAPI mode gate still offers the verification button\n"; +reset_state($USER); +ok(false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'didit-verify-btn'), '[didit_gate] renders the button in API mode'); + +echo "\nSession created as a guest for an order, completed after logging in\n"; +reset_state($USER); +$GLOBALS['didit_test_orders'][42] = new Didit_Test_Order(42, 'wc_order_key42', [ + '_didit_session_id' => '00000000-0000-4000-8000-000000000007', + '_didit_status' => 'Not Started', +]); +queue_decision([ + 'session_id' => '00000000-0000-4000-8000-000000000007', + 'status' => 'Approved', + 'vendor_data' => 'order-42', + 'metadata' => ['wc_order_id' => 42], +]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000007', 'Approved', + ['order_id' => 42, 'order_key' => 'wc_order_key42'])); +ok(!($result instanceof WP_Error) && 'Approved' === ($result['status'] ?? null), 'the completion is accepted through the order binding'); +ok('Approved' === $GLOBALS['didit_test_orders'][42]->get_meta('_didit_status'), 'the order takes the Didit decision'); +ok(null === user_meta($USER, '_didit_status') && null === user_meta($USER, '_didit_verified'), + 'the logged-in user, who does not own the session, is not changed'); +ok(0 === ($GLOBALS['didit_test_actions'][0][1] ?? null), 'didit_verification_completed carries no user for an order-only completion'); + +echo "\nOrder key with a session id that is not the order's\n"; +reset_state($USER); +$GLOBALS['didit_test_orders'][43] = new Didit_Test_Order(43, 'wc_order_key43', [ + '_didit_session_id' => '00000000-0000-4000-8000-000000000008', +]); +queue_decision(['status' => 'Approved', 'vendor_data' => 'order-99', 'metadata' => ['wc_order_id' => 99]]); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000009', 'Approved', + ['order_id' => 43, 'order_key' => 'wc_order_key43'])); +ok($result instanceof WP_Error && 403 === ($result->get_error_data()['status'] ?? 0), 'a session bound to neither the user nor the order is refused'); +ok('' === $GLOBALS['didit_test_orders'][43]->get_meta('_didit_status'), 'the order is not changed'); + +echo "\nSession binding is stored at creation\n"; +reset_state($USER); +$GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => 201], + 'body' => json_encode(['session_id' => '00000000-0000-4000-8000-000000000010', 'url' => 'https://verify.didit.me/session/abc']), +]; +$result = $plugin->rest_create_session(new Didit_Test_Request('{}')); +ok(is_array($result) && 'https://verify.didit.me/session/abc' === ($result['url'] ?? null), 'the session is created'); +ok(['00000000-0000-4000-8000-000000000010'] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'the session id is bound to the user server-side as pending, before any browser callback'); +ok(null === user_meta($USER, '_didit_session_id'), 'no decision has been stored yet'); +$GLOBALS['didit_test_options']['didit_webhook_secret'] = 'whsec'; +$ts = time(); +$status_payload = json_encode([ + 'timestamp' => $ts, + 'session_id' => '00000000-0000-4000-8000-000000000010', + 'status' => 'Approved', + 'webhook_type' => 'status.updated', +]); +$simple = hash_hmac('sha256', "{$ts}:00000000-0000-4000-8000-000000000010:Approved:status.updated", 'whsec'); +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'off'; +$result = $plugin->rest_webhook(new Didit_Test_Request($status_payload, ['X-Signature-Simple' => $simple])); +$GLOBALS['didit_test_options']['didit_webhook_secret'] = ''; +ok(is_array($result) && !empty($result['received']), 'a simple-signature webhook for that session is accepted'); +ok('Approved' === user_meta($USER, '_didit_status'), 'the webhook resolves the user from the creation-time binding without a browser callback'); +ok('00000000-0000-4000-8000-000000000010' === user_meta($USER, '_didit_session_id') + && [] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'the decided session becomes the granting session and leaves the pending list'); + +echo "\nDecision without metadata, session bound at creation\n"; +reset_state($USER); +$GLOBALS['didit_test_user_meta'][$USER . '|_didit_session_id'] = '00000000-0000-4000-8000-000000000011'; +queue_decision(['session_id' => '00000000-0000-4000-8000-000000000011', 'status' => 'In Review', 'vendor_data' => 'custom-7']); +$result = $plugin->rest_save_verification(completion_request('00000000-0000-4000-8000-000000000011', 'Approved')); +ok(!($result instanceof WP_Error) && 'In Review' === user_meta($USER, '_didit_status'), 'the stored binding alone authorizes the user update'); + + +function simple_webhook($plugin, $session_id, $status) +{ + $ts = time(); + $payload = json_encode([ + 'timestamp' => $ts, + 'session_id' => $session_id, + 'status' => $status, + 'webhook_type' => 'status.updated', + ]); + $simple = hash_hmac('sha256', "{$ts}:{$session_id}:{$status}:status.updated", 'whsec'); + $GLOBALS['didit_test_options']['didit_webhook_secret'] = 'whsec'; + $result = $plugin->rest_webhook(new Didit_Test_Request($payload, ['X-Signature-Simple' => $simple])); + $GLOBALS['didit_test_options']['didit_webhook_secret'] = ''; + return $result; +} + +function create_session($plugin, $session_id) +{ + $GLOBALS['didit_test_http_responses'][] = [ + 'response' => ['code' => 201], + 'body' => json_encode(['session_id' => $session_id, 'url' => 'https://verify.didit.me/session/' . $session_id]), + ]; + return $plugin->rest_create_session(new Didit_Test_Request('{}')); +} + +echo "\nStarting another session keeps the approved session revocable\n"; +reset_state($USER); +$GLOBALS['didit_test_options']['didit_wc_mode'] = 'off'; +$A = '00000000-0000-4000-8000-00000000000a'; +$B = '00000000-0000-4000-8000-00000000000b'; +queue_decision(['session_id' => $A, 'status' => 'Approved', 'vendor_data' => 'wp-7', 'metadata' => ['wp_user_id' => 7]]); +$result = $plugin->rest_save_verification(completion_request($A, 'Approved')); +ok(!($result instanceof WP_Error) && 1 === user_meta($USER, '_didit_verified'), 'session A is approved for the user'); +$result = create_session($plugin, $B); +ok(is_array($result) && isset($result['url']), 'the user starts a second session B'); +ok($A === user_meta($USER, '_didit_session_id'), 'the granting session A stays bound to the user'); +ok(['00000000-0000-4000-8000-00000000000b'] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), 'B is recorded as pending'); +ok('Approved' === user_meta($USER, '_didit_status') && false !== strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), + 'starting B does not change the stored decision'); +$result = simple_webhook($plugin, $A, 'Declined'); +ok(is_array($result) && !empty($result['received']), 'a simple-signature Declined webhook for A is accepted'); +ok('Declined' === user_meta($USER, '_didit_status') && null === user_meta($USER, '_didit_verified'), + 'the decline of A revokes the user even though B was started afterwards'); +ok(false === strpos($plugin->render_gate_shortcode([], 'SECRET'), 'SECRET'), '[didit_gate] locks again'); +ok([$B] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), 'B is still pending'); + +echo "\nThe pending session is usable by the callback and the webhook\n"; +$GLOBALS['didit_test_http_requests'] = []; +queue_decision(['session_id' => $B, 'status' => 'In Review', 'vendor_data' => 'custom-7']); +$result = $plugin->rest_save_verification(completion_request($B, 'Approved')); +ok(!($result instanceof WP_Error) && 'In Review' === user_meta($USER, '_didit_status'), + 'the pending binding alone authorizes the callback for B, with the status Didit reports'); +ok($B === user_meta($USER, '_didit_session_id') && [] === get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false), + 'B becomes the granting session and leaves the pending list'); +$result = simple_webhook($plugin, $B, 'Approved'); +ok(is_array($result) && !empty($result['received']) && 1 === user_meta($USER, '_didit_verified'), + 'the simple-signature webhook for B still resolves the user after the callback'); +$result = simple_webhook($plugin, $A, 'Approved'); +ok(is_array($result) && !empty($result['received']) && $B === user_meta($USER, '_didit_session_id'), + 'a late webhook for the superseded session A no longer resolves the user'); + +echo "\nPending bindings are capped per user\n"; +reset_state($USER); +for ($i = 1; $i <= Didit_Verify::PENDING_SESSION_LIMIT + 2; $i++) { + create_session($plugin, sprintf('00000000-0000-4000-8000-0000000000%02d', $i)); +} +create_session($plugin, sprintf('00000000-0000-4000-8000-0000000000%02d', Didit_Verify::PENDING_SESSION_LIMIT + 2)); +$pending = get_user_meta($USER, Didit_Verify::PENDING_SESSION_META, false); +ok(Didit_Verify::PENDING_SESSION_LIMIT === count($pending), 'at most ' . Didit_Verify::PENDING_SESSION_LIMIT . ' pending sessions are kept'); +ok('00000000-0000-4000-8000-000000000003' === $pending[0] + && sprintf('00000000-0000-4000-8000-0000000000%02d', Didit_Verify::PENDING_SESSION_LIMIT + 2) === end($pending), + 'the oldest pending sessions are dropped first and a repeated session id is not duplicated'); + +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 f692754..eb06c9b 100644 --- a/tests/wp-stubs.php +++ b/tests/wp-stubs.php @@ -10,6 +10,7 @@ define('ABSPATH', __DIR__ . '/'); define('MINUTE_IN_SECONDS', 60); +define('HOUR_IN_SECONDS', 3600); // Options the handler reads. Tests overwrite entries directly. $GLOBALS['didit_test_options'] = [ @@ -26,6 +27,13 @@ $GLOBALS['didit_test_enqueued_scripts'] = []; $GLOBALS['didit_test_localized_scripts'] = []; $GLOBALS['didit_test_inline_styles'] = []; +// The logged-in user rest_save_verification() acts for (0 = anonymous). +$GLOBALS['didit_test_current_user_id'] = 0; +// Capabilities the current user holds, e.g. ['manage_options' => true]. +$GLOBALS['didit_test_current_user_can'] = []; +// Queued wp_remote_get()/wp_remote_post() responses (FIFO) and the requests that consumed them. +$GLOBALS['didit_test_http_responses'] = []; +$GLOBALS['didit_test_http_requests'] = []; function add_action() {} function add_filter() {} @@ -68,18 +76,99 @@ function get_option($name, $default = false) { : $default; } +/** + * Matches users in $GLOBALS['didit_test_users'] on their stored meta, supporting + * the `meta_key`/`meta_value` pair and a `meta_query` with an AND/OR relation. + * A user's entry value doubles as a `_didit_session_id` when no meta was written. + */ function get_users($args) { + $clauses = []; + if (isset($args['meta_key'], $args['meta_value'])) { + $clauses[] = ['key' => $args['meta_key'], 'value' => $args['meta_value']]; + } + $relation = 'AND'; + foreach ((array) ($args['meta_query'] ?? []) as $name => $clause) { + if ('relation' === $name) { + $relation = strtoupper((string) $clause); + continue; + } + $clauses[] = $clause; + } + $matches = []; foreach ($GLOBALS['didit_test_users'] as $id => $session_id) { - if (isset($args['meta_key'], $args['meta_value']) - && '_didit_session_id' === $args['meta_key'] - && $session_id === $args['meta_value']) { + $hits = 0; + foreach ($clauses as $clause) { + $values = array_map('strval', get_user_meta($id, $clause['key'], false)); + if ('_didit_session_id' === $clause['key'] && !$values && '' !== (string) $session_id) { + $values = [(string) $session_id]; + } + if (in_array((string) $clause['value'], $values, true)) { + $hits++; + } + } + if ($clauses && ('OR' === $relation ? $hits > 0 : $hits === count($clauses))) { $matches[] = $id; } } return array_slice($matches, 0, isset($args['number']) ? (int) $args['number'] : count($matches)); } +function is_user_logged_in() { return $GLOBALS['didit_test_current_user_id'] > 0; } +function get_current_user_id() { return (int) $GLOBALS['didit_test_current_user_id']; } +function do_shortcode($content) { return (string) $content; } +function wp_json_encode($data) { return json_encode($data); } +function is_wp_error($thing) { return $thing instanceof WP_Error; } + +// Meta values are stored as a scalar (single) or a list (multi-value, via add_user_meta()). +function get_user_meta($user_id, $key, $single = false) { + $slot = $user_id . '|' . $key; + if (!array_key_exists($slot, $GLOBALS['didit_test_user_meta'])) { + return $single ? '' : []; + } + $stored = $GLOBALS['didit_test_user_meta'][$slot]; + $values = is_array($stored) ? array_values($stored) : [$stored]; + return $single ? ($values[0] ?? '') : $values; +} + +function add_user_meta($user_id, $key, $value, $unique = false) { + $values = get_user_meta($user_id, $key, false); + if ($unique && $values) { + return false; + } + $values[] = $value; + $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] = $values; + return count($values); +} + +function wp_remote_get($url, $args = []) { + $GLOBALS['didit_test_http_requests'][] = ['url' => $url, 'args' => $args]; + $response = array_shift($GLOBALS['didit_test_http_responses']); + return null === $response ? new WP_Error('http_request_failed', 'no queued response') : $response; +} + +function wp_remote_post($url, $args = []) { + return wp_remote_get($url, $args + ['method' => 'POST']); +} + +function wp_unslash($value) { return $value; } +function get_transient($key) { return false; } +function set_transient($key, $value, $expiration = 0) { return true; } +function current_user_can($capability) { return !empty($GLOBALS['didit_test_current_user_can'][$capability]); } + +function wp_get_current_user() { + $id = get_current_user_id(); + return (object) ['ID' => $id, 'user_email' => $id ? "user{$id}@example.test" : '']; +} + +function wp_remote_retrieve_response_code($response) { + return is_array($response) ? ($response['response']['code'] ?? 0) : 0; +} + +function wp_remote_retrieve_body($response) { + return is_array($response) ? ($response['body'] ?? '') : ''; +} + function get_userdata($user_id) { return isset($GLOBALS['didit_test_users'][$user_id]) ? (object) ['ID' => $user_id] : false; } @@ -88,8 +177,21 @@ function update_user_meta($user_id, $key, $value) { $GLOBALS['didit_test_user_meta'][$user_id . '|' . $key] = $value; } -function delete_user_meta($user_id, $key) { - unset($GLOBALS['didit_test_user_meta'][$user_id . '|' . $key]); +function delete_user_meta($user_id, $key, $value = '') { + $slot = $user_id . '|' . $key; + if ('' === $value) { + unset($GLOBALS['didit_test_user_meta'][$slot]); + return true; + } + $remaining = array_values(array_filter(get_user_meta($user_id, $key, false), function ($stored) use ($value) { + return (string) $stored !== (string) $value; + })); + if ($remaining) { + $GLOBALS['didit_test_user_meta'][$slot] = $remaining; + } else { + unset($GLOBALS['didit_test_user_meta'][$slot]); + } + return true; } function do_action($hook) { @@ -129,8 +231,45 @@ public function __construct($body, array $headers = []) { public function get_body() { return $this->body; } + public function get_json_params() { return json_decode($this->body, true); } + public function get_header($name) { $name = strtolower(str_replace('-', '_', $name)); return isset($this->headers[$name]) ? $this->headers[$name] : null; } } + +/** + * WooCommerce presence marker and the order surface the verification endpoints use. + * Orders live in $GLOBALS['didit_test_orders'] keyed by id. + */ +class WooCommerce {} + +class Didit_Test_Order { + public $id; + public $key; + public $meta = []; + public $notes = []; + public function __construct($id, $key, array $meta = []) { + $this->id = $id; + $this->key = $key; + $this->meta = $meta; + } + public function get_id() { return $this->id; } + public function get_order_key() { return $this->key; } + public function get_meta($key) { return $this->meta[$key] ?? ''; } + public function update_meta_data($key, $value) { $this->meta[$key] = $value; } + public function delete_meta_data($key) { unset($this->meta[$key]); } + public function add_order_note($note) { $this->notes[] = $note; } + public function has_status($status) { return false; } + public function save() {} +} + +function wc_get_order($order_id) { return $GLOBALS['didit_test_orders'][$order_id] ?? false; } + +function wc_get_orders($args) { + $session_id = $args['meta_query'][0]['value'] ?? null; + return array_values(array_filter($GLOBALS['didit_test_orders'] ?? [], function ($order) use ($session_id) { + return null !== $session_id && $order->get_meta('_didit_session_id') === $session_id; + })); +}