Merge pull request '[Story] Newsletter signup (serverless)' (#15) from feature/14 into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -13,3 +13,18 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Run test suite
|
- name: Run test suite
|
||||||
run: npm test
|
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
|
||||||
|
|||||||
@@ -10,6 +10,41 @@ vanilla JavaScript.
|
|||||||
rendered from `data/reading-list.js`)
|
rendered from `data/reading-list.js`)
|
||||||
- `contact.html` — contact page (opens the visitor's mail client with the form
|
- `contact.html` — contact page (opens the visitor's mail client with the form
|
||||||
fields pre-filled via a `mailto:` link)
|
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
|
## Reading list
|
||||||
|
|
||||||
@@ -56,11 +91,14 @@ npm test
|
|||||||
index.html Home page
|
index.html Home page
|
||||||
reading.html Reading list page
|
reading.html Reading list page
|
||||||
contact.html Contact page
|
contact.html Contact page
|
||||||
|
newsletter.html Newsletter signup page
|
||||||
css/style.css Global + responsive styles
|
css/style.css Global + responsive styles
|
||||||
data/reading-list.js Curated reading list data (edit to add links)
|
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/mailto.js Pure mailto: URL builder (unit tested)
|
||||||
js/contact.js Contact form wiring (browser + tests)
|
js/contact.js Contact form wiring (browser + tests)
|
||||||
js/reading-list.js Reading list renderer: data file -> grouped HTML (tested)
|
js/reading-list.js Reading list renderer: data file -> grouped HTML (tested)
|
||||||
tests/ Node built-in test suite
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html">Reading</a></li>
|
<li><a href="reading.html">Reading</a></li>
|
||||||
<li><a href="contact.html" aria-current="page">Contact</a></li>
|
<li><a href="contact.html" aria-current="page">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ button[type="submit"]:hover {
|
|||||||
filter: brightness(1.1);
|
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 list --- */
|
||||||
.reading-category {
|
.reading-category {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html">Reading</a></li>
|
<li><a href="reading.html">Reading</a></li>
|
||||||
<li><a href="contact.html">Contact</a></li>
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Newsletter | My Personal Blog</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<nav class="site-nav" aria-label="Main navigation">
|
||||||
|
<a class="brand" href="index.html">My Blog</a>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="index.html">Home</a></li>
|
||||||
|
<li><a href="reading.html">Reading</a></li>
|
||||||
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html" aria-current="page">Newsletter</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<h1>Newsletter</h1>
|
||||||
|
<p>Occasional updates on new posts — no spam, unsubscribe anytime. Enter your email to subscribe.</p>
|
||||||
|
|
||||||
|
<form id="newsletter-form" class="newsletter-form">
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="newsletter-email">Email</label>
|
||||||
|
<input type="email" id="newsletter-email" name="email" required autocomplete="email" placeholder="you@example.com" />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Subscribe</button>
|
||||||
|
<p id="newsletter-status" class="newsletter-status" role="status" aria-live="polite"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>© 2026 My Personal Blog</p>
|
||||||
|
</footer>
|
||||||
|
<script type="module" src="js/newsletter.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<li><a href="index.html">Home</a></li>
|
<li><a href="index.html">Home</a></li>
|
||||||
<li><a href="reading.html" aria-current="page">Reading</a></li>
|
<li><a href="reading.html" aria-current="page">Reading</a></li>
|
||||||
<li><a href="contact.html">Contact</a></li>
|
<li><a href="contact.html">Contact</a></li>
|
||||||
|
<li><a href="newsletter.html">Newsletter</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -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, /<form[^>]*id="newsletter-form"/);
|
||||||
|
assert.match(
|
||||||
|
newsletterHtml,
|
||||||
|
/<input[^>]*type="email"[^>]*id="newsletter-email"[^>]*name="email"/,
|
||||||
|
);
|
||||||
|
assert.match(newsletterHtml, /<button[^>]*type="submit"[^>]*>Subscribe<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the 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("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, /<a href="newsletter\.html"[^>]*>Newsletter<\/a>/);
|
||||||
|
}
|
||||||
|
assert.match(
|
||||||
|
newsletterHtml,
|
||||||
|
/<a href="newsletter\.html"[^>]*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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user