feat: add email change routes (BUG-090)
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 9m41s

This commit is contained in:
OpenClaw 2026-02-27 19:04:36 +00:00
parent 8b31d11e74
commit 480c794a85
3 changed files with 326 additions and 0 deletions

View file

@ -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"])
);
});
});

View file

@ -15,6 +15,7 @@ import { templatesRouter } from "./routes/templates.js";
import { healthRouter } from "./routes/health.js"; import { healthRouter } from "./routes/health.js";
import { demoRouter } from "./routes/demo.js"; import { demoRouter } from "./routes/demo.js";
import { recoverRouter } from "./routes/recover.js"; import { recoverRouter } from "./routes/recover.js";
import { emailChangeRouter } from "./routes/email-change.js";
import { billingRouter } from "./routes/billing.js"; import { billingRouter } from "./routes/billing.js";
import { authMiddleware } from "./middleware/auth.js"; import { authMiddleware } from "./middleware/auth.js";
import { usageMiddleware, loadUsageData } from "./middleware/usage.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/recover", recoverRouter);
app.use("/v1/email-change", emailChangeRouter);
app.use("/v1/billing", billingRouter); app.use("/v1/billing", billingRouter);
// Authenticated routes — conversion routes get tighter body limits (500KB) // Authenticated routes — conversion routes get tighter body limits (500KB)

204
src/routes/email-change.ts Normal file
View file

@ -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 };