@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.
Files changed (165) hide show
  1. package/lib/cjs/sdk.d.ts +7 -2
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +1 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  6. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  7. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  8. package/lib/cjs/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/custom_dashboards.test.js +99 -8
  10. package/lib/cjs/tests/api_tests/custom_dashboards.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  12. package/lib/cjs/tests/api_tests/default_summary_journey.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js +278 -0
  16. package/lib/cjs/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  18. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  19. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  20. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  21. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +212 -33
  22. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  23. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  24. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  25. package/lib/cjs/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  26. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  27. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  28. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  29. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  30. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  31. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
  32. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  33. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  34. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +242 -0
  36. package/lib/cjs/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  38. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +443 -0
  40. package/lib/cjs/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  41. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  42. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  43. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +231 -0
  44. package/lib/cjs/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  45. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  46. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  47. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  48. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  49. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  50. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +1003 -0
  51. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  52. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  53. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  54. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  55. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  56. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  57. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  58. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  59. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  60. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  61. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  62. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  63. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  64. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  65. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  66. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  67. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  68. package/lib/cjs/tests/tests.d.ts.map +1 -1
  69. package/lib/cjs/tests/tests.js +489 -255
  70. package/lib/cjs/tests/tests.js.map +1 -1
  71. package/lib/esm/enduser.d.ts +0 -1
  72. package/lib/esm/enduser.d.ts.map +1 -1
  73. package/lib/esm/sdk.d.ts +9 -5
  74. package/lib/esm/sdk.d.ts.map +1 -1
  75. package/lib/esm/sdk.js +1 -0
  76. package/lib/esm/sdk.js.map +1 -1
  77. package/lib/esm/session.d.ts +0 -1
  78. package/lib/esm/session.d.ts.map +1 -1
  79. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  80. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  81. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  82. package/lib/esm/tests/api_tests/custom_dashboards.test.d.ts.map +1 -1
  83. package/lib/esm/tests/api_tests/custom_dashboards.test.js +99 -8
  84. package/lib/esm/tests/api_tests/custom_dashboards.test.js.map +1 -1
  85. package/lib/esm/tests/api_tests/default_summary_journey.test.d.ts.map +1 -0
  86. package/lib/esm/tests/api_tests/default_summary_journey.test.js.map +1 -0
  87. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts +6 -0
  88. package/lib/esm/tests/api_tests/file_download_unicode_names.test.d.ts.map +1 -0
  89. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js +248 -0
  90. package/lib/esm/tests/api_tests/file_download_unicode_names.test.js.map +1 -0
  91. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  92. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  93. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  94. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  95. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +213 -34
  96. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  97. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.d.ts.map +1 -1
  98. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js +85 -9
  99. package/lib/esm/tests/api_tests/phone_calls_cold_transfer.test.js.map +1 -1
  100. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  101. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  102. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  103. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  104. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  105. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
  106. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  107. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts +2 -0
  108. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.d.ts.map +1 -0
  109. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js +238 -0
  110. package/lib/esm/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.js.map +1 -0
  111. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts +29 -0
  112. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.d.ts.map +1 -0
  113. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js +436 -0
  114. package/lib/esm/tests/api_tests/security/F-0015-webhook-outbound-ssrf.test.js.map +1 -0
  115. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts +35 -0
  116. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.d.ts.map +1 -0
  117. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js +224 -0
  118. package/lib/esm/tests/api_tests/security/F-0025-proxy-image-unauth-ssrf.test.js.map +1 -0
  119. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  120. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  121. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  122. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  123. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  124. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +996 -0
  125. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  126. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  127. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  128. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  129. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  130. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  131. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  132. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  133. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  134. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  135. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  136. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  137. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  138. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  139. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  140. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  141. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  142. package/lib/esm/tests/tests.d.ts.map +1 -1
  143. package/lib/esm/tests/tests.js +489 -255
  144. package/lib/esm/tests/tests.js.map +1 -1
  145. package/lib/tsconfig.tsbuildinfo +1 -1
  146. package/package.json +10 -10
  147. package/src/sdk.ts +4 -0
  148. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  149. package/src/tests/api_tests/custom_dashboards.test.ts +92 -0
  150. package/src/tests/api_tests/file_download_unicode_names.test.ts +223 -0
  151. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  152. package/src/tests/api_tests/openloop_webhooks.test.ts +138 -0
  153. package/src/tests/api_tests/phone_calls_cold_transfer.test.ts +54 -0
  154. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  155. package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
  156. package/src/tests/api_tests/security/F-0013-sanitize-user-html-with-iframes.test.ts +215 -0
  157. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  158. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +721 -0
  159. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  160. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  161. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  162. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  163. package/src/tests/tests.ts +232 -2
  164. package/test_generated.pdf +0 -0
  165. package/.env +0 -13
