Skip to content

Commit fe5a613

Browse files
authored
Reject unhandled HTTP requests during tests (#553)
1 parent e69fbb0 commit fe5a613

3 files changed

Lines changed: 73 additions & 7 deletions

File tree

src/pages/api/__tests__/feedback.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,11 @@ describe("POST /api/feedback", () => {
9999
expect((await callPost(request)).status).toBe(403);
100100
});
101101

102-
it("returns 403 for a localhost origin", async () => {
102+
it("accepts requests from localhost", async () => {
103103
const response = await callPost(
104104
makeRequest(validBody, { Origin: "http://localhost:4321" }),
105105
);
106-
expect(response.status).toBe(403);
106+
expect(response.status).toBe(200);
107107
});
108108

109109
it("returns 403 for a preview deploy origin", async () => {
@@ -140,6 +140,64 @@ describe("POST /api/feedback", () => {
140140
});
141141
});
142142

143+
describe("email notifications", () => {
144+
beforeEach(() => {
145+
env.RESEND_API_KEY = "test-key";
146+
vi.spyOn(global, "fetch").mockResolvedValue(
147+
new Response(null, { status: 200 }),
148+
);
149+
});
150+
151+
it("sends an email for production origin requests", async () => {
152+
await callPost(makeRequest(validBody));
153+
154+
expect(fetch).toHaveBeenCalledWith(
155+
"https://api.resend.com/emails",
156+
expect.objectContaining({
157+
method: "POST",
158+
headers: expect.objectContaining({
159+
Authorization: "Bearer test-key",
160+
}),
161+
body: expect.stringContaining("court-order-ma"),
162+
}),
163+
);
164+
});
165+
166+
it("includes the correct recipient and subject in the email", async () => {
167+
await callPost(makeRequest(validBody));
168+
169+
const [, options] = vi.mocked(fetch).mock.calls[0];
170+
const body = JSON.parse(options?.body as string);
171+
expect(body.to).toBe("hey@namesake.fyi");
172+
expect(body.subject).toMatch(/court-order-ma/);
173+
});
174+
175+
it("does not send an email for localhost origin requests", async () => {
176+
await callPost(
177+
makeRequest(validBody, { Origin: "http://localhost:4321" }),
178+
);
179+
180+
expect(fetch).not.toHaveBeenCalled();
181+
});
182+
183+
it("does not send an email when RESEND_API_KEY is absent", async () => {
184+
env.RESEND_API_KEY = undefined;
185+
await callPost(makeRequest(validBody));
186+
187+
expect(fetch).not.toHaveBeenCalled();
188+
});
189+
190+
it("still returns 200 when the Resend API returns an error", async () => {
191+
vi.mocked(fetch).mockResolvedValueOnce(
192+
new Response("Internal Server Error", { status: 500 }),
193+
);
194+
195+
const response = await callPost(makeRequest(validBody));
196+
expect(response.status).toBe(200);
197+
expect(await response.json()).toEqual({ success: true });
198+
});
199+
});
200+
143201
describe("successful submission", () => {
144202
it("returns 200 with success on a valid request", async () => {
145203
const response = await callPost(makeRequest(validBody));

src/pages/api/feedback.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
} from "../../constants/forms";
99
import { isRateLimited } from "../../utils/rateLimitByIp";
1010

11-
const ALLOWED_ORIGINS = ["https://namesake.fyi"];
11+
const PRODUCTION_ORIGIN = "https://namesake.fyi";
12+
const ALLOWED_ORIGINS = [PRODUCTION_ORIGIN, "http://localhost:4321"];
1213

1314
const FeedbackSchema = z.object({
1415
form_slug: z.enum(FORM_SLUGS),
@@ -87,10 +88,17 @@ export const POST: APIRoute = async ({ request }) => {
8788
.run();
8889

8990
const resendApiKey = env?.RESEND_API_KEY as string | undefined;
90-
if (resendApiKey) {
91+
if (resendApiKey && origin === PRODUCTION_ORIGIN) {
9192
const sentimentLabel = FORM_FEEDBACK_SENTIMENT[sentiment];
9293
const location = [city, region, country].filter(Boolean).join(", ");
9394

95+
const escapeHtml = (text: string) =>
96+
text
97+
.replace(/&/g, "&")
98+
.replace(/</g, "&lt;")
99+
.replace(/>/g, "&gt;")
100+
.replace(/"/g, "&quot;");
101+
94102
try {
95103
const emailRes = await fetch("https://api.resend.com/emails", {
96104
method: "POST",
@@ -102,7 +110,7 @@ export const POST: APIRoute = async ({ request }) => {
102110
from: "noreply@namesake.fyi",
103111
to: "hey@namesake.fyi",
104112
subject: `${sentimentLabel} feedback on ${form_slug}`,
105-
html: `<p>A user ${location ? `in <strong>${location}</strong>` : ""} submitted <strong>${sentimentLabel}</strong> feedback on <a href="https://namesake.fyi/forms/${form_slug}">${form_slug}</a>.</p><p>${commentValue ?? "<em>No comment</em>"}</p>`,
113+
html: `<p>A user ${location ? `in <strong>${location}</strong>` : ""} submitted <strong>${sentimentLabel}</strong> feedback on <a href="https://namesake.fyi/forms/${form_slug}">${form_slug}</a>.</p><p>${commentValue ? escapeHtml(commentValue) : "<em>No comment</em>"}</p>`,
106114
}),
107115
});
108116
if (!emailRes.ok) {

src/vitest.setup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ beforeEach(() => {
5454
}
5555
}
5656

57-
// For HTTP(S) URLs, use the original fetch
58-
return originalFetch(input);
57+
// Reject unhandled HTTP requests so tests don't silently hit real servers
58+
throw new Error(`Unmocked HTTP request in test: ${url}`);
5959
});
6060
});
6161

0 commit comments

Comments
 (0)