diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts new file mode 100644 index 0000000..d07ad5d --- /dev/null +++ b/src/__tests__/email-change.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +vi.mock("../services/verification.js"); +vi.mock("../services/email.js"); +vi.mock("../services/db.js"); +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { createPendingVerification, verifyCode } = await import("../services/verification.js"); + const { sendVerificationEmail } = await import("../services/email.js"); + const { queryWithRetry } = await import("../services/db.js"); + + vi.mocked(createPendingVerification).mockResolvedValue({ email: "new@example.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); + vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + // Default: apiKey exists, email not taken + vi.mocked(queryWithRetry).mockImplementation(async (sql: string, params?: any[]) => { + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("email =")) { + return { rows: [], rowCount: 0 }; + } + if (sql.includes("UPDATE")) { + return { rows: [{ email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const { emailChangeRouter } = await import("../routes/email-change.js"); + app = express(); + app.use(express.json()); + app.use("/v1/email-change", emailChangeRouter); +}); + +describe("POST /v1/email-change", () => { + it("returns 400 for missing apiKey", async () => { + const res = await request(app).post("/v1/email-change").send({ newEmail: "new@example.com" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing newEmail", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid email format", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "bad" }); + expect(res.status).toBe(400); + }); + + it("returns 403 for invalid API key", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); + expect(res.status).toBe(403); + }); + + it("returns 409 when email already taken", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("email =")) { + return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(409); + }); + + it("returns 200 with verification_sent on success", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("verification_sent"); + }); +}); + +describe("POST /v1/email-change/verify", () => { + it("returns 400 for missing fields", async () => { + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(400); + }); + + it("returns 200 and updates email on success", async () => { + const { queryWithRetry } = await import("../services/db.js"); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.newEmail).toBe("new@example.com"); + // Verify UPDATE was called + expect(queryWithRetry).toHaveBeenCalledWith( + expect.stringContaining("UPDATE"), + expect.arrayContaining(["new@example.com", "df_pro_xxx"]) + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index a0499b3..7ba5b7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; @@ -130,6 +131,7 @@ app.use("/v1/signup", (_req, res) => { }); }); app.use("/v1/recover", recoverRouter); +app.use("/v1/email-change", emailChangeRouter); app.use("/v1/billing", billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) diff --git a/src/routes/email-change.ts b/src/routes/email-change.ts new file mode 100644 index 0000000..3041b15 --- /dev/null +++ b/src/routes/email-change.ts @@ -0,0 +1,204 @@ +import { Router, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { queryWithRetry } from "../services/db.js"; +import logger from "../services/logger.js"; + +const router = Router(); + +const emailChangeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many email change attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => req.body?.apiKey || req.ip || "unknown", +}); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function validateApiKey(apiKey: string) { + const result = await queryWithRetry( + `SELECT key, email, tier FROM api_keys WHERE key = $1`, + [apiKey] + ); + return result.rows[0] || null; +} + +/** + * @openapi + * /v1/email-change: + * post: + * tags: [Account] + * summary: Request email change + * description: | + * Sends a 6-digit verification code to the new email address. + * Rate limited to 3 requests per hour per API key. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * responses: + * 200: + * description: Verification code sent + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verification_sent + * message: + * type: string + * 400: + * description: Missing or invalid fields + * 403: + * description: Invalid API key + * 409: + * description: Email already taken + * 429: + * description: Too many attempts + */ +router.post("/", emailChangeLimiter, async (req: Request, res: Response) => { + const { apiKey, newEmail } = req.body || {}; + + if (!apiKey || typeof apiKey !== "string") { + res.status(400).json({ error: "apiKey is required." }); + return; + } + + if (!newEmail || typeof newEmail !== "string") { + res.status(400).json({ error: "newEmail is required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + + if (!EMAIL_RE.test(cleanEmail)) { + res.status(400).json({ error: "Invalid email format." }); + return; + } + + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + + // Check if email is already taken by another key + const existing = await queryWithRetry( + `SELECT key FROM api_keys WHERE email = $1 AND key != $2`, + [cleanEmail, apiKey] + ); + if (existing.rows.length > 0) { + res.status(409).json({ error: "This email is already associated with another account." }); + return; + } + + const pending = await createPendingVerification(cleanEmail); + + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); + }); + + res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." }); +}); + +/** + * @openapi + * /v1/email-change/verify: + * post: + * tags: [Account] + * summary: Verify email change code + * description: Verifies the 6-digit code and updates the account email. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail, code] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * responses: + * 200: + * description: Email updated + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * newEmail: + * type: string + * 400: + * description: Missing fields or invalid code + * 403: + * description: Invalid API key + * 410: + * description: Code expired + * 429: + * description: Too many failed attempts + */ +router.post("/verify", async (req: Request, res: Response) => { + const { apiKey, newEmail, code } = req.body || {}; + + if (!apiKey || !newEmail || !code) { + res.status(400).json({ error: "apiKey, newEmail, and code are required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + const cleanCode = String(code).trim(); + + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + + const result = await verifyCode(cleanEmail, cleanCode); + + switch (result.status) { + case "ok": { + await queryWithRetry( + `UPDATE api_keys SET email = $1 WHERE key = $2`, + [cleanEmail, apiKey] + ); + logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed"); + res.json({ status: "ok", newEmail: cleanEmail }); + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); + +export { router as emailChangeRouter };