Skip to content

Commit 05d9be6

Browse files
authored
Merge pull request #675 from webstackdev/maintenance/newsletter-signup-honeypot-field
Maintenance/newsletter signup honeypot field
2 parents bf09dde + 118c87b commit 05d9be6

10 files changed

Lines changed: 187 additions & 6 deletions

File tree

src/actions/newsletter/@types/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export type NewsletterFormData = {
22
email: string
33
firstName?: string
4+
website_url?: string
45
consentGiven?: boolean
56
DataSubjectId?: string
67
}
@@ -22,6 +23,7 @@ export interface PendingSubscription {
2223
export type NewsletterSubscribeInput = {
2324
email: string
2425
firstName?: string
26+
website_url?: string
2527
consentGiven?: boolean
2628
DataSubjectId?: string
2729
}

src/actions/newsletter/__tests__/action.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,42 @@ beforeEach(() => {
138138
})
139139

140140
describe('newsletter.subscribe.handler', () => {
141+
it('silently drops submissions that fill the honeypot field', async () => {
142+
const { newsletter } = await import('../action')
143+
const { createConsentRecord } = await import('@actions/gdpr/entities/consent')
144+
const { createPendingSubscription } = await import('@actions/newsletter/domain')
145+
const { sendConfirmationEmail } = await import('@actions/newsletter/entities/email')
146+
147+
const context = {
148+
request: new Request('https://example.com/_actions/newsletter/subscribe', {
149+
method: 'POST',
150+
headers: { 'user-agent': 'ua-bot' },
151+
}),
152+
cookies: {} as unknown,
153+
clientAddress: '203.0.113.9',
154+
}
155+
156+
const response = await getMockedHandler<NewsletterSubscribeInput, NewsletterSubscribeOutput>(
157+
newsletter.subscribe
158+
)(
159+
{
160+
email: 'test@example.com',
161+
consentGiven: true,
162+
website_url: 'https://spam.example',
163+
},
164+
context
165+
)
166+
167+
expect(response).toEqual({
168+
success: true,
169+
message: 'Please check your email to confirm your subscription.',
170+
requiresConfirmation: true,
171+
})
172+
expect(createConsentRecord).not.toHaveBeenCalled()
173+
expect(createPendingSubscription).not.toHaveBeenCalled()
174+
expect(sendConfirmationEmail).not.toHaveBeenCalled()
175+
})
176+
141177
it('rejects when consent is missing', async () => {
142178
const { newsletter } = await import('../action')
143179

src/actions/newsletter/action.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
const subscribeSchema = z.object({
2323
email: z.string(),
2424
firstName: z.string().optional(),
25+
website_url: z.string().trim().max(200).optional(),
2526
consentGiven: z.boolean().optional(),
2627
DataSubjectId: z.string().optional(),
2728
})
@@ -94,6 +95,11 @@ export const newsletter = {
9495
context
9596
): Promise<{ success: true; message: string; requiresConfirmation: true }> => {
9697
const route = '/_actions/newsletter/subscribe'
98+
const successResponse = {
99+
success: true as const,
100+
message: 'Please check your email to confirm your subscription.',
101+
requiresConfirmation: true as const,
102+
}
97103
let stage: NewsletterSubscribeStage = 'buildRequestFingerprint'
98104
let fingerprint: string | undefined
99105
let consentFunctional = false
@@ -122,6 +128,10 @@ export const newsletter = {
122128
throw new ActionsFunctionError(`Try again in ${retryAfterSeconds}s`, { status: 429 })
123129
}
124130

131+
if (body.website_url) {
132+
return successResponse
133+
}
134+
125135
stage = 'validateEmail'
126136
const validatedEmail = validateEmail(body.email)
127137

@@ -175,11 +185,7 @@ export const newsletter = {
175185
stage = 'sendConfirmationEmail'
176186
await sendConfirmationEmail(validatedEmail, token, body.firstName)
177187

178-
return {
179-
success: true,
180-
message: 'Please check your email to confirm your subscription.',
181-
requiresConfirmation: true,
182-
}
188+
return successResponse
183189
} catch (error) {
184190
const errorContext = {
185191
route,

src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const defaultNewsletterProps: NewsletterProps = {
3838
variant: 'article',
3939
}
4040

41-
const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home']
41+
const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home', 'page']
4242

4343
const getElements = (root: NewsletterFormElement) => {
4444
const selectElement = <T extends Element>(selector: string): T => {
@@ -55,6 +55,7 @@ const getElements = (root: NewsletterFormElement) => {
5555
form: selectElement<HTMLFormElement>('#newsletter-form'),
5656
emailLabel: selectElement<HTMLLabelElement>('#newsletter-email-label'),
5757
emailInput: selectElement<HTMLInputElement>('#newsletter-email'),
58+
websiteUrlInput: selectElement<HTMLInputElement>('#newsletter-website_url'),
5859
consentCheckbox: selectElement<HTMLInputElement>('#newsletter-gdpr-consent'),
5960
submitButton: selectElement<HTMLButtonElement>('#newsletter-submit'),
6061
buttonText: selectElement<HTMLSpanElement>('#button-text'),
@@ -113,6 +114,7 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va
113114
expect(elements.description.id).toBe('newsletter-cta-' + variant + '-description')
114115
expect(elements.form.id).toBe('newsletter-form')
115116
expect(elements.emailInput.id).toBe('newsletter-email')
117+
expect(elements.websiteUrlInput.name).toBe('website_url')
116118
expect(elements.consentCheckbox.id).toBe('newsletter-gdpr-consent')
117119

118120
expect(elements.title.textContent).toContain(defaultNewsletterProps.title)
@@ -194,6 +196,27 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va
194196
})
195197
})
196198

199+
test('forwards the honeypot field when it is filled', async () => {
200+
newsletterSubscribeMock.mockResolvedValueOnce({
201+
data: { success: true, message: 'Please check your email to confirm your subscription.' },
202+
})
203+
204+
await renderNewsletter(async ({ elements }) => {
205+
elements.emailInput.value = 'test@example.com'
206+
elements.websiteUrlInput.value = 'https://spam.example'
207+
elements.consentCheckbox.checked = true
208+
209+
elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
210+
await flushPromises()
211+
212+
expect(newsletterSubscribeMock).toHaveBeenCalledWith({
213+
email: 'test@example.com',
214+
'website_url': 'https://spam.example',
215+
consentGiven: true,
216+
})
217+
})
218+
})
219+
197220
test('handles API error responses gracefully', async () => {
198221
newsletterSubscribeMock.mockResolvedValueOnce({
199222
error: { message: 'Subscription failed' },

src/components/CallToAction/Newsletter/client/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export class NewsletterFormElement extends LitElement {
227227
const email = this.emailInput.value.trim()
228228
const formData = this.form ? new FormData(this.form) : null
229229
const consentGiven = formData?.get('consent') === 'true'
230+
const websiteUrlRaw = formData?.get('website_url')
231+
const websiteUrl = typeof websiteUrlRaw === 'string' ? websiteUrlRaw.trim() : ''
230232

231233
const dataSubjectIdRaw = formData?.get('DataSubjectId')
232234
const dataSubjectId = typeof dataSubjectIdRaw === 'string' ? dataSubjectIdRaw.trim() : ''
@@ -263,6 +265,7 @@ export class NewsletterFormElement extends LitElement {
263265
try {
264266
result = await actions.newsletter.subscribe({
265267
email,
268+
...(websiteUrl ? { 'website_url': websiteUrl } : {}),
266269
consentGiven,
267270
...(DataSubjectId ? { DataSubjectId } : {}),
268271
})

src/components/CallToAction/Newsletter/layouts/article.astro

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ const {
4242
</p>
4343

4444
<form id="newsletter-form" class="space-y-4">
45+
<div
46+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
47+
aria-hidden="true"
48+
>
49+
<label for="newsletter-website_url">Website</label>
50+
<input
51+
type="text"
52+
id="newsletter-website_url"
53+
name="website_url"
54+
tabindex="-1"
55+
autocomplete="off"
56+
inputmode="url"
57+
/>
58+
</div>
59+
4560
<div class="flex flex-col md:flex-row gap-3 pt-4">
4661
<div
4762
class="relative flex items-center gap-2 flex-1 after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-within:after:-inset-1 focus-within:after:border-spotlight"

src/components/CallToAction/Newsletter/layouts/home.astro

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,21 @@ const {
127127
</div>
128128

129129
<form id="newsletter-form" class="space-y-4">
130+
<div
131+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
132+
aria-hidden="true"
133+
>
134+
<label for="newsletter-website_url">Website</label>
135+
<input
136+
type="text"
137+
id="newsletter-website_url"
138+
name="website_url"
139+
tabindex="-1"
140+
autocomplete="off"
141+
inputmode="url"
142+
/>
143+
</div>
144+
130145
<div
131146
class="relative space-y-1 after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-within:after:-inset-1 focus-within:after:border-spotlight"
132147
>

src/components/CallToAction/Newsletter/layouts/page.astro

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ const {
4848
</div>
4949

5050
<form id="newsletter-form" class="space-y-5">
51+
<div
52+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
53+
aria-hidden="true"
54+
>
55+
<label for="newsletter-website_url">Website</label>
56+
<input
57+
type="text"
58+
id="newsletter-website_url"
59+
name="website_url"
60+
tabindex="-1"
61+
autocomplete="off"
62+
inputmode="url"
63+
/>
64+
</div>
65+
5166
<div class="space-y-1">
5267
<label
5368
id="newsletter-email-label"

src/components/scripts/store/__tests__/consent.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,53 @@ describe('Consent side effects', () => {
706706
expect(handleScriptErrorSpy).not.toHaveBeenCalled()
707707
})
708708

709+
it('suppresses consent transport failures without reporting a script error', async () => {
710+
vi.useFakeTimers()
711+
712+
consentCreateMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))
713+
714+
const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError')
715+
716+
let consentListener:
717+
| ((_state: ConsentState, _oldState?: ConsentState) => Promise<void> | void)
718+
| undefined
719+
vi.spyOn($consent, 'subscribe').mockImplementation(listener => {
720+
consentListener = listener
721+
return () => {}
722+
})
723+
vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {})
724+
vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {})
725+
vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {})
726+
727+
initConsentSideEffects()
728+
729+
const oldState = {
730+
analytics: false,
731+
marketing: false,
732+
functional: false,
733+
DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
734+
}
735+
const newState = {
736+
analytics: true,
737+
marketing: false,
738+
functional: false,
739+
DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
740+
}
741+
742+
await consentListener?.(newState, oldState)
743+
744+
await vi.advanceTimersByTimeAsync(250)
745+
746+
await vi.waitFor(() => {
747+
expect(consentCreateMock).toHaveBeenCalledTimes(1)
748+
})
749+
750+
await vi.advanceTimersByTimeAsync(30_000)
751+
752+
expect(consentCreateMock).toHaveBeenCalledTimes(1)
753+
expect(handleScriptErrorSpy).not.toHaveBeenCalled()
754+
})
755+
709756
it('deletes the data subject id when functional consent is revoked', () => {
710757
let functionalListener: ((_hasConsent: boolean) => void) | undefined
711758
vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(listener => {

src/components/scripts/store/consent.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,21 @@ export function initConsentSideEffects(): void {
555555
return hasCheckpointMarkup
556556
}
557557

558+
const isConsentTransportError = (error?: ConsentActionError): boolean => {
559+
const normalizedMessage = error?.message?.toLowerCase()
560+
const causeRecord = getErrorRecord(error?.cause)
561+
const causeName =
562+
typeof causeRecord?.['name'] === 'string' ? causeRecord['name'].toLowerCase() : undefined
563+
564+
return Boolean(
565+
causeName === 'aborterror' ||
566+
normalizedMessage?.includes('failed to fetch') ||
567+
normalizedMessage?.includes('load failed') ||
568+
normalizedMessage?.includes('networkerror when attempting to fetch resource') ||
569+
normalizedMessage?.includes('the internet connection appears to be offline')
570+
)
571+
}
572+
558573
const ensureOnlineListener = () => {
559574
if (typeof window === 'undefined' || onlineListener) {
560575
return
@@ -667,6 +682,10 @@ export function initConsentSideEffects(): void {
667682
return
668683
}
669684

685+
if (isConsentTransportError(actionError)) {
686+
return
687+
}
688+
670689
throw new ClientScriptError({
671690
message: serverMessage ?? 'Failed to record consent',
672691
cause: {

0 commit comments

Comments
 (0)