Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/actions/newsletter/@types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type NewsletterFormData = {
email: string
firstName?: string
website_url?: string
consentGiven?: boolean
DataSubjectId?: string
}
Expand All @@ -22,6 +23,7 @@ export interface PendingSubscription {
export type NewsletterSubscribeInput = {
email: string
firstName?: string
website_url?: string
consentGiven?: boolean
DataSubjectId?: string
}
Expand Down
36 changes: 36 additions & 0 deletions src/actions/newsletter/__tests__/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ beforeEach(() => {
})

describe('newsletter.subscribe.handler', () => {
it('silently drops submissions that fill the honeypot field', async () => {
const { newsletter } = await import('../action')
const { createConsentRecord } = await import('@actions/gdpr/entities/consent')
const { createPendingSubscription } = await import('@actions/newsletter/domain')
const { sendConfirmationEmail } = await import('@actions/newsletter/entities/email')

const context = {
request: new Request('https://example.com/_actions/newsletter/subscribe', {
method: 'POST',
headers: { 'user-agent': 'ua-bot' },
}),
cookies: {} as unknown,
clientAddress: '203.0.113.9',
}

const response = await getMockedHandler<NewsletterSubscribeInput, NewsletterSubscribeOutput>(
newsletter.subscribe
)(
{
email: 'test@example.com',
consentGiven: true,
website_url: 'https://spam.example',
},
context
)

expect(response).toEqual({
success: true,
message: 'Please check your email to confirm your subscription.',
requiresConfirmation: true,
})
expect(createConsentRecord).not.toHaveBeenCalled()
expect(createPendingSubscription).not.toHaveBeenCalled()
expect(sendConfirmationEmail).not.toHaveBeenCalled()
})

it('rejects when consent is missing', async () => {
const { newsletter } = await import('../action')

Expand Down
16 changes: 11 additions & 5 deletions src/actions/newsletter/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
const subscribeSchema = z.object({
email: z.string(),
firstName: z.string().optional(),
website_url: z.string().trim().max(200).optional(),
consentGiven: z.boolean().optional(),
DataSubjectId: z.string().optional(),
})
Expand Down Expand Up @@ -94,6 +95,11 @@ export const newsletter = {
context
): Promise<{ success: true; message: string; requiresConfirmation: true }> => {
const route = '/_actions/newsletter/subscribe'
const successResponse = {
success: true as const,
message: 'Please check your email to confirm your subscription.',
requiresConfirmation: true as const,
}
let stage: NewsletterSubscribeStage = 'buildRequestFingerprint'
let fingerprint: string | undefined
let consentFunctional = false
Expand Down Expand Up @@ -122,6 +128,10 @@ export const newsletter = {
throw new ActionsFunctionError(`Try again in ${retryAfterSeconds}s`, { status: 429 })
}

if (body.website_url) {
return successResponse
}

stage = 'validateEmail'
const validatedEmail = validateEmail(body.email)

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

return {
success: true,
message: 'Please check your email to confirm your subscription.',
requiresConfirmation: true,
}
return successResponse
} catch (error) {
const errorContext = {
route,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const defaultNewsletterProps: NewsletterProps = {
variant: 'article',
}

const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home']
const newsletterVariants: NewsletterProps['variant'][] = ['article', 'home', 'page']

const getElements = (root: NewsletterFormElement) => {
const selectElement = <T extends Element>(selector: string): T => {
Expand All @@ -55,6 +55,7 @@ const getElements = (root: NewsletterFormElement) => {
form: selectElement<HTMLFormElement>('#newsletter-form'),
emailLabel: selectElement<HTMLLabelElement>('#newsletter-email-label'),
emailInput: selectElement<HTMLInputElement>('#newsletter-email'),
websiteUrlInput: selectElement<HTMLInputElement>('#newsletter-website_url'),
consentCheckbox: selectElement<HTMLInputElement>('#newsletter-gdpr-consent'),
submitButton: selectElement<HTMLButtonElement>('#newsletter-submit'),
buttonText: selectElement<HTMLSpanElement>('#button-text'),
Expand Down Expand Up @@ -113,6 +114,7 @@ describe.each(newsletterVariants)('NewsletterFormElement web component (%s)', va
expect(elements.description.id).toBe('newsletter-cta-' + variant + '-description')
expect(elements.form.id).toBe('newsletter-form')
expect(elements.emailInput.id).toBe('newsletter-email')
expect(elements.websiteUrlInput.name).toBe('website_url')
expect(elements.consentCheckbox.id).toBe('newsletter-gdpr-consent')

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

test('forwards the honeypot field when it is filled', async () => {
newsletterSubscribeMock.mockResolvedValueOnce({
data: { success: true, message: 'Please check your email to confirm your subscription.' },
})

await renderNewsletter(async ({ elements }) => {
elements.emailInput.value = 'test@example.com'
elements.websiteUrlInput.value = 'https://spam.example'
elements.consentCheckbox.checked = true

elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()

expect(newsletterSubscribeMock).toHaveBeenCalledWith({
email: 'test@example.com',
'website_url': 'https://spam.example',
consentGiven: true,
})
})
})

test('handles API error responses gracefully', async () => {
newsletterSubscribeMock.mockResolvedValueOnce({
error: { message: 'Subscription failed' },
Expand Down
3 changes: 3 additions & 0 deletions src/components/CallToAction/Newsletter/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ export class NewsletterFormElement extends LitElement {
const email = this.emailInput.value.trim()
const formData = this.form ? new FormData(this.form) : null
const consentGiven = formData?.get('consent') === 'true'
const websiteUrlRaw = formData?.get('website_url')
const websiteUrl = typeof websiteUrlRaw === 'string' ? websiteUrlRaw.trim() : ''

const dataSubjectIdRaw = formData?.get('DataSubjectId')
const dataSubjectId = typeof dataSubjectIdRaw === 'string' ? dataSubjectIdRaw.trim() : ''
Expand Down Expand Up @@ -263,6 +265,7 @@ export class NewsletterFormElement extends LitElement {
try {
result = await actions.newsletter.subscribe({
email,
...(websiteUrl ? { 'website_url': websiteUrl } : {}),
consentGiven,
...(DataSubjectId ? { DataSubjectId } : {}),
})
Expand Down
15 changes: 15 additions & 0 deletions src/components/CallToAction/Newsletter/layouts/article.astro
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ const {
</p>

<form id="newsletter-form" class="space-y-4">
<div
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
aria-hidden="true"
>
<label for="newsletter-website_url">Website</label>
<input
type="text"
id="newsletter-website_url"
name="website_url"
tabindex="-1"
autocomplete="off"
inputmode="url"
/>
</div>

<div class="flex flex-col md:flex-row gap-3 pt-4">
<div
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"
Expand Down
15 changes: 15 additions & 0 deletions src/components/CallToAction/Newsletter/layouts/home.astro
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ const {
</div>

<form id="newsletter-form" class="space-y-4">
<div
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
aria-hidden="true"
>
<label for="newsletter-website_url">Website</label>
<input
type="text"
id="newsletter-website_url"
name="website_url"
tabindex="-1"
autocomplete="off"
inputmode="url"
/>
</div>

<div
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"
>
Expand Down
15 changes: 15 additions & 0 deletions src/components/CallToAction/Newsletter/layouts/page.astro
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ const {
</div>

<form id="newsletter-form" class="space-y-5">
<div
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
aria-hidden="true"
>
<label for="newsletter-website_url">Website</label>
<input
type="text"
id="newsletter-website_url"
name="website_url"
tabindex="-1"
autocomplete="off"
inputmode="url"
/>
</div>

<div class="space-y-1">
<label
id="newsletter-email-label"
Expand Down
47 changes: 47 additions & 0 deletions src/components/scripts/store/__tests__/consent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,53 @@ describe('Consent side effects', () => {
expect(handleScriptErrorSpy).not.toHaveBeenCalled()
})

it('suppresses consent transport failures without reporting a script error', async () => {
vi.useFakeTimers()

consentCreateMock.mockRejectedValueOnce(new TypeError('Failed to fetch'))

const handleScriptErrorSpy = vi.spyOn(errorHandlerModule, 'handleScriptError')

let consentListener:
| ((_state: ConsentState, _oldState?: ConsentState) => Promise<void> | void)
| undefined
vi.spyOn($consent, 'subscribe').mockImplementation(listener => {
consentListener = listener
return () => {}
})
vi.spyOn($isConsentBannerVisible, 'subscribe').mockImplementation(() => () => {})
vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(() => () => {})
vi.spyOn($hasAnalyticsConsent, 'subscribe').mockImplementation(() => () => {})

initConsentSideEffects()

const oldState = {
analytics: false,
marketing: false,
functional: false,
DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
}
const newState = {
analytics: true,
marketing: false,
functional: false,
DataSubjectId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
}

await consentListener?.(newState, oldState)

await vi.advanceTimersByTimeAsync(250)

await vi.waitFor(() => {
expect(consentCreateMock).toHaveBeenCalledTimes(1)
})

await vi.advanceTimersByTimeAsync(30_000)

expect(consentCreateMock).toHaveBeenCalledTimes(1)
expect(handleScriptErrorSpy).not.toHaveBeenCalled()
})

it('deletes the data subject id when functional consent is revoked', () => {
let functionalListener: ((_hasConsent: boolean) => void) | undefined
vi.spyOn($hasFunctionalConsent, 'subscribe').mockImplementation(listener => {
Expand Down
19 changes: 19 additions & 0 deletions src/components/scripts/store/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,21 @@ export function initConsentSideEffects(): void {
return hasCheckpointMarkup
}

const isConsentTransportError = (error?: ConsentActionError): boolean => {
const normalizedMessage = error?.message?.toLowerCase()
const causeRecord = getErrorRecord(error?.cause)
const causeName =
typeof causeRecord?.['name'] === 'string' ? causeRecord['name'].toLowerCase() : undefined

return Boolean(
causeName === 'aborterror' ||
normalizedMessage?.includes('failed to fetch') ||
normalizedMessage?.includes('load failed') ||
normalizedMessage?.includes('networkerror when attempting to fetch resource') ||
normalizedMessage?.includes('the internet connection appears to be offline')
)
}

const ensureOnlineListener = () => {
if (typeof window === 'undefined' || onlineListener) {
return
Expand Down Expand Up @@ -667,6 +682,10 @@ export function initConsentSideEffects(): void {
return
}

if (isConsentTransportError(actionError)) {
return
}

throw new ClientScriptError({
message: serverMessage ?? 'Failed to record consent',
cause: {
Expand Down
Loading