test: component + unit tests for the newsletter signup
Covers the issue test plan: form renders and posts to the endpoint; token is read from config, never hardcoded in source; a failed-token response shows a user-safe error without leaking the secret; confirmation on success.
This commit is contained in:
@@ -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, /<form[^>]*id="newsletter-form"/);
|
||||||
|
assert.match(
|
||||||
|
newsletterHtml,
|
||||||
|
/<input[^>]*type="email"[^>]*id="newsletter-email"[^>]*name="email"/
|
||||||
|
);
|
||||||
|
assert.match(newsletterHtml, /<button[^>]*type="submit"[^>]*>Subscribe<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("newsletter email field is required and the page loads the wiring module", () => {
|
||||||
|
assert.match(newsletterHtml, /<input[^>]*id="newsletter-email"[^>]*required/);
|
||||||
|
assert.match(
|
||||||
|
newsletterHtml,
|
||||||
|
/<script[^>]*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, /<a href="newsletter\.html"[^>]*>Newsletter<\/a>/);
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
newsletterHtml,
|
||||||
|
/<a href="newsletter\.html"[^>]*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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user