fix: add global error handler + try/catch in recover & email-change routes (BUG-112)
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m57s

This commit is contained in:
OpenClaw Subagent 2026-03-17 17:10:36 +01:00
parent 2dfb0ac784
commit a3bba8f0d5
6 changed files with 469 additions and 166 deletions

View file

@ -171,3 +171,97 @@ describe("POST /v1/email-change/verify", () => {
); );
}); });
}); });
describe("POST /v1/email-change - Database failure handling", () => {
it("returns 500 when validateApiKey DB query fails", async () => {
const { queryWithRetry } = await import("../services/db.js");
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection pool exhausted"));
const res = await request(app).post("/v1/email-change").send({
apiKey: "df_pro_xxx",
newEmail: "new@example.com"
});
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
it("returns 500 when email existence check fails", async () => {
const { queryWithRetry } = await import("../services/db.js");
let callCount = 0;
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
callCount++;
// First call (validateApiKey) succeeds
if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) {
return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 };
}
// Second call (email check) fails
throw new Error("DB connection lost");
}) as any);
const res = await request(app).post("/v1/email-change").send({
apiKey: "df_pro_xxx",
newEmail: "new@example.com"
});
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
it("returns 500 when createPendingVerification fails", async () => {
const { createPendingVerification } = await import("../services/verification.js");
vi.mocked(createPendingVerification).mockRejectedValue(new Error("DB insert failed"));
const res = await request(app).post("/v1/email-change").send({
apiKey: "df_pro_xxx",
newEmail: "new@example.com"
});
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
});
describe("POST /v1/email-change/verify - Database failure handling", () => {
it("returns 500 when validateApiKey DB query fails", async () => {
const { queryWithRetry } = await import("../services/db.js");
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection timeout"));
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(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
it("returns 500 when UPDATE query fails", async () => {
const { queryWithRetry } = await import("../services/db.js");
let callCount = 0;
vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => {
callCount++;
// First call (validateApiKey) succeeds
if (callCount === 1 && sql.includes("SELECT") && sql.includes("key =")) {
return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 };
}
// Second call (UPDATE) fails
throw new Error("UPDATE failed - constraint violation");
}) as any);
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(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
});

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi } from "vitest";
import express, { Request, Response, NextFunction } from "express";
import request from "supertest";
const mockLogger = { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() };
vi.mock("../services/logger.js", () => ({
default: mockLogger,
}));
describe("Global error handler", () => {
it("returns 500 JSON for unhandled errors in API routes", async () => {
const app = express();
// Add request ID middleware (like in main app)
app.use((req, _res, next) => {
(req as any).requestId = "test-req-id";
next();
});
// Add a test route that throws an error
app.get("/v1/test-error", (_req: Request, _res: Response) => {
throw new Error("Test unhandled error");
});
// Add global error handler (same as in src/index.ts)
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const reqId = (req as any).requestId || "unknown";
mockLogger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error");
if (!res.headersSent) {
const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health");
if (isApi) {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(500).send("Internal server error");
}
}
});
const res = await request(app).get("/v1/test-error");
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
expect(res.headers["content-type"]).toMatch(/json/);
expect(mockLogger.error).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
path: "/v1/test-error",
}),
"Unhandled route error"
);
});
it("returns 500 text for unhandled errors in non-API routes", async () => {
const app = express();
// Add request ID middleware
app.use((req, _res, next) => {
(req as any).requestId = "test-req-id";
next();
});
// Add a test route that throws an error
app.get("/test-error-page", (_req: Request, _res: Response) => {
throw new Error("Test page error");
});
// Add global error handler
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const reqId = (req as any).requestId || "unknown";
mockLogger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error");
if (!res.headersSent) {
const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health");
if (isApi) {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(500).send("Internal server error");
}
}
});
const res = await request(app).get("/test-error-page");
expect(res.status).toBe(500);
expect(res.text).toBe("Internal server error");
expect(res.headers["content-type"]).toMatch(/text/);
expect(mockLogger.error).toHaveBeenCalled();
});
it("does not send response if headers already sent", async () => {
const app = express();
app.use(express.json());
app.get("/v1/test-headers-sent", (_req: Request, res: Response, next: NextFunction) => {
res.status(200).json({ ok: true });
next(new Error("Too late"));
});
// Add global error handler
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const reqId = (req as any).requestId || "unknown";
mockLogger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error");
if (!res.headersSent) {
const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health");
if (isApi) {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(500).send("Internal server error");
}
}
});
const res = await request(app).get("/v1/test-headers-sent");
// Should get the 200 response, error handler does nothing
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});

