@duffcloudservices/site-forms 0.5.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -3
- package/dist/composables/readExpectedJson.d.ts +158 -0
- package/dist/composables/useSitePhiPosture.d.ts +71 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +753 -588
- package/dist/index.js.map +1 -1
- package/dist/site-forms.css +1 -1
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/DcsForm.vue +61 -10
- package/src/__tests__/missing-form-error-surfacing.test.ts +7 -2
- package/src/__tests__/non-json-response.test.ts +282 -0
- package/src/__tests__/phi-guidance.test.ts +239 -0
- package/src/__tests__/receipt-validation.test.ts +425 -0
- package/src/__tests__/submission.test.ts +23 -6
- package/src/__tests__/visible-if.test.ts +17 -4
- package/src/composables/readExpectedJson.ts +360 -0
- package/src/composables/useFormSubmission.ts +55 -9
- package/src/composables/useSitePhiPosture.ts +123 -0
- package/src/index.ts +29 -0
- package/src/style.css +25 -0
- package/src/types.ts +16 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
3
|
+
import DcsForm from '../DcsForm.vue'
|
|
4
|
+
import {
|
|
5
|
+
PHI_GUIDANCE_COPY,
|
|
6
|
+
fetchSiteHandlesPhi,
|
|
7
|
+
resetSitePhiPostureCache,
|
|
8
|
+
} from '../composables/useSitePhiPosture'
|
|
9
|
+
import type { PortalFormDefinition } from '../types'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// C-415 — PHI guidance on HIPAA-mode tenants.
|
|
13
|
+
//
|
|
14
|
+
// The defect: a `hipaaMode: true` clinic whose AI chat told visitors not to
|
|
15
|
+
// share health details while its managed contact form — 6 required fields
|
|
16
|
+
// including a free-text `message` — said nothing at all. Measured 2026-07-31 on
|
|
17
|
+
// the served https://kineticenergypt.com/contact: /HIPAA/i, /protected health/i,
|
|
18
|
+
// /do not include/i and /medical information/i all NOT FOUND.
|
|
19
|
+
//
|
|
20
|
+
// The rule these tests must not let rot: the guidance is a function of the
|
|
21
|
+
// PLATFORM verdict and nothing else. Every test below therefore controls only
|
|
22
|
+
// what the compliance endpoint says, and asserts in BOTH directions — a test
|
|
23
|
+
// that passed while the component ignored the tenant flag would prove nothing,
|
|
24
|
+
// which is what `it('is vacuous if the flag is ignored')` pins explicitly.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const GUIDANCE = '[data-form-phi-guidance]'
|
|
28
|
+
|
|
29
|
+
function makeDef(
|
|
30
|
+
overrides: Partial<PortalFormDefinition> = {},
|
|
31
|
+
): PortalFormDefinition {
|
|
32
|
+
return {
|
|
33
|
+
formId: 'contact',
|
|
34
|
+
submission: { kind: 'lead' },
|
|
35
|
+
// The shape measured on KEPT: split name, email, phone, then two required
|
|
36
|
+
// free-text fields. `message` is the box a patient actually types into.
|
|
37
|
+
fields: [
|
|
38
|
+
{ id: 'first-name', type: 'text', label: 'First name', required: true, width: 'half' },
|
|
39
|
+
{ id: 'last-name', type: 'text', label: 'Last name', required: true, width: 'half' },
|
|
40
|
+
{ id: 'email', type: 'email', label: 'Your email', required: true, width: 'half' },
|
|
41
|
+
{ id: 'phone', type: 'tel', label: 'Phone number', required: true, width: 'half' },
|
|
42
|
+
{ id: 'subject', type: 'text', label: 'Subject', required: true, phi: true },
|
|
43
|
+
{ id: 'message', type: 'textarea', label: 'Your message', required: true, phi: true },
|
|
44
|
+
],
|
|
45
|
+
...overrides,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Installs a fetch that answers the compliance endpoint with `siteHandlesPhi`. */
|
|
50
|
+
function stubCompliance(siteHandlesPhi: boolean | undefined, init: Partial<{ ok: boolean }> = {}) {
|
|
51
|
+
const calls: string[] = []
|
|
52
|
+
const impl = vi.fn(async (url: unknown) => {
|
|
53
|
+
calls.push(String(url))
|
|
54
|
+
return {
|
|
55
|
+
ok: init.ok ?? true,
|
|
56
|
+
status: init.ok === false ? 500 : 200,
|
|
57
|
+
json: async () => ({ siteHandlesPhi }),
|
|
58
|
+
} as unknown as Response
|
|
59
|
+
})
|
|
60
|
+
vi.stubGlobal('fetch', impl)
|
|
61
|
+
return { calls, impl }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function mountForm(props: Record<string, unknown> = {}) {
|
|
65
|
+
const wrapper = mount(DcsForm, {
|
|
66
|
+
props: {
|
|
67
|
+
formId: 'contact',
|
|
68
|
+
siteSlug: 'kept',
|
|
69
|
+
apiBase: 'https://api.example.test/api/v1',
|
|
70
|
+
definitionOverride: makeDef(),
|
|
71
|
+
...props,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
await flushPromises()
|
|
75
|
+
return wrapper
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
resetSitePhiPostureCache()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
vi.unstubAllGlobals()
|
|
84
|
+
vi.restoreAllMocks()
|
|
85
|
+
resetSitePhiPostureCache()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('PHI guidance — the two directions', () => {
|
|
89
|
+
it('RENDERS the guidance when the platform reports the tenant PHI-handling', async () => {
|
|
90
|
+
stubCompliance(true)
|
|
91
|
+
const wrapper = await mountForm()
|
|
92
|
+
|
|
93
|
+
const note = wrapper.find(GUIDANCE)
|
|
94
|
+
expect(note.exists()).toBe(true)
|
|
95
|
+
expect(note.text()).toContain(PHI_GUIDANCE_COPY.lead)
|
|
96
|
+
expect(note.text()).toContain(PHI_GUIDANCE_COPY.detail)
|
|
97
|
+
// The copy the chat already ships on these tenants, so the two surfaces read
|
|
98
|
+
// as one product rather than two teams.
|
|
99
|
+
expect(note.text().toLowerCase()).toContain('personal health details')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('renders NOTHING on a non-PHI tenant', async () => {
|
|
103
|
+
stubCompliance(false)
|
|
104
|
+
const wrapper = await mountForm({ siteSlug: 'mi-handyman' })
|
|
105
|
+
|
|
106
|
+
expect(wrapper.find(GUIDANCE).exists()).toBe(false)
|
|
107
|
+
expect(wrapper.text().toLowerCase()).not.toContain('personal health details')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('is vacuous if the flag is ignored — the same mount must differ on the verdict alone', async () => {
|
|
111
|
+
// The kill test. Nothing changes between these two mounts except the boolean
|
|
112
|
+
// the platform returns, so a component that hardcoded either answer fails
|
|
113
|
+
// one of them. Written as one test on purpose: it is the DIFFERENCE that is
|
|
114
|
+
// the assertion.
|
|
115
|
+
stubCompliance(true)
|
|
116
|
+
const phi = await mountForm()
|
|
117
|
+
resetSitePhiPostureCache()
|
|
118
|
+
vi.unstubAllGlobals()
|
|
119
|
+
|
|
120
|
+
stubCompliance(false)
|
|
121
|
+
const nonPhi = await mountForm()
|
|
122
|
+
|
|
123
|
+
expect(phi.find(GUIDANCE).exists()).toBe(true)
|
|
124
|
+
expect(nonPhi.find(GUIDANCE).exists()).toBe(false)
|
|
125
|
+
expect(phi.find(GUIDANCE).exists()).not.toBe(nonPhi.find(GUIDANCE).exists())
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('PHI guidance — placement', () => {
|
|
130
|
+
it('sits immediately above the free-text field, not at the top of the form', async () => {
|
|
131
|
+
stubCompliance(true)
|
|
132
|
+
const wrapper = await mountForm()
|
|
133
|
+
|
|
134
|
+
const fields = wrapper.find('.dcs-form__fields').element
|
|
135
|
+
const children = Array.from(fields.children)
|
|
136
|
+
const noteIndex = children.findIndex((el) =>
|
|
137
|
+
el.hasAttribute('data-form-phi-guidance'),
|
|
138
|
+
)
|
|
139
|
+
const messageIndex = children.findIndex(
|
|
140
|
+
(el) => el.getAttribute('data-form-field-key') === 'message',
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
expect(noteIndex).toBeGreaterThan(0) // NOT parked at the top
|
|
144
|
+
expect(messageIndex).toBe(noteIndex + 1) // directly above the textarea
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('falls back to the top when the form/step has no free-text field', async () => {
|
|
148
|
+
stubCompliance(true)
|
|
149
|
+
const wrapper = await mountForm({
|
|
150
|
+
definitionOverride: makeDef({
|
|
151
|
+
fields: [
|
|
152
|
+
{ id: 'name', type: 'text', label: 'Name', required: true },
|
|
153
|
+
{ id: 'when', type: 'date', label: 'Preferred date' },
|
|
154
|
+
],
|
|
155
|
+
}),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const children = Array.from(wrapper.find('.dcs-form__fields').element.children)
|
|
159
|
+
expect(children[0]?.hasAttribute('data-form-phi-guidance')).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('anchors to a `message`-role field even when it is not a textarea', async () => {
|
|
163
|
+
stubCompliance(true)
|
|
164
|
+
const wrapper = await mountForm({
|
|
165
|
+
definitionOverride: makeDef({
|
|
166
|
+
fields: [
|
|
167
|
+
{ id: 'name', type: 'text', label: 'Name', required: true },
|
|
168
|
+
{ id: 'note', type: 'text', role: 'message', label: 'How can we help?' },
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const children = Array.from(wrapper.find('.dcs-form__fields').element.children)
|
|
174
|
+
const noteIndex = children.findIndex((el) =>
|
|
175
|
+
el.hasAttribute('data-form-phi-guidance'),
|
|
176
|
+
)
|
|
177
|
+
expect(children[noteIndex + 1]?.getAttribute('data-form-field-key')).toBe('note')
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe('the posture read itself', () => {
|
|
182
|
+
it('calls the slug-scoped compliance route', async () => {
|
|
183
|
+
const { calls } = stubCompliance(true)
|
|
184
|
+
await mountForm()
|
|
185
|
+
expect(calls).toContain(
|
|
186
|
+
'https://api.example.test/api/v1/sites/kept/forms/compliance',
|
|
187
|
+
)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('falls back to the slug-free route when the build has no baked slug', async () => {
|
|
191
|
+
const { calls } = stubCompliance(true)
|
|
192
|
+
await mountForm({ siteSlug: '' })
|
|
193
|
+
// Never the broken `/sites//forms/...` shape (the kimduffhomes.com 405).
|
|
194
|
+
expect(calls).toContain('https://api.example.test/api/v1/forms/compliance')
|
|
195
|
+
expect(calls.some((u) => u.includes('/sites//'))).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('memoizes one verdict per site so N forms on a page cost ONE round trip', async () => {
|
|
199
|
+
const { impl } = stubCompliance(true)
|
|
200
|
+
const opts = { apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }
|
|
201
|
+
const [a, b, c] = await Promise.all([
|
|
202
|
+
fetchSiteHandlesPhi(opts),
|
|
203
|
+
fetchSiteHandlesPhi(opts),
|
|
204
|
+
fetchSiteHandlesPhi(opts),
|
|
205
|
+
])
|
|
206
|
+
expect([a, b, c]).toEqual([true, true, true])
|
|
207
|
+
expect(impl).toHaveBeenCalledTimes(1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('reports false — and renders nothing — when the endpoint fails', async () => {
|
|
211
|
+
stubCompliance(true, { ok: false })
|
|
212
|
+
const wrapper = await mountForm()
|
|
213
|
+
expect(wrapper.find(GUIDANCE).exists()).toBe(false)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('reports false when the endpoint is unreachable', async () => {
|
|
217
|
+
vi.stubGlobal(
|
|
218
|
+
'fetch',
|
|
219
|
+
vi.fn(async () => {
|
|
220
|
+
throw new Error('network down')
|
|
221
|
+
}),
|
|
222
|
+
)
|
|
223
|
+
await expect(
|
|
224
|
+
fetchSiteHandlesPhi({ apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }),
|
|
225
|
+
).resolves.toBe(false)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('treats a body without a boolean verdict as NO verdict, never as yes', async () => {
|
|
229
|
+
// A 200 from an SPA shell / proxy interstitial must not be read as "PHI
|
|
230
|
+
// tenant" — the same class of failure C-301/C-310 hardened the POST against.
|
|
231
|
+
vi.stubGlobal(
|
|
232
|
+
'fetch',
|
|
233
|
+
vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ siteHandlesPhi: 'true' }) }) as unknown as Response),
|
|
234
|
+
)
|
|
235
|
+
await expect(
|
|
236
|
+
fetchSiteHandlesPhi({ apiBase: 'https://api.example.test/api/v1', siteSlug: 'kept' }),
|
|
237
|
+
).resolves.toBe(false)
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C-310 — a 2xx with valid JSON is NOT proof of storage. Only the endpoint's own
|
|
3
|
+
* receipt is.
|
|
4
|
+
*
|
|
5
|
+
* The gap this closes (Codex cross-model review §c, 2026-07-26): C-301/C-302
|
|
6
|
+
* hardened TRANSPORT parsing — an HTML shell, an empty body, or an unreadable
|
|
7
|
+
* stream all became visible failures. Anything that parsed as JSON still passed,
|
|
8
|
+
* so `{}`, `[]`, `"ok"` and `{"hello":"world"}` were all reported to the visitor
|
|
9
|
+
* as a stored lead. And `204` was exempt from the body check entirely, even
|
|
10
|
+
* though no guarded endpoint contracts `204`.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
13
|
+
import { mount, flushPromises } from '@vue/test-utils'
|
|
14
|
+
import DcsForm from '../DcsForm.vue'
|
|
15
|
+
import { submitFormValues } from '../composables/useFormSubmission'
|
|
16
|
+
import {
|
|
17
|
+
isBookingCancellationReceipt,
|
|
18
|
+
isEstimateReceipt,
|
|
19
|
+
isManagedFormReceipt,
|
|
20
|
+
isPasswordlessVerifyReceipt,
|
|
21
|
+
isSiteContactReceipt,
|
|
22
|
+
readExpectedJson,
|
|
23
|
+
} from '../composables/readExpectedJson'
|
|
24
|
+
import type { DcsFormSubmitError, PortalFormDefinition } from '../types'
|
|
25
|
+
|
|
26
|
+
const def: PortalFormDefinition = {
|
|
27
|
+
formId: 'contact',
|
|
28
|
+
submission: { kind: 'lead' },
|
|
29
|
+
fields: [
|
|
30
|
+
{ id: 'name', type: 'text', label: 'Name', required: true },
|
|
31
|
+
{ id: 'email', type: 'email', label: 'Email', required: true },
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `202` + `PublicSiteFormSubmissionResponse` — public_site_forms.go:509-524. */
|
|
36
|
+
const MANAGED_FORM_RECEIPT = {
|
|
37
|
+
id: '07438812886145977836-dd5a5baf3835082c',
|
|
38
|
+
status: 'accepted',
|
|
39
|
+
submittedAt: '2026-07-26T14:52:30Z',
|
|
40
|
+
message: 'Thanks — your form has been received.',
|
|
41
|
+
notificationQueued: true,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `201` + `ContactFormResponse` — contact_form.go:814-823. */
|
|
45
|
+
const SITE_CONTACT_RECEIPT = {
|
|
46
|
+
id: '3f2504e0-4f89-11d3-9a0c-0305e82c3301',
|
|
47
|
+
status: 'submitted',
|
|
48
|
+
submittedAt: '2026-07-26T14:52:30Z',
|
|
49
|
+
message: 'Thank you for reaching out! A member of the team will respond soon.',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `201` + `mapSiteVisitorEstimateResponse` — revenue_estimate_visitor.go:1419. */
|
|
53
|
+
const ESTIMATE_RECEIPT = {
|
|
54
|
+
id: '01J8Z9QKX2P7B3M4N5R6S7T8U9',
|
|
55
|
+
siteId: 'iron-oak-contractors',
|
|
56
|
+
customerEmail: 'jane@example.com',
|
|
57
|
+
status: 'pending_ai_review',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let consoleError: ReturnType<typeof vi.spyOn>
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
63
|
+
})
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
consoleError.mockRestore()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A fetch stub that answers with a FRESH Response per call.
|
|
70
|
+
*
|
|
71
|
+
* It used to `mockResolvedValue` one Response instance, which was only safe
|
|
72
|
+
* while `<DcsForm>` made exactly one request per mount. C-415 added a
|
|
73
|
+
* mount-time GET of the form-compliance posture, so a shared instance had its
|
|
74
|
+
* body consumed by the GET and the submission then failed with "body already
|
|
75
|
+
* read" — a harness artifact, not a product defect. A real transport hands out
|
|
76
|
+
* a new Response per request; so does this now.
|
|
77
|
+
*/
|
|
78
|
+
function respond(body: BodyInit | null, init: ResponseInit): typeof fetch {
|
|
79
|
+
return vi.fn().mockImplementation(async () => new Response(body, init)) as unknown as typeof fetch
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
83
|
+
return new Response(JSON.stringify(body), {
|
|
84
|
+
status,
|
|
85
|
+
headers: { 'content-type': 'application/json' },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// Transport layer: the removed 204 exemption
|
|
91
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
describe('C-310: the 204 exemption is gone', () => {
|
|
94
|
+
// KILL-TEST. On pre-C-310 code `readOkBody` returned `null` for a bodyless 204
|
|
95
|
+
// and `submitFormValues` resolved, so this expectation FAILS on the old tree —
|
|
96
|
+
// which is the whole point of asserting it.
|
|
97
|
+
it('a bodyless 204 is a FAILURE, not a quiet success', async () => {
|
|
98
|
+
const fetchImpl = respond(null, { status: 204 })
|
|
99
|
+
await expect(
|
|
100
|
+
submitFormValues({
|
|
101
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
102
|
+
siteSlug: 'kept',
|
|
103
|
+
fetchImpl,
|
|
104
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toMatchObject({ status: 204, nonJsonResponse: true })
|
|
107
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('the diagnostic explains WHY 204 is no longer trusted', async () => {
|
|
111
|
+
const response = new Response(null, { status: 204 })
|
|
112
|
+
await expect(readExpectedJson(response, isManagedFormReceipt)).rejects.toThrow(
|
|
113
|
+
'We could not confirm your message was received',
|
|
114
|
+
)
|
|
115
|
+
const logged = String(consoleError.mock.calls[0][0])
|
|
116
|
+
expect(logged).toContain('empty body')
|
|
117
|
+
expect(logged).toContain('204')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('a 204 that DOES carry a JSON receipt is still accepted', async () => {
|
|
121
|
+
// Belt and braces: the removal is about the EMPTY-body exemption, not about
|
|
122
|
+
// banning the status. If some hop ever answers 204 with a real receipt, the
|
|
123
|
+
// receipt is what decides.
|
|
124
|
+
const response = new Response(JSON.stringify(MANAGED_FORM_RECEIPT), {
|
|
125
|
+
status: 200,
|
|
126
|
+
headers: { 'content-type': 'application/json' },
|
|
127
|
+
})
|
|
128
|
+
await expect(readExpectedJson(response, isManagedFormReceipt)).resolves.toEqual(
|
|
129
|
+
MANAGED_FORM_RECEIPT,
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
135
|
+
// Receipt layer: valid JSON that proves nothing
|
|
136
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe('C-310: valid JSON without a receipt is a FAILURE', () => {
|
|
139
|
+
const notReceipts: Array<[string, unknown]> = [
|
|
140
|
+
['an empty object', {}],
|
|
141
|
+
['an empty array', []],
|
|
142
|
+
['a bare JSON string', 'ok'],
|
|
143
|
+
['a bare JSON number', 1],
|
|
144
|
+
['JSON null', null],
|
|
145
|
+
['an unrelated object', { hello: 'world' }],
|
|
146
|
+
['a receipt missing its id', { status: 'accepted', submittedAt: '2026-07-26T14:52:30Z' }],
|
|
147
|
+
['a receipt with a blank id', { id: ' ', status: 'accepted', submittedAt: '2026-07-26T14:52:30Z' }],
|
|
148
|
+
['a receipt with an unknown status', { id: 'S-1', status: 'maybe', submittedAt: '2026-07-26T14:52:30Z' }],
|
|
149
|
+
['a receipt missing submittedAt', { id: 'S-1', status: 'accepted' }],
|
|
150
|
+
['a receipt with an unparseable submittedAt', { id: 'S-1', status: 'accepted', submittedAt: 'soon' }],
|
|
151
|
+
// The exact shape the pre-C-310 test suite asserted as a success. It is not
|
|
152
|
+
// a shape the server can produce; testing against it is how a client drifts
|
|
153
|
+
// away from its own API with every test still green.
|
|
154
|
+
['the invented pre-C-310 test shape', { status: 'accepted', submissionId: 'S-1' }],
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
for (const [label, body] of notReceipts) {
|
|
158
|
+
it(`rejects ${label}`, async () => {
|
|
159
|
+
const fetchImpl = respond(JSON.stringify(body), {
|
|
160
|
+
status: 202,
|
|
161
|
+
headers: { 'content-type': 'application/json' },
|
|
162
|
+
})
|
|
163
|
+
await expect(
|
|
164
|
+
submitFormValues({
|
|
165
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
166
|
+
siteSlug: 'kept',
|
|
167
|
+
fetchImpl,
|
|
168
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toMatchObject({ status: 202, unconfirmedReceipt: true, nonJsonResponse: false })
|
|
171
|
+
// TERMINAL: an unconfirmable submission must never be retried, or a lead
|
|
172
|
+
// that WAS stored gets stored twice.
|
|
173
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
it('accepts the real managed-form receipt', async () => {
|
|
178
|
+
const result = await submitFormValues({
|
|
179
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
180
|
+
siteSlug: 'kept',
|
|
181
|
+
fetchImpl: respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
|
|
182
|
+
status: 202,
|
|
183
|
+
headers: { 'content-type': 'application/json' },
|
|
184
|
+
}),
|
|
185
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
186
|
+
})
|
|
187
|
+
expect(result.response).toEqual(MANAGED_FORM_RECEIPT)
|
|
188
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('separates a receipt failure from a transport failure on the error object', async () => {
|
|
192
|
+
// The two have different remedies: `nonJsonResponse` means "check routing",
|
|
193
|
+
// `unconfirmedReceipt` means "check the endpoint contract".
|
|
194
|
+
const html = respond('<!doctype html><html></html>', {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: { 'content-type': 'text/html' },
|
|
197
|
+
})
|
|
198
|
+
await expect(
|
|
199
|
+
submitFormValues({
|
|
200
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
201
|
+
siteSlug: 'kept',
|
|
202
|
+
fetchImpl: html,
|
|
203
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
204
|
+
}),
|
|
205
|
+
).rejects.toMatchObject({ nonJsonResponse: true, unconfirmedReceipt: false })
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
210
|
+
// C-319: the spam-quarantine shapes are the GENUINE receipts
|
|
211
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe("C-319: a quarantine response is the route's genuine receipt", () => {
|
|
214
|
+
// C-310 pinned a deliberate WIDENING here: `isLegacySpamOkReceipt` accepted a
|
|
215
|
+
// bare `{"status":"ok"}` because two of the five spam-quarantine paths
|
|
216
|
+
// answered that instead of their endpoint's real receipt, and rejecting it
|
|
217
|
+
// would have shown a visible failure to every quarantined submitter —
|
|
218
|
+
// including an AI false-positive victim whose message really was stored.
|
|
219
|
+
//
|
|
220
|
+
// C-319 removed the cause, so the widening is gone with it. All five
|
|
221
|
+
// quarantine paths now answer the route's genuine success receipt, built by
|
|
222
|
+
// the same writer the success path uses (server spam_receipts.go) from the
|
|
223
|
+
// row C-307 stores. These tests are the inverse of the ones they replace: the
|
|
224
|
+
// legacy shape must now FAIL, and the real quarantine receipts must PASS.
|
|
225
|
+
|
|
226
|
+
it('managed forms: the legacy 202 {"status":"ok"} is no longer a receipt', async () => {
|
|
227
|
+
await expect(
|
|
228
|
+
submitFormValues({
|
|
229
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
230
|
+
siteSlug: 'kept',
|
|
231
|
+
fetchImpl: respond(JSON.stringify({ status: 'ok' }), {
|
|
232
|
+
status: 202,
|
|
233
|
+
headers: { 'content-type': 'application/json' },
|
|
234
|
+
}),
|
|
235
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
236
|
+
}),
|
|
237
|
+
).rejects.toThrow()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('managed forms: a quarantined submission still resolves as success', async () => {
|
|
241
|
+
// What the server now answers a spam-classified managed-form submission
|
|
242
|
+
// with — indistinguishable from a genuine 202, because it IS one.
|
|
243
|
+
const result = await submitFormValues({
|
|
244
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
245
|
+
siteSlug: 'kept',
|
|
246
|
+
fetchImpl: respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
|
|
247
|
+
status: 202,
|
|
248
|
+
headers: { 'content-type': 'application/json' },
|
|
249
|
+
}),
|
|
250
|
+
payload: { formId: 'contact', values: { name: 'Buy cheap SEO' } },
|
|
251
|
+
})
|
|
252
|
+
expect(result.response).toEqual(MANAGED_FORM_RECEIPT)
|
|
253
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('managed forms: the DcsForm UI shows the success state for a quarantined spammer', async () => {
|
|
257
|
+
const originalFetch = globalThis.fetch
|
|
258
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = respond(
|
|
259
|
+
JSON.stringify(MANAGED_FORM_RECEIPT),
|
|
260
|
+
{ status: 202, headers: { 'content-type': 'application/json' } },
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
const wrapper = mount(DcsForm, {
|
|
264
|
+
props: { formId: 'contact', siteSlug: 'kept', definitionOverride: def, apiBase: '' },
|
|
265
|
+
})
|
|
266
|
+
await wrapper.find('[data-form-field-key="name"] input').setValue('Buy cheap SEO')
|
|
267
|
+
await wrapper.find('[data-form-field-key="email"] input').setValue('spam@example.com')
|
|
268
|
+
await wrapper.find('form').trigger('submit')
|
|
269
|
+
await flushPromises()
|
|
270
|
+
|
|
271
|
+
expect(wrapper.emitted('submit-error')).toBeUndefined()
|
|
272
|
+
expect(wrapper.emitted('submit-success')?.length).toBe(1)
|
|
273
|
+
|
|
274
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('site contact: the legacy {"status":"ok"} no longer satisfies the predicate', () => {
|
|
278
|
+
expect(isSiteContactReceipt({ status: 'ok' })).toBe(false)
|
|
279
|
+
expect(isManagedFormReceipt({ status: 'ok' })).toBe(false)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('site contact: a quarantined submission answers the genuine 201 receipt', async () => {
|
|
283
|
+
await expect(
|
|
284
|
+
readExpectedJson(jsonResponse(SITE_CONTACT_RECEIPT, 201), isSiteContactReceipt),
|
|
285
|
+
).resolves.toEqual(SITE_CONTACT_RECEIPT)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('the estimate honeypot decoy stays indistinguishable from a genuine estimate', () => {
|
|
289
|
+
// The honeypot answers a 201 built by the same writer and row constructor a
|
|
290
|
+
// genuine estimate uses, so `id` is a real ULID and the predicate passes.
|
|
291
|
+
// If it did not, a bot could detect the honeypot from the client's behaviour.
|
|
292
|
+
expect(isEstimateReceipt({ ...ESTIMATE_RECEIPT, id: '01J8ZDECOY0000000000000000' })).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
297
|
+
// Per-endpoint predicates (exported for the site backports)
|
|
298
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
describe('C-310: per-endpoint receipt predicates', () => {
|
|
301
|
+
it('site contact requires an id and a contract status', async () => {
|
|
302
|
+
await expect(
|
|
303
|
+
readExpectedJson(jsonResponse(SITE_CONTACT_RECEIPT, 201), isSiteContactReceipt),
|
|
304
|
+
).resolves.toEqual(SITE_CONTACT_RECEIPT)
|
|
305
|
+
expect(isSiteContactReceipt({ id: 'c-1', status: 'processing' })).toBe(true)
|
|
306
|
+
expect(isSiteContactReceipt({ id: 'c-1', status: 'accepted' })).toBe(false)
|
|
307
|
+
expect(isSiteContactReceipt({ status: 'submitted' })).toBe(false)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('estimate requires the returned estimate id', async () => {
|
|
311
|
+
await expect(
|
|
312
|
+
readExpectedJson(jsonResponse(ESTIMATE_RECEIPT, 201), isEstimateReceipt),
|
|
313
|
+
).resolves.toEqual(ESTIMATE_RECEIPT)
|
|
314
|
+
expect(isEstimateReceipt({ siteId: 'iron-oak-contractors' })).toBe(false)
|
|
315
|
+
expect(isEstimateReceipt({ id: '' })).toBe(false)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('passwordless verification requires success === true', async () => {
|
|
319
|
+
await expect(
|
|
320
|
+
readExpectedJson(
|
|
321
|
+
jsonResponse({ success: true, redirectTo: 'https://example.com/account' }),
|
|
322
|
+
isPasswordlessVerifyReceipt,
|
|
323
|
+
),
|
|
324
|
+
).resolves.toMatchObject({ success: true })
|
|
325
|
+
expect(isPasswordlessVerifyReceipt({ success: false })).toBe(false)
|
|
326
|
+
expect(isPasswordlessVerifyReceipt({ success: 'true' })).toBe(false)
|
|
327
|
+
expect(isPasswordlessVerifyReceipt({ redirectTo: '/account' })).toBe(false)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('booking cancellation requires success === true', async () => {
|
|
331
|
+
await expect(
|
|
332
|
+
readExpectedJson(
|
|
333
|
+
jsonResponse({ success: true, message: 'Booking cancelled successfully' }),
|
|
334
|
+
isBookingCancellationReceipt,
|
|
335
|
+
),
|
|
336
|
+
).resolves.toMatchObject({ success: true })
|
|
337
|
+
expect(isBookingCancellationReceipt({ success: false })).toBe(false)
|
|
338
|
+
expect(isBookingCancellationReceipt({ message: 'Booking cancelled successfully' })).toBe(false)
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
343
|
+
// A submission POST must never follow a redirect
|
|
344
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
describe('C-310: submission POSTs never follow redirects', () => {
|
|
347
|
+
it("sets redirect:'error' on the JSON POST", async () => {
|
|
348
|
+
const fetchImpl = respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
|
|
349
|
+
status: 202,
|
|
350
|
+
headers: { 'content-type': 'application/json' },
|
|
351
|
+
})
|
|
352
|
+
await submitFormValues({
|
|
353
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
354
|
+
siteSlug: 'kept',
|
|
355
|
+
fetchImpl,
|
|
356
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
357
|
+
})
|
|
358
|
+
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
359
|
+
expect((init as RequestInit).redirect).toBe('error')
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it("sets redirect:'error' on the multipart POST", async () => {
|
|
363
|
+
const fetchImpl = respond(JSON.stringify(MANAGED_FORM_RECEIPT), {
|
|
364
|
+
status: 202,
|
|
365
|
+
headers: { 'content-type': 'application/json' },
|
|
366
|
+
})
|
|
367
|
+
await submitFormValues({
|
|
368
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
369
|
+
siteSlug: 'kept',
|
|
370
|
+
fetchImpl,
|
|
371
|
+
payload: {
|
|
372
|
+
formId: 'contact',
|
|
373
|
+
values: { photo: new File(['x'], 'x.png', { type: 'image/png' }) },
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
const [, init] = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]
|
|
377
|
+
expect((init as RequestInit).redirect).toBe('error')
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
it('surfaces a rejected redirect as a visible failure', async () => {
|
|
381
|
+
// A 301/302 also rewrites the POST into a GET, so following one silently
|
|
382
|
+
// drops the submission body. `redirect:'error'` makes fetch reject instead.
|
|
383
|
+
const fetchImpl = vi
|
|
384
|
+
.fn()
|
|
385
|
+
.mockRejectedValue(new TypeError('Failed to fetch')) as unknown as typeof fetch
|
|
386
|
+
await expect(
|
|
387
|
+
submitFormValues({
|
|
388
|
+
apiBase: 'https://api.example.com/api/v1',
|
|
389
|
+
siteSlug: 'kept',
|
|
390
|
+
fetchImpl,
|
|
391
|
+
retries: 0,
|
|
392
|
+
payload: { formId: 'contact', values: { name: 'Jane' } },
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toMatchObject({ payload: { formId: 'contact' } })
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
399
|
+
// UI level
|
|
400
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
describe('C-310: the DcsForm UI never shows success without a receipt', () => {
|
|
403
|
+
it('a 202 {} is a visible failure, not a success state', async () => {
|
|
404
|
+
const originalFetch = globalThis.fetch
|
|
405
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = respond('{}', {
|
|
406
|
+
status: 202,
|
|
407
|
+
headers: { 'content-type': 'application/json' },
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const wrapper = mount(DcsForm, {
|
|
411
|
+
props: { formId: 'contact', siteSlug: 'kept', definitionOverride: def, apiBase: '' },
|
|
412
|
+
})
|
|
413
|
+
await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
|
|
414
|
+
await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
|
|
415
|
+
await wrapper.find('form').trigger('submit')
|
|
416
|
+
await flushPromises()
|
|
417
|
+
|
|
418
|
+
expect(wrapper.emitted('submit-success')).toBeUndefined()
|
|
419
|
+
const emitted = wrapper.emitted('submit-error')?.[0]?.[0] as DcsFormSubmitError
|
|
420
|
+
expect(emitted.unconfirmedReceipt).toBe(true)
|
|
421
|
+
expect(wrapper.text()).toContain('We could not confirm your message was received')
|
|
422
|
+
|
|
423
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
|
|
424
|
+
})
|
|
425
|
+
})
|