Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
191 lines
5.3 KiB
JavaScript
191 lines
5.3 KiB
JavaScript
jest.mock('../error-logger', () => ({
|
|
logError: jest.fn(),
|
|
}));
|
|
|
|
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
|
|
const {
|
|
AppError,
|
|
ValidationError,
|
|
AuthenticationError,
|
|
NotFoundError,
|
|
RateLimitError,
|
|
DockerError,
|
|
} = require('../errors');
|
|
|
|
describe('Error Handler', () => {
|
|
let req, res, next;
|
|
|
|
beforeEach(() => {
|
|
req = {
|
|
method: 'GET',
|
|
path: '/api/test',
|
|
ip: '127.0.0.1',
|
|
user: { id: 'user1' },
|
|
body: {},
|
|
};
|
|
res = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
next = jest.fn();
|
|
});
|
|
|
|
describe('asyncHandler', () => {
|
|
it('calls the wrapped function', async () => {
|
|
const fn = jest.fn().mockResolvedValue();
|
|
const wrapped = asyncHandler(fn);
|
|
await wrapped(req, res, next);
|
|
expect(fn).toHaveBeenCalledWith(req, res, next);
|
|
});
|
|
|
|
it('calls next(err) on rejected promise', async () => {
|
|
const error = new Error('async fail');
|
|
const fn = jest.fn().mockRejectedValue(error);
|
|
const wrapped = asyncHandler(fn);
|
|
await wrapped(req, res, next);
|
|
expect(next).toHaveBeenCalledWith(error);
|
|
});
|
|
});
|
|
|
|
describe('errorMiddleware', () => {
|
|
it('returns 400 for ValidationError', () => {
|
|
const err = new ValidationError('bad input', 'email');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
error: 'bad input',
|
|
code: 'DC-400',
|
|
field: 'email',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('returns 401 for AuthenticationError with requiresTotp', () => {
|
|
const err = new AuthenticationError('auth needed', true);
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
error: 'auth needed',
|
|
requiresTotp: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('returns 404 for NotFoundError with resource', () => {
|
|
const err = new NotFoundError('Service');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: 'Service not found',
|
|
resource: 'Service',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('returns 429 for RateLimitError with retryAfter', () => {
|
|
const err = new RateLimitError(30);
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(429);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
error: 'Rate limit exceeded',
|
|
retryAfter: 30,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('returns 500 with "Internal server error" for generic Error', () => {
|
|
const err = new Error('db connection lost');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
success: false,
|
|
error: 'Internal server error', // NOT the real message
|
|
})
|
|
);
|
|
});
|
|
|
|
it('includes error code in DC-XXX format', () => {
|
|
const err = new AppError('test', 418, 'DC-TEAPOT');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'DC-TEAPOT' })
|
|
);
|
|
});
|
|
|
|
it('includes details for DockerError', () => {
|
|
const err = new DockerError('container fail', 'create', { id: '123' });
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
details: { id: '123' },
|
|
})
|
|
);
|
|
});
|
|
|
|
it('includes stack trace in development mode', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'development';
|
|
|
|
const err = new AppError('test');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
const response = res.json.mock.calls[0][0];
|
|
expect(response.stack).toBeDefined();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('excludes stack trace in production mode', () => {
|
|
const origEnv = process.env.NODE_ENV;
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
const err = new AppError('test');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
const response = res.json.mock.calls[0][0];
|
|
expect(response.stack).toBeUndefined();
|
|
|
|
process.env.NODE_ENV = origEnv;
|
|
});
|
|
|
|
it('logs non-operational errors as FATAL', () => {
|
|
const origError = console.error;
|
|
console.error = jest.fn();
|
|
|
|
const err = new Error('programming bug');
|
|
errorMiddleware(err, req, res, next);
|
|
|
|
expect(console.error).toHaveBeenCalledWith(
|
|
'FATAL: Non-operational error detected',
|
|
expect.any(Object)
|
|
);
|
|
|
|
console.error = origError;
|
|
});
|
|
});
|
|
|
|
describe('notFoundHandler', () => {
|
|
it('passes NotFoundError to next()', () => {
|
|
notFoundHandler(req, res, next);
|
|
expect(next).toHaveBeenCalledWith(expect.any(NotFoundError));
|
|
const passedError = next.mock.calls[0][0];
|
|
expect(passedError.message).toContain('GET');
|
|
expect(passedError.message).toContain('/api/test');
|
|
});
|
|
});
|
|
});
|