diff --git a/tests/newsletter.test.js b/tests/newsletter.test.js deleted file mode 100644 index 16edf6c..0000000 --- a/tests/newsletter.test.js +++ /dev/null @@ -1,372 +0,0 @@ -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); -});