@@ -0,0 +1,1193 @@
1
+ var __assign = (this && this.__assign) || function () {
2
+ __assign = Object.assign || function(t) {
3
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
4
+ s = arguments[i];
5
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
6
+ t[p] = s[p];
7
+ }
8
+ return t;
9
+ };
10
+ return __assign.apply(this, arguments);
11
+ };
12
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
13
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
14
+ return new (P || (P = Promise))(function (resolve, reject) {
15
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
16
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
17
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
18
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
19
+ });
20
+ };
21
+ var __generator = (this && this.__generator) || function (thisArg, body) {
22
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
23
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
24
+ function verb(n) { return function (v) { return step([n, v]); }; }
25
+ function step(op) {
26
+ if (f) throw new TypeError("Generator is already executing.");
27
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
28
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
29
+ if (y = 0, t) op = [op[0] & 2, t.value];
30
+ switch (op[0]) {
31
+ case 0: case 1: t = op; break;
32
+ case 4: _.label++; return { value: op[1], done: false };
33
+ case 5: _.label++; y = op[1]; op = [0]; continue;
34
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
35
+ default:
36
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
37
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
38
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
39
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
40
+ if (t[2]) _.ops.pop();
41
+ _.trys.pop(); continue;
42
+ }
43
+ op = body.call(thisArg, _);
44
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
45
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
46
+ }
47
+ };
48
+ require('source-map-support').install();
49
+ import axios from "axios";
50
+ import { Session } from "../../../sdk";
51
+ import { assert, log_header, } from "@tellescope/testing";
52
+ import { setup_tests } from "../../setup";
53
+ import { FORM_INGESTION_API_KEY_SCOPE } from "@tellescope/types-models";
54
+ var host = process.env.API_URL || 'http://localhost:8080';
55
+ /**
56
+ * Regression test for: email / phone are locked against writes from the Formsort webhook when it
57
+ * IMPLICITLY matched a patient that already existed before the submission started.
58
+ *
59
+ * POST /v1/webhooks/formsort/* (alias: /v1/webhooks/form-ingestion/*)
60
+ *
61
+ * The handler resolves an enduser four ways, in precedence order:
62
+ * 1. ?enduserId= — an explicit assertion by the API-key-authenticated caller
63
+ * 2. existingResponse.enduserId — the patient attached to this responder_uuid's draft
64
+ * 3. findOne({ $or: [{ phone }, { email }] }) — a GUESS, optionally AND'ed with fname + lname
65
+ * 4. insert — brand-new patient
66
+ *
67
+ * On a finalized submission it then wrote a blanket `$set: enduserFields` onto whatever it landed
68
+ * on, and that object carries `email` and `phone`. Those are authentication channels:
69
+ * request-enduser-password-reset resolves purely by findOne({ email }), begin_login_flow accepts
70
+ * email OR phone, and send_otp delivers login codes to enduser.phone. So a submission that matched
71
+ * a patient on PHONE ALONE could repoint that patient's email (and vice versa), which chains to
72
+ * account takeover exactly as described in public_endpoint_auth_field_lock.test.ts.
73
+ *
74
+ * The rule under test: on path 3 — the guess — email and phone are dropped from the update. They
75
+ * remain writable on paths 1, 2 and 4.
76
+ *
77
+ * - Path 1 stays open deliberately. This endpoint is API-key authenticated, so a caller that
78
+ * names an enduserId knows which patient it means; keeping it writable preserves backwards
79
+ * compatibility and lets a sophisticated consumer update these fields when it is safe.
80
+ * - Path 2 stays open because Formsort fires a webhook per page: the patient who started the form
81
+ * must still be able to fix a typo'd email on page 3. It is gated on the STICKY
82
+ * FormResponse.authFieldIntakeDisabled flag, computed once when the response is first written,
83
+ * so a response first formed by a guess can never be re-opened by a later webhook.
84
+ *
85
+ * A "fill only if blank" fix would NOT close this. Phone-only patients are the common case
86
+ * (sms_messages.leave_message creates them), and filling their blank email is the primary attack —
87
+ * hence the UNSET victim shapes in R5.
88
+ *
89
+ * Scenario groups (pre-fix expectation in parens):
90
+ * R1 (RED) matched on phone — submitted email must not land
91
+ * R2 (RED) matched on email — submitted phone must not land
92
+ * R3 (RED) stickiness: draft matched a pre-existing patient, finalize must not repoint them
93
+ * R4 (RED) unresolvable ?enduserId= falls through to the guess — must stay blocked
94
+ * R5 (RED) victim shapes: blank email / blank phone are filled by the guess (the takeover case)
95
+ * G6 (GREEN) patient created by this responder's own draft may still correct their email
96
+ * G7 (GREEN) explicit ?enduserId= still writes email + phone (backwards-compat escape hatch)
97
+ * G8 (GREEN) ?createNewEnduser=true still writes email + phone
98
+ * G9 (GREEN) brand-new patient still inserted WITH submitted email + phone
99
+ * G10 (GREEN) fname/lname/dateOfBirth/gender/custom fields still update on a guessed match
100
+ * G11 (GREEN) explicit ?enduserId= overrides for that request only — the stored flag stays set
101
+ * G12 (GREEN) the Stripe grace: a BLANK phone on a contact the Stripe integration created within
102
+ * the last hour is filled from intake (see stripe_provisional_enduser.ts)
103
+ * R12 ...but a phone already stored on that contact is never overwritten
104
+ * R13 ...and its email is never repointed
105
+ * R14 both grace markers are load-bearing: neither source nor stripeCustomerId alone opens it
106
+ * R15 the grace is per-request — it does not clear the stored flag
107
+ *
108
+ * The Stripe grace is deliberately confined to THIS endpoint, which is API-key authenticated. The
109
+ * anonymous public-form path gets no exemption; that boundary is pinned by G28 in
110
+ * public_endpoint_auth_field_lock.test.ts.
111
+ *
112
+ * STRUCTURE: record -> report -> cleanup -> assert.
113
+ * EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
114
+ * first failure. Two consequences shape this file:
115
+ * 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
116
+ * single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
117
+ * instead of dying on the first probe.
118
+ * 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
119
+ * loop; otherwise a by-design-failing run would leak every fixture into the next run.
120
+ */
121
+ // The enduser update in this handler is fire-and-forget (.catch(console.error), not awaited).
122
+ // Reading the victim immediately would frequently show the un-overwritten value on a VULNERABLE
123
+ // server -> false pass. So poll for the attack LANDING: fails fast against vulnerable code, burns
124
+ // the full window against fixed code.
125
+ var OVERWRITE_WINDOW_MS = 3000;
126
+ var POLL_INTERVAL_MS = 150;
127
+ var sleep = function (ms) { return new Promise(function (resolve) { return setTimeout(resolve, ms); }); };
128
+ export var formsort_webhook_auth_field_lock_tests = function (_a) {
129
+ var sdk = _a.sdk, sdkNonAdmin = _a.sdkNonAdmin;
130
+ return __awaiter(void 0, void 0, void 0, function () {
131
+ var RUN, results, record, skip, runScenario, phoneOffset, phoneSeed, nextPhone, emailFor, uuidFor, createdEnduserIds, createdFormIds, createdApiKeyIds, form, apiKey, postWebhook, makeVictim, pollEnduser, matched, stripeVictim, failures, skipped, _i, results_1, r, status_1, _b, createdFormIds_1, formId, responses, _c, responses_1, response, _d, createdFormIds_2, id, uniqueEnduserIds, _e, uniqueEnduserIds_1, id, _f, createdApiKeyIds_1, id, _g, results_2, r;
132
+ return __generator(this, function (_h) {
133
+ switch (_h.label) {
134
+ case 0:
135
+ log_header("Formsort Webhook Auth Field Lock (email / phone)");
136
+ RUN = Date.now().toString(36);
137
+ results = [];
138
+ record = function (group, name, ok, detail) {
139
+ if (detail === void 0) { detail = ''; }
140
+ results.push({ group: group, name: name, ok: ok, detail: detail });
141
+ console.log(" ".concat(ok ? '✓' : '✗', " [").concat(group, "] ").concat(name).concat(detail ? " \u2014 ".concat(detail) : ''));
142
+ };
143
+ skip = function (group, name, detail) {
144
+ results.push({ group: group, name: name, ok: true, skipped: true, detail: detail });
145
+ console.log(" \u26A0 SKIP [".concat(group, "] ").concat(name, " \u2014 ").concat(detail));
146
+ };
147
+ runScenario = function (group, name, fn) { return __awaiter(void 0, void 0, void 0, function () {
148
+ var err_1, message;
149
+ var _a, _b;
150
+ return __generator(this, function (_c) {
151
+ switch (_c.label) {
152
+ case 0:
153
+ _c.trys.push([0, 2, , 3]);
154
+ return [4 /*yield*/, fn()];
155
+ case 1:
156
+ _c.sent();
157
+ return [3 /*break*/, 3];
158
+ case 2:
159
+ err_1 = _c.sent();
160
+ message = (_b = (_a = err_1 === null || err_1 === void 0 ? void 0 : err_1.message) !== null && _a !== void 0 ? _a : err_1 === null || err_1 === void 0 ? void 0 : err_1.error) !== null && _b !== void 0 ? _b : JSON.stringify(err_1 !== null && err_1 !== void 0 ? err_1 : {}).slice(0, 300);
161
+ record(group, name, false, "threw: ".concat(message));
162
+ return [3 /*break*/, 3];
163
+ case 3: return [2 /*return*/];
164
+ }
165
+ });
166
+ }); };
167
+ phoneOffset = 0;
168
+ phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0');
169
+ nextPhone = function () { return "+1555555".concat(String((phoneSeed + (phoneOffset += 1)) % 10000).padStart(4, '0')); };
170
+ emailFor = function (label) { return "fswh-".concat(label, "-").concat(RUN, "@tellescope.com").toLowerCase(); };
171
+ uuidFor = function (label) { return "fswh-".concat(label, "-").concat(RUN); };
172
+ createdEnduserIds = [];
173
+ createdFormIds = [];
174
+ createdApiKeyIds = [];
175
+ return [4 /*yield*/, sdk.api.forms.createOne({ title: "formsort auth field lock ".concat(RUN) })];
176
+ case 1:
177
+ form = _h.sent();
178
+ createdFormIds.push(form.id);
179
+ return [4 /*yield*/, sdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] })];
180
+ case 2:
181
+ apiKey = _h.sent();
182
+ createdApiKeyIds.push(apiKey.id);
183
+ postWebhook = function (o) { return __awaiter(void 0, void 0, void 0, function () {
184
+ var url, res;
185
+ return __generator(this, function (_a) {
186
+ switch (_a.label) {
187
+ case 0:
188
+ url = new URL("".concat(host, "/v1/webhooks/form-ingestion/").concat(apiKey.key));
189
+ url.searchParams.set('formId', form.id);
190
+ url.searchParams.set('returnJSON', 'true');
191
+ if (o.enduserId)
192
+ url.searchParams.set('enduserId', o.enduserId);
193
+ if (o.createNewEnduser)
194
+ url.searchParams.set('createNewEnduser', 'true');
195
+ return [4 /*yield*/, axios.post(url.toString(), { answers: o.answers, responder_uuid: uuidFor(o.label), finalized: o.finalized }, { validateStatus: function () { return true; } })
196
+ // In-progress webhooks always return a bare 200 (the handler returns before the returnJSON
197
+ // block), so `data` is only populated for finalized calls.
198
+ ];
199
+ case 1:
200
+ res = _a.sent();
201
+ // In-progress webhooks always return a bare 200 (the handler returns before the returnJSON
202
+ // block), so `data` is only populated for finalized calls.
203
+ return [2 /*return*/, { status: res.status, data: (res.data || {}) }];
204
+ }
205
+ });
206
+ }); };
207
+ makeVictim = function (label, fields) { return __awaiter(void 0, void 0, void 0, function () {
208
+ var victim;
209
+ return __generator(this, function (_a) {
210
+ switch (_a.label) {
211
+ case 0: return [4 /*yield*/, sdk.api.endusers.createOne(__assign({}, fields))];
212
+ case 1:
213
+ victim = _a.sent();
214
+ createdEnduserIds.push(victim.id);
215
+ return [2 /*return*/, victim];
216
+ }
217
+ });
218
+ }); };
219
+ pollEnduser = function (id, landed, windowMs) {
220
+ if (windowMs === void 0) { windowMs = OVERWRITE_WINDOW_MS; }
221
+ return __awaiter(void 0, void 0, void 0, function () {
222
+ var deadline, enduser;
223
+ return __generator(this, function (_a) {
224
+ switch (_a.label) {
225
+ case 0:
226
+ deadline = Date.now() + windowMs;
227
+ enduser = null;
228
+ _a.label = 1;
229
+ case 1:
230
+ if (!(Date.now() < deadline)) return [3 /*break*/, 4];
231
+ return [4 /*yield*/, sdk.api.endusers.getOne(id).catch(function () { return null; })];
232
+ case 2:
233
+ enduser = (_a.sent());
234
+ if (enduser && landed(enduser))
235
+ return [2 /*return*/, enduser];
236
+ return [4 /*yield*/, sleep(POLL_INTERVAL_MS)];
237
+ case 3:
238
+ _a.sent();
239
+ return [3 /*break*/, 1];
240
+ case 4: return [2 /*return*/, enduser];
241
+ }
242
+ });
243
+ });
244
+ };
245
+ matched = function (group, name, data, victimId) {
246
+ if ((data === null || data === void 0 ? void 0 : data.enduserId) === victimId)
247
+ return true;
248
+ record(group, name, false, "probe invalid: webhook landed on enduserId=".concat((data === null || data === void 0 ? void 0 : data.enduserId) || '(none)', " expected victim=").concat(victimId));
249
+ return false;
250
+ };
251
+ // ---------------------------------------------------------------------------------------------
252
+ // R1 — matched on PHONE, submitted email must not land.
253
+ // ---------------------------------------------------------------------------------------------
254
+ return [4 /*yield*/, runScenario('R1', 'phone match does not repoint email', function () { return __awaiter(void 0, void 0, void 0, function () {
255
+ var originalEmail, phone, victim, attackerEmail, data, enduser;
256
+ return __generator(this, function (_a) {
257
+ switch (_a.label) {
258
+ case 0:
259
+ originalEmail = emailFor('r1');
260
+ phone = nextPhone();
261
+ return [4 /*yield*/, makeVictim('r1', { email: originalEmail, phone: phone })];
262
+ case 1:
263
+ victim = _a.sent();
264
+ attackerEmail = emailFor('r1-attacker');
265
+ return [4 /*yield*/, postWebhook({
266
+ label: 'r1',
267
+ finalized: true,
268
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
269
+ })];
270
+ case 2:
271
+ data = (_a.sent()).data;
272
+ if (!matched('R1', 'phone match does not repoint email', data, victim.id))
273
+ return [2 /*return*/];
274
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; })];
275
+ case 3:
276
+ enduser = _a.sent();
277
+ record('R1', 'phone match does not repoint email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === originalEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(originalEmail));
278
+ return [2 /*return*/];
279
+ }
280
+ });
281
+ }); })
282
+ // ---------------------------------------------------------------------------------------------
283
+ // R2 — matched on EMAIL, submitted phone must not land.
284
+ // ---------------------------------------------------------------------------------------------
285
+ ];
286
+ case 3:
287
+ // ---------------------------------------------------------------------------------------------
288
+ // R1 — matched on PHONE, submitted email must not land.
289
+ // ---------------------------------------------------------------------------------------------
290
+ _h.sent();
291
+ // ---------------------------------------------------------------------------------------------
292
+ // R2 — matched on EMAIL, submitted phone must not land.
293
+ // ---------------------------------------------------------------------------------------------
294
+ return [4 /*yield*/, runScenario('R2', 'email match does not repoint phone', function () { return __awaiter(void 0, void 0, void 0, function () {
295
+ var email, originalPhone, victim, attackerPhone, data, enduser;
296
+ return __generator(this, function (_a) {
297
+ switch (_a.label) {
298
+ case 0:
299
+ email = emailFor('r2');
300
+ originalPhone = nextPhone();
301
+ return [4 /*yield*/, makeVictim('r2', { email: email, phone: originalPhone })];
302
+ case 1:
303
+ victim = _a.sent();
304
+ attackerPhone = nextPhone();
305
+ return [4 /*yield*/, postWebhook({
306
+ label: 'r2',
307
+ finalized: true,
308
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
309
+ })];
310
+ case 2:
311
+ data = (_a.sent()).data;
312
+ if (!matched('R2', 'email match does not repoint phone', data, victim.id))
313
+ return [2 /*return*/];
314
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === attackerPhone; })];
315
+ case 3:
316
+ enduser = _a.sent();
317
+ record('R2', 'email match does not repoint phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === originalPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(originalPhone));
318
+ return [2 /*return*/];
319
+ }
320
+ });
321
+ }); })
322
+ // ---------------------------------------------------------------------------------------------
323
+ // R3 — stickiness. The draft matched a pre-existing patient by phone; the finalize resolves that
324
+ // patient via existingResponse.enduserId (path 2), which is the OPEN path. It must stay blocked,
325
+ // because the response was formed by a guess — that is what the sticky flag records.
326
+ // ---------------------------------------------------------------------------------------------
327
+ ];
328
+ case 4:
329
+ // ---------------------------------------------------------------------------------------------
330
+ // R2 — matched on EMAIL, submitted phone must not land.
331
+ // ---------------------------------------------------------------------------------------------
332
+ _h.sent();
333
+ // ---------------------------------------------------------------------------------------------
334
+ // R3 — stickiness. The draft matched a pre-existing patient by phone; the finalize resolves that
335
+ // patient via existingResponse.enduserId (path 2), which is the OPEN path. It must stay blocked,
336
+ // because the response was formed by a guess — that is what the sticky flag records.
337
+ // ---------------------------------------------------------------------------------------------
338
+ return [4 /*yield*/, runScenario('R3', 'draft formed by a guess stays blocked on finalize', function () { return __awaiter(void 0, void 0, void 0, function () {
339
+ var originalEmail, phone, victim, attackerEmail, data, enduser;
340
+ return __generator(this, function (_a) {
341
+ switch (_a.label) {
342
+ case 0:
343
+ originalEmail = emailFor('r3');
344
+ phone = nextPhone();
345
+ return [4 /*yield*/, makeVictim('r3', { email: originalEmail, phone: phone })];
346
+ case 1:
347
+ victim = _a.sent();
348
+ attackerEmail = emailFor('r3-attacker');
349
+ // in-progress webhook: enough to match (phone present), creates the draft response
350
+ return [4 /*yield*/, postWebhook({ label: 'r3', finalized: false, answers: [{ key: 'phone', value: phone }] })];
351
+ case 2:
352
+ // in-progress webhook: enough to match (phone present), creates the draft response
353
+ _a.sent();
354
+ return [4 /*yield*/, postWebhook({
355
+ label: 'r3',
356
+ finalized: true,
357
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
358
+ })];
359
+ case 3:
360
+ data = (_a.sent()).data;
361
+ if (!matched('R3', 'draft formed by a guess stays blocked on finalize', data, victim.id))
362
+ return [2 /*return*/];
363
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; })];
364
+ case 4:
365
+ enduser = _a.sent();
366
+ record('R3', 'draft formed by a guess stays blocked on finalize', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === originalEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(originalEmail));
367
+ return [2 /*return*/];
368
+ }
369
+ });
370
+ }); })
371
+ // ---------------------------------------------------------------------------------------------
372
+ // R4 — an unresolvable ?enduserId= must NOT count as an explicit assertion. findById returns null,
373
+ // the chain falls through to the $or guess, and the write must stay blocked. This pins that the
374
+ // override keys off the RESOLVED record, not the mere presence of the query param. A cross-tenant
375
+ // id takes the same route: DB is businessId-scoped, so findById returns null for it too.
376
+ // ---------------------------------------------------------------------------------------------
377
+ ];
378
+ case 5:
379
+ // ---------------------------------------------------------------------------------------------
380
+ // R3 — stickiness. The draft matched a pre-existing patient by phone; the finalize resolves that
381
+ // patient via existingResponse.enduserId (path 2), which is the OPEN path. It must stay blocked,
382
+ // because the response was formed by a guess — that is what the sticky flag records.
383
+ // ---------------------------------------------------------------------------------------------
384
+ _h.sent();
385
+ // ---------------------------------------------------------------------------------------------
386
+ // R4 — an unresolvable ?enduserId= must NOT count as an explicit assertion. findById returns null,
387
+ // the chain falls through to the $or guess, and the write must stay blocked. This pins that the
388
+ // override keys off the RESOLVED record, not the mere presence of the query param. A cross-tenant
389
+ // id takes the same route: DB is businessId-scoped, so findById returns null for it too.
390
+ // ---------------------------------------------------------------------------------------------
391
+ return [4 /*yield*/, runScenario('R4', 'unresolvable enduserId falls through to the guess', function () { return __awaiter(void 0, void 0, void 0, function () {
392
+ var originalEmail, phone, victim, attackerEmail, data, enduser;
393
+ return __generator(this, function (_a) {
394
+ switch (_a.label) {
395
+ case 0:
396
+ originalEmail = emailFor('r4');
397
+ phone = nextPhone();
398
+ return [4 /*yield*/, makeVictim('r4', { email: originalEmail, phone: phone })];
399
+ case 1:
400
+ victim = _a.sent();
401
+ attackerEmail = emailFor('r4-attacker');
402
+ return [4 /*yield*/, postWebhook({
403
+ label: 'r4',
404
+ finalized: true,
405
+ enduserId: '0000000000000000000000ff',
406
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
407
+ })];
408
+ case 2:
409
+ data = (_a.sent()).data;
410
+ if (!matched('R4', 'unresolvable enduserId falls through to the guess', data, victim.id))
411
+ return [2 /*return*/];
412
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; })];
413
+ case 3:
414
+ enduser = _a.sent();
415
+ record('R4', 'unresolvable enduserId falls through to the guess', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === originalEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(originalEmail));
416
+ return [2 /*return*/];
417
+ }
418
+ });
419
+ }); })
420
+ // ---------------------------------------------------------------------------------------------
421
+ // R5 — victim shapes. A blank field is the LOAD-BEARING case: phone-only patients are common and
422
+ // cheap to create, and filling their blank email is the takeover primitive. "Only if blank" is
423
+ // therefore the wrong fix, and these two rows are what prove it.
424
+ // ---------------------------------------------------------------------------------------------
425
+ ];
426
+ case 6:
427
+ // ---------------------------------------------------------------------------------------------
428
+ // R4 — an unresolvable ?enduserId= must NOT count as an explicit assertion. findById returns null,
429
+ // the chain falls through to the $or guess, and the write must stay blocked. This pins that the
430
+ // override keys off the RESOLVED record, not the mere presence of the query param. A cross-tenant
431
+ // id takes the same route: DB is businessId-scoped, so findById returns null for it too.
432
+ // ---------------------------------------------------------------------------------------------
433
+ _h.sent();
434
+ // ---------------------------------------------------------------------------------------------
435
+ // R5 — victim shapes. A blank field is the LOAD-BEARING case: phone-only patients are common and
436
+ // cheap to create, and filling their blank email is the takeover primitive. "Only if blank" is
437
+ // therefore the wrong fix, and these two rows are what prove it.
438
+ // ---------------------------------------------------------------------------------------------
439
+ return [4 /*yield*/, runScenario('R5', 'blank email on a phone-only patient is not filled by a guess', function () { return __awaiter(void 0, void 0, void 0, function () {
440
+ var phone, victim, attackerEmail, data, enduser;
441
+ return __generator(this, function (_a) {
442
+ switch (_a.label) {
443
+ case 0:
444
+ phone = nextPhone();
445
+ return [4 /*yield*/, makeVictim('r5a', { phone: phone, fname: 'Blank', lname: "Email".concat(RUN) })];
446
+ case 1:
447
+ victim = _a.sent();
448
+ attackerEmail = emailFor('r5a-attacker');
449
+ return [4 /*yield*/, postWebhook({
450
+ label: 'r5a',
451
+ finalized: true,
452
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
453
+ })];
454
+ case 2:
455
+ data = (_a.sent()).data;
456
+ if (!matched('R5', 'blank email on a phone-only patient is not filled by a guess', data, victim.id))
457
+ return [2 /*return*/];
458
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; })];
459
+ case 3:
460
+ enduser = _a.sent();
461
+ record('R5', 'blank email on a phone-only patient is not filled by a guess', !(enduser === null || enduser === void 0 ? void 0 : enduser.email), "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=(unset)"));
462
+ return [2 /*return*/];
463
+ }
464
+ });
465
+ }); })];
466
+ case 7:
467
+ // ---------------------------------------------------------------------------------------------
468
+ // R5 — victim shapes. A blank field is the LOAD-BEARING case: phone-only patients are common and
469
+ // cheap to create, and filling their blank email is the takeover primitive. "Only if blank" is
470
+ // therefore the wrong fix, and these two rows are what prove it.
471
+ // ---------------------------------------------------------------------------------------------
472
+ _h.sent();
473
+ return [4 /*yield*/, runScenario('R5', 'blank phone on an email-only patient is not filled by a guess', function () { return __awaiter(void 0, void 0, void 0, function () {
474
+ var email, victim, attackerPhone, data, enduser;
475
+ return __generator(this, function (_a) {
476
+ switch (_a.label) {
477
+ case 0:
478
+ email = emailFor('r5b');
479
+ return [4 /*yield*/, makeVictim('r5b', { email: email })];
480
+ case 1:
481
+ victim = _a.sent();
482
+ attackerPhone = nextPhone();
483
+ return [4 /*yield*/, postWebhook({
484
+ label: 'r5b',
485
+ finalized: true,
486
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
487
+ })];
488
+ case 2:
489
+ data = (_a.sent()).data;
490
+ if (!matched('R5', 'blank phone on an email-only patient is not filled by a guess', data, victim.id))
491
+ return [2 /*return*/];
492
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === attackerPhone; })];
493
+ case 3:
494
+ enduser = _a.sent();
495
+ record('R5', 'blank phone on an email-only patient is not filled by a guess', !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=(unset)"));
496
+ return [2 /*return*/];
497
+ }
498
+ });
499
+ }); })
500
+ // ---------------------------------------------------------------------------------------------
501
+ // G6 — the carve-out. The draft CREATED this patient (no prior record matched), so the response is
502
+ // not flagged and the same person may still correct their email on a later page of the form.
503
+ // ---------------------------------------------------------------------------------------------
504
+ ];
505
+ case 8:
506
+ _h.sent();
507
+ // ---------------------------------------------------------------------------------------------
508
+ // G6 — the carve-out. The draft CREATED this patient (no prior record matched), so the response is
509
+ // not flagged and the same person may still correct their email on a later page of the form.
510
+ // ---------------------------------------------------------------------------------------------
511
+ return [4 /*yield*/, runScenario('G6', 'patient created by this draft may still correct their email', function () { return __awaiter(void 0, void 0, void 0, function () {
512
+ var firstEmail, correctedEmail, phone, data, enduser;
513
+ return __generator(this, function (_a) {
514
+ switch (_a.label) {
515
+ case 0:
516
+ firstEmail = emailFor('g6-typo');
517
+ correctedEmail = emailFor('g6-correct');
518
+ phone = nextPhone();
519
+ // in-progress webhook with a never-before-seen email: inserts a new patient, response unflagged
520
+ return [4 /*yield*/, postWebhook({
521
+ label: 'g6',
522
+ finalized: false,
523
+ answers: [{ key: 'email', value: firstEmail }, { key: 'phone', value: phone }],
524
+ })];
525
+ case 1:
526
+ // in-progress webhook with a never-before-seen email: inserts a new patient, response unflagged
527
+ _a.sent();
528
+ return [4 /*yield*/, postWebhook({
529
+ label: 'g6',
530
+ finalized: true,
531
+ answers: [{ key: 'email', value: correctedEmail }, { key: 'phone', value: phone }],
532
+ })];
533
+ case 2:
534
+ data = (_a.sent()).data;
535
+ if (!(data === null || data === void 0 ? void 0 : data.enduserId)) {
536
+ record('G6', 'patient created by this draft may still correct their email', false, 'probe invalid: webhook returned no enduserId');
537
+ return [2 /*return*/];
538
+ }
539
+ createdEnduserIds.push(data.enduserId);
540
+ return [4 /*yield*/, pollEnduser(data.enduserId, function (e) { return e.email === correctedEmail; })];
541
+ case 3:
542
+ enduser = _a.sent();
543
+ record('G6', 'patient created by this draft may still correct their email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === correctedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(correctedEmail));
544
+ return [2 /*return*/];
545
+ }
546
+ });
547
+ }); })
548
+ // ---------------------------------------------------------------------------------------------
549
+ // G7 — the backwards-compat escape hatch. An API-key-authenticated caller that names the patient
550
+ // explicitly still writes email and phone. Pinned so a later tightening can't silently remove it.
551
+ // ---------------------------------------------------------------------------------------------
552
+ ];
553
+ case 9:
554
+ // ---------------------------------------------------------------------------------------------
555
+ // G6 — the carve-out. The draft CREATED this patient (no prior record matched), so the response is
556
+ // not flagged and the same person may still correct their email on a later page of the form.
557
+ // ---------------------------------------------------------------------------------------------
558
+ _h.sent();
559
+ // ---------------------------------------------------------------------------------------------
560
+ // G7 — the backwards-compat escape hatch. An API-key-authenticated caller that names the patient
561
+ // explicitly still writes email and phone. Pinned so a later tightening can't silently remove it.
562
+ // ---------------------------------------------------------------------------------------------
563
+ return [4 /*yield*/, runScenario('G7', 'explicit enduserId still writes email and phone', function () { return __awaiter(void 0, void 0, void 0, function () {
564
+ var victim, newEmail, newPhone, data, enduser;
565
+ return __generator(this, function (_a) {
566
+ switch (_a.label) {
567
+ case 0: return [4 /*yield*/, makeVictim('g7', { email: emailFor('g7'), phone: nextPhone() })];
568
+ case 1:
569
+ victim = _a.sent();
570
+ newEmail = emailFor('g7-new');
571
+ newPhone = nextPhone();
572
+ return [4 /*yield*/, postWebhook({
573
+ label: 'g7',
574
+ finalized: true,
575
+ enduserId: victim.id,
576
+ answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
577
+ })];
578
+ case 2:
579
+ data = (_a.sent()).data;
580
+ if (!matched('G7', 'explicit enduserId still writes email and phone', data, victim.id))
581
+ return [2 /*return*/];
582
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === newEmail && e.phone === newPhone; })];
583
+ case 3:
584
+ enduser = _a.sent();
585
+ record('G7', 'explicit enduserId still writes email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
586
+ record('G7', 'explicit enduserId still writes phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
587
+ return [2 /*return*/];
588
+ }
589
+ });
590
+ }); })
591
+ // ---------------------------------------------------------------------------------------------
592
+ // G8 — ?createNewEnduser=true inserts a BLANK record up front and then fills it via the update
593
+ // branch, so email/phone must still land there.
594
+ // ---------------------------------------------------------------------------------------------
595
+ ];
596
+ case 10:
597
+ // ---------------------------------------------------------------------------------------------
598
+ // G7 — the backwards-compat escape hatch. An API-key-authenticated caller that names the patient
599
+ // explicitly still writes email and phone. Pinned so a later tightening can't silently remove it.
600
+ // ---------------------------------------------------------------------------------------------
601
+ _h.sent();
602
+ // ---------------------------------------------------------------------------------------------
603
+ // G8 — ?createNewEnduser=true inserts a BLANK record up front and then fills it via the update
604
+ // branch, so email/phone must still land there.
605
+ // ---------------------------------------------------------------------------------------------
606
+ return [4 /*yield*/, runScenario('G8', 'createNewEnduser still writes email and phone', function () { return __awaiter(void 0, void 0, void 0, function () {
607
+ var newEmail, newPhone, data, enduser;
608
+ return __generator(this, function (_a) {
609
+ switch (_a.label) {
610
+ case 0:
611
+ newEmail = emailFor('g8');
612
+ newPhone = nextPhone();
613
+ return [4 /*yield*/, postWebhook({
614
+ label: 'g8',
615
+ finalized: true,
616
+ createNewEnduser: true,
617
+ answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
618
+ })];
619
+ case 1:
620
+ data = (_a.sent()).data;
621
+ if (!(data === null || data === void 0 ? void 0 : data.enduserId)) {
622
+ record('G8', 'createNewEnduser still writes email and phone', false, 'probe invalid: webhook returned no enduserId');
623
+ return [2 /*return*/];
624
+ }
625
+ createdEnduserIds.push(data.enduserId);
626
+ return [4 /*yield*/, pollEnduser(data.enduserId, function (e) { return e.email === newEmail && e.phone === newPhone; })];
627
+ case 2:
628
+ enduser = _a.sent();
629
+ record('G8', 'createNewEnduser still writes email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
630
+ record('G8', 'createNewEnduser still writes phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
631
+ return [2 /*return*/];
632
+ }
633
+ });
634
+ }); })
635
+ // ---------------------------------------------------------------------------------------------
636
+ // G9 — brand-new patient, no prior record to match: the INSERT path keeps both fields.
637
+ // ---------------------------------------------------------------------------------------------
638
+ ];
639
+ case 11:
640
+ // ---------------------------------------------------------------------------------------------
641
+ // G8 — ?createNewEnduser=true inserts a BLANK record up front and then fills it via the update
642
+ // branch, so email/phone must still land there.
643
+ // ---------------------------------------------------------------------------------------------
644
+ _h.sent();
645
+ // ---------------------------------------------------------------------------------------------
646
+ // G9 — brand-new patient, no prior record to match: the INSERT path keeps both fields.
647
+ // ---------------------------------------------------------------------------------------------
648
+ return [4 /*yield*/, runScenario('G9', 'brand-new patient is inserted with email and phone', function () { return __awaiter(void 0, void 0, void 0, function () {
649
+ var newEmail, newPhone, data, enduser;
650
+ return __generator(this, function (_a) {
651
+ switch (_a.label) {
652
+ case 0:
653
+ newEmail = emailFor('g9');
654
+ newPhone = nextPhone();
655
+ return [4 /*yield*/, postWebhook({
656
+ label: 'g9',
657
+ finalized: true,
658
+ answers: [{ key: 'email', value: newEmail }, { key: 'phone', value: newPhone }],
659
+ })];
660
+ case 1:
661
+ data = (_a.sent()).data;
662
+ if (!(data === null || data === void 0 ? void 0 : data.enduserId)) {
663
+ record('G9', 'brand-new patient is inserted with email and phone', false, 'probe invalid: webhook returned no enduserId');
664
+ return [2 /*return*/];
665
+ }
666
+ createdEnduserIds.push(data.enduserId);
667
+ return [4 /*yield*/, pollEnduser(data.enduserId, function (e) { return !!e.email && !!e.phone; })];
668
+ case 2:
669
+ enduser = _a.sent();
670
+ record('G9', 'brand-new patient is inserted with email', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
671
+ record('G9', 'brand-new patient is inserted with phone', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === newPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(newPhone));
672
+ return [2 /*return*/];
673
+ }
674
+ });
675
+ }); })
676
+ // ---------------------------------------------------------------------------------------------
677
+ // G10 — the narrowing is email/phone ONLY. Everything else the webhook parses must still update
678
+ // on a guessed match, otherwise this fix has quietly broken Formsort intake.
679
+ // ---------------------------------------------------------------------------------------------
680
+ ];
681
+ case 12:
682
+ // ---------------------------------------------------------------------------------------------
683
+ // G9 — brand-new patient, no prior record to match: the INSERT path keeps both fields.
684
+ // ---------------------------------------------------------------------------------------------
685
+ _h.sent();
686
+ // ---------------------------------------------------------------------------------------------
687
+ // G10 — the narrowing is email/phone ONLY. Everything else the webhook parses must still update
688
+ // on a guessed match, otherwise this fix has quietly broken Formsort intake.
689
+ // ---------------------------------------------------------------------------------------------
690
+ return [4 /*yield*/, runScenario('G10', 'non-auth fields still update on a guessed match', function () { return __awaiter(void 0, void 0, void 0, function () {
691
+ var originalEmail, phone, victim, data, enduser;
692
+ var _a, _b;
693
+ return __generator(this, function (_c) {
694
+ switch (_c.label) {
695
+ case 0:
696
+ originalEmail = emailFor('g10');
697
+ phone = nextPhone();
698
+ return [4 /*yield*/, makeVictim('g10', {
699
+ email: originalEmail,
700
+ phone: phone,
701
+ fname: 'Before', lname: 'Before',
702
+ })];
703
+ case 1:
704
+ victim = _c.sent();
705
+ return [4 /*yield*/, postWebhook({
706
+ label: 'g10',
707
+ finalized: true,
708
+ answers: [
709
+ { key: 'phone', value: phone },
710
+ { key: 'email', value: emailFor('g10-attacker') },
711
+ { key: 'fname', value: 'After' },
712
+ { key: 'lname', value: 'After' },
713
+ { key: 'gender', value: 'Male' },
714
+ { key: 'dateOfBirth', value: '1990-01-02' },
715
+ { key: 'ts_enduser_lockprobe', value: "probe-".concat(RUN) },
716
+ ],
717
+ })];
718
+ case 2:
719
+ data = (_c.sent()).data;
720
+ if (!matched('G10', 'non-auth fields still update on a guessed match', data, victim.id))
721
+ return [2 /*return*/];
722
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.fname === 'After'; })];
723
+ case 3:
724
+ enduser = _c.sent();
725
+ record('G10', 'fname still updates', (enduser === null || enduser === void 0 ? void 0 : enduser.fname) === 'After', "fname=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.fname) || '(unset)', " expected=After"));
726
+ record('G10', 'lname still updates', (enduser === null || enduser === void 0 ? void 0 : enduser.lname) === 'After', "lname=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.lname) || '(unset)', " expected=After"));
727
+ record('G10', 'gender still updates', (enduser === null || enduser === void 0 ? void 0 : enduser.gender) === 'Male', "gender=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.gender) || '(unset)', " expected=Male"));
728
+ record('G10', 'dateOfBirth still updates', (enduser === null || enduser === void 0 ? void 0 : enduser.dateOfBirth) === '01-02-1990', "dateOfBirth=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.dateOfBirth) || '(unset)', " expected=01-02-1990"));
729
+ record('G10', 'custom fields still update', ((_a = enduser === null || enduser === void 0 ? void 0 : enduser.fields) === null || _a === void 0 ? void 0 : _a.lockprobe) === "probe-".concat(RUN), "fields.lockprobe=".concat(((_b = enduser === null || enduser === void 0 ? void 0 : enduser.fields) === null || _b === void 0 ? void 0 : _b.lockprobe) || '(unset)', " expected=probe-").concat(RUN));
730
+ record('G10', 'email still blocked while other fields update', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === originalEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(originalEmail));
731
+ return [2 /*return*/];
732
+ }
733
+ });
734
+ }); })
735
+ // ---------------------------------------------------------------------------------------------
736
+ // G11 — the explicit override is per-REQUEST, not a flag reset. A response formed by a guess keeps
737
+ // authFieldIntakeDisabled set, so the OTHER consumer of that flag
738
+ // (save_enduser_updates_for_form_response, reached by later staff edits) stays protected.
739
+ // ---------------------------------------------------------------------------------------------
740
+ ];
741
+ case 13:
742
+ // ---------------------------------------------------------------------------------------------
743
+ // G10 — the narrowing is email/phone ONLY. Everything else the webhook parses must still update
744
+ // on a guessed match, otherwise this fix has quietly broken Formsort intake.
745
+ // ---------------------------------------------------------------------------------------------
746
+ _h.sent();
747
+ // ---------------------------------------------------------------------------------------------
748
+ // G11 — the explicit override is per-REQUEST, not a flag reset. A response formed by a guess keeps
749
+ // authFieldIntakeDisabled set, so the OTHER consumer of that flag
750
+ // (save_enduser_updates_for_form_response, reached by later staff edits) stays protected.
751
+ // ---------------------------------------------------------------------------------------------
752
+ return [4 /*yield*/, runScenario('G11', 'explicit override does not clear the stored flag', function () { return __awaiter(void 0, void 0, void 0, function () {
753
+ var phone, victim, newEmail, data, enduser, response;
754
+ return __generator(this, function (_a) {
755
+ switch (_a.label) {
756
+ case 0:
757
+ phone = nextPhone();
758
+ return [4 /*yield*/, makeVictim('g11', { email: emailFor('g11'), phone: phone })];
759
+ case 1:
760
+ victim = _a.sent();
761
+ newEmail = emailFor('g11-new');
762
+ // draft formed by a guess -> flagged
763
+ return [4 /*yield*/, postWebhook({ label: 'g11', finalized: false, answers: [{ key: 'phone', value: phone }] })];
764
+ case 2:
765
+ // draft formed by a guess -> flagged
766
+ _a.sent();
767
+ return [4 /*yield*/, postWebhook({
768
+ label: 'g11',
769
+ finalized: true,
770
+ enduserId: victim.id,
771
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: newEmail }],
772
+ })];
773
+ case 3:
774
+ data = (_a.sent()).data;
775
+ if (!matched('G11', 'explicit override does not clear the stored flag', data, victim.id))
776
+ return [2 /*return*/];
777
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === newEmail; })];
778
+ case 4:
779
+ enduser = _a.sent();
780
+ record('G11', 'explicit override writes email even on a flagged response', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === newEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(newEmail));
781
+ if (!data.formResponseId) {
782
+ skip('G11', 'stored flag survives the override', 'webhook returned no formResponseId');
783
+ return [2 /*return*/];
784
+ }
785
+ return [4 /*yield*/, sdk.api.form_responses.getOne(data.formResponseId).catch(function () { return null; })];
786
+ case 5:
787
+ response = _a.sent();
788
+ if (!response) {
789
+ skip('G11', 'stored flag survives the override', 'form response not readable');
790
+ }
791
+ else if (response.authFieldIntakeDisabled === undefined) {
792
+ // The field is deliberately absent from schema.ts; if reads project it away there is nothing
793
+ // to assert here, and the enforcement is already covered by the scenarios above.
794
+ skip('G11', 'stored flag survives the override', 'authFieldIntakeDisabled not exposed on read');
795
+ }
796
+ else {
797
+ record('G11', 'stored flag survives the override', response.authFieldIntakeDisabled === true, "authFieldIntakeDisabled=".concat(response.authFieldIntakeDisabled, " expected=true"));
798
+ }
799
+ return [2 /*return*/];
800
+ }
801
+ });
802
+ }); })
803
+ // ---------------------------------------------------------------------------------------------
804
+ // G12 — the Stripe grace. A contact the Stripe integration created minutes ago carries payment data
805
+ // only (email + name + stripeCustomerId, never a phone), so intake is the first place a phone number
806
+ // exists at all. enduser_is_provisional_stripe_record re-opens BLANK email/phone for one hour after
807
+ // that insert; see api/integrations/payments/stripe_provisional_enduser.ts.
808
+ //
809
+ // The Stripe webhook cannot be driven locally (it needs a signature and an integration record), but
810
+ // nothing about the grace depends on Stripe having done the insert: source and stripeCustomerId are
811
+ // both enduserUpdatesDisabled, so an admin can create the same shape and drive the real webhook
812
+ // against it. A fixture created here always has a fresh ObjectId, so this exercises the in-window
813
+ // branch; the expired branch is pinned by the unit tests in
814
+ // api/integrations/payments/stripe_provisional_enduser.test.ts.
815
+ // ---------------------------------------------------------------------------------------------
816
+ ];
817
+ case 14:
818
+ // ---------------------------------------------------------------------------------------------
819
+ // G11 — the explicit override is per-REQUEST, not a flag reset. A response formed by a guess keeps
820
+ // authFieldIntakeDisabled set, so the OTHER consumer of that flag
821
+ // (save_enduser_updates_for_form_response, reached by later staff edits) stays protected.
822
+ // ---------------------------------------------------------------------------------------------
823
+ _h.sent();
824
+ stripeVictim = function (label, fields) { return makeVictim(label, __assign({ source: 'Stripe', stripeCustomerId: "cus_".concat(RUN).concat(label) }, fields)); };
825
+ return [4 /*yield*/, runScenario('G12', 'blank phone on a fresh Stripe contact is filled from intake', function () { return __awaiter(void 0, void 0, void 0, function () {
826
+ var email, victim, intakePhone, data, enduser;
827
+ return __generator(this, function (_a) {
828
+ switch (_a.label) {
829
+ case 0:
830
+ email = emailFor('g12');
831
+ return [4 /*yield*/, stripeVictim('g12', { email: email, fname: 'Stripe', lname: "Payer".concat(RUN) })];
832
+ case 1:
833
+ victim = _a.sent();
834
+ intakePhone = nextPhone();
835
+ return [4 /*yield*/, postWebhook({
836
+ label: 'g12',
837
+ finalized: true,
838
+ answers: [
839
+ { key: 'email', value: email },
840
+ { key: 'phone', value: intakePhone },
841
+ { key: 'dateOfBirth', value: '1990-01-02' },
842
+ { key: 'ts_enduser_stripeprobe', value: "probe-".concat(RUN) },
843
+ ],
844
+ })];
845
+ case 2:
846
+ data = (_a.sent()).data;
847
+ if (!matched('G12', 'blank phone on a fresh Stripe contact is filled from intake', data, victim.id))
848
+ return [2 /*return*/];
849
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === intakePhone; })];
850
+ case 3:
851
+ enduser = _a.sent();
852
+ record('G12', 'blank phone on a fresh Stripe contact is filled from intake', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === intakePhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(intakePhone));
853
+ // the grace must not disturb the ordinary update path around it
854
+ record('G12', 'non-auth fields still land alongside the graced phone', (enduser === null || enduser === void 0 ? void 0 : enduser.dateOfBirth) === '01-02-1990', "dateOfBirth=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.dateOfBirth) || '(unset)', " expected=01-02-1990"));
855
+ record('G12', 'email is unchanged by the grace', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === email, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(email));
856
+ return [2 /*return*/];
857
+ }
858
+ });
859
+ }); })
860
+ // ---------------------------------------------------------------------------------------------
861
+ // R12 / R13 — the grace is strictly BLANK-FILL. Repointing a stored email or phone is the takeover
862
+ // primitive this lock exists to stop, and being minutes old does not make it safe.
863
+ // ---------------------------------------------------------------------------------------------
864
+ ];
865
+ case 15:
866
+ _h.sent();
867
+ // ---------------------------------------------------------------------------------------------
868
+ // R12 / R13 — the grace is strictly BLANK-FILL. Repointing a stored email or phone is the takeover
869
+ // primitive this lock exists to stop, and being minutes old does not make it safe.
870
+ // ---------------------------------------------------------------------------------------------
871
+ return [4 /*yield*/, runScenario('R12', 'grace does not overwrite a phone already on a Stripe contact', function () { return __awaiter(void 0, void 0, void 0, function () {
872
+ var email, originalPhone, victim, attackerPhone, data, enduser;
873
+ return __generator(this, function (_a) {
874
+ switch (_a.label) {
875
+ case 0:
876
+ email = emailFor('r12');
877
+ originalPhone = nextPhone();
878
+ return [4 /*yield*/, stripeVictim('r12', { email: email, phone: originalPhone })];
879
+ case 1:
880
+ victim = _a.sent();
881
+ attackerPhone = nextPhone();
882
+ return [4 /*yield*/, postWebhook({
883
+ label: 'r12',
884
+ finalized: true,
885
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
886
+ })];
887
+ case 2:
888
+ data = (_a.sent()).data;
889
+ if (!matched('R12', 'grace does not overwrite a phone already on a Stripe contact', data, victim.id))
890
+ return [2 /*return*/];
891
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === attackerPhone; })];
892
+ case 3:
893
+ enduser = _a.sent();
894
+ record('R12', 'grace does not overwrite a phone already on a Stripe contact', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === originalPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(originalPhone));
895
+ return [2 /*return*/];
896
+ }
897
+ });
898
+ }); })];
899
+ case 16:
900
+ // ---------------------------------------------------------------------------------------------
901
+ // R12 / R13 — the grace is strictly BLANK-FILL. Repointing a stored email or phone is the takeover
902
+ // primitive this lock exists to stop, and being minutes old does not make it safe.
903
+ // ---------------------------------------------------------------------------------------------
904
+ _h.sent();
905
+ return [4 /*yield*/, runScenario('R13', 'grace does not repoint the email of a Stripe contact', function () { return __awaiter(void 0, void 0, void 0, function () {
906
+ var originalEmail, phone, victim, attackerEmail, data, enduser;
907
+ return __generator(this, function (_a) {
908
+ switch (_a.label) {
909
+ case 0:
910
+ originalEmail = emailFor('r13');
911
+ phone = nextPhone();
912
+ return [4 /*yield*/, stripeVictim('r13', { email: originalEmail, phone: phone })];
913
+ case 1:
914
+ victim = _a.sent();
915
+ attackerEmail = emailFor('r13-attacker');
916
+ return [4 /*yield*/, postWebhook({
917
+ label: 'r13',
918
+ finalized: true,
919
+ answers: [{ key: 'phone', value: phone }, { key: 'email', value: attackerEmail }],
920
+ })];
921
+ case 2:
922
+ data = (_a.sent()).data;
923
+ if (!matched('R13', 'grace does not repoint the email of a Stripe contact', data, victim.id))
924
+ return [2 /*return*/];
925
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; })];
926
+ case 3:
927
+ enduser = _a.sent();
928
+ record('R13', 'grace does not repoint the email of a Stripe contact', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === originalEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(originalEmail));
929
+ return [2 /*return*/];
930
+ }
931
+ });
932
+ }); })
933
+ // ---------------------------------------------------------------------------------------------
934
+ // R14 — BOTH markers are load-bearing. `source` is set by every other integration and by staff, and
935
+ // `stripeCustomerId` can be attached to a contact this integration never created, so neither alone
936
+ // may open the grace.
937
+ // ---------------------------------------------------------------------------------------------
938
+ ];
939
+ case 17:
940
+ _h.sent();
941
+ // ---------------------------------------------------------------------------------------------
942
+ // R14 — BOTH markers are load-bearing. `source` is set by every other integration and by staff, and
943
+ // `stripeCustomerId` can be attached to a contact this integration never created, so neither alone
944
+ // may open the grace.
945
+ // ---------------------------------------------------------------------------------------------
946
+ return [4 /*yield*/, runScenario('R14', 'stripeCustomerId without a Stripe source does not open the grace', function () { return __awaiter(void 0, void 0, void 0, function () {
947
+ var email, victim, attackerPhone, data, enduser;
948
+ return __generator(this, function (_a) {
949
+ switch (_a.label) {
950
+ case 0:
951
+ email = emailFor('r14a');
952
+ return [4 /*yield*/, makeVictim('r14a', { email: email, source: 'Elation', stripeCustomerId: "cus_".concat(RUN, "r14a") })];
953
+ case 1:
954
+ victim = _a.sent();
955
+ attackerPhone = nextPhone();
956
+ return [4 /*yield*/, postWebhook({
957
+ label: 'r14a',
958
+ finalized: true,
959
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
960
+ })];
961
+ case 2:
962
+ data = (_a.sent()).data;
963
+ if (!matched('R14', 'stripeCustomerId without a Stripe source does not open the grace', data, victim.id))
964
+ return [2 /*return*/];
965
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === attackerPhone; })];
966
+ case 3:
967
+ enduser = _a.sent();
968
+ record('R14', 'stripeCustomerId without a Stripe source does not open the grace', !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=(unset)"));
969
+ return [2 /*return*/];
970
+ }
971
+ });
972
+ }); })];
973
+ case 18:
974
+ // ---------------------------------------------------------------------------------------------
975
+ // R14 — BOTH markers are load-bearing. `source` is set by every other integration and by staff, and
976
+ // `stripeCustomerId` can be attached to a contact this integration never created, so neither alone
977
+ // may open the grace.
978
+ // ---------------------------------------------------------------------------------------------
979
+ _h.sent();
980
+ return [4 /*yield*/, runScenario('R14', 'Stripe source without a stripeCustomerId does not open the grace', function () { return __awaiter(void 0, void 0, void 0, function () {
981
+ var email, victim, attackerPhone, data, enduser;
982
+ return __generator(this, function (_a) {
983
+ switch (_a.label) {
984
+ case 0:
985
+ email = emailFor('r14b');
986
+ return [4 /*yield*/, makeVictim('r14b', { email: email, source: 'Stripe' })];
987
+ case 1:
988
+ victim = _a.sent();
989
+ attackerPhone = nextPhone();
990
+ return [4 /*yield*/, postWebhook({
991
+ label: 'r14b',
992
+ finalized: true,
993
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: attackerPhone }],
994
+ })];
995
+ case 2:
996
+ data = (_a.sent()).data;
997
+ if (!matched('R14', 'Stripe source without a stripeCustomerId does not open the grace', data, victim.id))
998
+ return [2 /*return*/];
999
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === attackerPhone; })];
1000
+ case 3:
1001
+ enduser = _a.sent();
1002
+ record('R14', 'Stripe source without a stripeCustomerId does not open the grace', !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=(unset)"));
1003
+ return [2 /*return*/];
1004
+ }
1005
+ });
1006
+ }); })
1007
+ // ---------------------------------------------------------------------------------------------
1008
+ // R15 — the grace is a per-REQUEST override, exactly like the explicit ?enduserId= hatch in G11: the
1009
+ // response still records that it attached to a pre-existing contact, so the other consumer of that
1010
+ // flag (save_enduser_updates_for_form_response, reached by later staff edits and by submissions
1011
+ // after the window closes) stays protected.
1012
+ // ---------------------------------------------------------------------------------------------
1013
+ ];
1014
+ case 19:
1015
+ _h.sent();
1016
+ // ---------------------------------------------------------------------------------------------
1017
+ // R15 — the grace is a per-REQUEST override, exactly like the explicit ?enduserId= hatch in G11: the
1018
+ // response still records that it attached to a pre-existing contact, so the other consumer of that
1019
+ // flag (save_enduser_updates_for_form_response, reached by later staff edits and by submissions
1020
+ // after the window closes) stays protected.
1021
+ // ---------------------------------------------------------------------------------------------
1022
+ return [4 /*yield*/, runScenario('R15', 'grace does not clear the stored flag', function () { return __awaiter(void 0, void 0, void 0, function () {
1023
+ var email, victim, intakePhone, data, enduser, response;
1024
+ return __generator(this, function (_a) {
1025
+ switch (_a.label) {
1026
+ case 0:
1027
+ email = emailFor('r15');
1028
+ return [4 /*yield*/, stripeVictim('r15', { email: email })];
1029
+ case 1:
1030
+ victim = _a.sent();
1031
+ intakePhone = nextPhone();
1032
+ return [4 /*yield*/, postWebhook({
1033
+ label: 'r15',
1034
+ finalized: true,
1035
+ answers: [{ key: 'email', value: email }, { key: 'phone', value: intakePhone }],
1036
+ })];
1037
+ case 2:
1038
+ data = (_a.sent()).data;
1039
+ if (!matched('R15', 'grace does not clear the stored flag', data, victim.id))
1040
+ return [2 /*return*/];
1041
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.phone === intakePhone; })];
1042
+ case 3:
1043
+ enduser = _a.sent();
1044
+ record('R15', 'grace landed the phone (probe precondition)', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === intakePhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(intakePhone));
1045
+ if (!data.formResponseId) {
1046
+ skip('R15', 'grace does not clear the stored flag', 'webhook returned no formResponseId');
1047
+ return [2 /*return*/];
1048
+ }
1049
+ return [4 /*yield*/, sdk.api.form_responses.getOne(data.formResponseId).catch(function () { return null; })];
1050
+ case 4:
1051
+ response = _a.sent();
1052
+ if (!response) {
1053
+ skip('R15', 'grace does not clear the stored flag', 'form response not readable');
1054
+ }
1055
+ else if (response.authFieldIntakeDisabled === undefined) {
1056
+ // deliberately absent from schema.ts; if reads project it away there is nothing to assert here
1057
+ skip('R15', 'grace does not clear the stored flag', 'authFieldIntakeDisabled not exposed on read');
1058
+ }
1059
+ else {
1060
+ record('R15', 'grace does not clear the stored flag', response.authFieldIntakeDisabled === true, "authFieldIntakeDisabled=".concat(response.authFieldIntakeDisabled, " expected=true"));
1061
+ }
1062
+ return [2 /*return*/];
1063
+ }
1064
+ });
1065
+ }); })
1066
+ // ---------------------------------------------------------------------------------------------
1067
+ // Report
1068
+ // ---------------------------------------------------------------------------------------------
1069
+ ];
1070
+ case 20:
1071
+ // ---------------------------------------------------------------------------------------------
1072
+ // R15 — the grace is a per-REQUEST override, exactly like the explicit ?enduserId= hatch in G11: the
1073
+ // response still records that it attached to a pre-existing contact, so the other consumer of that
1074
+ // flag (save_enduser_updates_for_form_response, reached by later staff edits and by submissions
1075
+ // after the window closes) stays protected.
1076
+ // ---------------------------------------------------------------------------------------------
1077
+ _h.sent();
1078
+ failures = results.filter(function (r) { return !r.ok; });
1079
+ skipped = results.filter(function (r) { return !!r.skipped; });
1080
+ console.log('');
1081
+ console.log(' ┌─ Formsort webhook auth-field lock — summary ───────────────────────────');
1082
+ for (_i = 0, results_1 = results; _i < results_1.length; _i++) {
1083
+ r = results_1[_i];
1084
+ status_1 = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL';
1085
+ console.log(" \u2502 ".concat(status_1, " ").concat(r.group.padEnd(3), " ").concat(r.name).concat(r.detail ? " \u2014 ".concat(r.detail) : ''));
1086
+ }
1087
+ console.log(" \u2514\u2500 ".concat(results.length - failures.length, "/").concat(results.length, " passed, ").concat(skipped.length, " skipped, ").concat(failures.length, " failed"));
1088
+ console.log('');
1089
+ _b = 0, createdFormIds_1 = createdFormIds;
1090
+ _h.label = 21;
1091
+ case 21:
1092
+ if (!(_b < createdFormIds_1.length)) return [3 /*break*/, 27];
1093
+ formId = createdFormIds_1[_b];
1094
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { formId: formId } }).catch(function () { return []; })];
1095
+ case 22:
1096
+ responses = _h.sent();
1097
+ _c = 0, responses_1 = responses;
1098
+ _h.label = 23;
1099
+ case 23:
1100
+ if (!(_c < responses_1.length)) return [3 /*break*/, 26];
1101
+ response = responses_1[_c];
1102
+ return [4 /*yield*/, sdk.api.form_responses.deleteOne(response.id).catch(function () { return null; })];
1103
+ case 24:
1104
+ _h.sent();
1105
+ _h.label = 25;
1106
+ case 25:
1107
+ _c++;
1108
+ return [3 /*break*/, 23];
1109
+ case 26:
1110
+ _b++;
1111
+ return [3 /*break*/, 21];
1112
+ case 27:
1113
+ _d = 0, createdFormIds_2 = createdFormIds;
1114
+ _h.label = 28;
1115
+ case 28:
1116
+ if (!(_d < createdFormIds_2.length)) return [3 /*break*/, 31];
1117
+ id = createdFormIds_2[_d];
1118
+ return [4 /*yield*/, sdk.api.forms.deleteOne(id).catch(function () { return null; })];
1119
+ case 29:
1120
+ _h.sent();
1121
+ _h.label = 30;
1122
+ case 30:
1123
+ _d++;
1124
+ return [3 /*break*/, 28];
1125
+ case 31:
1126
+ uniqueEnduserIds = createdEnduserIds.filter(function (id, i) { return !!id && createdEnduserIds.indexOf(id) === i; });
1127
+ _e = 0, uniqueEnduserIds_1 = uniqueEnduserIds;
1128
+ _h.label = 32;
1129
+ case 32:
1130
+ if (!(_e < uniqueEnduserIds_1.length)) return [3 /*break*/, 35];
1131
+ id = uniqueEnduserIds_1[_e];
1132
+ return [4 /*yield*/, sdk.api.endusers.deleteOne(id).catch(function () { return null; })];
1133
+ case 33:
1134
+ _h.sent();
1135
+ _h.label = 34;
1136
+ case 34:
1137
+ _e++;
1138
+ return [3 /*break*/, 32];
1139
+ case 35:
1140
+ _f = 0, createdApiKeyIds_1 = createdApiKeyIds;
1141
+ _h.label = 36;
1142
+ case 36:
1143
+ if (!(_f < createdApiKeyIds_1.length)) return [3 /*break*/, 39];
1144
+ id = createdApiKeyIds_1[_f];
1145
+ return [4 /*yield*/, sdk.api.api_keys.deleteOne(id).catch(function () { return null; })];
1146
+ case 37:
1147
+ _h.sent();
1148
+ _h.label = 38;
1149
+ case 38:
1150
+ _f++;
1151
+ return [3 /*break*/, 36];
1152
+ case 39:
1153
+ // ---------------------------------------------------------------------------------------------
1154
+ // Assert — single pass, last, so the full table above is always printed first.
1155
+ // ---------------------------------------------------------------------------------------------
1156
+ for (_g = 0, results_2 = results; _g < results_2.length; _g++) {
1157
+ r = results_2[_g];
1158
+ assert(r.ok, "[".concat(r.group, "] ").concat(r.name, " \u2014 ").concat(r.detail), "[".concat(r.group, "] ").concat(r.name));
1159
+ }
1160
+ return [2 /*return*/];
1161
+ }
1162
+ });
1163
+ });
1164
+ };
1165
+ // Allow running this test file independently
1166
+ if (require.main === module) {
1167
+ console.log("\uD83C\uDF10 Using API URL: ".concat(host));
1168
+ var sdk_1 = new Session({ host: host });
1169
+ var sdkNonAdmin_1 = new Session({ host: host });
1170
+ var runTests = function () { return __awaiter(void 0, void 0, void 0, function () {
1171
+ return __generator(this, function (_a) {
1172
+ switch (_a.label) {
1173
+ case 0: return [4 /*yield*/, setup_tests(sdk_1, sdkNonAdmin_1)];
1174
+ case 1:
1175
+ _a.sent();
1176
+ return [4 /*yield*/, formsort_webhook_auth_field_lock_tests({ sdk: sdk_1, sdkNonAdmin: sdkNonAdmin_1 })];
1177
+ case 2:
1178
+ _a.sent();
1179
+ return [2 /*return*/];
1180
+ }
1181
+ });
1182
+ }); };
1183
+ runTests()
1184
+ .then(function () {
1185
+ console.log("✅ Formsort webhook auth field lock tests completed successfully");
1186
+ process.exit(0);
1187
+ })
1188
+ .catch(function (error) {
1189
+ console.error("❌ Formsort webhook auth field lock tests failed:", error);
1190
+ process.exit(1);
1191
+ });
1192
+ }
1193
+ //# sourceMappingURL=formsort_webhook_auth_field_lock.test.js.map