diff --git a/.env.local.example b/.env.local.example index b92d37ed..24a29497 100644 --- a/.env.local.example +++ b/.env.local.example @@ -8,3 +8,9 @@ WORDPRESS_API_URL=https://wp.keploy.io/graphql # override here only if you need a different telemetry endpoint or key. # NEXT_PUBLIC_TELEMETRY_URL=https://telemetry.keploy.io # NEXT_PUBLIC_RECAPTCHA_SITE_KEY= + +# Google Chat notification for newsletter/lead form submissions. Server-only +# secret (NOT NEXT_PUBLIC_*) — never exposed to the browser. Create it in the +# Chat space: Apps & integrations → Webhooks → add → copy the URL here. +# If unset, submissions still succeed but no Chat message is delivered. +# GOOGLE_CHAT_WEBHOOK_URL=https://chat.googleapis.com/v1/spaces/.../messages?key=...&token=... diff --git a/components/subscribe-newsletter.tsx b/components/subscribe-newsletter.tsx index cfca3afa..ccc2933e 100644 --- a/components/subscribe-newsletter.tsx +++ b/components/subscribe-newsletter.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { useRouter } from "next/router"; import styles from "./subscribe-newsletter.module.css"; import { newsLetterSubscriptionUrl } from '../services/constants' import { useInvisibleRecaptcha, RecaptchaAttribution } from "../lib/use-invisible-recaptcha"; @@ -39,12 +40,23 @@ export const subscribeMutation = (formData: { fullName: string, email: string, c export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) { + const router = useRouter(); const myComponent = useRef(null); const [isVisible, setVisible] = useState(true); const [fullName, setFullName] = useState(''); const [email, setEmail] = useState(''); const [companyName, setCompanyName] = useState(''); + // Honeypot — hidden from humans; bots that auto-fill every field trip it. + const [companyWebsite, setCompanyWebsite] = useState(''); const [subscribed, setSubscribed] = useState(false); + // In-flight guard: blocks repeat clicks so we don't fire duplicate lead / + // Chat-notify POSTs (the subscription upserts by email, but the Chat space + // gets one ping per click otherwise). The ref is the airtight guard — a fast + // double-click fires two handlers before React re-renders, so reading the + // `submitting` state (or the disabled button) still sees the stale `false`. + // The state drives the disabled/opacity UI; the ref decides who actually runs. + const submittingRef = useRef(false); + const [submitting, setSubmitting] = useState(false); const [emailError, setEmailError] = useState(''); const message = "NEWSLETTER" // Don't load Google's script for every reader — only once someone actually @@ -76,6 +88,10 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) const submitHandler = (e) => { e.preventDefault(); + // Honeypot — bots fill the hidden company_website field; humans don't. + // Drop silently client-side too (the endpoint re-checks server-side). + if (companyWebsite) return; + if (!isValidEmail(email)) { setEmailError("Please enter a valid email address."); @@ -85,6 +101,13 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) return } + // Guard against double-submits — one Chat ping per click otherwise. + // Check/set the ref synchronously so a second click in the same tick can't + // slip through before the state re-renders. + if (submittingRef.current) return; + submittingRef.current = true; + setSubmitting(true); + const payload = { fullName, email, @@ -108,10 +131,30 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) }); }); - handleSubscribe(payload) + // Google Chat notification — fire-and-forget, fail-open. Forwards the lead + // to a Chat space via the server-side /api/blog-lead-notify handler (which + // holds the webhook secret). Never gates the subscription. + fetch(`${router.basePath || ''}/api/blog-lead-notify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + keepalive: true, + body: JSON.stringify({ + fullName: fullName.trim(), + email: email.trim().toLowerCase(), + companyName: companyName.trim(), + company_website: companyWebsite, + source: 'blog-newsletter', + page, + }), + }).catch(() => {}); + + handleSubscribe(payload).finally(() => { + submittingRef.current = false; + setSubmitting(false); + }); }; const isSubscribeDisabled = ()=>{ - return Boolean(!email || !fullName || !companyName) + return Boolean(!email || !fullName || !companyName || submitting) } return (
@@ -129,6 +172,18 @@ export default function SubscribeNewsletter(props: { isSmallScreen?: boolean }) onSubmit={submitHandler} onFocusCapture={() => setCaptchaActive(true)} > + {/* Honeypot — hidden from humans (off-screen, not tabbable, no + autofill); a filled value marks the submit as a bot. */} + setCompanyWebsite(e.target.value)} + style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }} + /> (); + +function isRateLimited(ip: string): boolean { + const now = Date.now(); + const cutoff = now - RATE_LIMIT_WINDOW_MS; + + // Opportunistically drop IPs whose most recent hit is outside the window, so + // the map doesn't accumulate an entry per distinct IP for the instance's life. + if (rateHits.size > RATE_MAP_MAX_KEYS) { + rateHits.forEach((times, key) => { + if (times.length === 0 || times[times.length - 1] <= cutoff) rateHits.delete(key); + }); + } + + const hits = (rateHits.get(ip) || []).filter((t) => t > cutoff); + // Record this hit even when it's the one that trips the limit. Deliberate: a + // sustained flooder keeps their own window sliding forward and stays limited, + // rather than earning a fresh allowance the instant they pause for a beat. + hits.push(now); + rateHits.set(ip, hits); + return hits.length > RATE_LIMIT_MAX; +} + +// Identify the client for rate limiting. On Vercel the trustworthy client IP is +// `x-real-ip` (set by the platform). Do NOT use the leftmost x-forwarded-for +// token — that end is client-supplied and lets an attacker rotate it to dodge +// the limit; the real IP is the LAST hop the trusted proxy appends. +function clientIp(req: NextApiRequest): string { + const realIp = req.headers["x-real-ip"]; + if (typeof realIp === "string" && realIp.trim()) return realIp.trim(); + + const fwd = req.headers["x-forwarded-for"]; + const raw = Array.isArray(fwd) ? fwd[0] : fwd; + if (raw) { + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + if (parts.length) return parts[parts.length - 1]; + } + return req.socket.remoteAddress || "unknown"; +} + +// Strip characters that carry meaning in Google Chat `text` messages so user +// input can't inject formatting (*bold*), fake clickable links (), +// or forge whole labelled fields with newlines. Also caps length. Every +// user-supplied field passes through this before it reaches the message. +// +// We strip `< > | * ` \r \n` but deliberately KEEP `_` and `~`. The test is NOT +// "is it atext" — `|`, `*` and `` ` `` are atext too, yet we strip them, because +// each injects something concrete: `|` completes a clickable , `*` +// bolds, and `` ` `` opens a code span. The distinction that actually earns `_` +// and `~` a pass is two-sided: +// • Risk: with `< > |` and newlines already gone, all `_`/`~` can still do is +// cosmetic matched-pair styling (_italic_, ~strike~; a lone one can't format) +// — never a forged field or a forged link. +// • Cost: `_`/`~` are legal in email local parts (RFC atext, which EMAIL_RE +// accepts) and `_` is common in UTM page URLs (utm_source, q3_launch), so +// unlike `* | ` ` ``, they appear in real lead data and stripping them would +// corrupt real leads. +function sanitize(value: string, max = 200): string { + return value + .replace(/[<>|*`\r\n]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, max); +} + +// Track the "webhook not configured" warning so it's logged once per instance +// instead of on every submit — the off state is expected until the env is set. +let warnedMissingWebhook = false; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== "POST") { + res.setHeader("Allow", "POST"); + return res.status(405).json({ ok: false, error: "method_not_allowed" }); + } + + const data = (req.body && typeof req.body === "object" ? req.body : {}) as Record< + string, + unknown + >; + + // Honeypot — the form renders a hidden `company_website` input that humans + // never see; a filled value means a bot. Silently accept and drop. + if (data.company_website) { + return res.status(200).json({ ok: true }); + } + + if (isRateLimited(clientIp(req))) { + return res.status(429).json({ ok: false, error: "rate_limited" }); + } + + // Sanitize BEFORE validating, same reason as the email below: sanitize() maps + // < > | * ` and newlines to spaces, so a raw-passing value like "***" would + // pass the non-empty check and then collapse to "", shipping a blank *Name:* + // line. Validating the sanitized form 400s those instead ('_'/'~' and real + // names survive sanitize, see its note). + const name = sanitize(String(data.fullName ?? ""), 120); + // Sanitize the email BEFORE validating, so the value we check is the value we + // deliver. EMAIL_RE accepts `*`, `|` and `` ` `` in a local part, but + // sanitize() turns each into a space — so validating the raw value could pass + // an address that sanitize then mangles (a*b@x.com -> "a b@x.com"), delivering + // a broken *Email:* line. Validating the sanitized form 400s those instead, + // and never harms real leads (`_`/`~` survive sanitize, see its note). + const email = sanitize(String(data.email ?? "").toLowerCase(), 254); + // Re-validate server-side: a request could hit this endpoint directly and + // bypass the client-side checks. + if (!name || !EMAIL_RE.test(email)) { + return res.status(400).json({ ok: false, error: "validation" }); + } + + const rawSource = String(data.source ?? "").trim(); + const lead = { + name, + // Already sanitized + lowercased above (before validation), so a direct hit + // can't create case-variant leads — same normalization the blog-mql path uses. + email, + company: sanitize(String(data.companyName ?? ""), 160), + page: sanitize(String(data.page ?? ""), 500), + source: ALLOWED_SOURCES.has(rawSource) ? rawSource : DEFAULT_SOURCE, + submittedAt: new Date().toISOString(), + }; + + const webhook = process.env.GOOGLE_CHAT_WEBHOOK_URL; + if (!webhook) { + // No PII in logs. Set GOOGLE_CHAT_WEBHOOK_URL to deliver leads to the space. + // Expected steady state until the env is set, so warn once (and only off + // production, matching blog-mql.ts) rather than on every submit. + if (!warnedMissingWebhook && process.env.NODE_ENV !== "production") { + warnedMissingWebhook = true; + console.warn( + "[blog-lead] GOOGLE_CHAT_WEBHOOK_URL is not configured — leads accepted but NOT delivered. Set the env var to enable delivery.", + ); + } + // Constant response — don't reveal to an unauthenticated caller whether the + // webhook is configured or whether their message landed. + return res.status(200).json({ ok: true }); + } + + const controller = new AbortController(); + // Abort a stuck webhook well under maxDuration (10s) so the catch runs and + // logs while the function is still alive; a Chat webhook answers in <1s, so + // 5s is generous headroom, not a tight bound. + const timer = setTimeout(() => controller.abort(), 5000); + try { + // page is rendered as plain (sanitized) text, not a link, and + // every field is sanitized, so a direct POST can't inject markup. + const text = + `*📨 New Keploy blog subscriber*\n` + + `*Name:* ${lead.name}\n` + + `*Email:* ${lead.email}\n` + + `*Company:* ${lead.company || "—"}\n` + + `*Source:* ${lead.source}\n` + + `*Page:* ${lead.page || "—"}\n` + + `*Submitted:* ${lead.submittedAt}`; + + const chatRes = await fetch(webhook, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify({ text }), + signal: controller.signal, + }); + // fetch only rejects on network-level failure, not on an HTTP error status, + // so a revoked webhook / deleted space / bad URL / quota rejection returns + // 4xx-5xx and would otherwise look like success — "leads quietly stop + // arriving and nobody notices". Surface the status so delivery breakage is + // visible. Logged in production too (unlike the missing-webhook warning): + // it's the only signal that a *configured* webhook has broken, and the line + // carries no PII — just the HTTP status code. + if (!chatRes.ok) { + console.warn( + `[blog-lead] Google Chat rejected the message (HTTP ${chatRes.status}) — verify GOOGLE_CHAT_WEBHOOK_URL is a valid, active incoming-webhook URL. Lead was NOT delivered.`, + ); + } + return res.status(200).json({ ok: true }); + } catch (err) { + // A network-level failure (DNS / refused connection / TLS / the 5s abort) + // is just as silent and permanent as a 4xx — leads quietly stop arriving — + // so surface it in production too, matching the !chatRes.ok branch above. + // The message is a fixed, PII-free string that never carries the webhook + // URL; the raw error (PII-free itself for undici, but belt-and-suspenders) + // is only attached off production. + const aborted = err instanceof Error && err.name === "AbortError"; + const msg = + `[blog-lead] delivery to Google Chat failed (${aborted ? "timed out" : "network error"}) — ` + + "verify GOOGLE_CHAT_WEBHOOK_URL is a valid incoming-webhook URL. Lead was NOT delivered."; + if (process.env.NODE_ENV !== "production") { + console.warn(msg, err); + } else { + console.warn(msg); + } + // Never fail the user — the newsletter subscription path is unaffected. + return res.status(200).json({ ok: true }); + } finally { + clearTimeout(timer); + } +} diff --git a/tests/e2e/LeadCapture.spec.ts b/tests/e2e/LeadCapture.spec.ts index 6162cf7a..f2138910 100644 --- a/tests/e2e/LeadCapture.spec.ts +++ b/tests/e2e/LeadCapture.spec.ts @@ -12,9 +12,11 @@ test.describe('Newsletter lead capture (blog-mql + reCAPTCHA)', () => { test.use({ viewport: { width: 1600, height: 1000 } }); let leadRequests: Array>; + let notifyRequests: Array>; test.beforeEach(async ({ page, baseURL }) => { leadRequests = []; + notifyRequests = []; await page.route('https://www.google.com/recaptcha/enterprise.js*', (route: Route) => route.fulfill({ status: 200, contentType: 'application/javascript', body: '/* stubbed loader */' }) @@ -40,6 +42,17 @@ test.describe('Newsletter lead capture (blog-mql + reCAPTCHA)', () => { }); }); + // Google Chat notification — stub it so the e2e run never hits the real + // endpoint, and capture the payload for assertions. + await page.route('**/blog-lead-notify', async (route: Route) => { + notifyRequests.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '{"ok":true,"delivered":true}', + }); + }); + const targetUrl = baseURL ? `${baseURL}/technology` : 'http://localhost:3000/blog/technology'; @@ -74,6 +87,19 @@ test.describe('Newsletter lead capture (blog-mql + reCAPTCHA)', () => { expect(lead.assetType).toBe('newsletter'); expect(lead.recaptchaToken).toBe('e2e-stub-token'); expect(String(lead.page)).toContain('/blog/'); + + // The Google Chat notification fires alongside the blog-mql lead, carrying + // the same submitter details. + await expect + .poll(() => notifyRequests.length, { timeout: 10000, message: 'notify POST should fire' }) + .toBeGreaterThan(0); + + const notify = notifyRequests[0]; + expect(notify.fullName).toBe('E2E Tester'); + expect(notify.email).toBe('e2e@keploy.io'); + expect(notify.companyName).toBe('Keploy'); + expect(notify.source).toBe('blog-newsletter'); + expect(String(notify.page)).toContain('/blog/'); }); test('shows the reCAPTCHA attribution required for the hidden badge', async ({ page }) => { @@ -105,4 +131,26 @@ test.describe('Newsletter lead capture (blog-mql + reCAPTCHA)', () => { .toBeGreaterThan(0); expect(leadRequests[0].recaptchaToken).toBe(''); }); + + test('a failing Chat notification does not block the newsletter subscription (fail open)', async ({ page }) => { + // Make the notify endpoint fail — the subscription lead must still go out. + await page.route('**/blog-lead-notify', (route: Route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"ok":false}' }) + ); + + const form = page.locator('form', { has: page.getByPlaceholder('Full Name') }).first(); + await form.scrollIntoViewIfNeeded(); + await expect(form).toBeVisible({ timeout: 15000 }); + + await form.getByPlaceholder('Full Name').fill('Notify Down'); + await form.getByPlaceholder('Email').fill('notify-down@keploy.io'); + await form.getByPlaceholder('Company Name').fill('Keploy'); + await form.locator('button[type="submit"]').click(); + + // The blog-mql lead still fires despite the notify failure. + await expect + .poll(() => leadRequests.length, { timeout: 10000, message: 'lead POST should fire even when notify fails' }) + .toBeGreaterThan(0); + expect(leadRequests[0].email).toBe('notify-down@keploy.io'); + }); }); diff --git a/tests/lib/blog-lead-notify.test.ts b/tests/lib/blog-lead-notify.test.ts new file mode 100644 index 00000000..39071220 --- /dev/null +++ b/tests/lib/blog-lead-notify.test.ts @@ -0,0 +1,253 @@ +/** + * Unit tests for the blog lead -> Google Chat notify endpoint + * (pages/api/blog-lead-notify.ts). + * + * Run via: `npm run test:unit` + * + * This is a public, unauthenticated POST whose side effect is a message in the + * team's Chat space, so the regressions that matter are security-shaped and + * easy to reintroduce in a refactor: + * + * 1. Chat-markup injection — user fields must be sanitized so a direct POST + * can't forge labelled fields, clickable links, or bold/italic. + * 2. Abuse controls — honeypot + per-IP rate limit must actually gate. + * 3. No oracle / no PII leak, and fail-open so a Chat hiccup never 500s. + */ + +import { test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +import handler from "../../pages/api/blog-lead-notify"; + +const WEBHOOK = "https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t"; + +let fetchCalls: Array<{ url: any; body: any }> = []; +const realFetch = global.fetch; + +beforeEach(() => { + fetchCalls = []; + // Capture the outbound Chat webhook call; pretend Chat accepted it. + global.fetch = (async (url: any, opts: any) => { + fetchCalls.push({ url, body: opts?.body ? JSON.parse(opts.body) : undefined }); + return { ok: true } as any; + }) as any; +}); + +afterEach(() => { + global.fetch = realFetch; + delete process.env.GOOGLE_CHAT_WEBHOOK_URL; +}); + +function mockRes() { + const res: any = { + statusCode: 0, + headers: {} as Record, + body: undefined as any, + setHeader(k: string, v: string) { res.headers[k.toLowerCase()] = v; }, + status(code: number) { res.statusCode = code; return res; }, + json(payload: any) { res.body = payload; return res; }, + }; + return res; +} + +// Distinct IP per call by default so the module-level rate-limit map doesn't +// bleed between tests. Pass a fixed ip to exercise the limit itself. +let ipSeq = 0; +function mockReq(overrides: any = {}) { + const ip = overrides.ip || `10.0.0.${(ipSeq++ % 250) + 1}`; + return { + method: "POST", + headers: { "x-real-ip": ip }, + socket: { remoteAddress: ip }, + body: { fullName: "Jane", email: "jane@keploy.io", companyName: "Acme", page: "https://keploy.io/blog/x" }, + ...overrides, + } as any; +} + +test("rejects non-POST with 405", async () => { + const res = mockRes(); + await handler(mockReq({ method: "GET" }), res); + assert.equal(res.statusCode, 405); + assert.equal(fetchCalls.length, 0); +}); + +test("honeypot: filled company_website is silently dropped, no webhook call, no leak", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq({ body: { fullName: "Bot", email: "b@b.com", company_website: "http://evil" } }), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); // no `delivered` oracle + assert.equal(fetchCalls.length, 0); +}); + +test("rejects invalid email and missing name with 400", async () => { + const r1 = mockRes(); + await handler(mockReq({ body: { fullName: "Jane", email: "not-an-email" } }), r1); + assert.equal(r1.statusCode, 400); + + const r2 = mockRes(); + await handler(mockReq({ body: { fullName: "", email: "jane@keploy.io" } }), r2); + assert.equal(r2.statusCode, 400); + assert.equal(fetchCalls.length, 0); +}); + +test("no webhook configured: accepts but does not deliver, and does not leak that", async () => { + const res = mockRes(); + await handler(mockReq(), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); + assert.equal(fetchCalls.length, 0); +}); + +test("valid lead with webhook: posts a sanitized message to Chat", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq(), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].url, WEBHOOK); + assert.match(fetchCalls[0].body.text, /New Keploy blog subscriber/); + assert.match(fetchCalls[0].body.text, /jane@keploy\.io/); +}); + +test("sanitizes Chat markup: no injected newlines, links, or bold/italic reach the message", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq({ + body: { + fullName: "Legit\n*Email:* ceo@keploy.io", + email: "attacker@mail.com", + companyName: "", + page: "https://keploy.io/blog/x`_*", + }, + }), res); + const text: string = fetchCalls[0].body.text; + // exactly the 7 template lines — attacker newlines can't forge extra lines + assert.equal(text.split("\n").length, 7); + // forged clickable link + labelled-field injection are stripped + assert.ok(!text.includes("")); + assert.ok(!text.includes("|Approve")); + assert.ok(!text.includes("Legit\n*Email:* ceo")); + // the sanitized Name line carries no markup metacharacters + const nameLine = text.split("\n").find((l) => l.startsWith("*Name:*")) || ""; + assert.ok(!/[<>|`_*]/.test(nameLine.replace(/^\*Name:\*/, ""))); +}); + +test("keeps underscores: email local part and UTM-tagged page survive intact", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq({ + body: { + fullName: "John Doe", + email: "john_doe@keploy.io", + companyName: "Acme", + page: "https://keploy.io/blog/technology/x?utm_source=twitter&utm_campaign=q3_launch", + }, + }), res); + const text: string = fetchCalls[0].body.text; + // `_` is legal in emails and common in UTM params — stripping it corrupts the + // lead the team needs to reply to and the campaign attribution URL. + assert.match(text, /\*Email:\* john_doe@keploy\.io/); + assert.match(text, /utm_source=twitter&utm_campaign=q3_launch/); +}); + +test("surfaces a non-OK Chat status without failing the user", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const warnings: string[] = []; + const realWarn = console.warn; + console.warn = ((...args: any[]) => { warnings.push(args.join(" ")); }) as any; + // Chat rejects the message (e.g. revoked webhook) — fetch resolves, not rejects. + global.fetch = (async (url: any, opts: any) => { + fetchCalls.push({ url, body: opts?.body ? JSON.parse(opts.body) : undefined }); + return { ok: false, status: 404 } as any; + }) as any; + try { + const res = mockRes(); + await handler(mockReq(), res); + // User is never blocked, and the constant response gives no delivery oracle. + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); + // ...but the broken delivery is logged so it doesn't fail silently. + assert.ok(warnings.some((w) => w.includes("404") && /rejected the message/i.test(w))); + } finally { + console.warn = realWarn; + } +}); + +test("rejects an email that only validates before sanitizing (a*b@x.com -> 400)", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + // `a*b@x.com` passes EMAIL_RE raw, but sanitize turns the `*` into a space, so + // validating the sanitized value must 400 rather than deliver "a b@x.com". + await handler(mockReq({ body: { fullName: "Jane", email: "a*b@x.com" } }), res); + assert.equal(res.statusCode, 400); + assert.deepEqual(res.body, { ok: false, error: "validation" }); + assert.equal(fetchCalls.length, 0); +}); + +test("pins source server-side: a forged source falls back to the default", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq({ body: { fullName: "Jane", email: "jane@keploy.io", source: "trusted-partner" } }), res); + assert.match(fetchCalls[0].body.text, /\*Source:\* blog-newsletter/); + assert.ok(!fetchCalls[0].body.text.includes("trusted-partner")); +}); + +test("caps oversized fields so delivery stays under Chat's limit", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const res = mockRes(); + await handler(mockReq({ body: { fullName: "n".repeat(500), email: "jane@keploy.io", companyName: "c".repeat(500), page: "p".repeat(2000) } }), res); + const text: string = fetchCalls[0].body.text; + assert.ok(text.length < 4096); +}); + +test("per-IP rate limit: 6th request in the window is 429", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const ip = "203.0.113.9"; + const codes: number[] = []; + for (let i = 0; i < 6; i++) { + const res = mockRes(); + await handler(mockReq({ ip, headers: { "x-real-ip": ip } }), res); + codes.push(res.statusCode); + } + assert.deepEqual(codes.slice(0, 5), [200, 200, 200, 200, 200]); + assert.equal(codes[5], 429); +}); + +test("fail-open: a Chat webhook error still returns 200 (never blocks the user)", async () => { + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + global.fetch = (async () => { throw new Error("network down"); }) as any; + const res = mockRes(); + await handler(mockReq(), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); +}); + +test("surfaces a network failure in production without leaking the webhook URL", async () => { + // The !chatRes.ok branch already logs in production; a network-level failure + // (DNS / refused connection / TLS / abort) is just as silent and permanent, + // so it must be visible too — otherwise leads quietly stop arriving. + process.env.GOOGLE_CHAT_WEBHOOK_URL = WEBHOOK; + const realEnv = process.env.NODE_ENV; + const warnings: string[] = []; + const realWarn = console.warn; + console.warn = ((...args: any[]) => { warnings.push(args.join(" ")); }) as any; + // undici surfaces network failures as a `fetch failed` TypeError whose detail + // lives in `cause` (host only, never the query string that holds the secret). + global.fetch = (async () => { throw new Error("fetch failed"); }) as any; + try { + (process.env as any).NODE_ENV = "production"; + const res = mockRes(); + await handler(mockReq(), res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); + // Logged even in production... + assert.ok(warnings.some((w) => /delivery to Google Chat failed/i.test(w))); + // ...but the secret webhook URL (key/token) never lands in the log. + assert.ok(!warnings.some((w) => w.includes("key=k") || w.includes("token=t") || w.includes(WEBHOOK))); + } finally { + console.warn = realWarn; + (process.env as any).NODE_ENV = realEnv; + } +});