@tellescope/sdk 1.255.18 → 1.256.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cjs/sdk.d.ts +7 -2
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
- package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
- package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
- package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
- package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
- package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
- package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
- package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
- package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
- package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
- package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
- package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
- package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +489 -255
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/esm/enduser.d.ts +0 -1
- package/lib/esm/enduser.d.ts.map +1 -1
- package/lib/esm/sdk.d.ts +9 -5
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/session.d.ts +0 -1
- package/lib/esm/session.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
- package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
- package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
- package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
- package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
- package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
- package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
- package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
- package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
- package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
- package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
- package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
- package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
- package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
- package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
- package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
- package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
- package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
- package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
- package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +489 -255
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +4 -0
- package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
- package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
- package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
- package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
- package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
- package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
- package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
- package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
- package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
- package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
- package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
- package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
- package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
- package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
- package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
- package/src/tests/tests.ts +232 -2
- package/test_generated.pdf +0 -0
- package/.env +0 -13
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import axios from "axios"
|
|
4
|
+
import { Session } from "../../../sdk"
|
|
5
|
+
import {
|
|
6
|
+
assert,
|
|
7
|
+
log_header,
|
|
8
|
+
} from "@tellescope/testing"
|
|
9
|
+
import { setup_tests } from "../../setup"
|
|
10
|
+
import { Enduser } from "@tellescope/types-client"
|
|
11
|
+
import { FORM_INGESTION_API_KEY_SCOPE } from "@tellescope/types-models"
|
|
12
|
+
|
|
13
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Regression test for: email / phone are locked against writes from the Formsort webhook when it
|
|
17
|
+
* IMPLICITLY matched a patient that already existed before the submission started.
|
|
18
|
+
*
|
|
19
|
+
* POST /v1/webhooks/formsort/* (alias: /v1/webhooks/form-ingestion/*)
|
|
20
|
+
*
|
|
21
|
+
* The handler resolves an enduser four ways, in precedence order:
|
|
22
|
+
* 1. ?enduserId= — an explicit assertion by the API-key-authenticated caller
|
|
23
|
+
* 2. existingResponse.enduserId — the patient attached to this responder_uuid's draft
|
|
24
|
+
* 3. findOne({ $or: [{ phone }, { email }] }) — a GUESS, optionally AND'ed with fname + lname
|
|
25
|
+
* 4. insert — brand-new patient
|
|
26
|
+
*
|
|
27
|
+
* On a finalized submission it then wrote a blanket `$set: enduserFields` onto whatever it landed
|
|
28
|
+
* on, and that object carries `email` and `phone`. Those are authentication channels:
|
|
29
|
+
* request-enduser-password-reset resolves purely by findOne({ email }), begin_login_flow accepts
|
|
30
|
+
* email OR phone, and send_otp delivers login codes to enduser.phone. So a submission that matched
|
|
31
|
+
* a patient on PHONE ALONE could repoint that patient's email (and vice versa), which chains to
|
|
32
|
+
* account takeover exactly as described in public_endpoint_auth_field_lock.test.ts.
|
|
33
|
+
*
|
|
34
|
+
* The rule under test: on path 3 — the guess — email and phone are dropped from the update. They
|
|
35
|
+
* remain writable on paths 1, 2 and 4.
|
|
36
|
+
*
|
|
37
|
+
* - Path 1 stays open deliberately. This endpoint is API-key authenticated, so a caller that
|
|
38
|
+
* names an enduserId knows which patient it means; keeping it writable preserves backwards
|
|
39
|
+
* compatibility and lets a sophisticated consumer update these fields when it is safe.
|
|
40
|
+
* - Path 2 stays open because Formsort fires a webhook per page: the patient who started the form
|
|
41
|
+
* must still be able to fix a typo'd email on page 3. It is gated on the STICKY
|
|
42
|
+
* FormResponse.authFieldIntakeDisabled flag, computed once when the response is first written,
|
|
43
|
+
* so a response first formed by a guess can never be re-opened by a later webhook.
|
|
44
|
+
*
|
|
45
|
+
* A "fill only if blank" fix would NOT close this. Phone-only patients are the common case
|
|
46
|
+
* (sms_messages.leave_message creates them), and filling their blank email is the primary attack —
|
|
47
|
+
* hence the UNSET victim shapes in R5.
|
|
48
|
+
*
|
|
49
|
+
* Scenario groups (pre-fix expectation in parens):
|
|
50
|
+
* R1 (RED) matched on phone — submitted email must not land
|
|
51
|
+
* R2 (RED) matched on email — submitted phone must not land
|
|
52
|
+
* R3 (RED) stickiness: draft matched a pre-existing patient, finalize must not repoint them
|
|
53
|
+
* R4 (RED) unresolvable ?enduserId= falls through to the guess — must stay blocked
|
|
54
|
+
* R5 (RED) victim shapes: blank email / blank phone are filled by the guess (the takeover case)
|
|
55
|
+
* G6 (GREEN) patient created by this responder's own draft may still correct their email
|
|
56
|
+
* G7 (GREEN) explicit ?enduserId= still writes email + phone (backwards-compat escape hatch)
|
|
57
|
+
* G8 (GREEN) ?createNewEnduser=true still writes email + phone
|
|
58
|
+
* G9 (GREEN) brand-new patient still inserted WITH submitted email + phone
|
|
59
|
+
* G10 (GREEN) fname/lname/dateOfBirth/gender/custom fields still update on a guessed match
|
|
60
|
+
* G11 (GREEN) explicit ?enduserId= overrides for that request only — the stored flag stays set
|
|
61
|
+
* G12 (GREEN) the Stripe grace: a BLANK phone on a contact the Stripe integration created within
|
|
62
|
+
* the last hour is filled from intake (see stripe_provisional_enduser.ts)
|
|
63
|
+
* R12 ...but a phone already stored on that contact is never overwritten
|
|
64
|
+
* R13 ...and its email is never repointed
|
|
65
|
+
* R14 both grace markers are load-bearing: neither source nor stripeCustomerId alone opens it
|
|
66
|
+
* R15 the grace is per-request — it does not clear the stored flag
|
|
67
|
+
*
|
|
68
|
+
* The Stripe grace is deliberately confined to THIS endpoint, which is API-key authenticated. The
|
|
69
|
+
* anonymous public-form path gets no exemption; that boundary is pinned by G28 in
|
|
70
|
+
* public_endpoint_auth_field_lock.test.ts.
|
|
71
|
+
*
|
|
72
|
+
* STRUCTURE: record -> report -> cleanup -> assert.
|
|
73
|
+
* EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
|
|
74
|
+
* first failure. Two consequences shape this file:
|
|
75
|
+
* 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
|
|
76
|
+
* single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
|
|
77
|
+
* instead of dying on the first probe.
|
|
78
|
+
* 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
|
|
79
|
+
* loop; otherwise a by-design-failing run would leak every fixture into the next run.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
// The enduser update in this handler is fire-and-forget (.catch(console.error), not awaited).
|
|
83
|
+
// Reading the victim immediately would frequently show the un-overwritten value on a VULNERABLE
|
|
84
|
+
// server -> false pass. So poll for the attack LANDING: fails fast against vulnerable code, burns
|
|
85
|
+
// the full window against fixed code.
|
|
86
|
+
const OVERWRITE_WINDOW_MS = 3000
|
|
87
|
+
const POLL_INTERVAL_MS = 150
|
|
88
|
+
|
|
89
|
+
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
90
|
+
|
|
91
|
+
type ScenarioResult = { group: string, name: string, ok: boolean, skipped?: boolean, detail: string }
|
|
92
|
+
|
|
93
|
+
export const formsort_webhook_auth_field_lock_tests = async (
|
|
94
|
+
{ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
|
|
95
|
+
) => {
|
|
96
|
+
log_header("Formsort Webhook Auth Field Lock (email / phone)")
|
|
97
|
+
|
|
98
|
+
// Every identity carries a per-run token so the handler's $or can only match our own records,
|
|
99
|
+
// and re-runs never collide.
|
|
100
|
+
const RUN = Date.now().toString(36)
|
|
101
|
+
|
|
102
|
+
const results: ScenarioResult[] = []
|
|
103
|
+
const record = (group: string, name: string, ok: boolean, detail = '') => {
|
|
104
|
+
results.push({ group, name, ok, detail })
|
|
105
|
+
console.log(` ${ok ? '✓' : '✗'} [${group}] ${name}${detail ? ` — ${detail}` : ''}`)
|
|
106
|
+
}
|
|
107
|
+
const skip = (group: string, name: string, detail: string) => {
|
|
108
|
+
results.push({ group, name, ok: true, skipped: true, detail })
|
|
109
|
+
console.log(` ⚠ SKIP [${group}] ${name} — ${detail}`)
|
|
110
|
+
}
|
|
111
|
+
// Converts a thrown exception into a recorded failure so one surprise doesn't abort the table.
|
|
112
|
+
const runScenario = async (group: string, name: string, fn: () => Promise<void>) => {
|
|
113
|
+
try {
|
|
114
|
+
await fn()
|
|
115
|
+
} catch (err: any) {
|
|
116
|
+
const message = err?.message ?? err?.error ?? JSON.stringify(err ?? {}).slice(0, 300)
|
|
117
|
+
record(group, name, false, `threw: ${message}`)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// phoneValidator normalizes to E.164, so generate +1555555xxxx values that are byte-comparable
|
|
122
|
+
// against what gets stored. 555-555-xxxx is the only 555 exchange libphonenumber accepts.
|
|
123
|
+
let phoneOffset = 0
|
|
124
|
+
const phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0')
|
|
125
|
+
const nextPhone = () => `+1555555${String((phoneSeed + (phoneOffset += 1)) % 10_000).padStart(4, '0')}`
|
|
126
|
+
|
|
127
|
+
// The handler lowercases submitted emails before storing, so normalize here too — the overwrite
|
|
128
|
+
// predicates compare submitted and stored values directly.
|
|
129
|
+
const emailFor = (label: string) => `fswh-${label}-${RUN}@tellescope.com`.toLowerCase()
|
|
130
|
+
|
|
131
|
+
// responder_uuid is the form_response lookup key ({ source: 'Formsort', externalId }), and it is
|
|
132
|
+
// NOT scoped by formId — so it must be unique per scenario or drafts collide across scenarios.
|
|
133
|
+
const uuidFor = (label: string) => `fswh-${label}-${RUN}`
|
|
134
|
+
|
|
135
|
+
const createdEnduserIds: string[] = []
|
|
136
|
+
const createdFormIds: string[] = []
|
|
137
|
+
const createdApiKeyIds: string[] = []
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------------------------
|
|
140
|
+
// Fixtures
|
|
141
|
+
// ---------------------------------------------------------------------------------------------
|
|
142
|
+
// The webhook parses `answers` by key directly (email / phone / fname / ts_enduser_*), NOT via
|
|
143
|
+
// form_fields, so the form needs no fields — it only has to exist and belong to this business.
|
|
144
|
+
const form = await sdk.api.forms.createOne({ title: `formsort auth field lock ${RUN}` })
|
|
145
|
+
createdFormIds.push(form.id)
|
|
146
|
+
|
|
147
|
+
// An UNSCOPED /v1 key does not authenticate this webhook (utf8 vs hex hashedKey — pinned by
|
|
148
|
+
// scoped_api_keys.test.ts). Create the key at runtime rather than depending on a seeded one.
|
|
149
|
+
const apiKey = await sdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] })
|
|
150
|
+
createdApiKeyIds.push(apiKey.id)
|
|
151
|
+
|
|
152
|
+
const postWebhook = async (o: {
|
|
153
|
+
label: string,
|
|
154
|
+
answers: { key: string, value: any }[],
|
|
155
|
+
finalized: boolean,
|
|
156
|
+
enduserId?: string,
|
|
157
|
+
createNewEnduser?: boolean,
|
|
158
|
+
}) => {
|
|
159
|
+
const url = new URL(`${host}/v1/webhooks/form-ingestion/${apiKey.key}`)
|
|
160
|
+
url.searchParams.set('formId', form.id)
|
|
161
|
+
url.searchParams.set('returnJSON', 'true')
|
|
162
|
+
if (o.enduserId) url.searchParams.set('enduserId', o.enduserId)
|
|
163
|
+
if (o.createNewEnduser) url.searchParams.set('createNewEnduser', 'true')
|
|
164
|
+
|
|
165
|
+
const res = await axios.post(
|
|
166
|
+
url.toString(),
|
|
167
|
+
{ answers: o.answers, responder_uuid: uuidFor(o.label), finalized: o.finalized },
|
|
168
|
+
{ validateStatus: () => true },
|
|
169
|
+
)
|
|
170
|
+
// In-progress webhooks always return a bare 200 (the handler returns before the returnJSON
|
|
171
|
+
// block), so `data` is only populated for finalized calls.
|
|
172
|
+
return { status: res.status, data: (res.data || {}) as { formResponseId?: string, enduserId?: string } }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const makeVictim = async (label: string, fields: Partial<Enduser>) => {
|
|
176
|
+
const victim = await sdk.api.endusers.createOne({ ...fields } as any)
|
|
177
|
+
createdEnduserIds.push(victim.id)
|
|
178
|
+
return victim
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const pollEnduser = async (id: string, landed: (e: Enduser) => boolean, windowMs = OVERWRITE_WINDOW_MS) => {
|
|
182
|
+
const deadline = Date.now() + windowMs
|
|
183
|
+
let enduser: Enduser | null = null
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
enduser = await sdk.api.endusers.getOne(id).catch(() => null) as Enduser | null
|
|
186
|
+
if (enduser && landed(enduser)) return enduser
|
|
187
|
+
await sleep(POLL_INTERVAL_MS)
|
|
188
|
+
}
|
|
189
|
+
return enduser
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A scenario that expects a MATCH is meaningless if the handler inserted instead — the victim
|
|
193
|
+
// would look untouched and the probe would false-pass. returnJSON hands back the id it landed on.
|
|
194
|
+
const matched = (group: string, name: string, data: { enduserId?: string }, victimId: string) => {
|
|
195
|
+
if (data?.enduserId === victimId) return true
|
|
196
|
+
record(group, name, false,
|
|
197
|
+
`probe invalid: webhook landed on enduserId=${data?.enduserId || '(none)'} expected victim=${victimId}`)
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------------------------
|
|
202
|
+
// R1 — matched on PHONE, submitted email must not land.
|
|
203
|
+
// ---------------------------------------------------------------------------------------------
|
|
204
|
+
await runScenario('R1', 'phone match does not repoint email', async () => {
|
|
205
|
+
const originalEmail = emailFor('r1')
|
|
206
|
+
const phone = nextPhone()
|
|
207
|
+
const victim = await makeVictim('r1', { email: originalEmail, phone })
|
|
208
|
+
const attackerEmail = emailFor('r1-attacker')
|
|
209
|
+
|
|
210
|
+
const { data } = await postWebhook({
|
|
211
|
+
label: 'r1',
|
|
212
|
+
finalized: true,
|
|
213
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
|
|
214
|
+
})
|
|
215
|
+
if (!matched('R1', 'phone match does not repoint email', data, victim.id)) return
|
|
216
|
+
|
|
217
|
+
const enduser = await pollEnduser(victim.id, e => e.email === attackerEmail)
|
|
218
|
+
record('R1', 'phone match does not repoint email', enduser?.email === originalEmail,
|
|
219
|
+
`email=${enduser?.email || '(unset)'} expected=${originalEmail}`)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------------------------
|
|
223
|
+
// R2 — matched on EMAIL, submitted phone must not land.
|
|
224
|
+
// ---------------------------------------------------------------------------------------------
|
|
225
|
+
await runScenario('R2', 'email match does not repoint phone', async () => {
|
|
226
|
+
const email = emailFor('r2')
|
|
227
|
+
const originalPhone = nextPhone()
|
|
228
|
+
const victim = await makeVictim('r2', { email, phone: originalPhone })
|
|
229
|
+
const attackerPhone = nextPhone()
|
|
230
|
+
|
|
231
|
+
const { data } = await postWebhook({
|
|
232
|
+
label: 'r2',
|
|
233
|
+
finalized: true,
|
|
234
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
|
|
235
|
+
})
|
|
236
|
+
if (!matched('R2', 'email match does not repoint phone', data, victim.id)) return
|
|
237
|
+
|
|
238
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === attackerPhone)
|
|
239
|
+
record('R2', 'email match does not repoint phone', enduser?.phone === originalPhone,
|
|
240
|
+
`phone=${enduser?.phone || '(unset)'} expected=${originalPhone}`)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------------------------
|
|
244
|
+
// R3 — stickiness. The draft matched a pre-existing patient by phone; the finalize resolves that
|
|
245
|
+
// patient via existingResponse.enduserId (path 2), which is the OPEN path. It must stay blocked,
|
|
246
|
+
// because the response was formed by a guess — that is what the sticky flag records.
|
|
247
|
+
// ---------------------------------------------------------------------------------------------
|
|
248
|
+
await runScenario('R3', 'draft formed by a guess stays blocked on finalize', async () => {
|
|
249
|
+
const originalEmail = emailFor('r3')
|
|
250
|
+
const phone = nextPhone()
|
|
251
|
+
const victim = await makeVictim('r3', { email: originalEmail, phone })
|
|
252
|
+
const attackerEmail = emailFor('r3-attacker')
|
|
253
|
+
|
|
254
|
+
// in-progress webhook: enough to match (phone present), creates the draft response
|
|
255
|
+
await postWebhook({ label: 'r3', finalized: false, answers: [{ key: 'phone', value: phone }] })
|
|
256
|
+
|
|
257
|
+
const { data } = await postWebhook({
|
|
258
|
+
label: 'r3',
|
|
259
|
+
finalized: true,
|
|
260
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
|
|
261
|
+
})
|
|
262
|
+
if (!matched('R3', 'draft formed by a guess stays blocked on finalize', data, victim.id)) return
|
|
263
|
+
|
|
264
|
+
const enduser = await pollEnduser(victim.id, e => e.email === attackerEmail)
|
|
265
|
+
record('R3', 'draft formed by a guess stays blocked on finalize', enduser?.email === originalEmail,
|
|
266
|
+
`email=${enduser?.email || '(unset)'} expected=${originalEmail}`)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------------------------
|
|
270
|
+
// R4 — an unresolvable ?enduserId= must NOT count as an explicit assertion. findById returns null,
|
|
271
|
+
// the chain falls through to the $or guess, and the write must stay blocked. This pins that the
|
|
272
|
+
// override keys off the RESOLVED record, not the mere presence of the query param. A cross-tenant
|
|
273
|
+
// id takes the same route: DB is businessId-scoped, so findById returns null for it too.
|
|
274
|
+
// ---------------------------------------------------------------------------------------------
|
|
275
|
+
await runScenario('R4', 'unresolvable enduserId falls through to the guess', async () => {
|
|
276
|
+
const originalEmail = emailFor('r4')
|
|
277
|
+
const phone = nextPhone()
|
|
278
|
+
const victim = await makeVictim('r4', { email: originalEmail, phone })
|
|
279
|
+
const attackerEmail = emailFor('r4-attacker')
|
|
280
|
+
|
|
281
|
+
const { data } = await postWebhook({
|
|
282
|
+
label: 'r4',
|
|
283
|
+
finalized: true,
|
|
284
|
+
enduserId: '0000000000000000000000ff', // well-formed, nonexistent
|
|
285
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
|
|
286
|
+
})
|
|
287
|
+
if (!matched('R4', 'unresolvable enduserId falls through to the guess', data, victim.id)) return
|
|
288
|
+
|
|
289
|
+
const enduser = await pollEnduser(victim.id, e => e.email === attackerEmail)
|
|
290
|
+
record('R4', 'unresolvable enduserId falls through to the guess', enduser?.email === originalEmail,
|
|
291
|
+
`email=${enduser?.email || '(unset)'} expected=${originalEmail}`)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------------------------
|
|
295
|
+
// R5 — victim shapes. A blank field is the LOAD-BEARING case: phone-only patients are common and
|
|
296
|
+
// cheap to create, and filling their blank email is the takeover primitive. "Only if blank" is
|
|
297
|
+
// therefore the wrong fix, and these two rows are what prove it.
|
|
298
|
+
// ---------------------------------------------------------------------------------------------
|
|
299
|
+
await runScenario('R5', 'blank email on a phone-only patient is not filled by a guess', async () => {
|
|
300
|
+
const phone = nextPhone()
|
|
301
|
+
const victim = await makeVictim('r5a', { phone, fname: 'Blank', lname: `Email${RUN}` })
|
|
302
|
+
const attackerEmail = emailFor('r5a-attacker')
|
|
303
|
+
|
|
304
|
+
const { data } = await postWebhook({
|
|
305
|
+
label: 'r5a',
|
|
306
|
+
finalized: true,
|
|
307
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
|
|
308
|
+
})
|
|
309
|
+
if (!matched('R5', 'blank email on a phone-only patient is not filled by a guess', data, victim.id)) return
|
|
310
|
+
|
|
311
|
+
const enduser = await pollEnduser(victim.id, e => e.email === attackerEmail)
|
|
312
|
+
record('R5', 'blank email on a phone-only patient is not filled by a guess', !enduser?.email,
|
|
313
|
+
`email=${enduser?.email || '(unset)'} expected=(unset)`)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
await runScenario('R5', 'blank phone on an email-only patient is not filled by a guess', async () => {
|
|
317
|
+
const email = emailFor('r5b')
|
|
318
|
+
const victim = await makeVictim('r5b', { email })
|
|
319
|
+
const attackerPhone = nextPhone()
|
|
320
|
+
|
|
321
|
+
const { data } = await postWebhook({
|
|
322
|
+
label: 'r5b',
|
|
323
|
+
finalized: true,
|
|
324
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
|
|
325
|
+
})
|
|
326
|
+
if (!matched('R5', 'blank phone on an email-only patient is not filled by a guess', data, victim.id)) return
|
|
327
|
+
|
|
328
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === attackerPhone)
|
|
329
|
+
record('R5', 'blank phone on an email-only patient is not filled by a guess', !enduser?.phone,
|
|
330
|
+
`phone=${enduser?.phone || '(unset)'} expected=(unset)`)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------------------------
|
|
334
|
+
// G6 — the carve-out. The draft CREATED this patient (no prior record matched), so the response is
|
|
335
|
+
// not flagged and the same person may still correct their email on a later page of the form.
|
|
336
|
+
// ---------------------------------------------------------------------------------------------
|
|
337
|
+
await runScenario('G6', 'patient created by this draft may still correct their email', async () => {
|
|
338
|
+
const firstEmail = emailFor('g6-typo')
|
|
339
|
+
const correctedEmail = emailFor('g6-correct')
|
|
340
|
+
const phone = nextPhone()
|
|
341
|
+
|
|
342
|
+
// in-progress webhook with a never-before-seen email: inserts a new patient, response unflagged
|
|
343
|
+
await postWebhook({
|
|
344
|
+
label: 'g6',
|
|
345
|
+
finalized: false,
|
|
346
|
+
answers: [{ key: 'email', value: firstEmail }, { key: 'phone', value: phone }],
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
const { data } = await postWebhook({
|
|
350
|
+
label: 'g6',
|
|
351
|
+
finalized: true,
|
|
352
|
+
answers: [{ key: 'email', value: correctedEmail }, { key: 'phone', value: phone }],
|
|
353
|
+
})
|
|
354
|
+
if (!data?.enduserId) {
|
|
355
|
+
record('G6', 'patient created by this draft may still correct their email', false,
|
|
356
|
+
'probe invalid: webhook returned no enduserId')
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
createdEnduserIds.push(data.enduserId)
|
|
360
|
+
|
|
361
|
+
const enduser = await pollEnduser(data.enduserId, e => e.email === correctedEmail)
|
|
362
|
+
record('G6', 'patient created by this draft may still correct their email', enduser?.email === correctedEmail,
|
|
363
|
+
`email=${enduser?.email || '(unset)'} expected=${correctedEmail}`)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------------------------
|
|
367
|
+
// G7 — the backwards-compat escape hatch. An API-key-authenticated caller that names the patient
|
|
368
|
+
// explicitly still writes email and phone. Pinned so a later tightening can't silently remove it.
|
|
369
|
+
// ---------------------------------------------------------------------------------------------
|
|
370
|
+
await runScenario('G7', 'explicit enduserId still writes email and phone', async () => {
|
|
371
|
+
const victim = await makeVictim('g7', { email: emailFor('g7'), phone: nextPhone() })
|
|
372
|
+
const newEmail = emailFor('g7-new')
|
|
373
|
+
const newPhone = nextPhone()
|
|
374
|
+
|
|
375
|
+
const { data } = await postWebhook({
|
|
376
|
+
label: 'g7',
|
|
377
|
+
finalized: true,
|
|
378
|
+
enduserId: victim.id,
|
|
379
|
+
answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
|
|
380
|
+
})
|
|
381
|
+
if (!matched('G7', 'explicit enduserId still writes email and phone', data, victim.id)) return
|
|
382
|
+
|
|
383
|
+
const enduser = await pollEnduser(victim.id, e => e.email === newEmail && e.phone === newPhone)
|
|
384
|
+
record('G7', 'explicit enduserId still writes email', enduser?.email === newEmail,
|
|
385
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
386
|
+
record('G7', 'explicit enduserId still writes phone', enduser?.phone === newPhone,
|
|
387
|
+
`phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------------------------
|
|
391
|
+
// G8 — ?createNewEnduser=true inserts a BLANK record up front and then fills it via the update
|
|
392
|
+
// branch, so email/phone must still land there.
|
|
393
|
+
// ---------------------------------------------------------------------------------------------
|
|
394
|
+
await runScenario('G8', 'createNewEnduser still writes email and phone', async () => {
|
|
395
|
+
const newEmail = emailFor('g8')
|
|
396
|
+
const newPhone = nextPhone()
|
|
397
|
+
|
|
398
|
+
const { data } = await postWebhook({
|
|
399
|
+
label: 'g8',
|
|
400
|
+
finalized: true,
|
|
401
|
+
createNewEnduser: true,
|
|
402
|
+
answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
|
|
403
|
+
})
|
|
404
|
+
if (!data?.enduserId) {
|
|
405
|
+
record('G8', 'createNewEnduser still writes email and phone', false,
|
|
406
|
+
'probe invalid: webhook returned no enduserId')
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
createdEnduserIds.push(data.enduserId)
|
|
410
|
+
|
|
411
|
+
const enduser = await pollEnduser(data.enduserId, e => e.email === newEmail && e.phone === newPhone)
|
|
412
|
+
record('G8', 'createNewEnduser still writes email', enduser?.email === newEmail,
|
|
413
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
414
|
+
record('G8', 'createNewEnduser still writes phone', enduser?.phone === newPhone,
|
|
415
|
+
`phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------------------------
|
|
419
|
+
// G9 — brand-new patient, no prior record to match: the INSERT path keeps both fields.
|
|
420
|
+
// ---------------------------------------------------------------------------------------------
|
|
421
|
+
await runScenario('G9', 'brand-new patient is inserted with email and phone', async () => {
|
|
422
|
+
const newEmail = emailFor('g9')
|
|
423
|
+
const newPhone = nextPhone()
|
|
424
|
+
|
|
425
|
+
const { data } = await postWebhook({
|
|
426
|
+
label: 'g9',
|
|
427
|
+
finalized: true,
|
|
428
|
+
answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
|
|
429
|
+
})
|
|
430
|
+
if (!data?.enduserId) {
|
|
431
|
+
record('G9', 'brand-new patient is inserted with email and phone', false,
|
|
432
|
+
'probe invalid: webhook returned no enduserId')
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
createdEnduserIds.push(data.enduserId)
|
|
436
|
+
|
|
437
|
+
const enduser = await pollEnduser(data.enduserId, e => !!e.email && !!e.phone)
|
|
438
|
+
record('G9', 'brand-new patient is inserted with email', enduser?.email === newEmail,
|
|
439
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
440
|
+
record('G9', 'brand-new patient is inserted with phone', enduser?.phone === newPhone,
|
|
441
|
+
`phone=${enduser?.phone || '(unset)'} expected=${newPhone}`)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------------------------
|
|
445
|
+
// G10 — the narrowing is email/phone ONLY. Everything else the webhook parses must still update
|
|
446
|
+
// on a guessed match, otherwise this fix has quietly broken Formsort intake.
|
|
447
|
+
// ---------------------------------------------------------------------------------------------
|
|
448
|
+
await runScenario('G10', 'non-auth fields still update on a guessed match', async () => {
|
|
449
|
+
const originalEmail = emailFor('g10')
|
|
450
|
+
const phone = nextPhone()
|
|
451
|
+
const victim = await makeVictim('g10', {
|
|
452
|
+
email: originalEmail, phone, fname: 'Before', lname: 'Before',
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
const { data } = await postWebhook({
|
|
456
|
+
label: 'g10',
|
|
457
|
+
finalized: true,
|
|
458
|
+
answers: [
|
|
459
|
+
{ key: 'phone', value: phone },
|
|
460
|
+
{ key: 'email', value: emailFor('g10-attacker') },
|
|
461
|
+
{ key: 'fname', value: 'After' },
|
|
462
|
+
{ key: 'lname', value: 'After' },
|
|
463
|
+
{ key: 'gender', value: 'Male' },
|
|
464
|
+
{ key: 'dateOfBirth', value: '1990-01-02' }, // YYYY-MM-DD in, MM-DD-YYYY stored
|
|
465
|
+
{ key: 'ts_enduser_lockprobe', value: `probe-${RUN}` },
|
|
466
|
+
],
|
|
467
|
+
})
|
|
468
|
+
if (!matched('G10', 'non-auth fields still update on a guessed match', data, victim.id)) return
|
|
469
|
+
|
|
470
|
+
const enduser = await pollEnduser(victim.id, e => e.fname === 'After')
|
|
471
|
+
record('G10', 'fname still updates', enduser?.fname === 'After',
|
|
472
|
+
`fname=${enduser?.fname || '(unset)'} expected=After`)
|
|
473
|
+
record('G10', 'lname still updates', enduser?.lname === 'After',
|
|
474
|
+
`lname=${enduser?.lname || '(unset)'} expected=After`)
|
|
475
|
+
record('G10', 'gender still updates', enduser?.gender === 'Male',
|
|
476
|
+
`gender=${enduser?.gender || '(unset)'} expected=Male`)
|
|
477
|
+
record('G10', 'dateOfBirth still updates', enduser?.dateOfBirth === '01-02-1990',
|
|
478
|
+
`dateOfBirth=${enduser?.dateOfBirth || '(unset)'} expected=01-02-1990`)
|
|
479
|
+
record('G10', 'custom fields still update', enduser?.fields?.lockprobe === `probe-${RUN}`,
|
|
480
|
+
`fields.lockprobe=${enduser?.fields?.lockprobe || '(unset)'} expected=probe-${RUN}`)
|
|
481
|
+
record('G10', 'email still blocked while other fields update', enduser?.email === originalEmail,
|
|
482
|
+
`email=${enduser?.email || '(unset)'} expected=${originalEmail}`)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------------------------
|
|
486
|
+
// G11 — the explicit override is per-REQUEST, not a flag reset. A response formed by a guess keeps
|
|
487
|
+
// authFieldIntakeDisabled set, so the OTHER consumer of that flag
|
|
488
|
+
// (save_enduser_updates_for_form_response, reached by later staff edits) stays protected.
|
|
489
|
+
// ---------------------------------------------------------------------------------------------
|
|
490
|
+
await runScenario('G11', 'explicit override does not clear the stored flag', async () => {
|
|
491
|
+
const phone = nextPhone()
|
|
492
|
+
const victim = await makeVictim('g11', { email: emailFor('g11'), phone })
|
|
493
|
+
const newEmail = emailFor('g11-new')
|
|
494
|
+
|
|
495
|
+
// draft formed by a guess -> flagged
|
|
496
|
+
await postWebhook({ label: 'g11', finalized: false, answers: [{ key: 'phone', value: phone }] })
|
|
497
|
+
|
|
498
|
+
const { data } = await postWebhook({
|
|
499
|
+
label: 'g11',
|
|
500
|
+
finalized: true,
|
|
501
|
+
enduserId: victim.id,
|
|
502
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: newEmail }],
|
|
503
|
+
})
|
|
504
|
+
if (!matched('G11', 'explicit override does not clear the stored flag', data, victim.id)) return
|
|
505
|
+
|
|
506
|
+
const enduser = await pollEnduser(victim.id, e => e.email === newEmail)
|
|
507
|
+
record('G11', 'explicit override writes email even on a flagged response', enduser?.email === newEmail,
|
|
508
|
+
`email=${enduser?.email || '(unset)'} expected=${newEmail}`)
|
|
509
|
+
|
|
510
|
+
if (!data.formResponseId) {
|
|
511
|
+
skip('G11', 'stored flag survives the override', 'webhook returned no formResponseId')
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
const response: any = await sdk.api.form_responses.getOne(data.formResponseId).catch(() => null)
|
|
515
|
+
if (!response) {
|
|
516
|
+
skip('G11', 'stored flag survives the override', 'form response not readable')
|
|
517
|
+
} else if (response.authFieldIntakeDisabled === undefined) {
|
|
518
|
+
// The field is deliberately absent from schema.ts; if reads project it away there is nothing
|
|
519
|
+
// to assert here, and the enforcement is already covered by the scenarios above.
|
|
520
|
+
skip('G11', 'stored flag survives the override', 'authFieldIntakeDisabled not exposed on read')
|
|
521
|
+
} else {
|
|
522
|
+
record('G11', 'stored flag survives the override', response.authFieldIntakeDisabled === true,
|
|
523
|
+
`authFieldIntakeDisabled=${response.authFieldIntakeDisabled} expected=true`)
|
|
524
|
+
}
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
// ---------------------------------------------------------------------------------------------
|
|
528
|
+
// G12 — the Stripe grace. A contact the Stripe integration created minutes ago carries payment data
|
|
529
|
+
// only (email + name + stripeCustomerId, never a phone), so intake is the first place a phone number
|
|
530
|
+
// exists at all. enduser_is_provisional_stripe_record re-opens BLANK email/phone for one hour after
|
|
531
|
+
// that insert; see api/integrations/payments/stripe_provisional_enduser.ts.
|
|
532
|
+
//
|
|
533
|
+
// The Stripe webhook cannot be driven locally (it needs a signature and an integration record), but
|
|
534
|
+
// nothing about the grace depends on Stripe having done the insert: source and stripeCustomerId are
|
|
535
|
+
// both enduserUpdatesDisabled, so an admin can create the same shape and drive the real webhook
|
|
536
|
+
// against it. A fixture created here always has a fresh ObjectId, so this exercises the in-window
|
|
537
|
+
// branch; the expired branch is pinned by the unit tests in
|
|
538
|
+
// api/integrations/payments/stripe_provisional_enduser.test.ts.
|
|
539
|
+
// ---------------------------------------------------------------------------------------------
|
|
540
|
+
const stripeVictim = (label: string, fields: Partial<Enduser>) => makeVictim(label, {
|
|
541
|
+
source: 'Stripe',
|
|
542
|
+
stripeCustomerId: `cus_${RUN}${label}`,
|
|
543
|
+
...fields,
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
await runScenario('G12', 'blank phone on a fresh Stripe contact is filled from intake', async () => {
|
|
547
|
+
const email = emailFor('g12')
|
|
548
|
+
const victim = await stripeVictim('g12', { email, fname: 'Stripe', lname: `Payer${RUN}` })
|
|
549
|
+
const intakePhone = nextPhone()
|
|
550
|
+
|
|
551
|
+
const { data } = await postWebhook({
|
|
552
|
+
label: 'g12',
|
|
553
|
+
finalized: true,
|
|
554
|
+
answers: [
|
|
555
|
+
{ key: 'email', value: email },
|
|
556
|
+
{ key: 'phone', value: intakePhone },
|
|
557
|
+
{ key: 'dateOfBirth', value: '1990-01-02' }, // Stripe has no DOB; intake is the first source
|
|
558
|
+
{ key: 'ts_enduser_stripeprobe', value: `probe-${RUN}` },
|
|
559
|
+
],
|
|
560
|
+
})
|
|
561
|
+
if (!matched('G12', 'blank phone on a fresh Stripe contact is filled from intake', data, victim.id)) return
|
|
562
|
+
|
|
563
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === intakePhone)
|
|
564
|
+
record('G12', 'blank phone on a fresh Stripe contact is filled from intake', enduser?.phone === intakePhone,
|
|
565
|
+
`phone=${enduser?.phone || '(unset)'} expected=${intakePhone}`)
|
|
566
|
+
// the grace must not disturb the ordinary update path around it
|
|
567
|
+
record('G12', 'non-auth fields still land alongside the graced phone', enduser?.dateOfBirth === '01-02-1990',
|
|
568
|
+
`dateOfBirth=${enduser?.dateOfBirth || '(unset)'} expected=01-02-1990`)
|
|
569
|
+
record('G12', 'email is unchanged by the grace', enduser?.email === email,
|
|
570
|
+
`email=${enduser?.email || '(unset)'} expected=${email}`)
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------------------------
|
|
574
|
+
// R12 / R13 — the grace is strictly BLANK-FILL. Repointing a stored email or phone is the takeover
|
|
575
|
+
// primitive this lock exists to stop, and being minutes old does not make it safe.
|
|
576
|
+
// ---------------------------------------------------------------------------------------------
|
|
577
|
+
await runScenario('R12', 'grace does not overwrite a phone already on a Stripe contact', async () => {
|
|
578
|
+
const email = emailFor('r12')
|
|
579
|
+
const originalPhone = nextPhone()
|
|
580
|
+
const victim = await stripeVictim('r12', { email, phone: originalPhone })
|
|
581
|
+
const attackerPhone = nextPhone()
|
|
582
|
+
|
|
583
|
+
const { data } = await postWebhook({
|
|
584
|
+
label: 'r12',
|
|
585
|
+
finalized: true,
|
|
586
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
|
|
587
|
+
})
|
|
588
|
+
if (!matched('R12', 'grace does not overwrite a phone already on a Stripe contact', data, victim.id)) return
|
|
589
|
+
|
|
590
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === attackerPhone)
|
|
591
|
+
record('R12', 'grace does not overwrite a phone already on a Stripe contact', enduser?.phone === originalPhone,
|
|
592
|
+
`phone=${enduser?.phone || '(unset)'} expected=${originalPhone}`)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
await runScenario('R13', 'grace does not repoint the email of a Stripe contact', async () => {
|
|
596
|
+
const originalEmail = emailFor('r13')
|
|
597
|
+
const phone = nextPhone()
|
|
598
|
+
const victim = await stripeVictim('r13', { email: originalEmail, phone })
|
|
599
|
+
const attackerEmail = emailFor('r13-attacker')
|
|
600
|
+
|
|
601
|
+
// matched on phone, so the submitted email is free to differ from the stored one
|
|
602
|
+
const { data } = await postWebhook({
|
|
603
|
+
label: 'r13',
|
|
604
|
+
finalized: true,
|
|
605
|
+
answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
|
|
606
|
+
})
|
|
607
|
+
if (!matched('R13', 'grace does not repoint the email of a Stripe contact', data, victim.id)) return
|
|
608
|
+
|
|
609
|
+
const enduser = await pollEnduser(victim.id, e => e.email === attackerEmail)
|
|
610
|
+
record('R13', 'grace does not repoint the email of a Stripe contact', enduser?.email === originalEmail,
|
|
611
|
+
`email=${enduser?.email || '(unset)'} expected=${originalEmail}`)
|
|
612
|
+
})
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------------------------
|
|
615
|
+
// R14 — BOTH markers are load-bearing. `source` is set by every other integration and by staff, and
|
|
616
|
+
// `stripeCustomerId` can be attached to a contact this integration never created, so neither alone
|
|
617
|
+
// may open the grace.
|
|
618
|
+
// ---------------------------------------------------------------------------------------------
|
|
619
|
+
await runScenario('R14', 'stripeCustomerId without a Stripe source does not open the grace', async () => {
|
|
620
|
+
const email = emailFor('r14a')
|
|
621
|
+
const victim = await makeVictim('r14a', { email, source: 'Elation', stripeCustomerId: `cus_${RUN}r14a` })
|
|
622
|
+
const attackerPhone = nextPhone()
|
|
623
|
+
|
|
624
|
+
const { data } = await postWebhook({
|
|
625
|
+
label: 'r14a',
|
|
626
|
+
finalized: true,
|
|
627
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
|
|
628
|
+
})
|
|
629
|
+
if (!matched('R14', 'stripeCustomerId without a Stripe source does not open the grace', data, victim.id)) return
|
|
630
|
+
|
|
631
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === attackerPhone)
|
|
632
|
+
record('R14', 'stripeCustomerId without a Stripe source does not open the grace', !enduser?.phone,
|
|
633
|
+
`phone=${enduser?.phone || '(unset)'} expected=(unset)`)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
await runScenario('R14', 'Stripe source without a stripeCustomerId does not open the grace', async () => {
|
|
637
|
+
const email = emailFor('r14b')
|
|
638
|
+
const victim = await makeVictim('r14b', { email, source: 'Stripe' })
|
|
639
|
+
const attackerPhone = nextPhone()
|
|
640
|
+
|
|
641
|
+
const { data } = await postWebhook({
|
|
642
|
+
label: 'r14b',
|
|
643
|
+
finalized: true,
|
|
644
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
|
|
645
|
+
})
|
|
646
|
+
if (!matched('R14', 'Stripe source without a stripeCustomerId does not open the grace', data, victim.id)) return
|
|
647
|
+
|
|
648
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === attackerPhone)
|
|
649
|
+
record('R14', 'Stripe source without a stripeCustomerId does not open the grace', !enduser?.phone,
|
|
650
|
+
`phone=${enduser?.phone || '(unset)'} expected=(unset)`)
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------------------------
|
|
654
|
+
// R15 — the grace is a per-REQUEST override, exactly like the explicit ?enduserId= hatch in G11: the
|
|
655
|
+
// response still records that it attached to a pre-existing contact, so the other consumer of that
|
|
656
|
+
// flag (save_enduser_updates_for_form_response, reached by later staff edits and by submissions
|
|
657
|
+
// after the window closes) stays protected.
|
|
658
|
+
// ---------------------------------------------------------------------------------------------
|
|
659
|
+
await runScenario('R15', 'grace does not clear the stored flag', async () => {
|
|
660
|
+
const email = emailFor('r15')
|
|
661
|
+
const victim = await stripeVictim('r15', { email })
|
|
662
|
+
const intakePhone = nextPhone()
|
|
663
|
+
|
|
664
|
+
const { data } = await postWebhook({
|
|
665
|
+
label: 'r15',
|
|
666
|
+
finalized: true,
|
|
667
|
+
answers: [{ key: 'email', value: email }, { key: 'phone', value: intakePhone }],
|
|
668
|
+
})
|
|
669
|
+
if (!matched('R15', 'grace does not clear the stored flag', data, victim.id)) return
|
|
670
|
+
|
|
671
|
+
const enduser = await pollEnduser(victim.id, e => e.phone === intakePhone)
|
|
672
|
+
record('R15', 'grace landed the phone (probe precondition)', enduser?.phone === intakePhone,
|
|
673
|
+
`phone=${enduser?.phone || '(unset)'} expected=${intakePhone}`)
|
|
674
|
+
|
|
675
|
+
if (!data.formResponseId) {
|
|
676
|
+
skip('R15', 'grace does not clear the stored flag', 'webhook returned no formResponseId')
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
const response: any = await sdk.api.form_responses.getOne(data.formResponseId).catch(() => null)
|
|
680
|
+
if (!response) {
|
|
681
|
+
skip('R15', 'grace does not clear the stored flag', 'form response not readable')
|
|
682
|
+
} else if (response.authFieldIntakeDisabled === undefined) {
|
|
683
|
+
// deliberately absent from schema.ts; if reads project it away there is nothing to assert here
|
|
684
|
+
skip('R15', 'grace does not clear the stored flag', 'authFieldIntakeDisabled not exposed on read')
|
|
685
|
+
} else {
|
|
686
|
+
record('R15', 'grace does not clear the stored flag', response.authFieldIntakeDisabled === true,
|
|
687
|
+
`authFieldIntakeDisabled=${response.authFieldIntakeDisabled} expected=true`)
|
|
688
|
+
}
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------------------------
|
|
692
|
+
// Report
|
|
693
|
+
// ---------------------------------------------------------------------------------------------
|
|
694
|
+
const failures = results.filter(r => !r.ok)
|
|
695
|
+
const skipped = results.filter(r => !!r.skipped)
|
|
696
|
+
console.log('')
|
|
697
|
+
console.log(' ┌─ Formsort webhook auth-field lock — summary ───────────────────────────')
|
|
698
|
+
for (const r of results) {
|
|
699
|
+
const status = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL'
|
|
700
|
+
console.log(` │ ${status} ${r.group.padEnd(3)} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)
|
|
701
|
+
}
|
|
702
|
+
console.log(` └─ ${results.length - failures.length}/${results.length} passed, ${skipped.length} skipped, ${failures.length} failed`)
|
|
703
|
+
console.log('')
|
|
704
|
+
|
|
705
|
+
// ---------------------------------------------------------------------------------------------
|
|
706
|
+
// Cleanup — MUST run before the assert loop (see the note at the top of this file).
|
|
707
|
+
// ---------------------------------------------------------------------------------------------
|
|
708
|
+
for (const formId of createdFormIds) {
|
|
709
|
+
const responses = await sdk.api.form_responses.getSome({ filter: { formId } }).catch(() => [])
|
|
710
|
+
for (const response of responses) {
|
|
711
|
+
await sdk.api.form_responses.deleteOne(response.id).catch(() => null)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
for (const id of createdFormIds) {
|
|
715
|
+
await sdk.api.forms.deleteOne(id).catch(() => null)
|
|
716
|
+
}
|
|
717
|
+
const uniqueEnduserIds = createdEnduserIds.filter((id, i) => !!id && createdEnduserIds.indexOf(id) === i)
|
|
718
|
+
for (const id of uniqueEnduserIds) {
|
|
719
|
+
await sdk.api.endusers.deleteOne(id).catch(() => null)
|
|
720
|
+
}
|
|
721
|
+
for (const id of createdApiKeyIds) {
|
|
722
|
+
await sdk.api.api_keys.deleteOne(id).catch(() => null)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// ---------------------------------------------------------------------------------------------
|
|
726
|
+
// Assert — single pass, last, so the full table above is always printed first.
|
|
727
|
+
// ---------------------------------------------------------------------------------------------
|
|
728
|
+
for (const r of results) {
|
|
729
|
+
assert(r.ok, `[${r.group}] ${r.name} — ${r.detail}`, `[${r.group}] ${r.name}`)
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Allow running this test file independently
|
|
734
|
+
if (require.main === module) {
|
|
735
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
736
|
+
const sdk = new Session({ host })
|
|
737
|
+
const sdkNonAdmin = new Session({ host })
|
|
738
|
+
|
|
739
|
+
const runTests = async () => {
|
|
740
|
+
await setup_tests(sdk, sdkNonAdmin)
|
|
741
|
+
await formsort_webhook_auth_field_lock_tests({ sdk, sdkNonAdmin })
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
runTests()
|
|
745
|
+
.then(() => {
|
|
746
|
+
console.log("✅ Formsort webhook auth field lock tests completed successfully")
|
|
747
|
+
process.exit(0)
|
|
748
|
+
})
|
|
749
|
+
.catch((error) => {
|
|
750
|
+
console.error("❌ Formsort webhook auth field lock tests failed:", error)
|
|
751
|
+
process.exit(1)
|
|
752
|
+
})
|
|
753
|
+
}
|