Implements all P0 features per OpenSpec change phase-1-mvp-implementation: - Agent Registry Service (CRUD) — full lifecycle management - OAuth 2.0 Token Service (Client Credentials flow) - Credential Management (generate, rotate, revoke) - Immutable Audit Log Service Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+ Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types Quality: 18 unit test suites, 244 tests passing, 97%+ coverage OpenAPI: 4 complete specs (14 endpoints total) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
/**
|
|
* Unit tests for src/middleware/errorHandler.ts
|
|
*/
|
|
|
|
import { Request, Response, NextFunction } from 'express';
|
|
import { errorHandler } from '../../../src/middleware/errorHandler';
|
|
import {
|
|
ValidationError,
|
|
AgentNotFoundError,
|
|
AgentAlreadyExistsError,
|
|
AgentAlreadyDecommissionedError,
|
|
CredentialNotFoundError,
|
|
CredentialAlreadyRevokedError,
|
|
CredentialError,
|
|
AuthenticationError,
|
|
AuthorizationError,
|
|
RateLimitError,
|
|
FreeTierLimitError,
|
|
InsufficientScopeError,
|
|
AuditEventNotFoundError,
|
|
RetentionWindowError,
|
|
} from '../../../src/utils/errors';
|
|
|
|
function makeRes(): { status: jest.Mock; json: jest.Mock } {
|
|
const res = {
|
|
status: jest.fn().mockReturnThis(),
|
|
json: jest.fn().mockReturnThis(),
|
|
};
|
|
return res;
|
|
}
|
|
|
|
const req = {} as Request;
|
|
const next = jest.fn() as jest.MockedFunction<NextFunction>;
|
|
|
|
describe('errorHandler', () => {
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
it('should return 400 for ValidationError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new ValidationError('bad input'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'VALIDATION_ERROR' }));
|
|
});
|
|
|
|
it('should return 404 for AgentNotFoundError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AgentNotFoundError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AGENT_NOT_FOUND' }));
|
|
});
|
|
|
|
it('should return 409 for AgentAlreadyExistsError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AgentAlreadyExistsError('test@test.com'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(409);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AGENT_ALREADY_EXISTS' }));
|
|
});
|
|
|
|
it('should return 409 for AgentAlreadyDecommissionedError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AgentAlreadyDecommissionedError('id'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(409);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'AGENT_ALREADY_DECOMMISSIONED' }),
|
|
);
|
|
});
|
|
|
|
it('should return 404 for CredentialNotFoundError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new CredentialNotFoundError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'CREDENTIAL_NOT_FOUND' }),
|
|
);
|
|
});
|
|
|
|
it('should return 409 for CredentialAlreadyRevokedError', () => {
|
|
const res = makeRes();
|
|
errorHandler(
|
|
new CredentialAlreadyRevokedError('cred-id', new Date().toISOString()),
|
|
req,
|
|
res as unknown as Response,
|
|
next,
|
|
);
|
|
expect(res.status).toHaveBeenCalledWith(409);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'CREDENTIAL_ALREADY_REVOKED' }),
|
|
);
|
|
});
|
|
|
|
it('should return 400 for CredentialError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new CredentialError('error', 'AGENT_NOT_ACTIVE'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
});
|
|
|
|
it('should return 401 for AuthenticationError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AuthenticationError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'UNAUTHORIZED' }));
|
|
});
|
|
|
|
it('should return 403 for AuthorizationError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AuthorizationError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'FORBIDDEN' }));
|
|
});
|
|
|
|
it('should return 429 for RateLimitError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new RateLimitError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(429);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'RATE_LIMIT_EXCEEDED' }),
|
|
);
|
|
});
|
|
|
|
it('should return 403 for FreeTierLimitError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new FreeTierLimitError('Limit reached'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'FREE_TIER_LIMIT_EXCEEDED' }),
|
|
);
|
|
});
|
|
|
|
it('should return 403 for InsufficientScopeError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new InsufficientScopeError('audit:read'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(403);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'INSUFFICIENT_SCOPE' }),
|
|
);
|
|
});
|
|
|
|
it('should return 404 for AuditEventNotFoundError', () => {
|
|
const res = makeRes();
|
|
errorHandler(new AuditEventNotFoundError(), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(404);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'AUDIT_EVENT_NOT_FOUND' }),
|
|
);
|
|
});
|
|
|
|
it('should return 400 for RetentionWindowError', () => {
|
|
const res = makeRes();
|
|
errorHandler(
|
|
new RetentionWindowError(90, '2025-12-28T00:00:00.000Z'),
|
|
req,
|
|
res as unknown as Response,
|
|
next,
|
|
);
|
|
expect(res.status).toHaveBeenCalledWith(400);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'RETENTION_WINDOW_EXCEEDED' }),
|
|
);
|
|
});
|
|
|
|
it('should return 500 for unknown errors', () => {
|
|
const res = makeRes();
|
|
errorHandler(new Error('unexpected'), req, res as unknown as Response, next);
|
|
expect(res.status).toHaveBeenCalledWith(500);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ code: 'INTERNAL_SERVER_ERROR' }),
|
|
);
|
|
});
|
|
|
|
it('should include details in the response when present', () => {
|
|
const res = makeRes();
|
|
errorHandler(
|
|
new ValidationError('bad', { field: 'email' }),
|
|
req,
|
|
res as unknown as Response,
|
|
next,
|
|
);
|
|
expect(res.json).toHaveBeenCalledWith(
|
|
expect.objectContaining({ details: { field: 'email' } }),
|
|
);
|
|
});
|
|
});
|