diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2815f6a..666af69 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,3 +13,18 @@ jobs: - uses: actions/checkout@v4 - name: Run test suite run: npm test + + gitleaks: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install gitleaks + run: | + curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz gitleaks + # `gitleaks detect` exits non-zero on any finding, so this step fails the + # build on any secret hit. `--no-git` scans the checked-out tree only; + # `--redact` masks matched secrets in the log output. + - name: Run gitleaks (fail on any hit) + run: ./gitleaks detect --source . --no-git --redact -v diff --git a/README.md b/README.md index 97ce69b..dcbaccc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,41 @@ vanilla JavaScript. rendered from `data/reading-list.js`) - `contact.html` — contact page (opens the visitor's mail client with the form fields pre-filled via a `mailto:` link) +- `newsletter.html` — newsletter signup page (posts the visitor's email to a + configured serverless endpoint; no self-hosted backend or subscriber list + management) + +## Newsletter signup + +The signup form on `newsletter.html` POSTs the visitor's email to a serverless +endpoint. The client ships only the endpoint URL — no credential is ever sent +in the request: + +- `NEWSLETTER_ENDPOINT` — the serverless endpoint the form POSTs to. This is + the **only** thing configured in `js/newsletter-config.js`, and it is + enforced `https://`-only at config load time and by tests (mirroring the + protocol allowlist in `js/reading-list.js`). + +**Authentication is server-side only.** The serverless function reads its API +token from platform env/secrets at deploy time — never from this repository and +never from any client-served asset. Do not add a token to `js/newsletter-config.js` +or anywhere else in the tree; anything committed here is public. + +When the endpoint is unavailable or rejects the submission, the visitor sees a +fixed, user-safe error message — the server-held token, the endpoint, the status +code, and any raw response body are never surfaced. After a successful signup a +confirmation message is shown. + +**Server-side validation (required follow-up on the function, not this diff):** +the serverless function must authoritatively validate submissions before +processing — email syntax, length caps, pinned `Content-Type`, and rejection of +unknown fields. Client-side checks here are UX only and are bypassable by +direct API calls. + +**Residual signup-abuse risk (accepted, out of scope):** this pass adds no spam +filtering or captcha. Scripted signups remain possible, so the serverless +function must mitigate abuse server-side with rate limiting and an origin +allowlist. ## Reading list @@ -56,11 +91,14 @@ npm test index.html Home page reading.html Reading list page contact.html Contact page +newsletter.html Newsletter signup page css/style.css Global + responsive styles data/reading-list.js Curated reading list data (edit to add links) +js/newsletter-config.js Newsletter endpoint (non-secret, https-only) — edit at deploy time +js/newsletter.js Newsletter signup wiring: credential-free POST + user-safe errors (tested) js/mailto.js Pure mailto: URL builder (unit tested) js/contact.js Contact form wiring (browser + tests) js/reading-list.js Reading list renderer: data file -> grouped HTML (tested) tests/ Node built-in test suite -.gitea/workflows/ ci.yml runs `npm test` on PRs and pushes to main +.gitea/workflows/ ci.yml runs `npm test` plus a gitleaks secret scan (fails on any hit) on PRs and pushes to main ``` diff --git a/contact.html b/contact.html index f442c69..cf224e0 100644 --- a/contact.html +++ b/contact.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter
  • diff --git a/css/style.css b/css/style.css index eef36d8..0cc0a3c 100644 --- a/css/style.css +++ b/css/style.css @@ -120,6 +120,30 @@ button[type="submit"]:hover { filter: brightness(1.1); } +/* --- Newsletter signup --- */ +.newsletter-form { + max-width: 36rem; + display: grid; + gap: 1rem; +} + +.newsletter-form .form-field input { + max-width: 24rem; +} + +.newsletter-status { + margin: 0; + font-weight: 600; +} + +.newsletter-status[data-kind="success"] { + color: #1e7e34; +} + +.newsletter-status[data-kind="error"] { + color: #b3261e; +} + /* --- Reading list --- */ .reading-category { margin-bottom: 2rem; diff --git a/index.html b/index.html index ca8bb82..8a6c2da 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter
  • diff --git a/js/newsletter-config.js b/js/newsletter-config.js new file mode 100644 index 0000000..66f77f2 --- /dev/null +++ b/js/newsletter-config.js @@ -0,0 +1,44 @@ +/** + * Newsletter signup configuration. + * + * Only the non-secret serverless endpoint URL lives here. The serverless + * function authenticates with an API token it reads from platform env/secrets + * at deploy time — never from this module and never from any client-served + * asset. Do NOT add a token or any other secret to this file: the client must + * never carry a credential, and anything committed here is public. + */ + +/** True when the value is an absolute URL whose protocol is https:. */ +export function isHttpsUrl(value) { + try { + return new URL(String(value)).protocol === "https:"; + } catch { + return false; + } +} + +/** + * Enforce the https-only rule on a configured endpoint, mirroring the protocol + * allowlist pattern in js/reading-list.js (tightened to https). Throws when the + * endpoint would silently downgrade submissions to plaintext. + * + * @param {string} endpoint + * @returns {true} + */ +export function validateEndpoint(endpoint) { + if (!isHttpsUrl(endpoint)) { + throw new Error("NEWSLETTER_ENDPOINT must be an https:// URL"); + } + return true; +} + +/** + * The serverless endpoint the signup form POSTs to. + * + * https-only is asserted here at config load time (and covered by tests), so a + * deployer pointing this at an http:// URL fails fast instead of shipping a + * downgraded endpoint. + */ +export const NEWSLETTER_ENDPOINT = "https://example.com/api/newsletter-subscribers"; + +validateEndpoint(NEWSLETTER_ENDPOINT); diff --git a/js/newsletter.js b/js/newsletter.js new file mode 100644 index 0000000..6a0b5cd --- /dev/null +++ b/js/newsletter.js @@ -0,0 +1,128 @@ +/** + * Newsletter signup — posts the visitor's email to a configured serverless + * endpoint. No credential is ever sent: the serverless function authenticates + * with an API token it reads from platform env/secrets at deploy time. + * + * Pure-ish by design (no DOM, injectable fetch), so every behaviour is unit + * testable in Node; the browser wiring at the bottom is guarded accordingly. + */ +import { NEWSLETTER_ENDPOINT } from "./newsletter-config.js"; + +/** User-safe message shown when the signup cannot be completed. */ +export const NEWSLETTER_ERROR_MESSAGE = + "Sorry, the newsletter signup isn't available right now. Please try again later."; + +/** Confirmation message shown after a successful signup. */ +export const NEWSLETTER_SUCCESS_MESSAGE = "Thanks for subscribing!"; + +/** Simple email shape check; the HTML5 `type="email"` input is the primary gate. */ +export function isValidEmail(value) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value).trim()); +} + +/** + * Read the trimmed email value from a form. + * Works with a real DOM form and with the minimal fake form used in tests. + * + * @param {{querySelectorAll(selector: string): ArrayLike<{name: string, value: string}>}} form + * @returns {string} + */ +export function readFormEmail(form) { + for (const el of form.querySelectorAll("[name]")) { + if (el.name === "email") return el.value.trim(); + } + return ""; +} + +/** + * POST the email to the serverless endpoint with no credential in the request + * — no auth header, no token in the body or URL. The serverless function reads + * its API token from platform env/secrets, never from the client. A clearly + * invalid email fails fast with a user-safe error and no network call. Any + * non-2xx response and any network failure map to the same generic message, so + * the endpoint internals, any status code, and any raw response body are never + * surfaced to the visitor. + * + * @param {string} email + * @param {{endpoint?: string, fetchImpl?: typeof fetch}} [options] + * @returns {Promise<{ok: boolean, message: string}>} + */ +export async function submitNewsletterSignup( + email, + { endpoint = NEWSLETTER_ENDPOINT, fetchImpl = fetch } = {}, +) { + if (!isValidEmail(email)) { + return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; + } + + let response; + try { + response = await fetchImpl(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + }); + } catch { + return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; + } + + if (!response.ok) { + return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; + } + return { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE }; +} + +/** + * Handle a newsletter form submit: validate the email, POST it, and surface + * the result (confirmation or user-safe error) through `setStatus`. + * + * @param {HTMLFormElement} form + * @param {{endpoint?: string, fetchImpl?: typeof fetch, setStatus?: (message: string, kind: "success"|"error") => void}} [options] + * @returns {Promise<{ok: boolean, message: string}>} + */ +export async function handleNewsletterSubmit( + form, + { + endpoint = NEWSLETTER_ENDPOINT, + fetchImpl = fetch, + setStatus = defaultSetStatus, + } = {}, +) { + const email = readFormEmail(form); + if (!isValidEmail(email)) { + setStatus(NEWSLETTER_ERROR_MESSAGE, "error"); + return { ok: false, message: NEWSLETTER_ERROR_MESSAGE }; + } + + const result = await submitNewsletterSignup(email, { endpoint, fetchImpl }); + setStatus(result.message, result.ok ? "success" : "error"); + return result; +} + +/** Default status renderer — puts the message into `#newsletter-status`. */ +function defaultSetStatus(message, kind) { + const el = document.getElementById("newsletter-status"); + if (el) { + el.textContent = message; + el.dataset.kind = kind; + } +} + +/** + * Wire the submit handler onto a newsletter form. + * @param {HTMLFormElement} form + */ +export function initNewsletterForm(form, options = {}) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + handleNewsletterSubmit(form, options); + }); +} + +// Browser-only wiring — guarded so this module stays importable in Node tests. +if (typeof window !== "undefined" && typeof document !== "undefined") { + const form = document.getElementById("newsletter-form"); + if (form) initNewsletterForm(form); +} diff --git a/newsletter.html b/newsletter.html new file mode 100644 index 0000000..3bf2871 --- /dev/null +++ b/newsletter.html @@ -0,0 +1,39 @@ + + + + + + Newsletter | My Personal Blog + + + + +
    +

    Newsletter

    +

    Occasional updates on new posts — no spam, unsubscribe anytime. Enter your email to subscribe.

    + +
    +
    + + +
    + + +
    +
    + + + + diff --git a/reading.html b/reading.html index 09d9219..a42b7de 100644 --- a/reading.html +++ b/reading.html @@ -14,6 +14,7 @@
  • Home
  • Reading
  • Contact
  • +
  • Newsletter
  • diff --git a/tests/newsletter.test.js b/tests/newsletter.test.js new file mode 100644 index 0000000..16edf6c --- /dev/null +++ b/tests/newsletter.test.js @@ -0,0 +1,372 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + NEWSLETTER_ERROR_MESSAGE, + NEWSLETTER_SUCCESS_MESSAGE, + handleNewsletterSubmit, + isValidEmail, + readFormEmail, + submitNewsletterSignup, +} from "../js/newsletter.js"; +import { + NEWSLETTER_ENDPOINT, + isHttpsUrl, + validateEndpoint, +} from "../js/newsletter-config.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const newsletterHtml = readFileSync(join(root, "newsletter.html"), "utf8"); +const indexHtml = readFileSync(join(root, "index.html"), "utf8"); +const readingHtml = readFileSync(join(root, "reading.html"), "utf8"); +const contactHtml = readFileSync(join(root, "contact.html"), "utf8"); +const newsletterJs = readFileSync(join(root, "js/newsletter.js"), "utf8"); +const configJs = readFileSync(join(root, "js/newsletter-config.js"), "utf8"); + +/** Minimal stand-in for a DOM form (has querySelectorAll("[name]")). */ +function fakeForm(email) { + return { + querySelectorAll(selector) { + assert.equal(selector, "[name]"); + return [{ name: "email", value: email }]; + }, + }; +} + +/** Record fetch calls; respond with an object or via a (url, init) => response fn. */ +function fakeFetch(respond) { + const calls = []; + const impl = async (url, init) => { + calls.push({ url, init }); + return typeof respond === "function" ? respond(url, init) : respond; + }; + impl.calls = calls; + return impl; +} + +// --------------------------------------------------------------------------- +// Component tests: the signup form renders on the blog +// --------------------------------------------------------------------------- + +test("newsletter page renders an email field and a submit button", () => { + assert.match(newsletterHtml, /]*id="newsletter-form"/); + assert.match( + newsletterHtml, + /]*type="email"[^>]*id="newsletter-email"[^>]*name="email"/, + ); + assert.match(newsletterHtml, /]*type="submit"[^>]*>Subscribe<\/button>/); +}); + +test("the email field is required and the page loads the wiring module", () => { + assert.match(newsletterHtml, /]*id="newsletter-email"[^>]*required/); + assert.match( + newsletterHtml, + /]*type="module"[^>]*src="js\/newsletter\.js"/, + ); +}); + +test("the newsletter page has a live status region for results", () => { + assert.match(newsletterHtml, /id="newsletter-status"/); + assert.match(newsletterHtml, /role="status"/); + assert.match(newsletterHtml, /aria-live="polite"/); +}); + +test("every site page links to the newsletter page, and it marks itself current", () => { + for (const html of [indexHtml, readingHtml, contactHtml, newsletterHtml]) { + assert.match(html, /]*>Newsletter<\/a>/); + } + assert.match( + newsletterHtml, + /]*aria-current="page"[^>]*>Newsletter<\/a>/, + ); +}); + +// --------------------------------------------------------------------------- +// Endpoint config: https-only, enforced at config/test time (mirrors the +// protocol allowlist pattern in js/reading-list.js) +// --------------------------------------------------------------------------- + +test("isHttpsUrl accepts https and rejects every other scheme", () => { + assert.equal(isHttpsUrl("https://api.example.com/newsletter"), true); + assert.equal(isHttpsUrl("https://example.com"), true); + assert.equal(isHttpsUrl("http://api.example.com/newsletter"), false); + assert.equal(isHttpsUrl("javascript:alert(1)"), false); + assert.equal(isHttpsUrl("ftp://example.com/newsletter"), false); + assert.equal(isHttpsUrl("not a url"), false); + assert.equal(isHttpsUrl(""), false); + assert.equal(isHttpsUrl(undefined), false); +}); + +test("validateEndpoint throws on a non-https endpoint and accepts an https one", () => { + assert.equal(validateEndpoint("https://api.example.com/newsletter"), true); + assert.throws(() => validateEndpoint("http://api.example.com/newsletter"), { + message: /https/, + }); + assert.throws(() => validateEndpoint("ftp://example.com/newsletter"), { + message: /https/, + }); +}); + +test("the configured endpoint is https at config load time (config-time assertion)", () => { + // Importing the module already runs validateEndpoint(NEWSLETTER_ENDPOINT); + // this asserts the shipped value satisfies the same rule. + assert.equal(isHttpsUrl(NEWSLETTER_ENDPOINT), true); + assert.ok(NEWSLETTER_ENDPOINT.startsWith("https://")); + assert.doesNotThrow(() => validateEndpoint(NEWSLETTER_ENDPOINT)); +}); + +// --------------------------------------------------------------------------- +// Secrets: no API token or secret in any committed source, fixture, or +// client-served asset (whole-tree scan) +// --------------------------------------------------------------------------- + +test("the page logic never sends or references a credential", () => { + assert.ok(!newsletterJs.includes("Authorization")); + assert.ok(!newsletterJs.includes("Bearer")); + assert.ok(!newsletterJs.includes("NEWSLETTER_API_TOKEN")); + assert.ok(!newsletterJs.includes("api_key")); +}); + +test("the config module ships the endpoint only — no token export or literal", () => { + assert.ok(!configJs.includes("NEWSLETTER_API_TOKEN")); + assert.ok(!configJs.includes("Authorization")); + assert.ok(!configJs.includes("Bearer")); + assert.ok(!configJs.match(/token\s*[:=]\s*["'][^"']{4,}["']/i)); +}); + +test("no secret-shaped literal ships anywhere in the tree (whole-tree scan)", () => { + const patterns = [ + /["'][A-Za-z0-9+/_]{32,}["']/, // long opaque strings (tokens, keys) + /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, + /\b(ghp|gho|github_pat|glpat)_[A-Za-z0-9_]{20,}\b/, + /\bsk-[A-Za-z0-9]{20,}\b/, + /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, + /\bAKIA[0-9A-Z]{16}\b/, + /\bAIza[0-9A-Za-z_-]{35}\b/, + /["']Bearer\s+[^"'\s]{8,}["']/, + ]; + + const files = []; + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (entry === ".git" || entry === "node_modules") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else files.push(full); + } + }; + walk(root); + + assert.ok(files.length > 10, "whole-tree scan should cover the repo"); + for (const file of files) { + const source = readFileSync(file, "utf8"); + for (const pattern of patterns) { + assert.doesNotMatch(source, pattern, `${file} contains a secret-shaped literal`); + } + } +}); + +test("CI runs a gitleaks step that fails on any secret hit", () => { + const ci = readFileSync(join(root, ".gitea/workflows/ci.yml"), "utf8"); + assert.match(ci, /gitleaks/i); + assert.match(ci, /detect/i); +}); + +// --------------------------------------------------------------------------- +// Submission: POSTs to the configured https-only endpoint with no credential +// --------------------------------------------------------------------------- + +test("submitNewsletterSignup POSTs the email with no Authorization header or credential", async () => { + const fetchImpl = fakeFetch({ ok: true }); + + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + fetchImpl, + }); + + assert.equal(result.ok, true); + assert.equal(fetchImpl.calls.length, 1); + const { url, init } = fetchImpl.calls[0]; + assert.equal(url, "https://api.example.com/newsletter"); + assert.equal(init.method, "POST"); + assert.equal(init.headers["Content-Type"], "application/json"); + assert.equal(init.headers.Authorization, undefined); + assert.ok( + !Object.keys(init.headers).some((h) => h.toLowerCase() === "authorization"), + "request must not carry an Authorization header", + ); + // No credential of any kind: no api-key/auth-token headers, and no token or + // secret in the body or URL either. + for (const header of Object.keys(init.headers)) { + const lower = header.toLowerCase(); + assert.ok( + !["x-api-key", "x-auth-token", "x-access-token", "cookie"].includes(lower), + `request must not carry credential header ${header}`, + ); + } + assert.deepEqual(JSON.parse(init.body), { email: "ada@example.com" }); + assert.ok(!url.includes("token")); + assert.ok(!url.includes("key")); + assert.ok(!url.includes("secret")); + assert.ok(!JSON.stringify(init.body).includes("token")); +}); + +test("submitNewsletterSignup defaults to the configured endpoint", async () => { + const fetchImpl = fakeFetch({ ok: true }); + await submitNewsletterSignup("ada@example.com", { fetchImpl }); + assert.equal(fetchImpl.calls[0].url, NEWSLETTER_ENDPOINT); +}); + +// --------------------------------------------------------------------------- +// Confirmation: a successful signup resolves and surfaces a confirmation +// --------------------------------------------------------------------------- + +test("submitNewsletterSignup resolves success with a confirmation message on a 2xx response", async () => { + for (const status of [200, 201, 204]) { + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + fetchImpl: fakeFetch({ ok: true, status }), + }); + assert.deepEqual(result, { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE }); + } +}); + +test("handleNewsletterSubmit shows the confirmation message after a successful signup", async () => { + const statuses = []; + const result = await handleNewsletterSubmit(fakeForm("ada@example.com"), { + endpoint: "https://api.example.com/newsletter", + fetchImpl: fakeFetch({ ok: true }), + setStatus: (message, kind) => statuses.push({ message, kind }), + }); + + assert.equal(result.ok, true); + assert.equal(result.message, NEWSLETTER_SUCCESS_MESSAGE); + assert.deepEqual(statuses, [ + { message: NEWSLETTER_SUCCESS_MESSAGE, kind: "success" }, + ]); +}); + +// --------------------------------------------------------------------------- +// Failure: a failed or unavailable submission surfaces a user-safe error that +// never leaks the server-held token, the endpoint, the status, or any raw body +// --------------------------------------------------------------------------- + +test("a failed submission shows a user-safe error that never leaks token, endpoint, status, or raw body", async () => { + const endpoint = "https://api.example.com/newsletter"; + // A raw body full of fragments the visitor must never see: a token-shaped + // value, the endpoint host, and status codes. + const rawBody = '{"error":"unauthorized","token":"example-secret-value","detail":"api.example.com 500"}'; + const fragments = [ + "example-secret-value", + "api.example.com", + "500", + "Bearer", + "token", + "secret", + ]; + + for (const status of [400, 401, 403, 404, 429, 500, 503]) { + const fetchImpl = fakeFetch({ ok: false, status, text: async () => rawBody }); + const result = await submitNewsletterSignup("ada@example.com", { + endpoint, + fetchImpl, + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + for (const fragment of fragments) { + assert.ok( + !result.message.toLowerCase().includes(fragment.toLowerCase()), + `message must not contain "${fragment}"`, + ); + } + assert.equal(fetchImpl.calls.length, 1); + } +}); + +test("a network failure shows the same user-safe error", async () => { + const fetchImpl = fakeFetch(() => { + throw new Error("network down"); + }); + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + fetchImpl, + }); + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); +}); + +test("a redirect (3xx) is treated as a failure with the same user-safe error", async () => { + for (const status of [301, 302, 307, 308]) { + const fetchImpl = fakeFetch({ ok: false, status, text: async () => "redirecting" }); + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + fetchImpl, + }); + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + } +}); + +test("an invalid email fails fast with a user-safe error and no network call", async () => { + const fetchImpl = fakeFetch({ ok: true }); + const result = await submitNewsletterSignup("not-an-email", { + endpoint: "https://api.example.com/newsletter", + fetchImpl, + }); + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + assert.equal(fetchImpl.calls.length, 0); +}); + +test("handleNewsletterSubmit surfaces a user-safe error for a failed submission", async () => { + const statuses = []; + const result = await handleNewsletterSubmit(fakeForm("ada@example.com"), { + endpoint: "https://api.example.com/newsletter", + fetchImpl: fakeFetch({ ok: false, status: 500 }), + setStatus: (message, kind) => statuses.push({ message, kind }), + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + assert.deepEqual(statuses, [ + { message: NEWSLETTER_ERROR_MESSAGE, kind: "error" }, + ]); +}); + +test("handleNewsletterSubmit rejects an invalid email with no network call", async () => { + const fetchImpl = fakeFetch({ ok: true }); + const statuses = []; + const result = await handleNewsletterSubmit(fakeForm("not-an-email"), { + endpoint: "https://api.example.com/newsletter", + fetchImpl, + setStatus: (message, kind) => statuses.push({ message, kind }), + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + assert.equal(fetchImpl.calls.length, 0); + assert.deepEqual(statuses, [ + { message: NEWSLETTER_ERROR_MESSAGE, kind: "error" }, + ]); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +test("readFormEmail returns the trimmed email value", () => { + assert.equal(readFormEmail(fakeForm(" ada@example.com ")), "ada@example.com"); + assert.equal(readFormEmail(fakeForm("")), ""); +}); + +test("isValidEmail accepts well-formed addresses and rejects malformed ones", () => { + assert.equal(isValidEmail("ada@example.com"), true); + assert.equal(isValidEmail("a.b+c@sub.example.co.uk"), true); + assert.equal(isValidEmail("not-an-email"), false); + assert.equal(isValidEmail("ada@"), false); + assert.equal(isValidEmail("@example.com"), false); + assert.equal(isValidEmail(""), false); +});