View file

@ -2,6 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
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; let app: express.Express;
beforeEach(async () => { beforeEach(async () => {
@ -13,6 +18,7 @@ beforeEach(async () => {
const { createPendingVerification, verifyCode } = await import("../services/verification.js"); const { createPendingVerification, verifyCode } = await import("../services/verification.js");
const { sendVerificationEmail } = await import("../services/email.js"); const { sendVerificationEmail } = await import("../services/email.js");
const { getAllKeys } = await import("../services/keys.js"); const { getAllKeys } = await import("../services/keys.js");
const { queryWithRetry } = await import("../services/db.js");
vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "654321", createdAt: "", expiresAt: "", attempts: 0 }); vi.mocked(createPendingVerification).mockResolvedValue({ email: "test@test.com", code: "654321", createdAt: "", expiresAt: "", attempts: 0 });
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
@ -20,6 +26,8 @@ beforeEach(async () => {
vi.mocked(getAllKeys).mockReturnValue([ vi.mocked(getAllKeys).mockReturnValue([
{ key: "existing-key", tier: "pro" as const, email: "found@test.com", createdAt: "2025-01-01" }, { key: "existing-key", tier: "pro" as const, email: "found@test.com", createdAt: "2025-01-01" },
]); ]);
// Default DB mock: returns empty (no user found in DB fallback)
vi.mocked(queryWithRetry).mockResolvedValue({ rows: [], rowCount: 0 } as any);
const { recoverRouter } = await import("../routes/recover.js"); const { recoverRouter } = await import("../routes/recover.js");
app = express(); app = express();
@ -94,3 +102,40 @@ describe("POST /recover/verify", () => {
expect(res.body.apiKey).toBeUndefined(); expect(res.body.apiKey).toBeUndefined();
}); });
}); });
describe("POST /recover - Database failure handling", () => {
it("returns 500 when queryWithRetry throws in DB fallback", async () => {
const { queryWithRetry } = await import("../services/db.js");
const { getAllKeys } = await import("../services/keys.js");
// Mock cache miss + DB failure
vi.mocked(getAllKeys).mockReturnValue([]);
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Connection pool exhausted"));
const res = await request(app).post("/recover").send({ email: "test@example.com" });
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
});
describe("POST /recover/verify - Database failure handling", () => {
it("returns 500 when queryWithRetry throws in DB fallback", async () => {
const { queryWithRetry } = await import("../services/db.js");
const { getAllKeys } = await import("../services/keys.js");
const { verifyCode } = await import("../services/verification.js");
// Mock cache miss + verifyCode success + DB failure
vi.mocked(getAllKeys).mockReturnValue([]);
vi.mocked(verifyCode).mockResolvedValue({ status: "ok" });
vi.mocked(queryWithRetry).mockRejectedValue(new Error("Unexpected DB schema error"));
const res = await request(app).post("/recover/verify").send({
email: "test@example.com",
code: "123456"
});
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: "Internal server error" });
});
});

View file

