@tellescope/sdk 1.255.18 → 1.255.19

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 (102) 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/healthie_multi_integration.test.d.ts.map +1 -1
  9. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js +203 -72
  10. package/lib/cjs/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  11. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  12. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  13. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js +102 -0
  14. package/lib/cjs/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  15. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  16. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +84 -81
  17. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  18. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  19. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  20. package/lib/cjs/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  21. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  22. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +773 -0
  24. package/lib/cjs/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  26. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +215 -0
  28. package/lib/cjs/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  30. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  31. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1200 -0
  32. package/lib/cjs/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  33. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  34. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  35. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2330 -0
  36. package/lib/cjs/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  37. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  38. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  39. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js +204 -0
  40. package/lib/cjs/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  41. package/lib/cjs/tests/tests.d.ts.map +1 -1
  42. package/lib/cjs/tests/tests.js +211 -178
  43. package/lib/cjs/tests/tests.js.map +1 -1
  44. package/lib/esm/sdk.d.ts +7 -2
  45. package/lib/esm/sdk.d.ts.map +1 -1
  46. package/lib/esm/sdk.js +1 -0
  47. package/lib/esm/sdk.js.map +1 -1
  48. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -1
  49. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +18 -8
  50. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -1
  51. package/lib/esm/tests/api_tests/healthie_multi_integration.test.d.ts.map +1 -1
  52. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js +205 -74
  53. package/lib/esm/tests/api_tests/healthie_multi_integration.test.js.map +1 -1
  54. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts +6 -0
  55. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.d.ts.map +1 -0
  56. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js +98 -0
  57. package/lib/esm/tests/api_tests/phone_calls_conference_hold.test.js.map +1 -0
  58. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -1
  59. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +84 -81
  60. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -1
  61. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.d.ts.map +1 -1
  62. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js +121 -78
  63. package/lib/esm/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.js.map +1 -1
  64. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts +6 -0
  65. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.d.ts.map +1 -0
  66. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js +766 -0
  67. package/lib/esm/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.js.map +1 -0
  68. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts +17 -0
  69. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.d.ts.map +1 -0
  70. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js +211 -0
  71. package/lib/esm/tests/api_tests/security/enduser-ai-summary-trust-gate.test.js.map +1 -0
  72. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts +6 -0
  73. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.d.ts.map +1 -0
  74. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js +1193 -0
  75. package/lib/esm/tests/api_tests/security/formsort_webhook_auth_field_lock.test.js.map +1 -0
  76. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts +6 -0
  77. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.d.ts.map +1 -0
  78. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js +2323 -0
  79. package/lib/esm/tests/api_tests/security/public_endpoint_auth_field_lock.test.js.map +1 -0
  80. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts +6 -0
  81. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.d.ts.map +1 -0
  82. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js +200 -0
  83. package/lib/esm/tests/api_tests/tickets_bulk_assign_care_team.test.js.map +1 -0
  84. package/lib/esm/tests/tests.d.ts.map +1 -1
  85. package/lib/esm/tests/tests.js +211 -178
  86. package/lib/esm/tests/tests.js.map +1 -1
  87. package/lib/tsconfig.tsbuildinfo +1 -1
  88. package/package.json +10 -10
  89. package/src/sdk.ts +4 -0
  90. package/src/tests/api_tests/auto_merge_form_submission.test.ts +13 -4
  91. package/src/tests/api_tests/healthie_multi_integration.test.ts +103 -5
  92. package/src/tests/api_tests/phone_calls_conference_hold.test.ts +62 -0
  93. package/src/tests/api_tests/scoped_api_keys.test.ts +5 -0
  94. package/src/tests/api_tests/security/F-0106-F-0110-enduser-write-restrictions.test.ts +38 -4
  95. package/src/tests/api_tests/security/F-0166-allowed-paths-wildcard-bypass.test.ts +506 -0
  96. package/src/tests/api_tests/security/enduser-ai-summary-trust-gate.test.ts +125 -0
  97. package/src/tests/api_tests/security/formsort_webhook_auth_field_lock.test.ts +753 -0
  98. package/src/tests/api_tests/security/public_endpoint_auth_field_lock.test.ts +1707 -0
  99. package/src/tests/api_tests/tickets_bulk_assign_care_team.test.ts +132 -0
  100. package/src/tests/tests.ts +20 -1
  101. package/test_generated.pdf +0 -0
  102. package/.env +0 -13
