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