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
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m57s
This commit is contained in:
parent
2dfb0ac784
commit
a3bba8f0d5
6 changed files with 469 additions and 166 deletions
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
118
src/__tests__/error-handler.test.ts
Normal file
118
src/__tests__/error-handler.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
24
src/index.ts
24
src/index.ts
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue