pcbjam/site/test/waitlist.test.ts
Viktor Vaczi 7edfade53c feat(site): move the marketing site from Vercel to Cloudflare Pages
www.pcbjam.com was the last piece of the stack on Vercel. It is now a
Cloudflare Pages project (pcbjam-site) deployed by deploy-site.yml on
every push to main touching site/** — content must not wait for a
release tag.

The Astro adapter is gone entirely: the build is pure static and the one
dynamic route, /api/waitlist, is a Pages Function. Going adapter-free
(rather than swapping in @astrojs/cloudflare, which has dropped Pages
support and only targets Workers) removes three problems at once — no
Astro/adapter major-version coupling, Footer.astro's build-time execSync
keeps working because prerendering stays in Node, and image optimisation
stays plain build-time sharp with no Cloudflare Images binding.

Verified against a real Pages runtime (wrangler pages dev): 21/21 parity
probes pass, versus 19/21 on live Vercel. The scripted runbook is in
deploy/site/ — every mutating step is dry-run by default.

Four behaviour differences were found by measurement and are handled here:

- The blog post's COOP/COEP was already broken in production. vercel.json
  scoped the headers to the bare URL, but the page's own canonical is the
  trailing-slash form, which served 200 with no isolation headers — so
  search arrivals lost SharedArrayBuffer and the embedded Gerber viewer
  degraded. public/_headers covers both forms.

- Pages answers unknown URLs with the homepage at HTTP 200 when the
  output has no 404.html — a soft-404 that invites indexing junk URLs as
  the homepage. Hence src/pages/404.astro.

- Vercel's edge refused cross-site form POSTs ("Cross-site POST form
  submissions are forbidden"); Pages does not, and a cross-site <form>
  submit needs no CORS permission to be sent, so the allowlist cannot
  stop it. The Function reproduces the guard; JSON posts stay exempt as
  that is demo.pcbjam.com's allowlisted path.

- Cache-Control: immutable on /_astro/* came from the Vercel adapter's
  generated route config, so it is now an explicit _headers rule.

Secrets move to `wrangler pages secret put --project-name pcbjam-site`
(RESEND_API_KEY, RESEND_SEGMENT_ID, WAITLIST_FROM_EMAIL);
WAITLIST_ALLOWED_ORIGINS stays unset so the allowlist stays in code.
Local dev reads .dev.vars, now gitignored — the root repo's **/.dev.vars
does not cover a nested git repo.

privacy.md and cookies.md named Vercel as a GDPR Art. 28 processor; those
mentions are removed and the existing Cloudflare entry widened to cover
website hosting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LAmkjM7okPdScp9XLW1JVr
2026-07-27 13:41:51 +02:00

118 lines
4.6 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
/**
* Waitlist Function hardening: per-key rate limiting, and no raw address in logs
* on the no-key path.
*
* The endpoint is a Cloudflare Pages Function, so its config arrives as an `env`
* object on the request context — no `astro:env/server` stub needed any more.
* Only the Resend SDK is mocked. RESEND_API_KEY is left undefined so the no-key
* branch (the one that logs) is exercised.
*/
vi.mock("resend", () => ({
Resend: class {
emails = { send: async () => ({}) };
contacts = { create: async () => ({}) };
},
}));
const { onRequestPost } = await import("../functions/api/waitlist.ts");
/**
* WAITLIST_ALLOWED_ORIGINS must be present (as "") rather than omitted: the
* Function falls back to its default allowlist when the key is absent, and these
* tests should not depend on that default.
*/
const env = {
RESEND_API_KEY: undefined,
RESEND_SEGMENT_ID: undefined,
WAITLIST_FROM_EMAIL: "hello@pcbjam.com",
WAITLIST_ALLOWED_ORIGINS: "",
};
function post(email: string, ip: string): Promise<Response> {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
// cf-connecting-ip, not x-forwarded-for: the Function prefers the Cloudflare
// header. Using the wrong one here would silently key every request on
// "unknown", collapsing both tests into one rate-limit bucket while the
// assertions below still passed.
headers: { "content-type": "application/json", "cf-connecting-ip": ip },
body: JSON.stringify({ email }),
});
// Pages passes a full EventContext; the handler only reads `request` and `env`.
return onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0]) as Promise<Response>;
}
describe("waitlist function hardening", () => {
it("rate-limits a burst from one IP (429 after the per-IP cap)", async () => {
const ip = "203.0.113.9";
const statuses: number[] = [];
for (let i = 0; i < 7; i++) statuses.push((await post(`u${i}@ex.com`, ip)).status);
expect(statuses.filter((s) => s === 200).length).toBe(5); // RATE_MAX_PER_IP
expect(statuses.filter((s) => s === 429).length).toBeGreaterThan(0);
});
it("does not write the raw email to logs in the no-key path", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await post("secret.person@example.com", "198.51.100.7");
const logged = warn.mock.calls.map((c) => c.join(" ")).join("\n");
expect(logged).not.toContain("secret.person@example.com"); // raw address masked
expect(logged).toContain("@example.com"); // domain kept for debugging
warn.mockRestore();
});
it("refuses a cross-site form POST (the guard Vercel's edge used to provide)", async () => {
// A cross-site <form> submit needs no CORS permission to be SENT, so the
// allowlist cannot stop it. Vercel's edge returned 403 for this; Cloudflare
// Pages does not, so the Function has to.
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"cf-connecting-ip": "198.51.100.9",
origin: "https://evil.example",
},
body: "email=victim%40example.com",
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.status).toBe(403);
});
it("allows a same-origin form POST (the no-JS path must keep working)", async () => {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"cf-connecting-ip": "198.51.100.10",
origin: "https://www.pcbjam.com",
},
body: "email=nojs%40example.com",
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.status).toBe(303);
expect(res.headers.get("location")).toBe("/?waitlist=ok#waitlist");
});
it("sends no CORS headers for an origin outside the allowlist", async () => {
const request = new Request("https://www.pcbjam.com/api/waitlist", {
method: "POST",
headers: {
"content-type": "application/json",
"cf-connecting-ip": "198.51.100.8",
origin: "https://not-allowed.example",
},
body: JSON.stringify({ email: "cors@example.com" }),
});
const res = (await onRequestPost({ request, env } as unknown as Parameters<
typeof onRequestPost
>[0])) as Response;
expect(res.headers.get("access-control-allow-origin")).toBeNull();
});
});