@@ -0,0 +1,2330 @@
1
+ "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
13
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
14
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
15
+ return new (P || (P = Promise))(function (resolve, reject) {
16
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
17
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
18
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
19
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
20
+ });
21
+ };
22
+ var __generator = (this && this.__generator) || function (thisArg, body) {
23
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g;
24
+ return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
25
+ function verb(n) { return function (v) { return step([n, v]); }; }
26
+ function step(op) {
27
+ if (f) throw new TypeError("Generator is already executing.");
28
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
29
+ 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;
30
+ if (y = 0, t) op = [op[0] & 2, t.value];
31
+ switch (op[0]) {
32
+ case 0: case 1: t = op; break;
33
+ case 4: _.label++; return { value: op[1], done: false };
34
+ case 5: _.label++; y = op[1]; op = [0]; continue;
35
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
36
+ default:
37
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
38
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
39
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
40
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
41
+ if (t[2]) _.ops.pop();
42
+ _.trys.pop(); continue;
43
+ }
44
+ op = body.call(thisArg, _);
45
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
46
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
47
+ }
48
+ };
49
+ var __importDefault = (this && this.__importDefault) || function (mod) {
50
+ return (mod && mod.__esModule) ? mod : { "default": mod };
51
+ };
52
+ Object.defineProperty(exports, "__esModule", { value: true });
53
+ exports.public_endpoint_auth_field_lock_tests = void 0;
54
+ require('source-map-support').install();
55
+ var axios_1 = __importDefault(require("axios"));
56
+ var sdk_1 = require("../../../sdk");
57
+ var testing_1 = require("@tellescope/testing");
58
+ var setup_1 = require("../../setup");
59
+ var host = process.env.API_URL || 'http://localhost:8080';
60
+ /**
61
+ * Regression test for: email / phone are locked against writes from public session-start endpoints.
62
+ *
63
+ * Two public, unauthenticated endpoints match an existing patient on a loose $or
64
+ * ({ email } OR { fname, lname, dateOfBirth }) and then update the matched record:
65
+ *
66
+ * A POST /v1/session-for-public-form
67
+ * B POST /v1/session-for-public-appointment-booking
68
+ *
69
+ * Both used to overwrite `email`, `phone` and `state` on the matched patient. `email` and `phone`
70
+ * are authentication channels, so that overwrite chains to account takeover:
71
+ *
72
+ * - email-less patients are common and cheap to create (sms_messages.leave_message is an
73
+ * unauthenticated enduser-creation primitive that writes only phone/fname/lname)
74
+ * - attacker submits a public form matching on fname + lname + dateOfBirth with their own email
75
+ * - POST /v1/request-enduser-password-reset resolves purely by findOne({ email }) -> mints a
76
+ * passwordReset token and mails the reset link to the attacker
77
+ * - reset_password sets hashedPassword; the attacker logs into the portal as the patient
78
+ *
79
+ * phone is equally load-bearing: begin_login_flow accepts email OR phone, and send_otp delivers
80
+ * login codes to enduser.phone. A "fill only if blank" fix does NOT close this — the email-less
81
+ * patient is precisely the common case, which is why the UNSET shapes below are the load-bearing
82
+ * rows of G1/G2.
83
+ *
84
+ * The rule under test: `email` and `phone` may be written only on the INSERT path. On a matched
85
+ * existing patient they are never written — not even when the stored value is blank.
86
+ *
87
+ * `state` deliberately still overwrites on match (G6 pins the status quo): it is not an auth
88
+ * channel, and moving it behind "if unset" would break a relocated patient booking with a
89
+ * provider licensed in their new state.
90
+ *
91
+ * Scenario groups (pre-fix expectation in parens):
92
+ * G1 (RED) A — overwrite blocked on match, 4 victim shapes (both set / email unset / phone unset / both unset)
93
+ * G2 (RED) B — same 4 shapes
94
+ * G3 (RED) A — overwrite blocked when the victim is targeted directly via the enduserId param
95
+ * G4 (RED) takeover chain: email-less victim -> overwrite via A -> password-reset resolvability
96
+ * G5 (GREEN) blank fname/lname/dateOfBirth/gender still filled; blank phone still NOT filled
97
+ * G6 (GREEN) state still overwrites on match (status-quo pin)
98
+ * G7 (GREEN) brand-new visitor still created WITH submitted email + phone
99
+ * G8 (GREEN) form submission completes end-to-end under the returned token
100
+ * G9 (GREEN) appointment availability / booking works under the returned session
101
+ * G10 (GREEN) skipMatch still inserts, and skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge
102
+ *
103
+ * SECOND WRITE PATH (G11-G18). Locking the endpoints is not sufficient on its own. `email` and
104
+ * `phone` are also UI-selectable form intakeField mappings, and save_enduser_updates_for_form_response
105
+ * (api/modules/forms.ts) copies those answers onto the enduser named by the form response — with no
106
+ * session argument and no only-if-blank guard. Two of its callers sit in the 'public-form' session scope
107
+ * (submit_form_response, and save_field_response for fields with options.saveIntakeOnPartial), so the
108
+ * whole chain above is reachable with the token the hardened endpoint itself mints: match the victim,
109
+ * get a session scoped to their id, then answer the form's Email question with the attacker address.
110
+ *
111
+ * The lock is scoped to "a public session that matched a PRE-EXISTING patient", not to every public
112
+ * session, because intakeEmailHidden / intakePhone: 'hidden' are supported configurations in which
113
+ * the intake question is the only source of a brand-new patient's contact info (G16).
114
+ *
115
+ * G11 (RED) intake answers do not overwrite a matched patient (submit path), 4 victim shapes
116
+ * G12 (RED) same via save_field_response + saveIntakeOnPartial, without ever submitting
117
+ * G13 (RED) full takeover chain driven through an intake answer
118
+ * G14 (RED) pivot: public session -> prepare_form_response for a NON-public form -> submit
119
+ * G15 (RED) save_field_response rejects another patient's formResponseId (accessCode path stays open)
120
+ * G16 (GREEN) brand-new visitor still gets email/phone from intake answers
121
+ * G17 (GREEN) authenticated (non-public) portal session can still self-update via intake
122
+ * G18 (GREEN) staff submitting on a patient's behalf can still set email/phone
123
+ *
124
+ * NULL-MATCH / COLD START (G19-G22). In both handlers' $or, `email` and `dateOfBirth` get `|| "never"`
125
+ * sentinels but fname/lname are interpolated RAW. `ignoreUndefined` is not set on the Mongo client and
126
+ * findOne does not strip undefined keys, so an absent fname serializes to `{fname: null}` — which
127
+ * matches every document where the field is null OR MISSING. A request supplying ONLY dateOfBirth can
128
+ * therefore match another patient and receive a session scoped to THEIR record. The cold-start pattern
129
+ * (a public form started with no intake info, which just creates an Enduser with an ID) is what
130
+ * manufactures those nameless endusers.
131
+ *
132
+ * G19 (RED) DOB-only request does not hijack a nameless patient — form endpoint
133
+ * G20 (RED) same — booking endpoint (no skipMatch, no phone guard, so reached unconditionally)
134
+ * G21 (GREEN) the match sentinel never leaks into a stored fname/lname
135
+ * G22 (GREEN) cold start still lets intake answers populate email/phone on the fresh record
136
+ *
137
+ * TENANT SCOPING (G23). addPublicEndpoint overwrites the schema's `required: true` for businessId with
138
+ * mongoIdStringOptional, and a falsy businessId leaves restrictAccess as the IDENTITY function in the
139
+ * query builders — the tenant clause is never appended, so the handler queries every tenant. It fails
140
+ * OPEN. session_for_public_appointment_booking already guards this; session_for_public_form did not.
141
+ *
142
+ * G23 (RED) session_for_public_form rejects a missing or malformed businessId
143
+ *
144
+ * LOCK BYPASSES (G24-G26). The lock is defeated two ways, each needing more than one hop — which is
145
+ * why the single-hop groups above pass while the chain is still open:
146
+ * - prepare_form_response mints a token that preserves allowedPaths but DROPS fromPublicSession, so
147
+ * a second hop through the same endpoint produces an unlocked response. That flag also gates PHI
148
+ * on refresh_session, so the chained token additionally reads the victim's whole record.
149
+ * - the lock is a snapshot taken at session start; a later repoint of the response at a pre-existing
150
+ * patient leaves a stale `false` behind unless it also writes the flag. Two callers repoint:
151
+ * autoMergeOnSubmission (G26) and the staff endusers.merge handler (G27).
152
+ *
153
+ * G24 (RED) chained prepare_form_response session stays locked
154
+ * G25 (RED) chained session does not expose the enduser record on refresh
155
+ * G26 (RED) auto-merge does not let an intake answer repoint the merged patient
156
+ * G27 (RED) the staff endusers.merge repoint applies the lock too
157
+ *
158
+ * SCOPE BOUNDARY (G28). The formsort / form-ingestion webhook re-opens a blank email/phone for one
159
+ * hour after the Stripe integration creates a contact, because that endpoint is API-key
160
+ * authenticated. This anonymous path gets no such exemption.
161
+ *
162
+ * G28 a fresh Stripe-created patient gets no grace here
163
+ *
164
+ * STRUCTURE: record -> report -> cleanup -> assert.
165
+ * EXIT_ON_FIRST_ERROR is true in testing.ts, so assert() calls process.exit(1) synchronously on the
166
+ * first failure. Two consequences shape this file:
167
+ * 1. No async_test here. Every scenario records into `results`, a summary table prints, and a
168
+ * single assert() loop runs LAST — so a pre-fix run prints the complete failure picture
169
+ * instead of dying on the first probe.
170
+ * 2. process.exit() does not unwind pending finally blocks, so cleanup runs BEFORE the assert
171
+ * loop; otherwise a by-design-failing run would leak every fixture into the next run.
172
+ */
173
+ var post = function (path, body) { return __awaiter(void 0, void 0, void 0, function () {
174
+ var res, err_1;
175
+ var _a, _b;
176
+ return __generator(this, function (_c) {
177
+ switch (_c.label) {
178
+ case 0:
179
+ _c.trys.push([0, 2, , 3]);
180
+ return [4 /*yield*/, axios_1.default.post("".concat(host).concat(path), body, { validateStatus: function () { return true; } })];
181
+ case 1:
182
+ res = _c.sent();
183
+ return [2 /*return*/, { status: res.status, data: res.data }];
184
+ case 2:
185
+ err_1 = _c.sent();
186
+ return [2 /*return*/, { status: (_a = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_1 === null || err_1 === void 0 ? void 0 : err_1.response) === null || _b === void 0 ? void 0 : _b.data }];
187
+ case 3: return [2 /*return*/];
188
+ }
189
+ });
190
+ }); };
191
+ // Endpoint A's update is fire-and-forget (.catch(console.error), not awaited); B's is awaited.
192
+ // Reading the victim immediately after calling A would frequently show the un-overwritten value on
193
+ // a VULNERABLE server -> false pass. So poll for the attack landing: fails fast against vulnerable
194
+ // code, burns the full window against fixed code.
195
+ var OVERWRITE_WINDOW_MS = 3000;
196
+ var AWAITED_WINDOW_MS = 1200;
197
+ var POLL_INTERVAL_MS = 150;
198
+ var sleep = function (ms) { return new Promise(function (resolve) { return setTimeout(resolve, ms); }); };
199
+ var public_endpoint_auth_field_lock_tests = function (_a) {
200
+ var sdk = _a.sdk, sdkNonAdmin = _a.sdkNonAdmin;
201
+ return __awaiter(void 0, void 0, void 0, function () {
202
+ var businessId, RUN, results, record, skip, runScenario, phoneOffset, phoneSeed, nextPhone, emailFor, createdEnduserIds, createdFormIds, createdFormFieldIds, createdUserIds, createdEventIds, createdTemplateIds, organization, MATCH_EMAIL_AND_NAMES, form, plainField, autoMergeForm, autoMergeField, makeIntakeForm, intake, partialIntake, pivotIntake, MERGE_CUSTOM_KEY, autoMergeIntake, template, hostUser, publicSDK, startForm, startBooking, startFormOn, asEnduser, postAuthed, tokenFromURL, patchAuthed, intakeAnswers, pollEnduser, describe, makeVictim, VICTIM_SHAPES, _loop_1, _i, VICTIM_SHAPES_1, shape, _loop_2, _b, VICTIM_SHAPES_2, shape, formSessions, bookingTokens, _loop_3, _c, VICTIM_SHAPES_3, shape, G12_NAME, G13_NAME, G14_NAME, G15_NAME, namelessDob, makeNamelessVictim, G19_NAME, G20_NAME, coldStartCase, G23_NAME, chainForVictim, G24_NAME, G25_NAME, G26_NAME, G27_NAME, G28_NAME, failures, skipped, _d, results_1, r, status_1, _e, createdEventIds_1, id, _f, createdFormIds_1, formId, responses, _g, responses_1, response, _h, createdFormFieldIds_1, id, _j, createdFormIds_2, id, uniqueEnduserIds, _k, uniqueEnduserIds_1, id, _l, createdTemplateIds_1, id, _m, createdUserIds_1, id, _o, results_2, r;
203
+ var _p, _q;
204
+ return __generator(this, function (_r) {
205
+ switch (_r.label) {
206
+ case 0:
207
+ (0, testing_1.log_header)("Public Endpoint Auth Field Lock (email / phone)");
208
+ businessId = sdk.userInfo.businessId;
209
+ RUN = Date.now().toString(36);
210
+ results = [];
211
+ record = function (group, name, ok, detail) {
212
+ if (detail === void 0) { detail = ''; }
213
+ results.push({ group: group, name: name, ok: ok, detail: detail });
214
+ console.log(" ".concat(ok ? '✓' : '✗', " [").concat(group, "] ").concat(name).concat(detail ? " \u2014 ".concat(detail) : ''));
215
+ };
216
+ skip = function (group, name, detail) {
217
+ results.push({ group: group, name: name, ok: true, skipped: true, detail: detail });
218
+ console.log(" \u26A0 SKIP [".concat(group, "] ").concat(name, " \u2014 ").concat(detail));
219
+ };
220
+ runScenario = function (group, name, fn) { return __awaiter(void 0, void 0, void 0, function () {
221
+ var err_2, message;
222
+ var _a, _b;
223
+ return __generator(this, function (_c) {
224
+ switch (_c.label) {
225
+ case 0:
226
+ _c.trys.push([0, 2, , 3]);
227
+ return [4 /*yield*/, fn()];
228
+ case 1:
229
+ _c.sent();
230
+ return [3 /*break*/, 3];
231
+ case 2:
232
+ err_2 = _c.sent();
233
+ message = (_b = (_a = err_2 === null || err_2 === void 0 ? void 0 : err_2.message) !== null && _a !== void 0 ? _a : err_2 === null || err_2 === void 0 ? void 0 : err_2.error) !== null && _b !== void 0 ? _b : JSON.stringify(err_2 !== null && err_2 !== void 0 ? err_2 : {}).slice(0, 300);
234
+ record(group, name, false, "threw: ".concat(message));
235
+ return [3 /*break*/, 3];
236
+ case 3: return [2 /*return*/];
237
+ }
238
+ });
239
+ }); };
240
+ phoneOffset = 0;
241
+ phoneSeed = Number(RUN.replace(/[^0-9]/g, '') || '0');
242
+ nextPhone = function () { return "+1555555".concat(String((phoneSeed + (phoneOffset += 1)) % 10000).padStart(4, '0')); };
243
+ emailFor = function (label) { return "".concat(label, "-").concat(RUN, "@tellescope.com").toLowerCase(); };
244
+ createdEnduserIds = [];
245
+ createdFormIds = [];
246
+ createdFormFieldIds = [];
247
+ createdUserIds = [];
248
+ createdEventIds = [];
249
+ createdTemplateIds = [];
250
+ return [4 /*yield*/, sdk.api.organizations.getOne(businessId).catch(function () { return null; })];
251
+ case 1:
252
+ organization = _r.sent();
253
+ MATCH_EMAIL_AND_NAMES = !!((_q = (_p = organization === null || organization === void 0 ? void 0 : organization.settings) === null || _p === void 0 ? void 0 : _p.endusers) === null || _q === void 0 ? void 0 : _q.matchEmailAndNames);
254
+ return [4 /*yield*/, sdk.api.forms.createOne({ title: "auth field lock ".concat(RUN), allowPublicURL: true })];
255
+ case 2:
256
+ form = _r.sent();
257
+ createdFormIds.push(form.id);
258
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
259
+ formId: form.id,
260
+ title: 'Anything',
261
+ type: 'string',
262
+ previousFields: [{ type: 'root', info: {} }],
263
+ })];
264
+ case 3:
265
+ plainField = _r.sent();
266
+ createdFormFieldIds.push(plainField.id);
267
+ return [4 /*yield*/, sdk.api.forms.createOne({
268
+ title: "auth field lock automerge ".concat(RUN),
269
+ allowPublicURL: true,
270
+ autoMergeOnSubmission: true,
271
+ })];
272
+ case 4:
273
+ autoMergeForm = _r.sent();
274
+ createdFormIds.push(autoMergeForm.id);
275
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
276
+ formId: autoMergeForm.id,
277
+ title: 'Anything',
278
+ type: 'string',
279
+ previousFields: [{ type: 'root', info: {} }],
280
+ })];
281
+ case 5:
282
+ autoMergeField = _r.sent();
283
+ createdFormFieldIds.push(autoMergeField.id);
284
+ makeIntakeForm = function (label, opts) {
285
+ if (opts === void 0) { opts = {}; }
286
+ return __awaiter(void 0, void 0, void 0, function () {
287
+ var intakeForm, fieldOptions, emailField, phoneField;
288
+ return __generator(this, function (_a) {
289
+ switch (_a.label) {
290
+ case 0: return [4 /*yield*/, sdk.api.forms.createOne(__assign({ title: "auth field lock ".concat(label, " ").concat(RUN), intakePhone: 'hidden' }, (opts.public === false ? {} : { allowPublicURL: true })))];
291
+ case 1:
292
+ intakeForm = _a.sent();
293
+ createdFormIds.push(intakeForm.id);
294
+ fieldOptions = opts.saveIntakeOnPartial ? { options: { saveIntakeOnPartial: true } } : {};
295
+ return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Email', type: 'email', intakeField: 'email', previousFields: [{ type: 'root', info: {} }] }, fieldOptions))];
296
+ case 2:
297
+ emailField = _a.sent();
298
+ createdFormFieldIds.push(emailField.id);
299
+ return [4 /*yield*/, sdk.api.form_fields.createOne(__assign({ formId: intakeForm.id, title: 'Phone Number', type: 'phone', intakeField: 'phone', previousFields: [{ type: 'after', info: { fieldId: emailField.id } }] }, fieldOptions))];
300
+ case 3:
301
+ phoneField = _a.sent();
302
+ createdFormFieldIds.push(phoneField.id);
303
+ return [2 /*return*/, { form: intakeForm, emailField: emailField, phoneField: phoneField }];
304
+ }
305
+ });
306
+ });
307
+ };
308
+ return [4 /*yield*/, makeIntakeForm('intake')];
309
+ case 6:
310
+ intake = _r.sent();
311
+ return [4 /*yield*/, makeIntakeForm('partial', { saveIntakeOnPartial: true })
312
+ // Deliberately NOT allowPublicURL. prepare_form_response does not require it, so a public session
313
+ // can pivot from any public form to any form in the org carrying an email/phone intake mapping.
314
+ ];
315
+ case 7:
316
+ partialIntake = _r.sent();
317
+ return [4 /*yield*/, makeIntakeForm('pivot', { public: false })
318
+ // Auto-merge fixture for G26. Needs real intakeField mappings, because resolveAutoMergeBeforeSubmission
319
+ // derives its match values from the submitted answers via those mappings — and the custom field is the
320
+ // anti-vacuity control (it proves the intake write ran at all).
321
+ ];
322
+ case 8:
323
+ pivotIntake = _r.sent();
324
+ MERGE_CUSTOM_KEY = "aflMerge".concat(RUN);
325
+ return [4 /*yield*/, (function () { return __awaiter(void 0, void 0, void 0, function () {
326
+ var form, spec, made, _i, spec_1, s, field, fname, lname, dateOfBirth, email, custom;
327
+ return __generator(this, function (_a) {
328
+ switch (_a.label) {
329
+ case 0: return [4 /*yield*/, sdk.api.forms.createOne({
330
+ title: "auth field lock automerge intake ".concat(RUN),
331
+ allowPublicURL: true,
332
+ autoMergeOnSubmission: true,
333
+ intakePhone: 'hidden',
334
+ })];
335
+ case 1:
336
+ form = _a.sent();
337
+ createdFormIds.push(form.id);
338
+ spec = [
339
+ { title: 'First Name', type: 'string', intakeField: 'fname' },
340
+ { title: 'Last Name', type: 'string', intakeField: 'lname' },
341
+ { title: 'Date of Birth', type: 'dateString', intakeField: 'dateOfBirth' },
342
+ { title: 'Email', type: 'email', intakeField: 'email' },
343
+ { title: 'Custom', type: 'string', intakeField: MERGE_CUSTOM_KEY },
344
+ ];
345
+ made = [];
346
+ _i = 0, spec_1 = spec;
347
+ _a.label = 2;
348
+ case 2:
349
+ if (!(_i < spec_1.length)) return [3 /*break*/, 5];
350
+ s = spec_1[_i];
351
+ return [4 /*yield*/, sdk.api.form_fields.createOne({
352
+ formId: form.id,
353
+ title: s.title,
354
+ type: s.type,
355
+ intakeField: s.intakeField,
356
+ previousFields: made.length
357
+ ? [{ type: 'after', info: { fieldId: made[made.length - 1].id } }]
358
+ : [{ type: 'root', info: {} }],
359
+ })];
360
+ case 3:
361
+ field = _a.sent();
362
+ createdFormFieldIds.push(field.id);
363
+ made.push(field);
364
+ _a.label = 4;
365
+ case 4:
366
+ _i++;
367
+ return [3 /*break*/, 2];
368
+ case 5:
369
+ fname = made[0], lname = made[1], dateOfBirth = made[2], email = made[3], custom = made[4];
370
+ return [2 /*return*/, { form: form, fname: fname, lname: lname, dateOfBirth: dateOfBirth, email: email, custom: custom }];
371
+ }
372
+ });
373
+ }); })()];
374
+ case 9:
375
+ autoMergeIntake = _r.sent();
376
+ return [4 /*yield*/, sdk.api.calendar_event_templates.createOne({
377
+ title: "auth field lock ".concat(RUN),
378
+ durationInMinutes: 60,
379
+ confirmationEmailDisabled: true,
380
+ confirmationSMSDisabled: true,
381
+ reminders: [],
382
+ })];
383
+ case 10:
384
+ template = _r.sent();
385
+ createdTemplateIds.push(template.id);
386
+ return [4 /*yield*/, sdk.api.users.createOne({
387
+ email: emailFor('lock-host'),
388
+ verifiedEmail: true,
389
+ fname: 'Lock',
390
+ lname: "Host".concat(RUN),
391
+ })];
392
+ case 11:
393
+ hostUser = _r.sent();
394
+ createdUserIds.push(hostUser.id);
395
+ return [4 /*yield*/, sdk.api.users.updateOne(hostUser.id, {
396
+ weeklyAvailabilities: [0, 1, 2, 3, 4, 5, 6].map(function (dayOfWeekStartingSundayIndexedByZero) { return ({
397
+ dayOfWeekStartingSundayIndexedByZero: dayOfWeekStartingSundayIndexedByZero,
398
+ startTimeInMinutes: 9 * 60,
399
+ endTimeInMinutes: 17 * 60,
400
+ intervalInMinutes: 60,
401
+ }); }),
402
+ calendarEventLimits: [],
403
+ }, { replaceObjectFields: true })];
404
+ case 12:
405
+ _r.sent();
406
+ return [4 /*yield*/, sleep(250)
407
+ // ---------------------------------------------------------------------------------------------
408
+ // Helpers
409
+ // ---------------------------------------------------------------------------------------------
410
+ ]; // let form.numFields settle before the public endpoints validate it
411
+ case 13:
412
+ _r.sent(); // let form.numFields settle before the public endpoints validate it
413
+ publicSDK = function () { return new sdk_1.EnduserSession({ host: host, businessId: businessId }); };
414
+ startForm = function (args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: form.id, businessId: businessId }, args)); };
415
+ startBooking = function (args) { return publicSDK().api.calendar_events.session_for_public_appointment_booking(__assign({ calendarEventTemplateId: template.id, businessId: businessId }, args)); };
416
+ startFormOn = function (formId, args) { return publicSDK().api.form_responses.session_for_public_form(__assign({ formId: formId, businessId: businessId }, args)); };
417
+ asEnduser = function (authToken) { return new sdk_1.EnduserSession({ host: host, businessId: businessId, authToken: authToken }); };
418
+ postAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
419
+ var res, err_3;
420
+ var _a, _b;
421
+ return __generator(this, function (_c) {
422
+ switch (_c.label) {
423
+ case 0:
424
+ _c.trys.push([0, 2, , 3]);
425
+ return [4 /*yield*/, axios_1.default.post("".concat(host).concat(path), body, {
426
+ headers: { Authorization: "Bearer ".concat(authToken) },
427
+ validateStatus: function () { return true; },
428
+ })];
429
+ case 1:
430
+ res = _c.sent();
431
+ return [2 /*return*/, { status: res.status, data: res.data }];
432
+ case 2:
433
+ err_3 = _c.sent();
434
+ return [2 /*return*/, { status: (_a = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_3 === null || err_3 === void 0 ? void 0 : err_3.response) === null || _b === void 0 ? void 0 : _b.data }];
435
+ case 3: return [2 /*return*/];
436
+ }
437
+ });
438
+ }); };
439
+ tokenFromURL = function (fullURL) { var _a; return (_a = /[?&]token=([^&]+)/.exec(fullURL !== null && fullURL !== void 0 ? fullURL : '')) === null || _a === void 0 ? void 0 : _a[1]; };
440
+ patchAuthed = function (path, body, authToken) { return __awaiter(void 0, void 0, void 0, function () {
441
+ var res, err_4;
442
+ var _a, _b;
443
+ return __generator(this, function (_c) {
444
+ switch (_c.label) {
445
+ case 0:
446
+ _c.trys.push([0, 2, , 3]);
447
+ return [4 /*yield*/, axios_1.default.patch("".concat(host).concat(path), body, {
448
+ headers: { Authorization: "Bearer ".concat(authToken) },
449
+ validateStatus: function () { return true; },
450
+ })];
451
+ case 1:
452
+ res = _c.sent();
453
+ return [2 /*return*/, { status: res.status, data: res.data }];
454
+ case 2:
455
+ err_4 = _c.sent();
456
+ return [2 /*return*/, { status: (_a = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _a === void 0 ? void 0 : _a.status, data: (_b = err_4 === null || err_4 === void 0 ? void 0 : err_4.response) === null || _b === void 0 ? void 0 : _b.data }];
457
+ case 3: return [2 /*return*/];
458
+ }
459
+ });
460
+ }); };
461
+ intakeAnswers = function (f, email, phone) { return ([
462
+ { fieldId: f.emailField.id, fieldTitle: f.emailField.title, answer: { type: 'email', value: email } },
463
+ { fieldId: f.phoneField.id, fieldTitle: f.phoneField.title, answer: { type: 'phone', value: phone } },
464
+ ]); };
465
+ pollEnduser = function (id, landed, windowMs) { return __awaiter(void 0, void 0, void 0, function () {
466
+ var deadline, latest;
467
+ return __generator(this, function (_a) {
468
+ switch (_a.label) {
469
+ case 0:
470
+ deadline = Date.now() + windowMs;
471
+ latest = null;
472
+ _a.label = 1;
473
+ case 1:
474
+ if (!true) return [3 /*break*/, 4];
475
+ return [4 /*yield*/, sdk.api.endusers.getOne(id).catch(function () { return null; })];
476
+ case 2:
477
+ latest = _a.sent();
478
+ if (latest && landed(latest))
479
+ return [2 /*return*/, { landed: true, enduser: latest }];
480
+ if (Date.now() >= deadline)
481
+ return [2 /*return*/, { landed: false, enduser: latest }];
482
+ return [4 /*yield*/, sleep(POLL_INTERVAL_MS)];
483
+ case 3:
484
+ _a.sent();
485
+ return [3 /*break*/, 1];
486
+ case 4: return [2 /*return*/];
487
+ }
488
+ });
489
+ }); };
490
+ describe = function (e) { return "email=".concat((e === null || e === void 0 ? void 0 : e.email) || '(unset)', " phone=").concat((e === null || e === void 0 ? void 0 : e.phone) || '(unset)'); };
491
+ makeVictim = function (tag, fields) {
492
+ if (fields === void 0) { fields = {}; }
493
+ return __awaiter(void 0, void 0, void 0, function () {
494
+ var victim;
495
+ return __generator(this, function (_a) {
496
+ switch (_a.label) {
497
+ case 0: return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ fname: "Lock".concat(RUN), lname: "Victim".concat(RUN).concat(tag), dateOfBirth: '01-02-1990' }, fields))];
498
+ case 1:
499
+ victim = _a.sent();
500
+ createdEnduserIds.push(victim.id);
501
+ return [2 /*return*/, victim];
502
+ }
503
+ });
504
+ });
505
+ };
506
+ VICTIM_SHAPES = [
507
+ { tag: 'A1', label: 'victim has email + phone', withEmail: true, withPhone: true },
508
+ { tag: 'A2', label: 'victim email UNSET (load-bearing)', withEmail: false, withPhone: true },
509
+ { tag: 'A3', label: 'victim phone UNSET', withEmail: true, withPhone: false },
510
+ { tag: 'A4', label: 'victim email + phone UNSET (load-bearing)', withEmail: false, withPhone: false },
511
+ ];
512
+ _loop_1 = function (shape) {
513
+ var name_1;
514
+ return __generator(this, function (_s) {
515
+ switch (_s.label) {
516
+ case 0:
517
+ name_1 = "A blocks email/phone overwrite \u2014 ".concat(shape.label);
518
+ return [4 /*yield*/, runScenario('G1', name_1, function () { return __awaiter(void 0, void 0, void 0, function () {
519
+ var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
520
+ return __generator(this, function (_b) {
521
+ switch (_b.label) {
522
+ case 0: return [4 /*yield*/, makeVictim(shape.tag, __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-v-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
523
+ case 1:
524
+ victim = _b.sent();
525
+ attackerEmail = emailFor("atk-".concat(shape.tag));
526
+ attackerPhone = nextPhone();
527
+ return [4 /*yield*/, startForm({
528
+ fname: victim.fname,
529
+ lname: victim.lname,
530
+ dateOfBirth: victim.dateOfBirth,
531
+ email: attackerEmail,
532
+ phone: attackerPhone,
533
+ })
534
+ // Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
535
+ // unchanged and the test would pass for the wrong reason.
536
+ ];
537
+ case 2:
538
+ session = _b.sent();
539
+ // Probe-validity guard: if the handler INSERTED instead of matching, the victim is trivially
540
+ // unchanged and the test would pass for the wrong reason.
541
+ if (session.enduserId !== victim.id) {
542
+ if (session.enduserId)
543
+ createdEnduserIds.push(session.enduserId);
544
+ record('G1', name_1, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
545
+ return [2 /*return*/];
546
+ }
547
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
548
+ case 3:
549
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
550
+ record('G1', name_1, !landed, landed
551
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
552
+ : "unchanged (".concat(describe(enduser), ")"));
553
+ return [2 /*return*/];
554
+ }
555
+ });
556
+ }); })];
557
+ case 1:
558
+ _s.sent();
559
+ return [2 /*return*/];
560
+ }
561
+ });
562
+ };
563
+ _i = 0, VICTIM_SHAPES_1 = VICTIM_SHAPES;
564
+ _r.label = 14;
565
+ case 14:
566
+ if (!(_i < VICTIM_SHAPES_1.length)) return [3 /*break*/, 17];
567
+ shape = VICTIM_SHAPES_1[_i];
568
+ return [5 /*yield**/, _loop_1(shape)];
569
+ case 15:
570
+ _r.sent();
571
+ _r.label = 16;
572
+ case 16:
573
+ _i++;
574
+ return [3 /*break*/, 14];
575
+ case 17:
576
+ _loop_2 = function (shape) {
577
+ var name_2;
578
+ return __generator(this, function (_t) {
579
+ switch (_t.label) {
580
+ case 0:
581
+ name_2 = "B blocks email/phone overwrite \u2014 ".concat(shape.label);
582
+ return [4 /*yield*/, runScenario('G2', name_2, function () { return __awaiter(void 0, void 0, void 0, function () {
583
+ var victim, attackerEmail, attackerPhone, matching, _i, matching_1, m, _a, landed, enduser;
584
+ return __generator(this, function (_b) {
585
+ switch (_b.label) {
586
+ case 0: return [4 /*yield*/, makeVictim("B".concat(shape.tag), __assign(__assign({}, (shape.withEmail ? { email: emailFor("lock-vb-".concat(shape.tag)) } : {})), (shape.withPhone ? { phone: nextPhone() } : {})))];
587
+ case 1:
588
+ victim = _b.sent();
589
+ attackerEmail = emailFor("atkb-".concat(shape.tag));
590
+ attackerPhone = nextPhone();
591
+ return [4 /*yield*/, startBooking({
592
+ fname: victim.fname,
593
+ lname: victim.lname,
594
+ dateOfBirth: victim.dateOfBirth,
595
+ email: attackerEmail,
596
+ phone: attackerPhone,
597
+ })];
598
+ case 2:
599
+ _b.sent();
600
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: victim.lname } })];
601
+ case 3:
602
+ matching = _b.sent();
603
+ if (matching.length !== 1 || matching[0].id !== victim.id) {
604
+ for (_i = 0, matching_1 = matching; _i < matching_1.length; _i++) {
605
+ m = matching_1[_i];
606
+ if (m.id !== victim.id)
607
+ createdEnduserIds.push(m.id);
608
+ }
609
+ record('G2', name_2, false, "probe invalid \u2014 ".concat(matching.length, " records match lname ").concat(victim.lname, "; endpoint inserted instead of matching"));
610
+ return [2 /*return*/];
611
+ }
612
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, AWAITED_WINDOW_MS)];
613
+ case 4:
614
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
615
+ record('G2', name_2, !landed, landed
616
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
617
+ : "unchanged (".concat(describe(enduser), ")"));
618
+ return [2 /*return*/];
619
+ }
620
+ });
621
+ }); })];
622
+ case 1:
623
+ _t.sent();
624
+ return [2 /*return*/];
625
+ }
626
+ });
627
+ };
628
+ _b = 0, VICTIM_SHAPES_2 = VICTIM_SHAPES;
629
+ _r.label = 18;
630
+ case 18:
631
+ if (!(_b < VICTIM_SHAPES_2.length)) return [3 /*break*/, 21];
632
+ shape = VICTIM_SHAPES_2[_b];
633
+ return [5 /*yield**/, _loop_2(shape)];
634
+ case 19:
635
+ _r.sent();
636
+ _r.label = 20;
637
+ case 20:
638
+ _b++;
639
+ return [3 /*break*/, 18];
640
+ case 21:
641
+ // ---------------------------------------------------------------------------------------------
642
+ // G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
643
+ // as an attack through the first-party frontend, but trivially reachable by POSTing directly.
644
+ // The field lock must hold on this path too.
645
+ // ---------------------------------------------------------------------------------------------
646
+ return [4 /*yield*/, runScenario('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', function () { return __awaiter(void 0, void 0, void 0, function () {
647
+ var victim, attackerEmail, attackerPhone, session, _a, landed, enduser;
648
+ return __generator(this, function (_b) {
649
+ switch (_b.label) {
650
+ case 0: return [4 /*yield*/, makeVictim('C1')]; // no email, no phone — the takeover-ready shape
651
+ case 1:
652
+ victim = _b.sent() // no email, no phone — the takeover-ready shape
653
+ ;
654
+ attackerEmail = emailFor('atk-c1');
655
+ attackerPhone = nextPhone();
656
+ return [4 /*yield*/, startForm({ enduserId: victim.id, email: attackerEmail, phone: attackerPhone })];
657
+ case 2:
658
+ session = _b.sent();
659
+ if (session.enduserId !== victim.id) {
660
+ if (session.enduserId)
661
+ createdEnduserIds.push(session.enduserId);
662
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', false, "probe invalid \u2014 endpoint returned ".concat(session.enduserId, ", expected victim ").concat(victim.id));
663
+ return [2 /*return*/];
664
+ }
665
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
666
+ case 3:
667
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
668
+ record('G3', 'A blocks email/phone overwrite when victim targeted via enduserId', !landed, landed
669
+ ? "EXPLOIT: attacker values landed on victim (".concat(describe(enduser), ")")
670
+ : "unchanged (".concat(describe(enduser), ")"));
671
+ return [2 /*return*/];
672
+ }
673
+ });
674
+ }); })
675
+ // ---------------------------------------------------------------------------------------------
676
+ // G4 — The takeover chain itself.
677
+ //
678
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
679
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
680
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
681
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
682
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
683
+ // minting anything, so an empty set proves non-issuance.
684
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
685
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
686
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
687
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
688
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
689
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
690
+ // this file runs as part of tests.ts).
691
+ // ---------------------------------------------------------------------------------------------
692
+ ];
693
+ case 22:
694
+ // ---------------------------------------------------------------------------------------------
695
+ // G3 — Endpoint A with the raw enduserId parameter (findById, no ownership check). Not reachable
696
+ // as an attack through the first-party frontend, but trivially reachable by POSTing directly.
697
+ // The field lock must hold on this path too.
698
+ // ---------------------------------------------------------------------------------------------
699
+ _r.sent();
700
+ // ---------------------------------------------------------------------------------------------
701
+ // G4 — The takeover chain itself.
702
+ //
703
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
704
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
705
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
706
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
707
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
708
+ // minting anything, so an empty set proves non-issuance.
709
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
710
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
711
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
712
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
713
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
714
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
715
+ // this file runs as part of tests.ts).
716
+ // ---------------------------------------------------------------------------------------------
717
+ return [4 /*yield*/, runScenario('G4', 'takeover chain: email-less victim is not made resolvable by A', function () { return __awaiter(void 0, void 0, void 0, function () {
718
+ var victim, attackerEmail, baseline, baselineResult, session, enduser, resetRes, resolvable, after, afterResult;
719
+ var _a, _b;
720
+ return __generator(this, function (_c) {
721
+ switch (_c.label) {
722
+ case 0: return [4 /*yield*/, makeVictim('D1', { phone: nextPhone() })]; // email-less: the common, cheap case
723
+ case 1:
724
+ victim = _c.sent() // email-less: the common, cheap case
725
+ ;
726
+ attackerEmail = emailFor('atk-d1');
727
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
728
+ case 2:
729
+ baseline = _c.sent();
730
+ baselineResult = (_a = baseline.data) === null || _a === void 0 ? void 0 : _a.result;
731
+ if (baseline.status === 429) {
732
+ skip('G4', 'begin_login_flow oracle baseline', 'rate limited (429) — oracle unavailable this run');
733
+ }
734
+ else {
735
+ record('G4', 'begin_login_flow oracle baseline is continue-with-password', baselineResult === 'continue-with-password', "result=".concat(baselineResult, " status=").concat(baseline.status));
736
+ }
737
+ return [4 /*yield*/, startForm({
738
+ fname: victim.fname,
739
+ lname: victim.lname,
740
+ dateOfBirth: victim.dateOfBirth,
741
+ email: attackerEmail,
742
+ phone: nextPhone(),
743
+ })];
744
+ case 3:
745
+ session = _c.sent();
746
+ if (session.enduserId !== victim.id) {
747
+ if (session.enduserId)
748
+ createdEnduserIds.push(session.enduserId);
749
+ record('G4', 'takeover chain: email-less victim is not made resolvable by A', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
750
+ return [2 /*return*/];
751
+ }
752
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
753
+ // Informational only: 204 regardless of outcome.
754
+ ];
755
+ case 4:
756
+ enduser = (_c.sent()).enduser;
757
+ return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
758
+ case 5:
759
+ resetRes = _c.sent();
760
+ console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
761
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
762
+ case 6:
763
+ resolvable = _c.sent();
764
+ record('G4', 'attacker email resolves to no enduser (password reset cannot be issued)', resolvable.length === 0, resolvable.length === 0
765
+ ? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
766
+ : "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be minted and mailed to the attacker"));
767
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
768
+ case 7:
769
+ after = _c.sent();
770
+ afterResult = (_b = after.data) === null || _b === void 0 ? void 0 : _b.result;
771
+ if (after.status === 429) {
772
+ skip('G4', 'begin_login_flow oracle after attack', 'rate limited (429) — oracle unavailable this run');
773
+ }
774
+ else {
775
+ record('G4', 'begin_login_flow still returns continue-with-password after attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
776
+ ? "result=".concat(afterResult)
777
+ : "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
778
+ }
779
+ return [2 /*return*/];
780
+ }
781
+ });
782
+ }); })
783
+ // ---------------------------------------------------------------------------------------------
784
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
785
+ // ---------------------------------------------------------------------------------------------
786
+ ];
787
+ case 23:
788
+ // ---------------------------------------------------------------------------------------------
789
+ // G4 — The takeover chain itself.
790
+ //
791
+ // access_tokens is not readable from a test (written via getInternalQueries(), no schema entry,
792
+ // no SDK surface) and request-enduser-password-reset returns 204 unconditionally — success,
793
+ // no-match and throttled alike — so it carries zero signal. Two sound substitutes:
794
+ // 1. endusers.getSome({ filter: { email: attackerEmail } }) is empty. That is the exact
795
+ // predicate the handler gates on (findOne({ email }); if (!enduser) return 204) before
796
+ // minting anything, so an empty set proves non-issuance.
797
+ // 2. begin-enduser-login-flow with the attacker email still returns continue-with-password.
798
+ // On a vulnerable server the now-email-bearing, password-less victim flips this to
799
+ // sent-email/sent-sms and an OTP is genuinely dispatched to the attacker.
800
+ // Rate-limit budget: begin_login_flow is 5 / 10 min per identifier and 10 / 60s per IP. Exactly
801
+ // two calls are made, on the same run-unique identifier. A 429 is recorded as SKIP, not a
802
+ // failure (reset_db is deliberately NOT called here — it would wipe other suites' fixtures when
803
+ // this file runs as part of tests.ts).
804
+ // ---------------------------------------------------------------------------------------------
805
+ _r.sent();
806
+ // ---------------------------------------------------------------------------------------------
807
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
808
+ // ---------------------------------------------------------------------------------------------
809
+ return [4 /*yield*/, runScenario('G5', 'blank match keys filled on match, blank phone NOT filled', function () { return __awaiter(void 0, void 0, void 0, function () {
810
+ var victimEmail, victim, submittedPhone, session, filled, phoneLanded;
811
+ var _a, _b, _c, _d, _e;
812
+ return __generator(this, function (_f) {
813
+ switch (_f.label) {
814
+ case 0:
815
+ victimEmail = emailFor('lock-v-e1');
816
+ return [4 /*yield*/, sdk.api.endusers.createOne(__assign({ email: victimEmail }, (MATCH_EMAIL_AND_NAMES ? { fname: "Lock".concat(RUN), lname: "Victim".concat(RUN, "E1") } : {})))];
817
+ case 1:
818
+ victim = _f.sent();
819
+ createdEnduserIds.push(victim.id);
820
+ if (MATCH_EMAIL_AND_NAMES) {
821
+ skip('G5', 'blank fname/lname fill', 'organization has settings.endusers.matchEmailAndNames — a name-less record is unmatchable');
822
+ }
823
+ submittedPhone = nextPhone();
824
+ return [4 /*yield*/, startForm({
825
+ email: victimEmail,
826
+ fname: "Lock".concat(RUN),
827
+ lname: "Victim".concat(RUN, "E1"),
828
+ dateOfBirth: '03-04-1991',
829
+ gender: 'Female',
830
+ phone: submittedPhone,
831
+ })];
832
+ case 2:
833
+ session = _f.sent();
834
+ if (session.enduserId !== victim.id) {
835
+ if (session.enduserId)
836
+ createdEnduserIds.push(session.enduserId);
837
+ record('G5', 'blank match keys filled on match, blank phone NOT filled', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
838
+ return [2 /*return*/];
839
+ }
840
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.dateOfBirth && !!e.gender && (MATCH_EMAIL_AND_NAMES || (!!e.fname && !!e.lname)); }, OVERWRITE_WINDOW_MS)];
841
+ case 3:
842
+ filled = _f.sent();
843
+ record('G5', 'blank fname/lname/dateOfBirth/gender still filled on match', filled.landed, "fname=".concat(((_a = filled.enduser) === null || _a === void 0 ? void 0 : _a.fname) || '(unset)', " lname=").concat(((_b = filled.enduser) === null || _b === void 0 ? void 0 : _b.lname) || '(unset)', " ")
844
+ + "dateOfBirth=".concat(((_c = filled.enduser) === null || _c === void 0 ? void 0 : _c.dateOfBirth) || '(unset)', " gender=").concat(((_d = filled.enduser) === null || _d === void 0 ? void 0 : _d.gender) || '(unset)'));
845
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return !!e.phone; }, AWAITED_WINDOW_MS)];
846
+ case 4:
847
+ phoneLanded = _f.sent();
848
+ record('G5', 'blank phone still NOT filled on match', !phoneLanded.landed, phoneLanded.landed
849
+ ? "EXPLOIT: phone=".concat((_e = phoneLanded.enduser) === null || _e === void 0 ? void 0 : _e.phone, " written into a blank field (submitted ").concat(submittedPhone, ")")
850
+ : 'phone remains unset');
851
+ return [2 /*return*/];
852
+ }
853
+ });
854
+ }); })
855
+ // ---------------------------------------------------------------------------------------------
856
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
857
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
858
+ // provider licensed in their new state.
859
+ // ---------------------------------------------------------------------------------------------
860
+ ];
861
+ case 24:
862
+ // ---------------------------------------------------------------------------------------------
863
+ // G5 — Intended intake behavior preserved: blank match keys still get filled, blank phone does not.
864
+ // ---------------------------------------------------------------------------------------------
865
+ _r.sent();
866
+ // ---------------------------------------------------------------------------------------------
867
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
868
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
869
+ // provider licensed in their new state.
870
+ // ---------------------------------------------------------------------------------------------
871
+ return [4 /*yield*/, runScenario('G6', 'state still overwrites on match (status-quo pin)', function () { return __awaiter(void 0, void 0, void 0, function () {
872
+ var victim, session, _a, landed, enduser;
873
+ return __generator(this, function (_b) {
874
+ switch (_b.label) {
875
+ case 0: return [4 /*yield*/, makeVictim('F1', { email: emailFor('lock-v-f1'), state: 'NY' })];
876
+ case 1:
877
+ victim = _b.sent();
878
+ return [4 /*yield*/, startForm({
879
+ fname: victim.fname,
880
+ lname: victim.lname,
881
+ dateOfBirth: victim.dateOfBirth,
882
+ phone: nextPhone(),
883
+ state: 'CA',
884
+ })];
885
+ case 2:
886
+ session = _b.sent();
887
+ if (session.enduserId !== victim.id) {
888
+ if (session.enduserId)
889
+ createdEnduserIds.push(session.enduserId);
890
+ record('G6', 'state still overwrites on match (status-quo pin)', false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
891
+ return [2 /*return*/];
892
+ }
893
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.state === 'CA'; }, OVERWRITE_WINDOW_MS)];
894
+ case 3:
895
+ _a = _b.sent(), landed = _a.landed, enduser = _a.enduser;
896
+ record('G6', 'state still overwrites on match (status-quo pin)', landed, "state=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.state) || '(unset)'));
897
+ return [2 /*return*/];
898
+ }
899
+ });
900
+ }); })
901
+ // ---------------------------------------------------------------------------------------------
902
+ // G7 — Insert path unchanged: a brand-new visitor is still created with the submitted email/phone.
903
+ // ---------------------------------------------------------------------------------------------
904
+ // Accumulator arrays rather than `let ... | null`: values are assigned inside runScenario's
905
+ // callback, and TS's control-flow narrowing does not account for closure assignments.
906
+ ];
907
+ case 25:
908
+ // ---------------------------------------------------------------------------------------------
909
+ // G6 — Status-quo pin: state still overwrites on match. This is deliberate, not incidental —
910
+ // state is not an auth channel, and locking it would break a relocated patient booking with a
911
+ // provider licensed in their new state.
912
+ // ---------------------------------------------------------------------------------------------
913
+ _r.sent();
914
+ formSessions = [];
915
+ return [4 /*yield*/, runScenario('G7', 'A creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
916
+ var email, phone, session, created, ok;
917
+ return __generator(this, function (_a) {
918
+ switch (_a.label) {
919
+ case 0:
920
+ email = emailFor('lock-new-a');
921
+ phone = nextPhone();
922
+ return [4 /*yield*/, startForm({
923
+ fname: "Lock".concat(RUN), lname: "NewA".concat(RUN), dateOfBirth: '05-06-1992',
924
+ email: email,
925
+ phone: phone,
926
+ })];
927
+ case 1:
928
+ session = _a.sent();
929
+ if (session.enduserId)
930
+ createdEnduserIds.push(session.enduserId);
931
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
932
+ case 2:
933
+ created = _a.sent();
934
+ ok = created.email === email && created.phone === phone
935
+ && !!session.accessCode && !!session.authToken && !!session.enduserId;
936
+ record('G7', 'A creates a new visitor with submitted email + phone', ok, "".concat(describe(created), "; accessCode=").concat(!!session.accessCode, " authToken=").concat(!!session.authToken, " enduserId=").concat(!!session.enduserId));
937
+ if (ok)
938
+ formSessions.push(session);
939
+ return [2 /*return*/];
940
+ }
941
+ });
942
+ }); })];
943
+ case 26:
944
+ _r.sent();
945
+ bookingTokens = [];
946
+ return [4 /*yield*/, runScenario('G7', 'B creates a new visitor with submitted email + phone', function () { return __awaiter(void 0, void 0, void 0, function () {
947
+ var email, phone, lname, session, matching, _i, matching_2, m, created, ok;
948
+ return __generator(this, function (_a) {
949
+ switch (_a.label) {
950
+ case 0:
951
+ email = emailFor('lock-new-b');
952
+ phone = nextPhone();
953
+ lname = "NewB".concat(RUN);
954
+ return [4 /*yield*/, startBooking({
955
+ fname: "Lock".concat(RUN),
956
+ lname: lname,
957
+ dateOfBirth: '07-08-1993',
958
+ email: email,
959
+ phone: phone,
960
+ })];
961
+ case 1:
962
+ session = _a.sent();
963
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { lname: lname } })];
964
+ case 2:
965
+ matching = _a.sent();
966
+ for (_i = 0, matching_2 = matching; _i < matching_2.length; _i++) {
967
+ m = matching_2[_i];
968
+ createdEnduserIds.push(m.id);
969
+ }
970
+ created = matching.length === 1 ? matching[0] : null;
971
+ ok = !!session.authToken && !!created && created.email === email && created.phone === phone;
972
+ record('G7', 'B creates a new visitor with submitted email + phone', ok, "authToken=".concat(!!session.authToken, "; ").concat(matching.length, " record(s); ").concat(describe(created)));
973
+ if (session.authToken)
974
+ bookingTokens.push(session.authToken);
975
+ return [2 /*return*/];
976
+ }
977
+ });
978
+ }); })
979
+ // ---------------------------------------------------------------------------------------------
980
+ // G8 — The returned public-form session still works end-to-end.
981
+ // ---------------------------------------------------------------------------------------------
982
+ ];
983
+ case 27:
984
+ _r.sent();
985
+ // ---------------------------------------------------------------------------------------------
986
+ // G8 — The returned public-form session still works end-to-end.
987
+ // ---------------------------------------------------------------------------------------------
988
+ return [4 /*yield*/, runScenario('G8', 'form submission completes under the returned token', function () { return __awaiter(void 0, void 0, void 0, function () {
989
+ var session, authed, formResponse;
990
+ return __generator(this, function (_a) {
991
+ switch (_a.label) {
992
+ case 0:
993
+ session = formSessions[0];
994
+ if (!session) {
995
+ record('G8', 'form submission completes under the returned token', false, 'no session available from G7');
996
+ return [2 /*return*/];
997
+ }
998
+ authed = new sdk_1.EnduserSession({ host: host, businessId: businessId, authToken: session.authToken });
999
+ return [4 /*yield*/, authed.api.form_responses.submit_form_response({
1000
+ accessCode: session.accessCode,
1001
+ responses: [{
1002
+ fieldId: plainField.id,
1003
+ fieldTitle: plainField.title,
1004
+ answer: { type: 'string', value: "lock test ".concat(RUN) },
1005
+ }],
1006
+ })];
1007
+ case 1:
1008
+ formResponse = (_a.sent()).formResponse;
1009
+ record('G8', 'form submission completes under the returned token', !!(formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt), "formResponse=".concat(formResponse === null || formResponse === void 0 ? void 0 : formResponse.id, " submittedAt=").concat((formResponse === null || formResponse === void 0 ? void 0 : formResponse.submittedAt) ? 'set' : '(unset)'));
1010
+ return [2 /*return*/];
1011
+ }
1012
+ });
1013
+ }); })
1014
+ // ---------------------------------------------------------------------------------------------
1015
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1016
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1017
+ // regression.
1018
+ // ---------------------------------------------------------------------------------------------
1019
+ ];
1020
+ case 28:
1021
+ // ---------------------------------------------------------------------------------------------
1022
+ // G8 — The returned public-form session still works end-to-end.
1023
+ // ---------------------------------------------------------------------------------------------
1024
+ _r.sent();
1025
+ // ---------------------------------------------------------------------------------------------
1026
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1027
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1028
+ // regression.
1029
+ // ---------------------------------------------------------------------------------------------
1030
+ return [4 /*yield*/, runScenario('G9', 'availability + booking work under the returned session', function () { return __awaiter(void 0, void 0, void 0, function () {
1031
+ var bookingAuthToken, booking, DAY_IN_MS, from, to, availabilityBlocks, slots, createdEvent;
1032
+ return __generator(this, function (_a) {
1033
+ switch (_a.label) {
1034
+ case 0:
1035
+ bookingAuthToken = bookingTokens[0];
1036
+ if (!bookingAuthToken) {
1037
+ record('G9', 'availability + booking work under the returned session', false, 'no booking session available from G7');
1038
+ return [2 /*return*/];
1039
+ }
1040
+ booking = new sdk_1.EnduserSession({ host: host, businessId: businessId, authToken: bookingAuthToken });
1041
+ DAY_IN_MS = 24 * 60 * 60 * 1000;
1042
+ from = new Date(Date.now() + DAY_IN_MS);
1043
+ to = new Date(from.getTime() + (7 * DAY_IN_MS));
1044
+ return [4 /*yield*/, booking.api.calendar_events.get_appointment_availability({
1045
+ calendarEventTemplateId: template.id,
1046
+ userIds: [hostUser.id],
1047
+ restrictedByState: false,
1048
+ from: from,
1049
+ to: to,
1050
+ })];
1051
+ case 1:
1052
+ availabilityBlocks = (_a.sent()).availabilityBlocks;
1053
+ slots = availabilityBlocks.filter(function (b) { return b.userId === hostUser.id && b.startTimeInMS > Date.now(); });
1054
+ record('G9', 'get_appointment_availability succeeds under the confined booking token', true, "".concat(availabilityBlocks.length, " block(s), ").concat(slots.length, " future slot(s) for the throwaway host"));
1055
+ if (slots.length === 0) {
1056
+ skip('G9', 'book_appointment under the confined booking token', 'no future availability slot in the 7-day window');
1057
+ return [2 /*return*/];
1058
+ }
1059
+ return [4 /*yield*/, booking.api.calendar_events.book_appointment({
1060
+ userId: hostUser.id,
1061
+ calendarEventTemplateId: template.id,
1062
+ startTime: new Date(slots[0].startTimeInMS),
1063
+ timezone: 'America/New_York',
1064
+ })];
1065
+ case 2:
1066
+ createdEvent = (_a.sent()).createdEvent;
1067
+ if (createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id)
1068
+ createdEventIds.push(createdEvent.id);
1069
+ record('G9', 'book_appointment succeeds under the confined booking token', !!(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id), "createdEvent=".concat(createdEvent === null || createdEvent === void 0 ? void 0 : createdEvent.id));
1070
+ return [2 /*return*/];
1071
+ }
1072
+ });
1073
+ }); })
1074
+ // ---------------------------------------------------------------------------------------------
1075
+ // G10 — skipMatch behavior unchanged.
1076
+ // ---------------------------------------------------------------------------------------------
1077
+ ];
1078
+ case 29:
1079
+ // ---------------------------------------------------------------------------------------------
1080
+ // G9 — The returned booking session still works. Tiered: availability is always asserted; an
1081
+ // actual booking is attempted only when a slot exists, so an empty window never reads as a
1082
+ // regression.
1083
+ // ---------------------------------------------------------------------------------------------
1084
+ _r.sent();
1085
+ // ---------------------------------------------------------------------------------------------
1086
+ // G10 — skipMatch behavior unchanged.
1087
+ // ---------------------------------------------------------------------------------------------
1088
+ return [4 /*yield*/, runScenario('G10', 'skipMatch=true still inserts a new record', function () { return __awaiter(void 0, void 0, void 0, function () {
1089
+ var victim, session;
1090
+ return __generator(this, function (_a) {
1091
+ switch (_a.label) {
1092
+ case 0: return [4 /*yield*/, makeVictim('H1', { email: emailFor('lock-v-h1'), phone: nextPhone() })];
1093
+ case 1:
1094
+ victim = _a.sent();
1095
+ return [4 /*yield*/, startForm({
1096
+ fname: victim.fname,
1097
+ lname: victim.lname,
1098
+ dateOfBirth: victim.dateOfBirth,
1099
+ email: emailFor('lock-skip'),
1100
+ phone: nextPhone(),
1101
+ skipMatch: true,
1102
+ })];
1103
+ case 2:
1104
+ session = _a.sent();
1105
+ if (session.enduserId)
1106
+ createdEnduserIds.push(session.enduserId);
1107
+ record('G10', 'skipMatch=true still inserts a new record', session.enduserId !== victim.id, "inserted=".concat(session.enduserId, " victim=").concat(victim.id));
1108
+ return [2 /*return*/];
1109
+ }
1110
+ });
1111
+ }); })];
1112
+ case 30:
1113
+ // ---------------------------------------------------------------------------------------------
1114
+ // G10 — skipMatch behavior unchanged.
1115
+ // ---------------------------------------------------------------------------------------------
1116
+ _r.sent();
1117
+ return [4 /*yield*/, runScenario('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', function () { return __awaiter(void 0, void 0, void 0, function () {
1118
+ var session, created;
1119
+ return __generator(this, function (_a) {
1120
+ switch (_a.label) {
1121
+ case 0: return [4 /*yield*/, publicSDK().api.form_responses.session_for_public_form({
1122
+ formId: autoMergeForm.id,
1123
+ businessId: businessId,
1124
+ skipMatch: true,
1125
+ fname: "Lock".concat(RUN),
1126
+ lname: "Merge".concat(RUN),
1127
+ dateOfBirth: '09-10-1994',
1128
+ email: emailFor('lock-merge'),
1129
+ phone: nextPhone(),
1130
+ })];
1131
+ case 1:
1132
+ session = _a.sent();
1133
+ if (session.enduserId)
1134
+ createdEnduserIds.push(session.enduserId);
1135
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1136
+ case 2:
1137
+ created = _a.sent();
1138
+ record('G10', 'skipMatch + autoMergeOnSubmission sets eligibleForAutoMerge', created.eligibleForAutoMerge === true, "eligibleForAutoMerge=".concat(created.eligibleForAutoMerge));
1139
+ return [2 /*return*/];
1140
+ }
1141
+ });
1142
+ }); })
1143
+ // ---------------------------------------------------------------------------------------------
1144
+ // G11 — Intake answers must not overwrite email/phone on a matched patient (submit path).
1145
+ // ---------------------------------------------------------------------------------------------
1146
+ ];
1147
+ case 31:
1148
+ _r.sent();
1149
+ _loop_3 = function (shape) {
1150
+ var name_3;
1151
+ return __generator(this, function (_u) {
1152
+ switch (_u.label) {
1153
+ case 0:
1154
+ name_3 = "intake answers do not overwrite a matched patient \u2014 ".concat(shape.label);
1155
+ return [4 /*yield*/, runScenario('G11', name_3, function () { return __awaiter(void 0, void 0, void 0, function () {
1156
+ var victimEmail, victimPhone, victim, attackerEmail, attackerPhone, session, enduser, emailHeld, phoneHeld;
1157
+ return __generator(this, function (_a) {
1158
+ switch (_a.label) {
1159
+ case 0:
1160
+ victimEmail = shape.withEmail ? emailFor("lock-i-".concat(shape.tag)) : undefined;
1161
+ victimPhone = shape.withPhone ? nextPhone() : undefined;
1162
+ return [4 /*yield*/, makeVictim("I".concat(shape.tag), __assign(__assign({}, (victimEmail ? { email: victimEmail } : {})), (victimPhone ? { phone: victimPhone } : {})))];
1163
+ case 1:
1164
+ victim = _a.sent();
1165
+ attackerEmail = emailFor("atk-i-".concat(shape.tag));
1166
+ attackerPhone = nextPhone();
1167
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1168
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1169
+ })];
1170
+ case 2:
1171
+ session = _a.sent();
1172
+ if (session.enduserId)
1173
+ createdEnduserIds.push(session.enduserId);
1174
+ if (session.enduserId !== victim.id) {
1175
+ record('G11', name_3, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1176
+ return [2 /*return*/];
1177
+ }
1178
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1179
+ accessCode: session.accessCode,
1180
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
1181
+ })];
1182
+ case 3:
1183
+ _a.sent();
1184
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
1185
+ case 4:
1186
+ enduser = (_a.sent()).enduser;
1187
+ emailHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.email) || undefined) === victimEmail;
1188
+ phoneHeld = ((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || undefined) === victimPhone;
1189
+ record('G11', "".concat(name_3, " \u2014 email"), emailHeld, emailHeld
1190
+ ? "email still ".concat(victimEmail || '(unset)')
1191
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail || '(unset)', ")"));
1192
+ record('G11', "".concat(name_3, " \u2014 phone"), phoneHeld, phoneHeld
1193
+ ? "phone still ".concat(victimPhone || '(unset)')
1194
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone || '(unset)', ")"));
1195
+ return [2 /*return*/];
1196
+ }
1197
+ });
1198
+ }); })];
1199
+ case 1:
1200
+ _u.sent();
1201
+ return [2 /*return*/];
1202
+ }
1203
+ });
1204
+ };
1205
+ _c = 0, VICTIM_SHAPES_3 = VICTIM_SHAPES;
1206
+ _r.label = 32;
1207
+ case 32:
1208
+ if (!(_c < VICTIM_SHAPES_3.length)) return [3 /*break*/, 35];
1209
+ shape = VICTIM_SHAPES_3[_c];
1210
+ return [5 /*yield**/, _loop_3(shape)];
1211
+ case 33:
1212
+ _r.sent();
1213
+ _r.label = 34;
1214
+ case 34:
1215
+ _c++;
1216
+ return [3 /*break*/, 32];
1217
+ case 35:
1218
+ G12_NAME = 'saveIntakeOnPartial answers do not overwrite a matched patient';
1219
+ return [4 /*yield*/, runScenario('G12', G12_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1220
+ var victim, attackerEmail, session, enduser;
1221
+ return __generator(this, function (_a) {
1222
+ switch (_a.label) {
1223
+ case 0: return [4 /*yield*/, makeVictim('J1', { phone: nextPhone() })]; // email-less: the load-bearing shape
1224
+ case 1:
1225
+ victim = _a.sent() // email-less: the load-bearing shape
1226
+ ;
1227
+ attackerEmail = emailFor('atk-j1');
1228
+ return [4 /*yield*/, startFormOn(partialIntake.form.id, {
1229
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1230
+ })];
1231
+ case 2:
1232
+ session = _a.sent();
1233
+ if (session.enduserId)
1234
+ createdEnduserIds.push(session.enduserId);
1235
+ if (session.enduserId !== victim.id) {
1236
+ record('G12', G12_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1237
+ return [2 /*return*/];
1238
+ }
1239
+ // No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
1240
+ // triggers the intake write.
1241
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.save_field_response({
1242
+ accessCode: session.accessCode,
1243
+ response: {
1244
+ fieldId: partialIntake.emailField.id,
1245
+ fieldTitle: partialIntake.emailField.title,
1246
+ answer: { type: 'email', value: attackerEmail },
1247
+ },
1248
+ })];
1249
+ case 3:
1250
+ // No submit at all: for fields carrying options.saveIntakeOnPartial, save_field_response alone
1251
+ // triggers the intake write.
1252
+ _a.sent();
1253
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
1254
+ case 4:
1255
+ enduser = (_a.sent()).enduser;
1256
+ record('G12', G12_NAME, !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
1257
+ ? 'email still (unset) after partial save'
1258
+ : "EXPLOIT: partial save wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " without ever submitting the form"));
1259
+ return [2 /*return*/];
1260
+ }
1261
+ });
1262
+ }); })
1263
+ // ---------------------------------------------------------------------------------------------
1264
+ // G13 — The full takeover chain, driven through an intake answer instead of the endpoint.
1265
+ // ---------------------------------------------------------------------------------------------
1266
+ ];
1267
+ case 36:
1268
+ _r.sent();
1269
+ G13_NAME = 'takeover chain via intake answer: victim not made resolvable';
1270
+ return [4 /*yield*/, runScenario('G13', G13_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1271
+ var victim, attackerEmail, session, enduser, resetRes, resolvable, after, afterResult;
1272
+ var _a;
1273
+ return __generator(this, function (_b) {
1274
+ switch (_b.label) {
1275
+ case 0: return [4 /*yield*/, makeVictim('K1', { phone: nextPhone() })];
1276
+ case 1:
1277
+ victim = _b.sent();
1278
+ attackerEmail = emailFor('atk-k1');
1279
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1280
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1281
+ })];
1282
+ case 2:
1283
+ session = _b.sent();
1284
+ if (session.enduserId)
1285
+ createdEnduserIds.push(session.enduserId);
1286
+ if (session.enduserId !== victim.id) {
1287
+ record('G13', G13_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1288
+ return [2 /*return*/];
1289
+ }
1290
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1291
+ accessCode: session.accessCode,
1292
+ responses: intakeAnswers(intake, attackerEmail, nextPhone()),
1293
+ })];
1294
+ case 3:
1295
+ _b.sent();
1296
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)
1297
+ // Informational only: 204 regardless of outcome.
1298
+ ];
1299
+ case 4:
1300
+ enduser = (_b.sent()).enduser;
1301
+ return [4 /*yield*/, post('/v1/request-enduser-password-reset', { email: attackerEmail, businessId: businessId })];
1302
+ case 5:
1303
+ resetRes = _b.sent();
1304
+ console.log(" (info) request-enduser-password-reset -> HTTP ".concat(resetRes.status, " \u2014 unconditional, carries no signal"));
1305
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { email: attackerEmail } })];
1306
+ case 6:
1307
+ resolvable = _b.sent();
1308
+ record('G13', 'attacker email resolves to no enduser after intake submit', resolvable.length === 0, resolvable.length === 0
1309
+ ? "no enduser carries ".concat(attackerEmail, " (victim still ").concat(describe(enduser), ")")
1310
+ : "EXPLOIT: ".concat(resolvable.length, " enduser(s) now resolve for ").concat(attackerEmail, " \u2014 reset token would be mailed to the attacker"));
1311
+ return [4 /*yield*/, post('/v1/begin-enduser-login-flow', { email: attackerEmail, businessId: businessId })];
1312
+ case 7:
1313
+ after = _b.sent();
1314
+ afterResult = (_a = after.data) === null || _a === void 0 ? void 0 : _a.result;
1315
+ if (after.status === 429) {
1316
+ skip('G13', 'begin_login_flow oracle after intake attack', 'rate limited (429) — oracle unavailable this run');
1317
+ }
1318
+ else {
1319
+ record('G13', 'begin_login_flow still returns continue-with-password after intake attack', afterResult === 'continue-with-password', afterResult === 'continue-with-password'
1320
+ ? "result=".concat(afterResult)
1321
+ : "EXPLOIT: result=".concat(afterResult, " \u2014 an OTP / magic link was dispatched for the attacker's address"));
1322
+ }
1323
+ return [2 /*return*/];
1324
+ }
1325
+ });
1326
+ }); })
1327
+ // ---------------------------------------------------------------------------------------------
1328
+ // G14 — Pivot: a public session can prepare a response for a NON-public form and submit that.
1329
+ // ---------------------------------------------------------------------------------------------
1330
+ ];
1331
+ case 37:
1332
+ _r.sent();
1333
+ G14_NAME = 'pivot via prepare_form_response to a non-public form is also blocked';
1334
+ return [4 /*yield*/, runScenario('G14', G14_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1335
+ var victimPhone, victim, attackerEmail, attackerPhone, session, authed, prepared, enduser;
1336
+ return __generator(this, function (_a) {
1337
+ switch (_a.label) {
1338
+ case 0:
1339
+ victimPhone = nextPhone();
1340
+ return [4 /*yield*/, makeVictim('L1', { phone: victimPhone })];
1341
+ case 1:
1342
+ victim = _a.sent();
1343
+ attackerEmail = emailFor('atk-l1');
1344
+ attackerPhone = nextPhone();
1345
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1346
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1347
+ })];
1348
+ case 2:
1349
+ session = _a.sent();
1350
+ if (session.enduserId)
1351
+ createdEnduserIds.push(session.enduserId);
1352
+ if (session.enduserId !== victim.id) {
1353
+ record('G14', G14_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
1354
+ return [2 /*return*/];
1355
+ }
1356
+ authed = asEnduser(session.authToken);
1357
+ return [4 /*yield*/, authed.api.form_responses.prepare_form_response({
1358
+ formId: pivotIntake.form.id,
1359
+ enduserId: victim.id,
1360
+ })];
1361
+ case 3:
1362
+ prepared = _a.sent();
1363
+ return [4 /*yield*/, authed.api.form_responses.submit_form_response({
1364
+ accessCode: prepared.accessCode,
1365
+ responses: intakeAnswers(pivotIntake, attackerEmail, attackerPhone),
1366
+ })];
1367
+ case 4:
1368
+ _a.sent();
1369
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)];
1370
+ case 5:
1371
+ enduser = (_a.sent()).enduser;
1372
+ record('G14', "".concat(G14_NAME, " \u2014 email"), !(enduser === null || enduser === void 0 ? void 0 : enduser.email), !(enduser === null || enduser === void 0 ? void 0 : enduser.email)
1373
+ ? 'email still (unset) after pivot submit'
1374
+ : "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email));
1375
+ record('G14', "".concat(G14_NAME, " \u2014 phone"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone, (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === victimPhone
1376
+ ? "phone still ".concat(victimPhone)
1377
+ : "EXPLOIT: pivot to a non-public form wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.phone, " (was ").concat(victimPhone, ")"));
1378
+ return [2 /*return*/];
1379
+ }
1380
+ });
1381
+ }); })
1382
+ // ---------------------------------------------------------------------------------------------
1383
+ // G15 — save_field_response ownership: a guessable formResponseId must not reach another patient.
1384
+ // ---------------------------------------------------------------------------------------------
1385
+ ];
1386
+ case 38:
1387
+ _r.sent();
1388
+ G15_NAME = "save_field_response rejects another patient's formResponseId";
1389
+ return [4 /*yield*/, runScenario('G15', G15_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1390
+ var patientA, patientB, sessionA, bPrepared, bResponseId, answer, byId, byCode, byIdWrongCode, aResponse, ownBoth;
1391
+ var _a, _b, _c;
1392
+ return __generator(this, function (_d) {
1393
+ switch (_d.label) {
1394
+ case 0: return [4 /*yield*/, makeVictim('M1', { phone: nextPhone() })];
1395
+ case 1:
1396
+ patientA = _d.sent();
1397
+ return [4 /*yield*/, makeVictim('M2', { phone: nextPhone() })];
1398
+ case 2:
1399
+ patientB = _d.sent();
1400
+ return [4 /*yield*/, startFormOn(partialIntake.form.id, {
1401
+ fname: patientA.fname, lname: patientA.lname, dateOfBirth: patientA.dateOfBirth,
1402
+ })];
1403
+ case 3:
1404
+ sessionA = _d.sent();
1405
+ if (sessionA.enduserId)
1406
+ createdEnduserIds.push(sessionA.enduserId);
1407
+ if (sessionA.enduserId !== patientA.id) {
1408
+ record('G15', G15_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(sessionA.enduserId, " instead of matching patient A ").concat(patientA.id));
1409
+ return [2 /*return*/];
1410
+ }
1411
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1412
+ formId: partialIntake.form.id, enduserId: patientB.id,
1413
+ })];
1414
+ case 4:
1415
+ bPrepared = _d.sent();
1416
+ bResponseId = (_b = (_a = bPrepared.response) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : (_c = bPrepared.response) === null || _c === void 0 ? void 0 : _c._id;
1417
+ answer = {
1418
+ fieldId: partialIntake.emailField.id,
1419
+ fieldTitle: partialIntake.emailField.title,
1420
+ answer: { type: 'email', value: emailFor('atk-m1') },
1421
+ };
1422
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, response: answer }, sessionA.authToken)];
1423
+ case 5:
1424
+ byId = _d.sent();
1425
+ record('G15', G15_NAME, byId.status === 403, byId.status === 403
1426
+ ? 'HTTP 403 as expected'
1427
+ : "EXPLOIT: HTTP ".concat(byId.status, " \u2014 patient A wrote into patient B's in-progress response"));
1428
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { accessCode: bPrepared.accessCode, response: answer }, sessionA.authToken)];
1429
+ case 6:
1430
+ byCode = _d.sent();
1431
+ record('G15', 'save_field_response still accepts a known accessCode', byCode.status === 200, "HTTP ".concat(byCode.status));
1432
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', { formResponseId: bResponseId, accessCode: 'not-a-real-access-code', response: answer }, sessionA.authToken)];
1433
+ case 7:
1434
+ byIdWrongCode = _d.sent();
1435
+ record('G15', "another patient's formResponseId + wrong accessCode is rejected", byIdWrongCode.status === 403, "HTTP ".concat(byIdWrongCode.status));
1436
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: sessionA.accessCode } })];
1437
+ case 8:
1438
+ aResponse = (_d.sent())[0];
1439
+ return [4 /*yield*/, patchAuthed('/v1/save-field-response', {
1440
+ formResponseId: aResponse === null || aResponse === void 0 ? void 0 : aResponse.id,
1441
+ accessCode: sessionA.accessCode,
1442
+ response: {
1443
+ fieldId: partialIntake.emailField.id,
1444
+ fieldTitle: partialIntake.emailField.title,
1445
+ answer: { type: 'email', value: emailFor('own-both') },
1446
+ },
1447
+ }, sessionA.authToken)];
1448
+ case 9:
1449
+ ownBoth = _d.sent();
1450
+ record('G15', 'own response with accessCode + formResponseId still succeeds (real UI shape)', ownBoth.status === 200, ownBoth.status === 200 ? 'HTTP 200' : "REGRESSION: HTTP ".concat(ownBoth.status, " \u2014 this is how the form UI calls it"));
1451
+ return [2 /*return*/];
1452
+ }
1453
+ });
1454
+ }); })
1455
+ // ---------------------------------------------------------------------------------------------
1456
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1457
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1458
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1459
+ // ---------------------------------------------------------------------------------------------
1460
+ ];
1461
+ case 39:
1462
+ _r.sent();
1463
+ // ---------------------------------------------------------------------------------------------
1464
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1465
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1466
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1467
+ // ---------------------------------------------------------------------------------------------
1468
+ return [4 /*yield*/, runScenario('G16', 'brand-new visitor still gets email/phone from intake answers', function () { return __awaiter(void 0, void 0, void 0, function () {
1469
+ var submittedEmail, submittedPhone, session, created, enduser;
1470
+ return __generator(this, function (_a) {
1471
+ switch (_a.label) {
1472
+ case 0:
1473
+ submittedEmail = emailFor('lock-new-intake');
1474
+ submittedPhone = nextPhone();
1475
+ return [4 /*yield*/, startFormOn(intake.form.id, {
1476
+ fname: "Lock".concat(RUN), lname: "NewIntake".concat(RUN), dateOfBirth: '05-06-1992',
1477
+ })];
1478
+ case 1:
1479
+ session = _a.sent();
1480
+ if (session.enduserId)
1481
+ createdEnduserIds.push(session.enduserId);
1482
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1483
+ case 2:
1484
+ created = _a.sent();
1485
+ record('G16', 'new visitor starts with no contact info from the endpoint', !created.email && !created.phone, describe(created));
1486
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1487
+ accessCode: session.accessCode,
1488
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1489
+ })];
1490
+ case 3:
1491
+ _a.sent();
1492
+ return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
1493
+ case 4:
1494
+ enduser = (_a.sent()).enduser;
1495
+ record('G16', 'brand-new visitor still gets email from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
1496
+ record('G16', 'brand-new visitor still gets phone from intake answers', (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
1497
+ return [2 /*return*/];
1498
+ }
1499
+ });
1500
+ }); })
1501
+ // ---------------------------------------------------------------------------------------------
1502
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1503
+ // ---------------------------------------------------------------------------------------------
1504
+ ];
1505
+ case 40:
1506
+ // ---------------------------------------------------------------------------------------------
1507
+ // G16 — No-regression: the intake path is the ONLY source of contact info for a new visitor.
1508
+ // This is the intakeEmailHidden / intakePhone:'hidden' configuration, and the reason the lock is
1509
+ // scoped to "matched a pre-existing patient" rather than "any public session".
1510
+ // ---------------------------------------------------------------------------------------------
1511
+ _r.sent();
1512
+ // ---------------------------------------------------------------------------------------------
1513
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1514
+ // ---------------------------------------------------------------------------------------------
1515
+ return [4 /*yield*/, runScenario('G17', 'authenticated portal enduser can still self-update via intake', function () { return __awaiter(void 0, void 0, void 0, function () {
1516
+ var patient, newEmail, newPhone, portalSDK, prepared, enduser;
1517
+ return __generator(this, function (_a) {
1518
+ switch (_a.label) {
1519
+ case 0: return [4 /*yield*/, makeVictim('N1', { email: emailFor('lock-n1'), phone: nextPhone() })];
1520
+ case 1:
1521
+ patient = _a.sent();
1522
+ newEmail = emailFor('lock-n1-new');
1523
+ newPhone = nextPhone();
1524
+ portalSDK = new sdk_1.EnduserSession({ host: host, businessId: businessId });
1525
+ return [4 /*yield*/, (0, setup_1.authenticate_enduser_via_token)(sdk, portalSDK, { id: patient.id })];
1526
+ case 2:
1527
+ _a.sent();
1528
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1529
+ formId: intake.form.id, enduserId: patient.id,
1530
+ })];
1531
+ case 3:
1532
+ prepared = _a.sent();
1533
+ return [4 /*yield*/, portalSDK.api.form_responses.submit_form_response({
1534
+ accessCode: prepared.accessCode,
1535
+ responses: intakeAnswers(intake, newEmail, newPhone),
1536
+ })];
1537
+ case 4:
1538
+ _a.sent();
1539
+ return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
1540
+ case 5:
1541
+ enduser = (_a.sent()).enduser;
1542
+ record('G17', 'authenticated portal intake still updates 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));
1543
+ record('G17', 'authenticated portal intake still updates 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));
1544
+ return [2 /*return*/];
1545
+ }
1546
+ });
1547
+ }); })
1548
+ // ---------------------------------------------------------------------------------------------
1549
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1550
+ // ---------------------------------------------------------------------------------------------
1551
+ ];
1552
+ case 41:
1553
+ // ---------------------------------------------------------------------------------------------
1554
+ // G17 — No-regression: a fully authenticated (non-public) portal session may still self-update.
1555
+ // ---------------------------------------------------------------------------------------------
1556
+ _r.sent();
1557
+ // ---------------------------------------------------------------------------------------------
1558
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1559
+ // ---------------------------------------------------------------------------------------------
1560
+ return [4 /*yield*/, runScenario('G18', 'staff intake submission still updates email/phone', function () { return __awaiter(void 0, void 0, void 0, function () {
1561
+ var patient, newEmail, newPhone, prepared, enduser;
1562
+ return __generator(this, function (_a) {
1563
+ switch (_a.label) {
1564
+ case 0: return [4 /*yield*/, makeVictim('O1', { email: emailFor('lock-o1'), phone: nextPhone() })];
1565
+ case 1:
1566
+ patient = _a.sent();
1567
+ newEmail = emailFor('lock-o1-new');
1568
+ newPhone = nextPhone();
1569
+ return [4 /*yield*/, sdk.api.form_responses.prepare_form_response({
1570
+ formId: intake.form.id, enduserId: patient.id,
1571
+ })];
1572
+ case 2:
1573
+ prepared = _a.sent();
1574
+ return [4 /*yield*/, sdk.api.form_responses.submit_form_response({
1575
+ accessCode: prepared.accessCode,
1576
+ responses: intakeAnswers(intake, newEmail, newPhone),
1577
+ })];
1578
+ case 3:
1579
+ _a.sent();
1580
+ return [4 /*yield*/, pollEnduser(patient.id, function (e) { return e.email === newEmail; }, AWAITED_WINDOW_MS)];
1581
+ case 4:
1582
+ enduser = (_a.sent()).enduser;
1583
+ record('G18', 'staff intake submission still updates 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));
1584
+ record('G18', 'staff intake submission still updates 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));
1585
+ return [2 /*return*/];
1586
+ }
1587
+ });
1588
+ }); })
1589
+ // ---------------------------------------------------------------------------------------------
1590
+ // G19 / G20 — Cross-patient session via a null fname/lname match.
1591
+ //
1592
+ // In both handlers' $or, `email` and `dateOfBirth` get `|| "never"` sentinels but fname/lname are
1593
+ // interpolated RAW. `ignoreUndefined` is not set on the Mongo client (database.ts connect options;
1594
+ // driver 5.7.0 defaults it to false), and findOne does not strip undefined keys — so an absent
1595
+ // fname serializes to `{fname: null}`, which matches every document where the field is null OR
1596
+ // MISSING. fname/lname have no schema initializer, so a cold-started enduser has them absent.
1597
+ //
1598
+ // Net: a request supplying ONLY dateOfBirth queries {fname: null, lname: null, dateOfBirth: X} and
1599
+ // can match another patient, returning a session scoped to THEIR record. The cold-start pattern is
1600
+ // what manufactures nameless endusers, and a later intakeField: 'dateOfBirth' question fills the DOB.
1601
+ // ---------------------------------------------------------------------------------------------
1602
+ // dateOfBirth is only string-validated and the handlers merely trim it, so a run-tokenized value is
1603
+ // accepted. Deliberately opaque rather than date-shaped, so match-vs-insert is unambiguous even when
1604
+ // this file runs inside the full suite (no reset_db between files).
1605
+ ];
1606
+ case 42:
1607
+ // ---------------------------------------------------------------------------------------------
1608
+ // G18 — No-regression: staff submitting on a patient's behalf may still set email/phone.
1609
+ // ---------------------------------------------------------------------------------------------
1610
+ _r.sent();
1611
+ namelessDob = "01-02-1990-".concat(RUN);
1612
+ makeNamelessVictim = function (tag) { return __awaiter(void 0, void 0, void 0, function () {
1613
+ var victim, stored;
1614
+ return __generator(this, function (_a) {
1615
+ switch (_a.label) {
1616
+ case 0: return [4 /*yield*/, sdk.api.endusers.createOne({ dateOfBirth: "".concat(namelessDob, "-").concat(tag) })];
1617
+ case 1:
1618
+ victim = _a.sent();
1619
+ createdEnduserIds.push(victim.id);
1620
+ return [4 /*yield*/, sdk.api.endusers.getOne(victim.id)];
1621
+ case 2:
1622
+ stored = _a.sent();
1623
+ return [2 /*return*/, { victim: victim, stored: stored }];
1624
+ }
1625
+ });
1626
+ }); };
1627
+ G19_NAME = 'DOB-only request does not hijack a nameless patient (form endpoint)';
1628
+ return [4 /*yield*/, runScenario('G19', G19_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1629
+ var _a, victim, stored, session;
1630
+ return __generator(this, function (_b) {
1631
+ switch (_b.label) {
1632
+ case 0: return [4 /*yield*/, makeNamelessVictim('f')
1633
+ // Probe validity: a stored '' would not be null-matched and would make this test vacuous.
1634
+ ];
1635
+ case 1:
1636
+ _a = _b.sent(), victim = _a.victim, stored = _a.stored;
1637
+ // Probe validity: a stored '' would not be null-matched and would make this test vacuous.
1638
+ record('G19', 'nameless victim really has absent fname/lname (probe validity)', stored.fname === undefined && stored.lname === undefined, "fname=".concat(JSON.stringify(stored.fname), " lname=").concat(JSON.stringify(stored.lname)));
1639
+ return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: stored.dateOfBirth })];
1640
+ case 2:
1641
+ session = _b.sent();
1642
+ if (session.enduserId)
1643
+ createdEnduserIds.push(session.enduserId);
1644
+ record('G19', G19_NAME, session.enduserId !== victim.id, session.enduserId !== victim.id
1645
+ ? "inserted a new record (".concat(session.enduserId, ") instead of matching the victim")
1646
+ : "EXPLOIT: returned a session for the victim's record ".concat(victim.id, " from a DOB-only request"));
1647
+ return [2 /*return*/];
1648
+ }
1649
+ });
1650
+ }); })];
1651
+ case 43:
1652
+ _r.sent();
1653
+ G20_NAME = 'DOB-only request does not hijack a nameless patient (booking endpoint)';
1654
+ return [4 /*yield*/, runScenario('G20', G20_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1655
+ var _a, victim, stored, dob, withDob;
1656
+ return __generator(this, function (_b) {
1657
+ switch (_b.label) {
1658
+ case 0: return [4 /*yield*/, makeNamelessVictim('b')];
1659
+ case 1:
1660
+ _a = _b.sent(), victim = _a.victim, stored = _a.stored;
1661
+ dob = stored.dateOfBirth;
1662
+ // The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
1663
+ // reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
1664
+ // records carrying this run-unique DOB.
1665
+ return [4 /*yield*/, startBooking({ dateOfBirth: dob })];
1666
+ case 2:
1667
+ // The booking endpoint has no skipMatch option and no phone guard at all, so this filter is
1668
+ // reached unconditionally. It returns only { authToken }, so detect match-vs-insert by counting
1669
+ // records carrying this run-unique DOB.
1670
+ _b.sent();
1671
+ return [4 /*yield*/, sdk.api.endusers.getSome({ filter: { dateOfBirth: dob } })];
1672
+ case 3:
1673
+ withDob = _b.sent();
1674
+ withDob.forEach(function (e) { if (e.id !== victim.id)
1675
+ createdEnduserIds.push(e.id); });
1676
+ record('G20', G20_NAME, withDob.length > 1, withDob.length > 1
1677
+ ? "inserted a new record (".concat(withDob.length, " total with this DOB) instead of matching the victim")
1678
+ : "EXPLOIT: matched the victim's record ".concat(victim.id, " from a DOB-only request \u2014 a booking session was issued for it"));
1679
+ return [2 /*return*/];
1680
+ }
1681
+ });
1682
+ }); })
1683
+ // ---------------------------------------------------------------------------------------------
1684
+ // G21 — The match sentinel must never be stored as a patient's name.
1685
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1686
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1687
+ // ---------------------------------------------------------------------------------------------
1688
+ ];
1689
+ case 44:
1690
+ _r.sent();
1691
+ // ---------------------------------------------------------------------------------------------
1692
+ // G21 — The match sentinel must never be stored as a patient's name.
1693
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1694
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1695
+ // ---------------------------------------------------------------------------------------------
1696
+ return [4 /*yield*/, runScenario('G21', 'match sentinel never leaks into stored fname/lname', function () { return __awaiter(void 0, void 0, void 0, function () {
1697
+ var session, created, leaked, response;
1698
+ return __generator(this, function (_a) {
1699
+ switch (_a.label) {
1700
+ case 0: return [4 /*yield*/, startFormOn(intake.form.id, { dateOfBirth: "".concat(namelessDob, "-sentinel") })];
1701
+ case 1:
1702
+ session = _a.sent();
1703
+ if (session.enduserId)
1704
+ createdEnduserIds.push(session.enduserId);
1705
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1706
+ case 2:
1707
+ created = _a.sent();
1708
+ leaked = function (v) { return v === '__never__' || v === 'never'; };
1709
+ record('G21', 'created enduser fname/lname are not the sentinel', !leaked(created.fname) && !leaked(created.lname), "fname=".concat(JSON.stringify(created.fname), " lname=").concat(JSON.stringify(created.lname)));
1710
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
1711
+ case 3:
1712
+ response = (_a.sent())[0];
1713
+ record('G21', 'form_responses fname/lname are not the sentinel', !leaked(response === null || response === void 0 ? void 0 : response.fname) && !leaked(response === null || response === void 0 ? void 0 : response.lname), "fname=".concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.fname), " lname=").concat(JSON.stringify(response === null || response === void 0 ? void 0 : response.lname)));
1714
+ return [2 /*return*/];
1715
+ }
1716
+ });
1717
+ }); })
1718
+ // ---------------------------------------------------------------------------------------------
1719
+ // G22 — The cold-start pattern: a public form started with NO intake info, which just creates an
1720
+ // Enduser with an ID. Intake answers (including the authentication fields email/phone) must still
1721
+ // populate that fresh record — the lock is scoped to "matched a pre-existing patient", not to
1722
+ // "any public session", precisely so this keeps working.
1723
+ // ---------------------------------------------------------------------------------------------
1724
+ ];
1725
+ case 45:
1726
+ // ---------------------------------------------------------------------------------------------
1727
+ // G21 — The match sentinel must never be stored as a patient's name.
1728
+ // Guard rail on the fix: the fallback belongs in the filter literal only, never written into
1729
+ // enduserInfo (which is spread into both the enduser insert and the form_responses record).
1730
+ // ---------------------------------------------------------------------------------------------
1731
+ _r.sent();
1732
+ coldStartCase = function (label, args) { return __awaiter(void 0, void 0, void 0, function () {
1733
+ var group, submittedEmail, submittedPhone, session, created, enduser;
1734
+ return __generator(this, function (_a) {
1735
+ switch (_a.label) {
1736
+ case 0:
1737
+ group = 'G22';
1738
+ submittedEmail = emailFor("cold-".concat(label));
1739
+ submittedPhone = nextPhone();
1740
+ return [4 /*yield*/, startFormOn(intake.form.id, args)];
1741
+ case 1:
1742
+ session = _a.sent();
1743
+ if (session.enduserId)
1744
+ createdEnduserIds.push(session.enduserId);
1745
+ return [4 /*yield*/, sdk.api.endusers.getOne(session.enduserId)];
1746
+ case 2:
1747
+ created = _a.sent();
1748
+ record(group, "".concat(label, ": cold start creates a bare enduser"), !created.email && !created.phone && !!session.enduserId, describe(created));
1749
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
1750
+ accessCode: session.accessCode,
1751
+ responses: intakeAnswers(intake, submittedEmail, submittedPhone),
1752
+ })];
1753
+ case 3:
1754
+ _a.sent();
1755
+ return [4 /*yield*/, pollEnduser(session.enduserId, function (e) { return !!e.email && !!e.phone; }, AWAITED_WINDOW_MS)];
1756
+ case 4:
1757
+ enduser = (_a.sent()).enduser;
1758
+ record(group, "".concat(label, ": intake email (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === submittedEmail, "email=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.email) || '(unset)', " expected=").concat(submittedEmail));
1759
+ record(group, "".concat(label, ": intake phone (an auth field) still populates the cold-started record"), (enduser === null || enduser === void 0 ? void 0 : enduser.phone) === submittedPhone, "phone=".concat((enduser === null || enduser === void 0 ? void 0 : enduser.phone) || '(unset)', " expected=").concat(submittedPhone));
1760
+ return [2 /*return*/];
1761
+ }
1762
+ });
1763
+ }); };
1764
+ // The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
1765
+ return [4 /*yield*/, runScenario('G22', 'cold start with skipMatch (real webapp shape)', function () { return coldStartCase('skipMatch', { skipMatch: true }); })
1766
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1767
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1768
+ ];
1769
+ case 46:
1770
+ // The shape the real webapp sends (public_form.tsx handleNoIntakeRedirect): nothing but ids.
1771
+ _r.sent();
1772
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1773
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1774
+ return [4 /*yield*/, runScenario('G22', 'cold start without skipMatch', function () { return coldStartCase('no-skipMatch', {}); })
1775
+ // ---------------------------------------------------------------------------------------------
1776
+ // G23 — businessId must be required on session_for_public_form.
1777
+ //
1778
+ // addPublicEndpoint overwrites the schema's `required: true` for businessId with
1779
+ // mongoIdStringOptional (routing.ts, and its own "todo: make businessId required"), and TypeScript
1780
+ // cannot catch it because CustomAction declares businessId as a non-optional string.
1781
+ //
1782
+ // A falsy businessId then leaves restrictAccess as the IDENTITY function in
1783
+ // buildAllQueries/buildOrganizationWideQueries (database.ts): the tenant clause is not appended at
1784
+ // all, so every query in the handler runs across ALL tenants. It fails OPEN, which is worse than
1785
+ // matching nothing. login_enduser signs the JWT from enduser.businessId, not from the request, so
1786
+ // the returned token is a fully valid session on whatever record matched.
1787
+ //
1788
+ // session_for_public_appointment_booking already guards this (its own comment says the schema
1789
+ // cannot enforce it); this endpoint did not. Note the sentinel fix in G19/G20 does NOT cover this:
1790
+ // sentinels constrain which record shapes the filter can match, not which tenants it searches.
1791
+ // ---------------------------------------------------------------------------------------------
1792
+ ];
1793
+ case 47:
1794
+ // And the same with no skipMatch, which still cannot match: the email branch needs email === "never"
1795
+ // (unstorable) and the name branch needs dateOfBirth === "never" literally.
1796
+ _r.sent();
1797
+ G23_NAME = 'session_for_public_form requires businessId';
1798
+ return [4 /*yield*/, runScenario('G23', G23_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1799
+ var victim, omitted, blocked, wrongBiz, badBiz;
1800
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
1801
+ return __generator(this, function (_m) {
1802
+ switch (_m.label) {
1803
+ case 0: return [4 /*yield*/, makeVictim('P1', { email: emailFor('lock-bizid'), phone: nextPhone() })
1804
+ // (a) THE ISSUE. Omit businessId entirely and name the patient directly via enduserId, which
1805
+ // reaches an untenanted findById — no name/DOB guessing required. Pre-fix this hands back a
1806
+ // working session for that patient; post-fix it must 400 before touching the database.
1807
+ ];
1808
+ case 1:
1809
+ victim = _m.sent();
1810
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1811
+ formId: intake.form.id,
1812
+ enduserId: victim.id,
1813
+ })];
1814
+ case 2:
1815
+ omitted = _m.sent();
1816
+ if ((_a = omitted.data) === null || _a === void 0 ? void 0 : _a.enduserId)
1817
+ createdEnduserIds.push(omitted.data.enduserId);
1818
+ blocked = omitted.status === 400 && ((_b = omitted.data) === null || _b === void 0 ? void 0 : _b.message) === 'businessId is required';
1819
+ record('G23', "".concat(G23_NAME, " \u2014 omitted businessId is rejected"), blocked, blocked
1820
+ ? 'HTTP 400 businessId is required'
1821
+ : "EXPLOIT: HTTP ".concat(omitted.status, " \u2014 got enduserId=").concat((_c = omitted.data) === null || _c === void 0 ? void 0 : _c.enduserId, " authToken=").concat(!!((_d = omitted.data) === null || _d === void 0 ? void 0 : _d.authToken), " for victim ").concat(victim.id, " with NO businessId, i.e. tenant filtering was disabled entirely"));
1822
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1823
+ formId: intake.form.id,
1824
+ enduserId: victim.id,
1825
+ businessId: '000000000000000000000001',
1826
+ })];
1827
+ case 3:
1828
+ wrongBiz = _m.sent();
1829
+ record('G23', "".concat(G23_NAME, " \u2014 a wrong businessId cannot reach the patient"), wrongBiz.status !== 200 && ((_e = wrongBiz.data) === null || _e === void 0 ? void 0 : _e.enduserId) !== victim.id, "HTTP ".concat(wrongBiz.status, " ").concat(JSON.stringify((_j = (_g = (_f = wrongBiz.data) === null || _f === void 0 ? void 0 : _f.message) !== null && _g !== void 0 ? _g : (_h = wrongBiz.data) === null || _h === void 0 ? void 0 : _h.enduserId) !== null && _j !== void 0 ? _j : '')));
1830
+ return [4 /*yield*/, post('/v1/session-for-public-form', {
1831
+ formId: intake.form.id,
1832
+ enduserId: victim.id,
1833
+ businessId: 'not-a-mongo-id',
1834
+ })];
1835
+ case 4:
1836
+ badBiz = _m.sent();
1837
+ record('G23', "".concat(G23_NAME, " \u2014 a malformed businessId is rejected"), badBiz.status === 400, "HTTP ".concat(badBiz.status, " ").concat(JSON.stringify((_l = (_k = badBiz.data) === null || _k === void 0 ? void 0 : _k.message) !== null && _l !== void 0 ? _l : '')));
1838
+ return [2 /*return*/];
1839
+ }
1840
+ });
1841
+ }); })
1842
+ // ---------------------------------------------------------------------------------------------
1843
+ // G24 / G25 — The token prepare_form_response mints drops `fromPublicSession`.
1844
+ //
1845
+ // The lock in prepare_form_response keys off session.fromPublicSession, but the token it mints
1846
+ // preserved the session's confinement WITHOUT that flag and handed it back in fullURL (?token=...).
1847
+ // Since prepare_form_response is itself in the 'public-form' session scope, a caller can hop twice:
1848
+ // the first hop's response is locked, the second hop's is not — because by then the session no longer
1849
+ // looks like a public one. Submitting an intakeField: 'email' answer against that second response
1850
+ // then repoints the victim's login channel.
1851
+ //
1852
+ // The same flag also gates PHI on refresh_session (G25): when it is falsy the handler returns
1853
+ // redact_field(enduser, 'hashedPassword') — the whole record — instead of just assignedTo.
1854
+ // ---------------------------------------------------------------------------------------------
1855
+ ];
1856
+ case 48:
1857
+ _r.sent();
1858
+ chainForVictim = function (tag, victim) { return __awaiter(void 0, void 0, void 0, function () {
1859
+ var t1, hop1;
1860
+ return __generator(this, function (_a) {
1861
+ switch (_a.label) {
1862
+ case 0: return [4 /*yield*/, startFormOn(intake.form.id, {
1863
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
1864
+ })];
1865
+ case 1:
1866
+ t1 = _a.sent();
1867
+ if (t1.enduserId)
1868
+ createdEnduserIds.push(t1.enduserId);
1869
+ if (t1.enduserId !== victim.id)
1870
+ return [2 /*return*/, { matched: false, t1: t1, t2: undefined, hop1: undefined }];
1871
+ return [4 /*yield*/, asEnduser(t1.authToken).api.form_responses.prepare_form_response({
1872
+ formId: pivotIntake.form.id, enduserId: victim.id,
1873
+ })];
1874
+ case 2:
1875
+ hop1 = _a.sent();
1876
+ return [2 /*return*/, { matched: true, t1: t1, hop1: hop1, t2: tokenFromURL(hop1 === null || hop1 === void 0 ? void 0 : hop1.fullURL) }];
1877
+ }
1878
+ });
1879
+ }); };
1880
+ G24_NAME = 'chained prepare_form_response session stays locked';
1881
+ return [4 /*yield*/, runScenario('G24', G24_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1882
+ var victimEmail, victim, attackerEmail, _a, matched, t2, hop2, hop2Response, enduser;
1883
+ return __generator(this, function (_b) {
1884
+ switch (_b.label) {
1885
+ case 0:
1886
+ victimEmail = emailFor('lock-q1');
1887
+ return [4 /*yield*/, makeVictim('Q1', { email: victimEmail, phone: nextPhone() })];
1888
+ case 1:
1889
+ victim = _b.sent();
1890
+ attackerEmail = emailFor('atk-q1');
1891
+ return [4 /*yield*/, chainForVictim('Q1', victim)];
1892
+ case 2:
1893
+ _a = _b.sent(), matched = _a.matched, t2 = _a.t2;
1894
+ if (!matched) {
1895
+ record('G24', G24_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
1896
+ return [2 /*return*/];
1897
+ }
1898
+ record('G24', 'chained token is returned in fullURL (probe validity)', !!t2, "token extracted=".concat(!!t2));
1899
+ if (!t2)
1900
+ return [2 /*return*/];
1901
+ return [4 /*yield*/, asEnduser(t2).api.form_responses.prepare_form_response({
1902
+ formId: pivotIntake.form.id, enduserId: victim.id,
1903
+ })];
1904
+ case 3:
1905
+ hop2 = _b.sent();
1906
+ record('G24', 'second-hop prepare succeeds (probe validity)', !!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode), "accessCode=".concat(!!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode)));
1907
+ if (!(hop2 === null || hop2 === void 0 ? void 0 : hop2.accessCode))
1908
+ return [2 /*return*/];
1909
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: hop2.accessCode } })];
1910
+ case 4:
1911
+ hop2Response = (_b.sent())[0];
1912
+ record('G24', "".concat(G24_NAME, " \u2014 second-hop response carries the lock"), (hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled) === true, "authFieldIntakeDisabled=".concat(JSON.stringify(hop2Response === null || hop2Response === void 0 ? void 0 : hop2Response.authFieldIntakeDisabled)));
1913
+ // The assertion that actually matters: does the victim's login channel move?
1914
+ return [4 /*yield*/, asEnduser(t2).api.form_responses.submit_form_response({
1915
+ accessCode: hop2.accessCode,
1916
+ responses: intakeAnswers(pivotIntake, attackerEmail, nextPhone()),
1917
+ })];
1918
+ case 5:
1919
+ // The assertion that actually matters: does the victim's login channel move?
1920
+ _b.sent();
1921
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
1922
+ case 6:
1923
+ enduser = (_b.sent()).enduser;
1924
+ record('G24', "".concat(G24_NAME, " \u2014 victim email unchanged after chained submit"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
1925
+ ? "email still ".concat(victimEmail)
1926
+ : "EXPLOIT: chained session wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " via an intake answer"));
1927
+ return [2 /*return*/];
1928
+ }
1929
+ });
1930
+ }); })];
1931
+ case 49:
1932
+ _r.sent();
1933
+ G25_NAME = 'chained session does not expose the enduser record on refresh';
1934
+ return [4 /*yield*/, runScenario('G25', G25_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1935
+ var victim, _a, matched, t1, t2, PHI_KEYS, leakedIn, base, chained, leaked;
1936
+ return __generator(this, function (_b) {
1937
+ switch (_b.label) {
1938
+ case 0: return [4 /*yield*/, makeVictim('R1', { email: emailFor('lock-r1'), phone: nextPhone() })];
1939
+ case 1:
1940
+ victim = _b.sent();
1941
+ return [4 /*yield*/, chainForVictim('R1', victim)];
1942
+ case 2:
1943
+ _a = _b.sent(), matched = _a.matched, t1 = _a.t1, t2 = _a.t2;
1944
+ if (!matched) {
1945
+ record('G25', G25_NAME, false, "probe invalid \u2014 first hop did not match victim ".concat(victim.id));
1946
+ return [2 /*return*/];
1947
+ }
1948
+ PHI_KEYS = ['email', 'phone', 'dateOfBirth', 'fname', 'lname'];
1949
+ leakedIn = function (body) { return PHI_KEYS.filter(function (k) { var _a; return ((_a = body === null || body === void 0 ? void 0 : body.enduser) !== null && _a !== void 0 ? _a : {})[k] !== undefined; }); };
1950
+ return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t1.authToken)];
1951
+ case 3:
1952
+ base = _b.sent();
1953
+ record('G25', 'baseline: original public token withholds the record (control)', base.status === 200 && leakedIn(base.data).length === 0, "HTTP ".concat(base.status, " leaked=[").concat(leakedIn(base.data).join(', '), "]"));
1954
+ if (!t2) {
1955
+ record('G25', G25_NAME, false, 'probe invalid — no chained token');
1956
+ return [2 /*return*/];
1957
+ }
1958
+ return [4 /*yield*/, postAuthed('/v1/refresh-enduser-session', {}, t2)];
1959
+ case 4:
1960
+ chained = _b.sent();
1961
+ leaked = leakedIn(chained.data);
1962
+ record('G25', G25_NAME, chained.status === 200 && leaked.length === 0, leaked.length === 0
1963
+ ? "HTTP ".concat(chained.status, ", no PHI fields returned")
1964
+ : "EXPLOIT: refresh under the chained token returned [".concat(leaked.join(', '), "] for victim ").concat(victim.id));
1965
+ return [2 /*return*/];
1966
+ }
1967
+ });
1968
+ }); })
1969
+ // ---------------------------------------------------------------------------------------------
1970
+ // G26 — Auto-merge re-points the response after the lock was computed.
1971
+ //
1972
+ // authFieldIntakeDisabled is a snapshot taken at session start (`!!existing`). With skipMatch it is
1973
+ // correctly false, because a fresh record really was inserted. But on submission
1974
+ // resolveAutoMergeBeforeSubmission repoints the response at a PRE-EXISTING patient and persists only
1975
+ // enduserId — so the intake write later reads a stale `false` and lands email/phone on the victim.
1976
+ // The merge matches on { fname, lname, dateOfBirth }, so the attacker never needs to know the
1977
+ // victim's email or phone.
1978
+ //
1979
+ // performAutoMerge copies no demographic fields itself, so the intake write is the only channel —
1980
+ // which is why the custom-field assertion below is essential: it proves the write RAN, so "email
1981
+ // unchanged" cannot pass just because nothing happened.
1982
+ // ---------------------------------------------------------------------------------------------
1983
+ ];
1984
+ case 50:
1985
+ _r.sent();
1986
+ G26_NAME = 'auto-merge does not let an intake answer repoint the merged patient';
1987
+ return [4 /*yield*/, runScenario('G26', G26_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
1988
+ var victimEmail, victimPhone, victimDob, victim, attackerEmail, session, sourceId, f, after, merged, customLanded;
1989
+ var _a, _b, _c;
1990
+ return __generator(this, function (_d) {
1991
+ switch (_d.label) {
1992
+ case 0:
1993
+ victimEmail = emailFor('lock-s1');
1994
+ victimPhone = nextPhone();
1995
+ victimDob = '07-08-1988';
1996
+ return [4 /*yield*/, sdk.api.endusers.createOne({
1997
+ fname: "Lock".concat(RUN),
1998
+ lname: "Merge".concat(RUN, "S1"),
1999
+ dateOfBirth: victimDob,
2000
+ email: victimEmail,
2001
+ phone: victimPhone,
2002
+ })];
2003
+ case 1:
2004
+ victim = _d.sent();
2005
+ createdEnduserIds.push(victim.id);
2006
+ attackerEmail = emailFor('atk-s1');
2007
+ return [4 /*yield*/, startFormOn(autoMergeIntake.form.id, { skipMatch: true })];
2008
+ case 2:
2009
+ session = _d.sent();
2010
+ if (session.enduserId)
2011
+ createdEnduserIds.push(session.enduserId);
2012
+ sourceId = session.enduserId;
2013
+ f = autoMergeIntake;
2014
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
2015
+ accessCode: session.accessCode,
2016
+ responses: [
2017
+ { fieldId: f.fname.id, fieldTitle: f.fname.title, answer: { type: 'string', value: victim.fname } },
2018
+ { fieldId: f.lname.id, fieldTitle: f.lname.title, answer: { type: 'string', value: victim.lname } },
2019
+ { fieldId: f.dateOfBirth.id, fieldTitle: f.dateOfBirth.title, answer: { type: 'dateString', value: victimDob } },
2020
+ { fieldId: f.email.id, fieldTitle: f.email.title, answer: { type: 'email', value: attackerEmail } },
2021
+ { fieldId: f.custom.id, fieldTitle: f.custom.title, answer: { type: 'string', value: 'merged-ok' } },
2022
+ ],
2023
+ })];
2024
+ case 3:
2025
+ _d.sent();
2026
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail; }, OVERWRITE_WINDOW_MS)];
2027
+ case 4:
2028
+ after = (_d.sent()).enduser;
2029
+ merged = ((_a = after === null || after === void 0 ? void 0 : after.mergedIds) !== null && _a !== void 0 ? _a : []).includes(sourceId);
2030
+ record('G26', 'auto-merge actually resolved to the victim (probe validity)', merged, "mergedIds=".concat(JSON.stringify(after === null || after === void 0 ? void 0 : after.mergedIds), " source=").concat(sourceId));
2031
+ customLanded = ((_b = after === null || after === void 0 ? void 0 : after.fields) === null || _b === void 0 ? void 0 : _b[MERGE_CUSTOM_KEY]) === 'merged-ok';
2032
+ record('G26', 'non-auth intake fields still reconcile onto the merged patient (anti-vacuity)', customLanded, "fields.".concat(MERGE_CUSTOM_KEY, "=").concat(JSON.stringify((_c = after === null || after === void 0 ? void 0 : after.fields) === null || _c === void 0 ? void 0 : _c[MERGE_CUSTOM_KEY])));
2033
+ record('G26', "".concat(G26_NAME, " \u2014 email"), (after === null || after === void 0 ? void 0 : after.email) === victimEmail, (after === null || after === void 0 ? void 0 : after.email) === victimEmail
2034
+ ? "email still ".concat(victimEmail)
2035
+ : "EXPLOIT: auto-merge intake write set ".concat(after === null || after === void 0 ? void 0 : after.email, " on the merged-into victim"));
2036
+ record('G26', "".concat(G26_NAME, " \u2014 phone"), (after === null || after === void 0 ? void 0 : after.phone) === victimPhone, (after === null || after === void 0 ? void 0 : after.phone) === victimPhone ? "phone still ".concat(victimPhone) : "EXPLOIT: phone became ".concat(after === null || after === void 0 ? void 0 : after.phone));
2037
+ return [2 /*return*/];
2038
+ }
2039
+ });
2040
+ }); })
2041
+ // ---------------------------------------------------------------------------------------------
2042
+ // G27 — The STAFF merge re-points responses too, and must also apply the lock.
2043
+ //
2044
+ // Same shape as G26 but through endusers.merge rather than autoMergeOnSubmission: the response is
2045
+ // created by a skipMatch public session (so the flag is correctly false — a fresh record really was
2046
+ // inserted), then staff resolve that ephemeral duplicate into a real patient. The merge loop copies
2047
+ // enduserId onto every model that has one, so without the fix the response ends up attached to a
2048
+ // patient who pre-existed it while still carrying a stale `false`.
2049
+ //
2050
+ // Note the source enduser is DELETED by the merge, which kills the original public token — so the
2051
+ // stored flag is the load-bearing assertion here, not a follow-up submit.
2052
+ // ---------------------------------------------------------------------------------------------
2053
+ ];
2054
+ case 51:
2055
+ _r.sent();
2056
+ G27_NAME = 'staff merge applies the lock when it repoints a response';
2057
+ return [4 /*yield*/, runScenario('G27', G27_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
2058
+ var victimEmail, victim, session, beforeMerge, afterMerge, repointed, stillVictim;
2059
+ return __generator(this, function (_a) {
2060
+ switch (_a.label) {
2061
+ case 0:
2062
+ victimEmail = emailFor('lock-t1');
2063
+ return [4 /*yield*/, makeVictim('T1', { email: victimEmail, phone: nextPhone() })];
2064
+ case 1:
2065
+ victim = _a.sent();
2066
+ return [4 /*yield*/, startFormOn(intake.form.id, { skipMatch: true })];
2067
+ case 2:
2068
+ session = _a.sent();
2069
+ if (session.enduserId)
2070
+ createdEnduserIds.push(session.enduserId);
2071
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })
2072
+ // Probe validity: if this were already true the test could not distinguish the fix from the
2073
+ // pre-existing state.
2074
+ ];
2075
+ case 3:
2076
+ beforeMerge = (_a.sent())[0];
2077
+ // Probe validity: if this were already true the test could not distinguish the fix from the
2078
+ // pre-existing state.
2079
+ record('G27', 'skipMatch response starts unlocked (probe validity)', (beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled) === false, "authFieldIntakeDisabled=".concat(JSON.stringify(beforeMerge === null || beforeMerge === void 0 ? void 0 : beforeMerge.authFieldIntakeDisabled)));
2080
+ return [4 /*yield*/, sdk.api.endusers.merge({
2081
+ sourceEnduserId: session.enduserId,
2082
+ destinationEnduserId: victim.id,
2083
+ })];
2084
+ case 4:
2085
+ _a.sent();
2086
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { accessCode: session.accessCode } })];
2087
+ case 5:
2088
+ afterMerge = (_a.sent())[0];
2089
+ repointed = (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId) === victim.id;
2090
+ record('G27', 'merge repointed the response at the pre-existing patient (probe validity)', repointed, "enduserId=".concat(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.enduserId, " expected=").concat(victim.id));
2091
+ record('G27', G27_NAME, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true, (afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled) === true
2092
+ ? 'authFieldIntakeDisabled=true'
2093
+ : "EXPLOIT: response now points at pre-existing patient ".concat(victim.id, " with ")
2094
+ + "authFieldIntakeDisabled=".concat(JSON.stringify(afterMerge === null || afterMerge === void 0 ? void 0 : afterMerge.authFieldIntakeDisabled), " \u2014 ")
2095
+ + "an intake email/phone answer would repoint their login channel");
2096
+ return [4 /*yield*/, sdk.api.endusers.getOne(victim.id).catch(function () { return null; })];
2097
+ case 6:
2098
+ stillVictim = _a.sent();
2099
+ record('G27', 'merge did not itself move the victim email', (stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) === victimEmail, "email=".concat((stillVictim === null || stillVictim === void 0 ? void 0 : stillVictim.email) || '(unset)', " expected=").concat(victimEmail));
2100
+ return [2 /*return*/];
2101
+ }
2102
+ });
2103
+ }); })
2104
+ // ---------------------------------------------------------------------------------------------
2105
+ // G28 — SCOPE BOUNDARY for the Stripe intake grace.
2106
+ //
2107
+ // The formsort / form-ingestion webhook re-opens a BLANK email/phone for one hour after the Stripe
2108
+ // integration creates a contact (enduser_is_provisional_stripe_record, exercised by G12/R12-R15 in
2109
+ // formsort_webhook_auth_field_lock.test.ts). That endpoint is API-key authenticated. THIS path is
2110
+ // anonymous, and its match only needs a name + DOB or an email, so the same exemption here would
2111
+ // hand an attacker an OTP login channel on a patient who just paid. It must not apply.
2112
+ //
2113
+ // This gate exists because the tempting refactor — moving the grace down into
2114
+ // save_enduser_updates_for_form_response, the single chokepoint — would silently extend it to every
2115
+ // caller including this one, with no other test noticing.
2116
+ // ---------------------------------------------------------------------------------------------
2117
+ ];
2118
+ case 52:
2119
+ _r.sent();
2120
+ G28_NAME = 'a fresh Stripe-created patient gets no grace on the public form path';
2121
+ return [4 /*yield*/, runScenario('G28', G28_NAME, function () { return __awaiter(void 0, void 0, void 0, function () {
2122
+ var victimEmail, victim, attackerEmail, attackerPhone, session, enduser;
2123
+ return __generator(this, function (_a) {
2124
+ switch (_a.label) {
2125
+ case 0:
2126
+ victimEmail = emailFor('lock-stripe');
2127
+ return [4 /*yield*/, makeVictim('Stripe', {
2128
+ email: victimEmail,
2129
+ source: 'Stripe',
2130
+ stripeCustomerId: "cus_".concat(RUN, "g28"),
2131
+ })];
2132
+ case 1:
2133
+ victim = _a.sent();
2134
+ attackerEmail = emailFor('atk-stripe');
2135
+ attackerPhone = nextPhone();
2136
+ return [4 /*yield*/, startFormOn(intake.form.id, {
2137
+ fname: victim.fname, lname: victim.lname, dateOfBirth: victim.dateOfBirth,
2138
+ })];
2139
+ case 2:
2140
+ session = _a.sent();
2141
+ if (session.enduserId)
2142
+ createdEnduserIds.push(session.enduserId);
2143
+ if (session.enduserId !== victim.id) {
2144
+ record('G28', G28_NAME, false, "probe invalid \u2014 endpoint inserted ".concat(session.enduserId, " instead of matching victim ").concat(victim.id));
2145
+ return [2 /*return*/];
2146
+ }
2147
+ return [4 /*yield*/, asEnduser(session.authToken).api.form_responses.submit_form_response({
2148
+ accessCode: session.accessCode,
2149
+ responses: intakeAnswers(intake, attackerEmail, attackerPhone),
2150
+ })];
2151
+ case 3:
2152
+ _a.sent();
2153
+ return [4 /*yield*/, pollEnduser(victim.id, function (e) { return e.email === attackerEmail || e.phone === attackerPhone; }, OVERWRITE_WINDOW_MS)
2154
+ // phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
2155
+ ];
2156
+ case 4:
2157
+ enduser = (_a.sent()).enduser;
2158
+ // phone is UNSET on the victim, so this is exactly the shape the webhook grace would have filled
2159
+ record('G28', "".concat(G28_NAME, " \u2014 phone"), !(enduser === null || enduser === void 0 ? void 0 : enduser.phone), (enduser === null || enduser === void 0 ? void 0 : enduser.phone)
2160
+ ? "EXPLOIT: intake answer wrote phone ".concat(enduser.phone, " on a Stripe-created patient (was unset)")
2161
+ : 'phone still (unset)');
2162
+ record('G28', "".concat(G28_NAME, " \u2014 email"), (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail, (enduser === null || enduser === void 0 ? void 0 : enduser.email) === victimEmail
2163
+ ? "email still ".concat(victimEmail)
2164
+ : "EXPLOIT: intake answer wrote ".concat(enduser === null || enduser === void 0 ? void 0 : enduser.email, " (was ").concat(victimEmail, ")"));
2165
+ return [2 /*return*/];
2166
+ }
2167
+ });
2168
+ }); })
2169
+ // ---------------------------------------------------------------------------------------------
2170
+ // Report
2171
+ // ---------------------------------------------------------------------------------------------
2172
+ ];
2173
+ case 53:
2174
+ _r.sent();
2175
+ failures = results.filter(function (r) { return !r.ok; });
2176
+ skipped = results.filter(function (r) { return !!r.skipped; });
2177
+ console.log('');
2178
+ console.log(' ┌─ Public endpoint auth-field lock — summary ────────────────────────────');
2179
+ for (_d = 0, results_1 = results; _d < results_1.length; _d++) {
2180
+ r = results_1[_d];
2181
+ status_1 = r.skipped ? 'SKIP' : r.ok ? 'PASS' : 'FAIL';
2182
+ console.log(" \u2502 ".concat(status_1, " ").concat(r.group.padEnd(3), " ").concat(r.name).concat(r.detail ? " \u2014 ".concat(r.detail) : ''));
2183
+ }
2184
+ console.log(" \u2514\u2500 ".concat(results.length - failures.length, "/").concat(results.length, " passed, ").concat(skipped.length, " skipped, ").concat(failures.length, " failed"));
2185
+ console.log('');
2186
+ _e = 0, createdEventIds_1 = createdEventIds;
2187
+ _r.label = 54;
2188
+ case 54:
2189
+ if (!(_e < createdEventIds_1.length)) return [3 /*break*/, 57];
2190
+ id = createdEventIds_1[_e];
2191
+ return [4 /*yield*/, sdk.api.calendar_events.deleteOne(id).catch(function () { return null; })];
2192
+ case 55:
2193
+ _r.sent();
2194
+ _r.label = 56;
2195
+ case 56:
2196
+ _e++;
2197
+ return [3 /*break*/, 54];
2198
+ case 57:
2199
+ _f = 0, createdFormIds_1 = createdFormIds;
2200
+ _r.label = 58;
2201
+ case 58:
2202
+ if (!(_f < createdFormIds_1.length)) return [3 /*break*/, 64];
2203
+ formId = createdFormIds_1[_f];
2204
+ return [4 /*yield*/, sdk.api.form_responses.getSome({ filter: { formId: formId } }).catch(function () { return []; })];
2205
+ case 59:
2206
+ responses = _r.sent();
2207
+ _g = 0, responses_1 = responses;
2208
+ _r.label = 60;
2209
+ case 60:
2210
+ if (!(_g < responses_1.length)) return [3 /*break*/, 63];
2211
+ response = responses_1[_g];
2212
+ return [4 /*yield*/, sdk.api.form_responses.deleteOne(response.id).catch(function () { return null; })];
2213
+ case 61:
2214
+ _r.sent();
2215
+ _r.label = 62;
2216
+ case 62:
2217
+ _g++;
2218
+ return [3 /*break*/, 60];
2219
+ case 63:
2220
+ _f++;
2221
+ return [3 /*break*/, 58];
2222
+ case 64:
2223
+ _h = 0, createdFormFieldIds_1 = createdFormFieldIds;
2224
+ _r.label = 65;
2225
+ case 65:
2226
+ if (!(_h < createdFormFieldIds_1.length)) return [3 /*break*/, 68];
2227
+ id = createdFormFieldIds_1[_h];
2228
+ return [4 /*yield*/, sdk.api.form_fields.deleteOne(id).catch(function () { return null; })];
2229
+ case 66:
2230
+ _r.sent();
2231
+ _r.label = 67;
2232
+ case 67:
2233
+ _h++;
2234
+ return [3 /*break*/, 65];
2235
+ case 68:
2236
+ _j = 0, createdFormIds_2 = createdFormIds;
2237
+ _r.label = 69;
2238
+ case 69:
2239
+ if (!(_j < createdFormIds_2.length)) return [3 /*break*/, 72];
2240
+ id = createdFormIds_2[_j];
2241
+ return [4 /*yield*/, sdk.api.forms.deleteOne(id).catch(function () { return null; })];
2242
+ case 70:
2243
+ _r.sent();
2244
+ _r.label = 71;
2245
+ case 71:
2246
+ _j++;
2247
+ return [3 /*break*/, 69];
2248
+ case 72:
2249
+ uniqueEnduserIds = createdEnduserIds.filter(function (id, i) { return !!id && createdEnduserIds.indexOf(id) === i; });
2250
+ _k = 0, uniqueEnduserIds_1 = uniqueEnduserIds;
2251
+ _r.label = 73;
2252
+ case 73:
2253
+ if (!(_k < uniqueEnduserIds_1.length)) return [3 /*break*/, 76];
2254
+ id = uniqueEnduserIds_1[_k];
2255
+ return [4 /*yield*/, sdk.api.endusers.deleteOne(id).catch(function () { return null; })];
2256
+ case 74:
2257
+ _r.sent();
2258
+ _r.label = 75;
2259
+ case 75:
2260
+ _k++;
2261
+ return [3 /*break*/, 73];
2262
+ case 76:
2263
+ _l = 0, createdTemplateIds_1 = createdTemplateIds;
2264
+ _r.label = 77;
2265
+ case 77:
2266
+ if (!(_l < createdTemplateIds_1.length)) return [3 /*break*/, 80];
2267
+ id = createdTemplateIds_1[_l];
2268
+ return [4 /*yield*/, sdk.api.calendar_event_templates.deleteOne(id).catch(function () { return null; })];
2269
+ case 78:
2270
+ _r.sent();
2271
+ _r.label = 79;
2272
+ case 79:
2273
+ _l++;
2274
+ return [3 /*break*/, 77];
2275
+ case 80:
2276
+ _m = 0, createdUserIds_1 = createdUserIds;
2277
+ _r.label = 81;
2278
+ case 81:
2279
+ if (!(_m < createdUserIds_1.length)) return [3 /*break*/, 84];
2280
+ id = createdUserIds_1[_m];
2281
+ return [4 /*yield*/, sdk.api.users.deleteOne(id).catch(function () { return null; })];
2282
+ case 82:
2283
+ _r.sent();
2284
+ _r.label = 83;
2285
+ case 83:
2286
+ _m++;
2287
+ return [3 /*break*/, 81];
2288
+ case 84:
2289
+ // ---------------------------------------------------------------------------------------------
2290
+ // Assert — single pass, last, so the full table above is always printed first.
2291
+ // ---------------------------------------------------------------------------------------------
2292
+ for (_o = 0, results_2 = results; _o < results_2.length; _o++) {
2293
+ r = results_2[_o];
2294
+ (0, testing_1.assert)(r.ok, "\u274C [".concat(r.group, "] ").concat(r.name, " \u2014 ").concat(r.detail), "[".concat(r.group, "] ").concat(r.name).concat(r.skipped ? ' (SKIPPED)' : ''));
2295
+ }
2296
+ return [2 /*return*/];
2297
+ }
2298
+ });
2299
+ });
2300
+ };
2301
+ exports.public_endpoint_auth_field_lock_tests = public_endpoint_auth_field_lock_tests;
2302
+ // Allow running this test file independently
2303
+ if (require.main === module) {
2304
+ console.log("\uD83C\uDF10 Using API URL: ".concat(host));
2305
+ var sdk_2 = new sdk_1.Session({ host: host });
2306
+ var sdkNonAdmin_1 = new sdk_1.Session({ host: host });
2307
+ var runTests = function () { return __awaiter(void 0, void 0, void 0, function () {
2308
+ return __generator(this, function (_a) {
2309
+ switch (_a.label) {
2310
+ case 0: return [4 /*yield*/, (0, setup_1.setup_tests)(sdk_2, sdkNonAdmin_1)];
2311
+ case 1:
2312
+ _a.sent();
2313
+ return [4 /*yield*/, (0, exports.public_endpoint_auth_field_lock_tests)({ sdk: sdk_2, sdkNonAdmin: sdkNonAdmin_1 })];
2314
+ case 2:
2315
+ _a.sent();
2316
+ return [2 /*return*/];
2317
+ }
2318
+ });
2319
+ }); };
2320
+ runTests()
2321
+ .then(function () {
2322
+ console.log("✅ Public endpoint auth field lock test suite completed successfully");
2323
+ process.exit(0);
2324
+ })
2325
+ .catch(function (error) {
2326
+ console.error("❌ Public endpoint auth field lock test suite failed:", error);
2327
+ process.exit(1);
2328
+ });
2329
+ }
2330
+ //# sourceMappingURL=public_endpoint_auth_field_lock.test.js.map