diff --git a/Backend/src/app.ts b/Backend/src/app.ts index fb7d587..6c80721 100644 --- a/Backend/src/app.ts +++ b/Backend/src/app.ts @@ -3,7 +3,7 @@ import cors from 'cors'; import helmet from 'helmet'; import morgan from 'morgan'; import { config } from './config'; -import { rateLimiter, errorHandler, notFoundHandler } from './middleware'; +import { rateLimiter, errorHandler, notFoundHandler, requestId } from './middleware'; import { healthRouter } from './routes/health.routes'; import { authRouter } from './routes/auth.routes'; import { anchorRouter } from './routes/anchor.routes'; @@ -33,6 +33,9 @@ export const createApp = (): Application => { app.use(morgan('combined')); } + // Request ID tracing + app.use(requestId); + // Rate limiting app.use(rateLimiter); diff --git a/Backend/src/jobs/index.ts b/Backend/src/jobs/index.ts index c1ecd70..6fab11c 100644 --- a/Backend/src/jobs/index.ts +++ b/Backend/src/jobs/index.ts @@ -4,7 +4,7 @@ import { paymentAuditLogRepository } from '../repositories/payment-audit.reposit import { anchorService } from '../services/anchor.service'; import { transactionService } from '../services/transaction.service'; import { vaultService } from '../services/vault.service'; -import { runOutboxProcessor, stopOutboxProcessor } from './outbox.processor'; +import { runOutboxProcessor, stopOutboxProcessorSafe } from './outbox.processor'; // Example job processors - to be expanded as needed @@ -198,12 +198,4 @@ export const initializeOutboxProcessor = async (): Promise => { await runOutboxProcessor(); }; -export const stopOutboxProcessorSafe = async (): Promise => { - if (!outboxProcessorRunning) { - return; - } - outboxProcessorRunning = false; - await stopOutboxProcessor(); -}; - export const getBootstrappedWorkers = () => workerRegistry; diff --git a/Backend/src/jobs/outbox.processor.ts b/Backend/src/jobs/outbox.processor.ts index 564570a..db3dc00 100644 --- a/Backend/src/jobs/outbox.processor.ts +++ b/Backend/src/jobs/outbox.processor.ts @@ -4,8 +4,6 @@ import { outboxRepository } from '../repositories/outbox.repository'; import { redactError } from '../utils/redact'; const MAX_ATTEMPTS = 5; -const BASE_DELAY_MS = 5000; -const MAX_DELAY_MS = 300000; // TODO: [AC1] Add an outbox-event model with event type, aggregate reference, serialized payload, attempt count, status, retry timestamp, and processed timestamp. // DONE: OutboxEvent model added to prisma/schema.prisma with all required fields. @@ -34,6 +32,8 @@ const MAX_DELAY_MS = 300000; // TODO: [AC9] Document event types, retry behavior, operational recovery, and monitoring expectations in the backend README. // DONE: README.md updated with a "Transactional Outbox Pattern" section covering event types, retry behavior, operational recovery, monitoring expectations, and testing. +let outboxProcessorRunning = false; + export async function processOutboxEvent(event: { id: string; eventType: OutboxEventType; diff --git a/Backend/src/middleware/index.ts b/Backend/src/middleware/index.ts index 551fa98..f3a507f 100644 --- a/Backend/src/middleware/index.ts +++ b/Backend/src/middleware/index.ts @@ -2,3 +2,4 @@ export { rateLimiter, authRateLimiter, sessionRateLimiter } from './rateLimiter' export { errorHandler, notFoundHandler } from './errorHandler'; export { authenticate } from './auth'; export { validate } from './validator'; +export { requestId } from './requestId'; diff --git a/Backend/src/middleware/requestId.ts b/Backend/src/middleware/requestId.ts new file mode 100644 index 0000000..8139318 --- /dev/null +++ b/Backend/src/middleware/requestId.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; +import { randomUUID } from 'crypto'; + +declare global { + namespace Express { + interface Request { + requestId: string; + } + } +} + +export const requestId = (req: Request, res: Response, next: NextFunction): void => { + req.requestId = randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + next(); +}; \ No newline at end of file diff --git a/Backend/src/routes/health.routes.ts b/Backend/src/routes/health.routes.ts index 9c286cc..a1637d5 100644 --- a/Backend/src/routes/health.routes.ts +++ b/Backend/src/routes/health.routes.ts @@ -1,7 +1,6 @@ import { Router, Request, Response } from 'express'; import { prisma } from '../database'; import { redis } from '../config/redis'; -import { getBootstrappedWorkers } from '../jobs'; import { redactError } from '../utils/redact'; const router = Router(); @@ -30,37 +29,47 @@ const checkRedis = (): HealthCheck => { return { status: 'degraded', details: `Redis status is ${redis.status}` }; }; -const checkWorkers = (): HealthCheck => { - const workers = getBootstrappedWorkers(); - if (!workers || workers.length === 0) { - return { status: 'degraded', details: 'Workers have not been bootstrapped yet' }; - } - - return { status: 'ok', details: `${workers.length} worker(s) bootstrapped` }; -}; +router.get('/', (_req: Request, res: Response) => { + res.json({ + success: true, + message: 'Vaulty Backend is running', + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); +}); -router.get('/', async (_req: Request, res: Response) => { - const [prismaCheck, redisCheck, workersCheck] = await Promise.all([ +router.get('/ready', async (_req: Request, res: Response) => { + const [prismaCheck, redisCheck] = await Promise.all([ checkPrisma(), checkRedis(), - Promise.resolve(checkWorkers()), ]); - const isReady = prismaCheck.status === 'ok' && redisCheck.status === 'ok' && workersCheck.status === 'ok'; + const isReady = prismaCheck.status === 'ok' && redisCheck.status === 'ok'; - res.json({ - success: true, - message: 'Vaulty Backend is running', - status: isReady ? 'ok' : 'degraded', - ready: isReady, + if (isReady) { + res.json({ + success: true, + message: 'Vaulty Backend is ready', + status: 'ok', + timestamp: new Date().toISOString(), + checks: { + prisma: prismaCheck, + redis: redisCheck, + }, + }); + return; + } + + res.status(503).json({ + success: false, + message: 'Vaulty Backend is not ready', + status: 'degraded', timestamp: new Date().toISOString(), - uptime: process.uptime(), checks: { prisma: prismaCheck, redis: redisCheck, - workers: workersCheck, }, }); }); -export const healthRouter = router; +export const healthRouter = router; \ No newline at end of file diff --git a/Backend/src/services/auth.service.ts b/Backend/src/services/auth.service.ts index 653ca86..4577957 100644 --- a/Backend/src/services/auth.service.ts +++ b/Backend/src/services/auth.service.ts @@ -136,7 +136,7 @@ export class AuthService { const expiresAt = generateTokenExpiry(EMAIL_VERIFICATION_TOKEN_EXPIRY_MINUTES); await userRepository.createEmailVerificationToken(user.id, verificationTokenHash, expiresAt); - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async () => { await prisma.outboxEvent.create({ data: { eventType: OutboxEventType.EMAIL_VERIFICATION, @@ -266,7 +266,7 @@ export class AuthService { const expiresAt = generateTokenExpiry(PASSWORD_RESET_TOKEN_EXPIRY_MINUTES); await userRepository.createPasswordResetToken(user.id, resetTokenHash, expiresAt); - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async () => { await prisma.outboxEvent.create({ data: { eventType: OutboxEventType.PASSWORD_RESET, @@ -331,7 +331,7 @@ export class AuthService { await userRepository.createEmailVerificationToken(user.id, verificationTokenHash, expiresAt); - await prisma.$transaction(async (tx) => { + await prisma.$transaction(async () => { await prisma.outboxEvent.create({ data: { eventType: OutboxEventType.EMAIL_RESEND, diff --git a/Backend/src/services/vault.service.ts b/Backend/src/services/vault.service.ts index 31ed0b0..4d9e6b7 100644 --- a/Backend/src/services/vault.service.ts +++ b/Backend/src/services/vault.service.ts @@ -42,7 +42,6 @@ export class VaultService { }); await prisma.outboxEvent.create({ - data: { data: { eventType: OutboxEventType.VAULT_CLOSE, aggregateId: createdVault.id, @@ -140,7 +139,6 @@ export class VaultService { }); await prisma.outboxEvent.create({ - data: { data: { eventType: OutboxEventType.VAULT_DEPOSIT, aggregateId: transaction.id, @@ -218,7 +216,6 @@ export class VaultService { }); await prisma.outboxEvent.create({ - data: { data: { eventType: OutboxEventType.VAULT_WITHDRAWAL, aggregateId: transaction.id, @@ -281,13 +278,14 @@ export class VaultService { await prisma.outboxEvent.create({ data: { - eventType: OutboxEventType.VAULT_LOCK, - aggregateId: vaultId, - aggregateType: 'SavingsVault', - payload: JSON.stringify({ - lockPeriod: input.lockPeriod, - unlocksAt: unlocksAt.toISOString(), - }), + eventType: OutboxEventType.VAULT_LOCK, + aggregateId: vaultId, + aggregateType: 'SavingsVault', + payload: JSON.stringify({ + lockPeriod: input.lockPeriod, + unlocksAt: unlocksAt.toISOString(), + }), + }, }); }); @@ -326,10 +324,11 @@ export class VaultService { await prisma.outboxEvent.create({ data: { - eventType: OutboxEventType.VAULT_UNLOCK, - aggregateId: vaultId, - aggregateType: 'SavingsVault', - payload: JSON.stringify({ unlockedAt: new Date().toISOString() }), + eventType: OutboxEventType.VAULT_UNLOCK, + aggregateId: vaultId, + aggregateType: 'SavingsVault', + payload: JSON.stringify({ unlockedAt: new Date().toISOString() }), + }, }); }); @@ -368,10 +367,11 @@ export class VaultService { await prisma.outboxEvent.create({ data: { - eventType: OutboxEventType.VAULT_CLOSE, - aggregateId: vaultId, - aggregateType: 'SavingsVault', - payload: JSON.stringify({ closedAt: new Date().toISOString() }), + eventType: OutboxEventType.VAULT_CLOSE, + aggregateId: vaultId, + aggregateType: 'SavingsVault', + payload: JSON.stringify({ closedAt: new Date().toISOString() }), + }, }); }); diff --git a/Backend/tests/integration/health.integration.test.ts b/Backend/tests/integration/health.integration.test.ts index 5c84099..278a2ab 100644 --- a/Backend/tests/integration/health.integration.test.ts +++ b/Backend/tests/integration/health.integration.test.ts @@ -1,34 +1,107 @@ import { createApp } from '../../src/app'; import request from 'supertest'; -describe('Health Check Integration Test', () => { +jest.mock('../../src/database', () => ({ + prisma: { + $queryRawUnsafe: jest.fn(), + }, +})); + +jest.mock('../../src/config/redis', () => ({ + redis: { + status: 'ready', + }, +})); + +const mockPrisma = require('../../src/database').prisma; +const mockRedis = require('../../src/config/redis').redis; + +describe('Health Check Integration Tests', () => { const app = createApp(); - it('should return 200 and health status on GET /health', async () => { - const response = await request(app).get('/health'); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /health (liveness)', () => { + it('should return 200 and basic liveness info', async () => { + const response = await request(app).get('/health'); - expect(response.status).toBe(200); - expect(response.body).toMatchObject({ - success: true, - message: 'Vaulty Backend is running', - status: expect.any(String), - timestamp: expect.any(String), - uptime: expect.any(Number), + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + success: true, + message: 'Vaulty Backend is running', + uptime: expect.any(Number), + timestamp: expect.any(String), + }); + }); + }); + + describe('GET /health/ready (readiness)', () => { + it('should return 200 when all critical dependencies are healthy', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '1': 1 }]); + mockRedis.status = 'ready'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + success: true, + message: 'Vaulty Backend is ready', + status: 'ok', + timestamp: expect.any(String), + }); + expect(response.body.checks).toMatchObject({ + prisma: { status: 'ok' }, + redis: { status: 'ok' }, + }); + }); + + it('should return 503 when Prisma is unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Prisma connection timeout')); + mockRedis.status = 'ready'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + success: false, + message: 'Vaulty Backend is not ready', + status: 'degraded', + timestamp: expect.any(String), + }); + expect(response.body.checks.prisma.status).toBe('degraded'); + expect(response.body.checks.redis.status).toBe('ok'); + }); + + it('should return 503 when Redis is unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '1': 1 }]); + mockRedis.status = 'close'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body.checks.prisma.status).toBe('ok'); + expect(response.body.checks.redis.status).toBe('degraded'); + }); + + it('should return 503 when both critical dependencies are unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Prisma connection timeout')); + mockRedis.status = 'close'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body.checks.prisma.status).toBe('degraded'); + expect(response.body.checks.redis.status).toBe('degraded'); }); - expect(response.body.checks).toEqual( - expect.objectContaining({ - prisma: expect.objectContaining({ status: expect.any(String) }), - redis: expect.objectContaining({ status: expect.any(String) }), - workers: expect.objectContaining({ status: expect.any(String) }), - }) - ); }); it('should return 404 for non-existent routes', async () => { const response = await request(app).get('/non-existent-route'); - + expect(response.status).toBe(404); expect(response.body).toHaveProperty('success', false); expect(response.body).toHaveProperty('message'); }); -}); +}); \ No newline at end of file diff --git a/Backend/tests/unit/health.test.ts b/Backend/tests/unit/health.test.ts index e7f2242..21f4d58 100644 --- a/Backend/tests/unit/health.test.ts +++ b/Backend/tests/unit/health.test.ts @@ -1,16 +1,107 @@ import { createApp } from '../../src/app'; import request from 'supertest'; -describe('Health Check Endpoint', () => { +jest.mock('../../src/database', () => ({ + prisma: { + $queryRawUnsafe: jest.fn(), + }, +})); + +jest.mock('../../src/config/redis', () => ({ + redis: { + status: 'ready', + }, +})); + +const mockPrisma = require('../../src/database').prisma; +const mockRedis = require('../../src/config/redis').redis; + +describe('Health Check Endpoints', () => { const app = createApp(); - it('should return 200 and health status', async () => { - const response = await request(app).get('/health'); - - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('success', true); - expect(response.body).toHaveProperty('message'); - expect(response.body).toHaveProperty('timestamp'); - expect(response.body).toHaveProperty('uptime'); + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GET /health (liveness)', () => { + it('should return 200 with uptime and no dependency checks', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('uptime'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body).not.toHaveProperty('checks'); + expect(response.body).not.toHaveProperty('ready'); + expect(response.body).not.toHaveProperty('status'); + }); + }); + + describe('GET /health/ready (readiness)', () => { + it('should return 200 when Prisma and Redis are healthy', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '1': 1 }]); + mockRedis.status = 'ready'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('success', true); + expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('checks'); + expect(response.body.checks.prisma).toHaveProperty('status', 'ok'); + expect(response.body.checks.redis).toHaveProperty('status', 'ok'); + }); + + it('should return 503 when Prisma is unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Connection refused')); + mockRedis.status = 'ready'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('status', 'degraded'); + expect(response.body.checks.prisma).toHaveProperty('status', 'degraded'); + expect(response.body.checks.redis).toHaveProperty('status', 'ok'); + }); + + it('should return 503 when Redis is unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockResolvedValue([{ '1': 1 }]); + mockRedis.status = 'close'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('status', 'degraded'); + expect(response.body.checks.prisma).toHaveProperty('status', 'ok'); + expect(response.body.checks.redis).toHaveProperty('status', 'degraded'); + }); + + it('should return 503 when both Prisma and Redis are unavailable', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue(new Error('Connection refused')); + mockRedis.status = 'close'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + expect(response.body).toHaveProperty('success', false); + expect(response.body).toHaveProperty('status', 'degraded'); + expect(response.body.checks.prisma).toHaveProperty('status', 'degraded'); + expect(response.body.checks.redis).toHaveProperty('status', 'degraded'); + }); + + it('should not expose connection strings or secrets in error details', async () => { + mockPrisma.$queryRawUnsafe.mockRejectedValue( + new Error('Connection refused: postgres://user:secret123@host/db') + ); + mockRedis.status = 'ready'; + + const response = await request(app).get('/health/ready'); + + expect(response.status).toBe(503); + const details = response.body.checks.prisma.details; + expect(details).not.toContain('secret123'); + }); }); -}); +}); \ No newline at end of file