diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5b724c1..d605de2 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -44,16 +44,33 @@ relying on a filter to exclude private rows. ## Contact Form * The contact form is a public, unauthenticated endpoint and is treated as an - abuse surface. Requirements: - * Server-side input validation on all fields. - * Rate limiting to prevent bulk submission abuse. - * Basic bot mitigation (e.g. a honeypot field or equivalent low-friction - measure) — full CAPTCHA is avoided if possible, given accessibility concerns - with CAPTCHA and this site's accessibility positioning; if a bot-mitigation - measure with accessibility implications is ever considered, it is a - stop-and-consult design decision, not an implementation-time default. + abuse surface. Implemented protections, layered: + * Server-side input validation on all fields (name, email format, topic + against an allowed list, minimum message length), independent of the + client-side validation in the page itself. + * A honeypot field (`website`), hidden from sighted and assistive-technology + users alike (`aria-hidden`, visually off-screen, not part of the tab + order). A populated honeypot is silently rejected without revealing that + detection occurred. + * Google reCAPTCHA v2 (checkbox variant, not the distorted-text challenge). + Chosen over reCAPTCHA v3 specifically because v3's behavioral scoring has + a documented history of penalizing atypical interaction patterns, + including keyboard-only and screen-reader-driven navigation — a real risk + given this site's audience. The checkbox variant can still occasionally + escalate to a secondary challenge for sessions Google's own risk engine + flags, which is outside this project's control; Google provides an audio + alternative for that case. This is a third-party script that sends + visitor behavioral data to Google — treated as the explicit third-party + tracking decision called for above, not a default. + * Rate limiting via a Cloudflare KV-backed counter, keyed by client IP + (`src/lib/contact/rateLimit.ts`), capped per time window. * Submitted data is not publicly queryable and is not exposed through any public - API route. + API route. Messages are relayed via the Gmail API (OAuth2, not raw SMTP — + Cloudflare Workers does not reliably support raw SMTP), using credentials + supplied by the primary contributor, stored as Cloudflare Worker secrets. +* All four contact-form logic modules (validation, reCAPTCHA verification, + rate limiting, Gmail send) are pure/testable and have unit test coverage, + per the CI policy in `PROJECT.md`. ## Third-Party Services @@ -71,6 +88,12 @@ relying on a filter to exclude private rows. ## Open Items -* Specific email delivery service selection for the contact form (not yet decided). -* Specific rate-limiting implementation (Cloudflare-native rate limiting vs. - application-level) — to be decided when the contact form is designed. +* The contact form's KV namespace (`RATE_LIMIT`), Gmail API OAuth2 + credentials, and reCAPTCHA site/secret key pair are not yet provisioned. + All three require manual setup outside this repository (Cloudflare KV + namespace creation; a Google Cloud project with Gmail API enabled and an + OAuth consent flow run once to obtain a refresh token; reCAPTCHA site + registration in Google's admin console) before the contact form is + functional in production. The code is written against these as named + bindings/secrets (see `wrangler.toml`, `src/env.d.ts`) and will fail + clearly, not silently, if they are unset. diff --git a/package-lock.json b/package-lock.json index df19547..d437201 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.9.0", + "@cloudflare/workers-types": "^5.20260809.1", "@eslint/js": "^9.0.0", "@playwright/test": "^1.45.0", "@vitest/coverage-v8": "^3.2.7", @@ -374,6 +375,13 @@ "node": ">=16" } }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260809.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260809.1.tgz", + "integrity": "sha512-sBM+0I5lCY9LgTnorn/N2UyrA6KVbUzj9tncxwH8v6sH8tVeAyJj+i0z3xXWY4l4fd1oDcwt8CGf50vGwVISYQ==", + "devOptional": true, + "license": "MIT OR Apache-2.0" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", diff --git a/package.json b/package.json index 931147b..a583c6a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.9.0", + "@cloudflare/workers-types": "^5.20260809.1", "@eslint/js": "^9.0.0", "@playwright/test": "^1.45.0", "@vitest/coverage-v8": "^3.2.7", diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..b68cce3 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,13 @@ +/// +/// + +declare namespace Cloudflare { + interface Env { + RATE_LIMIT: KVNamespace; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; + GOOGLE_REFRESH_TOKEN: string; + GMAIL_SENDER: string; + RECAPTCHA_SECRET: string; + } +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index df983bc..4cee224 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -15,7 +15,36 @@ const { title } = Astro.props; +
+ +
+ @@ -32,4 +61,45 @@ const { title } = Astro.props; color: #fff; padding: 0.5em 1em; } + .main-nav ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 0.5rem; + } + .main-nav a { + display: inline-block; + min-width: 24px; + min-height: 24px; + padding: 0.75rem 1rem; + } + .site-footer { + margin-block-start: 3rem; + border-top: 1px solid currentColor; + } + .site-footer ul { + display: flex; + list-style: none; + margin: 0; + padding: 0; + gap: 0.5rem; + } + .site-footer a { + display: inline-block; + min-width: 24px; + min-height: 24px; + padding: 0.75rem 1rem; + } + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } diff --git a/src/lib/contact/gmail.ts b/src/lib/contact/gmail.ts new file mode 100644 index 0000000..6db6f75 --- /dev/null +++ b/src/lib/contact/gmail.ts @@ -0,0 +1,90 @@ +const TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'; + +export interface GmailCredentials { + clientId: string; + clientSecret: string; + refreshToken: string; + sender: string; +} + +export interface ContactMessage { + name: string; + email: string; + topic: string; + message: string; +} + +function base64UrlEncode(input: string): string { + const bytes = new TextEncoder().encode(input); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function buildRawMessage(credentials: GmailCredentials, contact: ContactMessage): string { + const lines = [ + `From: ${credentials.sender}`, + `To: ${credentials.sender}`, + `Reply-To: ${contact.email}`, + `Subject: BlindTechMage contact form: ${contact.topic}`, + 'Content-Type: text/plain; charset=utf-8', + '', + `Name: ${contact.name}`, + `Email: ${contact.email}`, + `Topic: ${contact.topic}`, + '', + contact.message, + ]; + return base64UrlEncode(lines.join('\r\n')); +} + +async function getAccessToken( + credentials: GmailCredentials, + fetchImpl: typeof fetch +): Promise { + const response = await fetchImpl(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: credentials.clientId, + client_secret: credentials.clientSecret, + refresh_token: credentials.refreshToken, + grant_type: 'refresh_token', + }), + }); + + if (!response.ok) { + throw new Error(`Failed to obtain Gmail access token: ${response.status}`); + } + + const data = (await response.json()) as { access_token?: string }; + if (!data.access_token) { + throw new Error('Gmail token response did not include an access token.'); + } + return data.access_token; +} + +export async function sendContactEmail( + credentials: GmailCredentials, + contact: ContactMessage, + fetchImpl: typeof fetch = fetch +): Promise { + const accessToken = await getAccessToken(credentials, fetchImpl); + const raw = buildRawMessage(credentials, contact); + + const response = await fetchImpl(SEND_URL, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ raw }), + }); + + if (!response.ok) { + throw new Error(`Failed to send contact email: ${response.status}`); + } +} diff --git a/src/lib/contact/rateLimit.ts b/src/lib/contact/rateLimit.ts new file mode 100644 index 0000000..020a934 --- /dev/null +++ b/src/lib/contact/rateLimit.ts @@ -0,0 +1,20 @@ +export const RATE_LIMIT_WINDOW_SECONDS = 60 * 60; +export const RATE_LIMIT_MAX_SUBMISSIONS = 5; + +export interface RateLimitKV { + get(key: string): Promise; + put(key: string, value: string, options?: { expirationTtl?: number }): Promise; +} + +export async function checkRateLimit(kv: RateLimitKV, identifier: string): Promise { + const key = `contact-form:${identifier}`; + const current = await kv.get(key); + const count = current ? Number.parseInt(current, 10) : 0; + + if (count >= RATE_LIMIT_MAX_SUBMISSIONS) { + return false; + } + + await kv.put(key, String(count + 1), { expirationTtl: RATE_LIMIT_WINDOW_SECONDS }); + return true; +} diff --git a/src/lib/contact/recaptcha.ts b/src/lib/contact/recaptcha.ts new file mode 100644 index 0000000..84a34c6 --- /dev/null +++ b/src/lib/contact/recaptcha.ts @@ -0,0 +1,34 @@ +const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + +export interface RecaptchaVerifyResult { + success: boolean; +} + +export async function verifyRecaptcha( + token: string, + secret: string, + remoteIp: string | undefined, + fetchImpl: typeof fetch = fetch +): Promise { + if (!token) { + return { success: false }; + } + + const body = new URLSearchParams({ secret, response: token }); + if (remoteIp) { + body.set('remoteip', remoteIp); + } + + const response = await fetchImpl(VERIFY_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!response.ok) { + return { success: false }; + } + + const data = (await response.json()) as { success?: boolean }; + return { success: data.success === true }; +} diff --git a/src/lib/contact/validation.ts b/src/lib/contact/validation.ts new file mode 100644 index 0000000..b02384d --- /dev/null +++ b/src/lib/contact/validation.ts @@ -0,0 +1,59 @@ +export const CONTACT_TOPICS = [ + 'general', + 'consulting', + 'collaboration', + 'speaking', + 'other', +] as const; + +export type ContactTopic = (typeof CONTACT_TOPICS)[number]; + +export const MIN_MESSAGE_LENGTH = 20; + +export interface ContactFormInput { + name: string; + email: string; + topic: string; + message: string; +} + +export interface ContactFormErrors { + name?: string; + email?: string; + topic?: string; + message?: string; +} + +export interface ContactFormValidationResult { + valid: boolean; + errors: ContactFormErrors; +} + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function validateContactForm(input: ContactFormInput): ContactFormValidationResult { + const errors: ContactFormErrors = {}; + + if (!input.name.trim()) { + errors.name = 'Please enter your name.'; + } + + if (!input.email.trim()) { + errors.email = 'Please enter your email address.'; + } else if (!EMAIL_PATTERN.test(input.email.trim())) { + errors.email = 'Please enter a valid email address.'; + } + + if (!CONTACT_TOPICS.includes(input.topic as ContactTopic)) { + errors.topic = 'Please choose a topic.'; + } + + if (input.message.trim().length < MIN_MESSAGE_LENGTH) { + errors.message = `Please enter at least ${MIN_MESSAGE_LENGTH} characters.`; + } + + return { + valid: Object.keys(errors).length === 0, + errors, + }; +} diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts new file mode 100644 index 0000000..858125d --- /dev/null +++ b/src/pages/api/contact.ts @@ -0,0 +1,78 @@ +import type { APIRoute } from 'astro'; +import { env } from 'cloudflare:workers'; +import { validateContactForm } from '../../lib/contact/validation'; +import { verifyRecaptcha } from '../../lib/contact/recaptcha'; +import { checkRateLimit } from '../../lib/contact/rateLimit'; +import { sendContactEmail } from '../../lib/contact/gmail'; + +export const prerender = false; + +function jsonResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export const POST: APIRoute = async ({ request, clientAddress }) => { + const formData = await request.formData(); + + // Honeypot: real users never see or fill this field. If it's populated, + // reject without revealing that detection occurred. + const honeypot = String(formData.get('website') ?? ''); + if (honeypot.trim() !== '') { + return jsonResponse({ ok: true }, 200); + } + + const input = { + name: String(formData.get('name') ?? ''), + email: String(formData.get('email') ?? ''), + topic: String(formData.get('topic') ?? ''), + message: String(formData.get('message') ?? ''), + }; + + const validation = validateContactForm(input); + if (!validation.valid) { + return jsonResponse({ ok: false, errors: validation.errors }, 400); + } + + const recaptchaToken = String(formData.get('g-recaptcha-response') ?? ''); + const recaptchaResult = await verifyRecaptcha( + recaptchaToken, + env.RECAPTCHA_SECRET, + clientAddress + ); + if (!recaptchaResult.success) { + return jsonResponse( + { ok: false, errors: { recaptcha: 'Please complete the checkbox verification.' } }, + 400 + ); + } + + const withinLimit = await checkRateLimit(env.RATE_LIMIT, clientAddress ?? 'unknown'); + if (!withinLimit) { + return jsonResponse( + { ok: false, errors: { form: 'Too many submissions. Please try again later.' } }, + 429 + ); + } + + try { + await sendContactEmail( + { + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + refreshToken: env.GOOGLE_REFRESH_TOKEN, + sender: env.GMAIL_SENDER, + }, + input + ); + } catch { + return jsonResponse( + { ok: false, errors: { form: 'Something went wrong sending your message. Please try again.' } }, + 502 + ); + } + + return jsonResponse({ ok: true }, 200); +}; diff --git a/src/pages/contact.astro b/src/pages/contact.astro new file mode 100644 index 0000000..5789150 --- /dev/null +++ b/src/pages/contact.astro @@ -0,0 +1,187 @@ +--- +export const prerender = true; + +import BaseLayout from '../layouts/BaseLayout.astro'; +import { CONTACT_TOPICS, MIN_MESSAGE_LENGTH } from '../lib/contact/validation'; + +const siteKey = import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY ?? ''; +--- + + + +
+

