Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
92 changes: 53 additions & 39 deletions apps/api/src/routes/waitlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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";
Expand All @@ -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();
});
});
18 changes: 12 additions & 6 deletions apps/api/src/routes/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down