diff --git a/tests/newsletter.test.js b/tests/newsletter.test.js new file mode 100644 index 0000000..4189769 --- /dev/null +++ b/tests/newsletter.test.js @@ -0,0 +1,278 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } 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_API_TOKEN, + NEWSLETTER_ENDPOINT, +} 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 calls and respond — pass an object or 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("newsletter 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("newsletter page has a live status region for the result message", () => { + 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>/ + ); +}); + +// --------------------------------------------------------------------------- +// Config & secrets: token comes from config, never hardcoded in source +// --------------------------------------------------------------------------- + +test("endpoint and token are imported from the config module, never defined in the page logic", () => { + assert.match( + newsletterJs, + /import\s*\{[\s\S]*NEWSLETTER_ENDPOINT[\s\S]*\}\s*from\s*"\.\/newsletter-config\.js"/ + ); + assert.match( + newsletterJs, + /import\s*\{[\s\S]*NEWSLETTER_API_TOKEN[\s\S]*\}\s*from\s*"\.\/newsletter-config\.js"/ + ); + // The page logic must not assign a token literal itself. + assert.doesNotMatch(newsletterJs, /NEWSLETTER_API_TOKEN\s*=\s*["'][^"']+["']/); +}); + +test("the committed config carries an empty placeholder token, so no secret is committed", () => { + assert.equal(NEWSLETTER_API_TOKEN, ""); + assert.equal(typeof NEWSLETTER_ENDPOINT, "string"); + assert.ok(NEWSLETTER_ENDPOINT.startsWith("https://")); +}); + +test("no shipped page or script embeds a secret-shaped token literal", () => { + const secretShape = /["'][A-Za-z0-9+/_-]{32,}["']/; + for (const source of [newsletterJs, configJs, newsletterHtml]) { + assert.doesNotMatch(source, secretShape); + } +}); + +// --------------------------------------------------------------------------- +// Unit tests: submitting POSTs to the configured serverless endpoint +// --------------------------------------------------------------------------- + +test("submitNewsletterSignup POSTs the email to the endpoint with the token in the Authorization header", async () => { + const fetchImpl = fakeFetch({ ok: true }); + + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + token: "test-token-123", + 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"], "Bearer test-token-123"); + assert.deepEqual(JSON.parse(init.body), { email: "ada@example.com" }); +}); + +test("submitNewsletterSignup resolves success with a confirmation message on a 2xx response", async () => { + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + token: "test-token-123", + fetchImpl: fakeFetch({ ok: true, status: 200 }), + }); + assert.deepEqual(result, { ok: true, message: NEWSLETTER_SUCCESS_MESSAGE }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: missing or invalid token → user-safe error, no secret leak +// --------------------------------------------------------------------------- + +test("a missing token fails with a user-safe error and never calls the network", async () => { + const fetchImpl = fakeFetch({ ok: true }); + + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + token: "", + fetchImpl, + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + assert.equal(fetchImpl.calls.length, 0); + // The message reveals nothing about the failure cause or configuration. + assert.ok(!result.message.includes("token")); + assert.ok(!result.message.includes("Bearer")); + assert.ok(!result.message.includes("secret")); + assert.ok(!result.message.includes("api.example.com")); +}); + +test("an invalid token (401 from the endpoint) shows a user-safe error without leaking the secret", async () => { + const token = "sekrit-token-abc123"; + const fetchImpl = fakeFetch({ ok: false, status: 401 }); + + const result = await submitNewsletterSignup("ada@example.com", { + endpoint: "https://api.example.com/newsletter", + token, + fetchImpl, + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); + assert.ok(!result.message.includes(token)); + assert.ok(!result.message.includes("401")); + assert.ok(!result.message.includes("api.example.com")); +}); + +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", + token: "test-token-123", + fetchImpl, + }); + + assert.equal(result.ok, false); + assert.equal(result.message, NEWSLETTER_ERROR_MESSAGE); +}); + +// --------------------------------------------------------------------------- +// Unit tests: form handling wires result → status region +// --------------------------------------------------------------------------- + +test("handleNewsletterSubmit shows the confirmation message after a successful signup", async () => { + const fetchImpl = fakeFetch({ ok: true }); + const statuses = []; + + const result = await handleNewsletterSubmit(fakeForm("ada@example.com"), { + endpoint: "https://api.example.com/newsletter", + token: "test-token-123", + fetchImpl, + 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" }, + ]); + assert.equal(fetchImpl.calls.length, 1); +}); + +test("handleNewsletterSubmit rejects an invalid email without any network call", async () => { + const fetchImpl = fakeFetch({ ok: true }); + const statuses = []; + + const result = await handleNewsletterSubmit(fakeForm("not-an-email"), { + token: "test-token-123", + 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" }, + ]); +}); + +test("handleNewsletterSubmit surfaces a user-safe error when the config token is missing", async () => { + const fetchImpl = fakeFetch({ ok: true }); + const statuses = []; + + const result = await handleNewsletterSubmit(fakeForm("ada@example.com"), { + endpoint: "https://api.example.com/newsletter", + token: "", + 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" }, + ]); +}); + +// --------------------------------------------------------------------------- +// Unit tests: 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); +});