Contact

+

+ Use this form to get in touch. Fields marked with an asterisk are + required. +

+ +
+ +
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+
+ + + + diff --git a/src/pages/index.astro b/src/pages/index.astro index 5776a09..a0149d0 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -7,6 +7,10 @@ import BaseLayout from '../layouts/BaseLayout.astro';

Blind Tech Mage

-

Site under construction. Content and information architecture are still being designed.

+

Systems engineer, architect, and entrepreneur.

+

+ This site is under active construction. More content is on the way — + in the meantime, feel free to get in touch. +

diff --git a/tests/e2e/contact.a11y.spec.ts b/tests/e2e/contact.a11y.spec.ts new file mode 100644 index 0000000..dcde86d --- /dev/null +++ b/tests/e2e/contact.a11y.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +test('contact page has no detectable WCAG 2.2 AA violations', async ({ page }) => { + await page.goto('/contact'); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) + .analyze(); + + expect(results.violations).toEqual([]); +}); + +test('submit button remains enabled with an empty form', async ({ page }) => { + await page.goto('/contact'); + const submit = page.getByRole('button', { name: 'Send message' }); + await expect(submit).toBeEnabled(); +}); + +test('submitting an empty form shows inline errors and focuses the first invalid field', async ({ + page, +}) => { + await page.goto('/contact'); + await page.getByRole('button', { name: 'Send message' }).click(); + + await expect(page.locator('#name-error')).toHaveText('Please enter your name.'); + await expect(page.locator('#name')).toBeFocused(); +}); diff --git a/tests/unit/contact/gmail.test.ts b/tests/unit/contact/gmail.test.ts new file mode 100644 index 0000000..a9c5f8d --- /dev/null +++ b/tests/unit/contact/gmail.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildRawMessage, sendContactEmail } from '../../../src/lib/contact/gmail'; + +const credentials = { + clientId: 'client-id', + clientSecret: 'client-secret', + refreshToken: 'refresh-token', + sender: 'me@example.com', +}; + +const contact = { + name: 'Jad', + email: 'jad@example.com', + topic: 'general', + message: 'Hello there, this is a test message.', +}; + +describe('buildRawMessage', () => { + it('produces a base64url-encoded MIME message with no padding characters', () => { + const raw = buildRawMessage(credentials, contact); + expect(raw).not.toContain('+'); + expect(raw).not.toContain('/'); + expect(raw).not.toContain('='); + }); + + it('embeds the sender as Reply-To so replies go to the actual submitter', () => { + const raw = buildRawMessage(credentials, contact); + const decoded = atob(raw.replace(/-/g, '+').replace(/_/g, '/')); + expect(decoded).toContain(`Reply-To: ${contact.email}`); + }); +}); + +describe('sendContactEmail', () => { + it('exchanges the refresh token then sends via the Gmail API', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ access_token: 'access-token' }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({}) }); + + await sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + const [sendUrl, sendOptions] = fetchImpl.mock.calls[1]; + expect(sendUrl).toContain('gmail.googleapis.com'); + expect(sendOptions.headers.authorization).toBe('Bearer access-token'); + }); + + it('throws when the token exchange fails', async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce({ ok: false, json: async () => ({}) }); + await expect( + sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch) + ).rejects.toThrow(); + }); + + it('throws when the send request fails', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ access_token: 'access-token' }) }) + .mockResolvedValueOnce({ ok: false, json: async () => ({}) }); + await expect( + sendContactEmail(credentials, contact, fetchImpl as unknown as typeof fetch) + ).rejects.toThrow(); + }); +}); diff --git a/tests/unit/contact/rateLimit.test.ts b/tests/unit/contact/rateLimit.test.ts new file mode 100644 index 0000000..2a4a73e --- /dev/null +++ b/tests/unit/contact/rateLimit.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + checkRateLimit, + RATE_LIMIT_MAX_SUBMISSIONS, + type RateLimitKV, +} from '../../../src/lib/contact/rateLimit'; + +function createFakeKV(initial: Record = {}): RateLimitKV { + const store = new Map(Object.entries(initial)); + return { + async get(key) { + return store.get(key) ?? null; + }, + async put(key, value) { + store.set(key, value); + }, + }; +} + +describe('checkRateLimit', () => { + it('allows the first submission from a new identifier', async () => { + const kv = createFakeKV(); + expect(await checkRateLimit(kv, '1.2.3.4')).toBe(true); + }); + + it('allows submissions up to the configured maximum', async () => { + const kv = createFakeKV(); + for (let i = 0; i < RATE_LIMIT_MAX_SUBMISSIONS; i += 1) { + expect(await checkRateLimit(kv, '1.2.3.4')).toBe(true); + } + }); + + it('rejects submissions beyond the configured maximum', async () => { + const kv = createFakeKV({ 'contact-form:1.2.3.4': String(RATE_LIMIT_MAX_SUBMISSIONS) }); + expect(await checkRateLimit(kv, '1.2.3.4')).toBe(false); + }); + + it('tracks identifiers independently', async () => { + const kv = createFakeKV({ 'contact-form:1.2.3.4': String(RATE_LIMIT_MAX_SUBMISSIONS) }); + expect(await checkRateLimit(kv, '5.6.7.8')).toBe(true); + }); +}); diff --git a/tests/unit/contact/recaptcha.test.ts b/tests/unit/contact/recaptcha.test.ts new file mode 100644 index 0000000..8541619 --- /dev/null +++ b/tests/unit/contact/recaptcha.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { verifyRecaptcha } from '../../../src/lib/contact/recaptcha'; + +function mockFetch(responseBody: unknown, ok = true): typeof fetch { + return vi.fn().mockResolvedValue({ + ok, + json: async () => responseBody, + }) as unknown as typeof fetch; +} + +describe('verifyRecaptcha', () => { + it('returns false immediately for an empty token, without calling fetch', async () => { + const fetchImpl = mockFetch({ success: true }); + const result = await verifyRecaptcha('', 'secret', '1.2.3.4', fetchImpl); + expect(result.success).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('returns true when Google reports success', async () => { + const fetchImpl = mockFetch({ success: true }); + const result = await verifyRecaptcha('token', 'secret', '1.2.3.4', fetchImpl); + expect(result.success).toBe(true); + }); + + it('returns false when Google reports failure', async () => { + const fetchImpl = mockFetch({ success: false }); + const result = await verifyRecaptcha('token', 'secret', '1.2.3.4', fetchImpl); + expect(result.success).toBe(false); + }); + + it('returns false when the request itself fails', async () => { + const fetchImpl = mockFetch({}, false); + const result = await verifyRecaptcha('token', 'secret', '1.2.3.4', fetchImpl); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/unit/contact/validation.test.ts b/tests/unit/contact/validation.test.ts new file mode 100644 index 0000000..52cad2d --- /dev/null +++ b/tests/unit/contact/validation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { validateContactForm, MIN_MESSAGE_LENGTH } from '../../../src/lib/contact/validation'; + +const validInput = { + name: 'Jad', + email: 'jad@example.com', + topic: 'general', + message: 'a'.repeat(MIN_MESSAGE_LENGTH), +}; + +describe('validateContactForm', () => { + it('accepts fully valid input', () => { + expect(validateContactForm(validInput).valid).toBe(true); + }); + + it('rejects an empty name', () => { + const result = validateContactForm({ ...validInput, name: ' ' }); + expect(result.valid).toBe(false); + expect(result.errors.name).toBeDefined(); + }); + + it('rejects a malformed email address', () => { + const result = validateContactForm({ ...validInput, email: 'not-an-email' }); + expect(result.valid).toBe(false); + expect(result.errors.email).toBeDefined(); + }); + + it('rejects an unknown topic', () => { + const result = validateContactForm({ ...validInput, topic: 'not-a-real-topic' }); + expect(result.valid).toBe(false); + expect(result.errors.topic).toBeDefined(); + }); + + it('rejects a message shorter than the minimum length', () => { + const result = validateContactForm({ ...validInput, message: 'too short' }); + expect(result.valid).toBe(false); + expect(result.errors.message).toBeDefined(); + }); + + it('accepts a message exactly at the minimum length', () => { + const result = validateContactForm({ + ...validInput, + message: 'a'.repeat(MIN_MESSAGE_LENGTH), + }); + expect(result.valid).toBe(true); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index c297980..35f442e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -16,3 +16,19 @@ compatibility_date = "2026-01-01" # binding = "DB" # database_name = "blindtechmage-resources" # database_id = "REPLACE_WITH_REAL_ID" + +# Contact form rate limiting. Requires a real KV namespace, created via +# `wrangler kv namespace create RATE_LIMIT` before this is active — not yet +# provisioned. +# +# [[kv_namespaces]] +# binding = "RATE_LIMIT" +# id = "REPLACE_WITH_REAL_ID" + +# Contact form secrets (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, +# GOOGLE_REFRESH_TOKEN, GMAIL_SENDER, RECAPTCHA_SECRET) are set via +# `wrangler secret put `, never committed here. See docs/SECURITY.md. +# +# The reCAPTCHA site key (PUBLIC_RECAPTCHA_SITE_KEY) is not secret — it is a +# build-time public environment variable, set via a `.env` file locally +# (untracked, see .gitignore) or a repository/environment variable in CI.