@ -1,4 +1,4 @@
import express, { Request, Response } from "express"; import express, { Request, Response, NextFunction } from "express";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { AuthenticatedRequest } from "./types.js"; import { AuthenticatedRequest } from "./types.js";
import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot import "./types.js"; // Augments Express.Request with requestId, acquirePdfSlot, releasePdfSlot
@ -221,7 +221,29 @@ app.use((req, res) => {
} }
}); });
// Global error handler — must be after all routes
app.use((err: unknown, req: Request, res: Response, _next: NextFunction) => {
const reqId = (req as any).requestId || "unknown";
// Check if this is a JSON parse error from express.json()
if (err instanceof SyntaxError && 'status' in err && (err as any).status === 400 && 'body' in err) {
logger.warn({ err, requestId: reqId, method: req.method, path: req.path }, "Invalid JSON body");
if (!res.headersSent) {
res.status(400).json({ error: "Invalid JSON in request body" });
}
return;
}
logger.error({ err, requestId: reqId, method: req.method, path: req.path }, "Unhandled route error");
if (!res.headersSent) {
const isApi = req.path.startsWith("/v1/") || req.path.startsWith("/health");
if (isApi) {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(500).send("Internal server error");
}
}
});
async function start() { async function start() {
// Initialize PostgreSQL // Initialize PostgreSQL

View file

@ -71,6 +71,7 @@ async function validateApiKey(apiKey: string) {
* description: Too many attempts * description: Too many attempts
*/ */
router.post("/", emailChangeLimiter, async (req: Request, res: Response) => { router.post("/", emailChangeLimiter, async (req: Request, res: Response) => {
try {
const { apiKey, newEmail } = req.body || {}; const { apiKey, newEmail } = req.body || {};
if (!apiKey || typeof apiKey !== "string") { if (!apiKey || typeof apiKey !== "string") {
@ -113,6 +114,11 @@ router.post("/", emailChangeLimiter, async (req: Request, res: Response) => {
}); });
res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." }); res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." });
} catch (err: unknown) {
const reqId = (req as any).requestId || "unknown";
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change");
res.status(500).json({ error: "Internal server error" });
}
}); });
/** /**
@ -161,6 +167,7 @@ router.post("/", emailChangeLimiter, async (req: Request, res: Response) => {
* description: Too many failed attempts * description: Too many failed attempts
*/ */
router.post("/verify", async (req: Request, res: Response) => { router.post("/verify", async (req: Request, res: Response) => {
try {
const { apiKey, newEmail, code } = req.body || {}; const { apiKey, newEmail, code } = req.body || {};
if (!apiKey || !newEmail || !code) { if (!apiKey || !newEmail || !code) {
@ -199,6 +206,11 @@ router.post("/verify", async (req: Request, res: Response) => {
res.status(400).json({ error: "Invalid verification code." }); res.status(400).json({ error: "Invalid verification code." });
break; break;
} }
} catch (err: unknown) {
const reqId = (req as any).requestId || "unknown";
logger.error({ err, requestId: reqId }, "Unhandled error in POST /email-change/verify");
res.status(500).json({ error: "Internal server error" });
}
}); });
export { router as emailChangeRouter }; export { router as emailChangeRouter };

View file

@ -57,6 +57,7 @@ const recoverLimiter = rateLimit({
* description: Too many recovery attempts * description: Too many recovery attempts
*/ */
router.post("/", recoverLimiter, async (req: Request, res: Response) => { router.post("/", recoverLimiter, async (req: Request, res: Response) => {
try {
const { email } = req.body || {}; const { email } = req.body || {};
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
@ -92,6 +93,11 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => {
}); });
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });
} catch (err: unknown) {
const reqId = (req as any).requestId || "unknown";
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover");
res.status(500).json({ error: "Internal server error" });
}
}); });
/** /**
@ -141,6 +147,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => {
* description: Too many failed attempts * description: Too many failed attempts
*/ */
router.post("/verify", recoverLimiter, async (req: Request, res: Response) => { router.post("/verify", recoverLimiter, async (req: Request, res: Response) => {
try {
const { email, code } = req.body || {}; const { email, code } = req.body || {};
if (!email || !code) { if (!email || !code) {
@ -202,6 +209,11 @@ router.post("/verify", recoverLimiter, async (req: Request, res: Response) => {
res.status(400).json({ error: "Invalid verification code." }); res.status(400).json({ error: "Invalid verification code." });
break; break;
} }
} catch (err: unknown) {
const reqId = (req as any).requestId || "unknown";
logger.error({ err, requestId: reqId }, "Unhandled error in POST /recover/verify");
res.status(500).json({ error: "Internal server error" });
}
}); });
export { router as recoverRouter }; export { router as recoverRouter };