archetecture security fix
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Response } from 'express'
|
||||
import { z } from 'zod'
|
||||
import { AppError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError } from './index'
|
||||
import { errorMiddleware } from './errorMiddleware'
|
||||
|
||||
function createResponseStub() {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
}
|
||||
|
||||
res.status.mockReturnValue(res)
|
||||
res.json.mockReturnValue(res)
|
||||
|
||||
return res as unknown as Response & typeof res
|
||||
}
|
||||
|
||||
function handle(error: unknown) {
|
||||
const res = createResponseStub()
|
||||
errorMiddleware(error, {} as any, res, vi.fn())
|
||||
return res
|
||||
}
|
||||
|
||||
describe('AppError subclasses', () => {
|
||||
it.each([
|
||||
[new ValidationError('Bad payload'), 400, 'validation_error'],
|
||||
[new UnauthorizedError(), 401, 'unauthorized'],
|
||||
[new ForbiddenError(), 403, 'forbidden'],
|
||||
[new NotFoundError(), 404, 'not_found'],
|
||||
[new ConflictError(), 409, 'conflict'],
|
||||
])('sets stable status and error code for %s', (error, statusCode, code) => {
|
||||
expect(error).toBeInstanceOf(AppError)
|
||||
expect(error.statusCode).toBe(statusCode)
|
||||
expect(error.error).toBe(code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('errorMiddleware', () => {
|
||||
it('normalizes Zod errors into validation responses', () => {
|
||||
const result = z.object({ email: z.string().email() }).safeParse({ email: 'not-an-email' })
|
||||
expect(result.success).toBe(false)
|
||||
|
||||
const res = handle(result.success ? new Error('unexpected') : result.error)
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400)
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
statusCode: 400,
|
||||
issues: expect.any(Array),
|
||||
}))
|
||||
})
|
||||
|
||||
it('normalizes Prisma not found errors', () => {
|
||||
const res = handle({ code: 'P2025' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'not_found',
|
||||
message: 'Resource not found',
|
||||
statusCode: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes Prisma unique constraint errors', () => {
|
||||
const res = handle({ code: 'P2002' })
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(409)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'conflict',
|
||||
message: 'A resource with this value already exists',
|
||||
statusCode: 409,
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves AppError metadata in the response body', () => {
|
||||
const res = handle(new AppError('Plan required', 402, 'payment_required', { requiredPlan: 'PRO' }))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(402)
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'payment_required',
|
||||
message: 'Plan required',
|
||||
statusCode: 402,
|
||||
requiredPlan: 'PRO',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a 500 internal error response', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
|
||||
const res = handle(new Error('boom'))
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500)
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'internal_error', message: 'boom', statusCode: 500 })
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { AppError } from './index'
|
||||
|
||||
function withRequestId(req: Request, payload: Record<string, unknown>) {
|
||||
return { ...payload, requestId: req.requestId }
|
||||
}
|
||||
|
||||
export function errorMiddleware(err: any, req: Request, res: Response, _next: NextFunction) {
|
||||
if (err.name === 'ZodError') {
|
||||
return res.status(400).json(withRequestId(req, {
|
||||
error: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
issues: err.issues,
|
||||
statusCode: 400,
|
||||
}))
|
||||
}
|
||||
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json(withRequestId(req, { error: 'not_found', message: 'Resource not found', statusCode: 404 }))
|
||||
}
|
||||
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(409).json(withRequestId(req, { error: 'conflict', message: 'A resource with this value already exists', statusCode: 409 }))
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.statusCode >= 500) console.error('[API Error]', { requestId: req.requestId, err })
|
||||
return res.status(err.statusCode).json(withRequestId(req, {
|
||||
error: err.error,
|
||||
message: err.statusCode >= 500 ? 'Internal server error' : err.message,
|
||||
statusCode: err.statusCode,
|
||||
...(err.statusCode >= 500 ? {} : err.data),
|
||||
}))
|
||||
}
|
||||
|
||||
const statusCode = typeof err.statusCode === 'number' ? err.statusCode : 500
|
||||
const safeStatusCode = statusCode >= 400 && statusCode < 600 ? statusCode : 500
|
||||
|
||||
if (safeStatusCode >= 500) {
|
||||
console.error('[API Error]', { requestId: req.requestId, err })
|
||||
}
|
||||
|
||||
res.status(safeStatusCode).json(withRequestId(req, {
|
||||
error: safeStatusCode >= 500 ? 'internal_error' : (err.code ?? 'request_error'),
|
||||
message: safeStatusCode >= 500 ? 'Internal server error' : (err.message ?? 'Request failed'),
|
||||
statusCode: safeStatusCode,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class AppError extends Error {
|
||||
readonly statusCode: number
|
||||
readonly error: string
|
||||
readonly data?: Record<string, unknown>
|
||||
|
||||
constructor(message: string, statusCode: number, error: string, data?: Record<string, unknown>) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.error = error
|
||||
this.data = data
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message = 'Validation error') {
|
||||
super(message, 400, 'validation_error')
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(message = 'Resource not found') {
|
||||
super(message, 404, 'not_found')
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message = 'A resource with this value already exists') {
|
||||
super(message, 409, 'conflict')
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'Forbidden') {
|
||||
super(message, 403, 'forbidden')
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 401, 'unauthorized')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user