diff --git a/apps/api/src/middleware/rate-limit.ts b/apps/api/src/middleware/rate-limit.ts index fd98f0e..48518b5 100644 --- a/apps/api/src/middleware/rate-limit.ts +++ b/apps/api/src/middleware/rate-limit.ts @@ -261,11 +261,11 @@ export const webhookRotationLimiter = rateLimit({ }); /** - * Waitlist signup: 5 req / 15 min per IP. + * Waitlist signup: 5 req / hour per IP. * Public endpoint — keyed by IP to prevent bulk scraping. */ export const waitlistLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, + windowMs: 60 * 60 * 1000, max: 5, standardHeaders: "draft-7", legacyHeaders: false, diff --git a/apps/api/src/routes/waitlist.test.ts b/apps/api/src/routes/waitlist.test.ts index 7daea97..6350d40 100644 --- a/apps/api/src/routes/waitlist.test.ts +++ b/apps/api/src/routes/waitlist.test.ts @@ -5,6 +5,14 @@ import { errorHandler } from "../middleware/error"; const mocks = vi.hoisted(() => ({ dbQuery: vi.fn().mockResolvedValue({ rows: [] }), + blockWaitlist: false, + waitlistLimiter: vi.fn((_: any, res: any, next: any) => { + if (mocks.blockWaitlist) { + res.status(429).json({ error: "Too many signup attempts, please try again later" }); + return; + } + next(); + }), })); vi.mock("../db/index", () => ({ @@ -14,7 +22,7 @@ vi.mock("../db/index", () => ({ vi.mock("../middleware/rate-limit", () => ({ apiLimiter: (_req: any, _res: any, next: any) => next(), - waitlistLimiter: (_req: any, _res: any, next: any) => next(), + waitlistLimiter: mocks.waitlistLimiter, })); import waitlistRouter from "./waitlist"; @@ -27,76 +35,82 @@ app.use(errorHandler); describe("POST /waitlist", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.blockWaitlist = false; }); - it("returns 200 on successful signup", async () => { - mocks.dbQuery.mockResolvedValueOnce({ rows: [] }); + it("returns 201 on successful signup", async () => { + mocks.dbQuery.mockResolvedValueOnce({ rows: [{ id: "waitlist-1" }] }); - const res = await request(app) - .post("/waitlist") - .send({ email: "test@example.com" }); + const res = await request(app).post("/waitlist").send({ email: " TEST@example.com " }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ success: true }); - expect(mocks.dbQuery).toHaveBeenCalledWith( - expect.stringContaining("ON CONFLICT (email) DO NOTHING"), - ["test@example.com", null] - ); + expect(res.status).toBe(201); + expect(res.body).toEqual({ message: "You're on the list!" }); + expect(mocks.dbQuery).toHaveBeenCalledWith(expect.stringContaining("RETURNING id"), [ + "test@example.com", + null, + ]); }); - it("returns 200 even for a duplicate email (idempotent)", async () => { - // ON CONFLICT DO NOTHING means the INSERT is a no-op; same 200 response + it("returns 200 even for a duplicate email", async () => { mocks.dbQuery.mockResolvedValueOnce({ rows: [] }); - const res = await request(app) - .post("/waitlist") - .send({ email: "duplicate@example.com" }); + const res = await request(app).post("/waitlist").send({ email: "duplicate@example.com" }); expect(res.status).toBe(200); - expect(res.body).toEqual({ success: true }); + expect(res.body).toEqual({ message: "You're on the list!" }); }); it("accepts optional referral_code", async () => { - mocks.dbQuery.mockResolvedValueOnce({ rows: [] }); + mocks.dbQuery.mockResolvedValueOnce({ rows: [{ id: "waitlist-1" }] }); const res = await request(app) .post("/waitlist") .send({ email: "ref@example.com", referral_code: "FRIENDS10" }); - expect(res.status).toBe(200); + expect(res.status).toBe(201); expect(mocks.dbQuery).toHaveBeenCalledWith( expect.stringContaining("ON CONFLICT (email) DO NOTHING"), ["ref@example.com", "FRIENDS10"] ); }); - it("returns 400 for an invalid email", async () => { + it("returns 422 for an invalid email", async () => { + const res = await request(app).post("/waitlist").send({ email: "not-an-email" }); + + expect(res.status).toBe(422); + expect(mocks.dbQuery).not.toHaveBeenCalled(); + }); + + it("returns 422 for an email longer than 254 characters", async () => { const res = await request(app) .post("/waitlist") - .send({ email: "not-an-email" }); + .send({ email: `${"a".repeat(245)}@example.com` }); - expect(res.status).toBe(400); + expect(res.status).toBe(422); + expect(mocks.dbQuery).not.toHaveBeenCalled(); + }); + + it("applies the waitlist rate limiter", async () => { + mocks.dbQuery.mockResolvedValueOnce({ rows: [{ id: "waitlist-1" }] }); + + await request(app) + .post("/waitlist") + .set("X-Forwarded-For", "10.0.0.1") + .send({ email: "rate@example.com" }); + + expect(mocks.waitlistLimiter).toHaveBeenCalled(); }); it("returns 429 when rate limited", async () => { - // Re-create app with a blocking limiter - const blockedApp = express(); - blockedApp.use(express.json()); - - vi.doMock("../middleware/rate-limit", () => ({ - apiLimiter: (_req: any, _res: any, next: any) => next(), - waitlistLimiter: (_req: any, res: any) => - res.status(429).json({ error: "Too many signup attempts, please try again later" }), - })); - - // The static import won't pick up doMock at runtime in this test; verify via static limiter - // The integration is covered by the middleware unit — this confirms the shape: - const rateLimitRes = await request(app) + mocks.blockWaitlist = true; + + const res = await request(app) .post("/waitlist") - .set("X-Forwarded-For", "10.0.0.1") // ensure IP is set + .set("X-Forwarded-For", "10.0.0.1") .send({ email: "rate@example.com" }); - // With the mocked pass-through limiter this should be 200; 429 shape is verified above - expect([200, 429]).toContain(rateLimitRes.status); + expect(res.status).toBe(429); + expect(res.body).toEqual({ error: "Too many signup attempts, please try again later" }); + expect(mocks.dbQuery).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/routes/waitlist.ts b/apps/api/src/routes/waitlist.ts index 23aeee3..8c804a7 100644 --- a/apps/api/src/routes/waitlist.ts +++ b/apps/api/src/routes/waitlist.ts @@ -7,23 +7,29 @@ import { apiLimiter, waitlistLimiter } from "../middleware/rate-limit"; const router = Router(); const WaitlistSchema = z.object({ - email: z.string().email(), + email: z.string().trim().email().max(254), referral_code: z.string().max(64).optional(), }); router.post("/", waitlistLimiter, async (req, res) => { - const body = WaitlistSchema.parse(req.body); + const parsed = WaitlistSchema.safeParse(req.body); + if (!parsed.success) { + res.status(422).json({ error: "Validation Error", details: parsed.error.issues }); + return; + } + + const body = parsed.data; const email = body.email.toLowerCase().trim(); - await query( + const result = await query<{ id: string }>( `INSERT INTO waitlist (email, referral_code) VALUES ($1, $2) - ON CONFLICT (email) DO NOTHING`, + ON CONFLICT (email) DO NOTHING + RETURNING id`, [email, body.referral_code ?? null] ); - // Always return 200 to prevent email enumeration. - res.json({ success: true }); + res.status(result.rows.length > 0 ? 201 : 200).json({ message: "You're on the list!" }); }); router.get("/position/:email", apiLimiter, async (req, res) => {