Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -33,6 +33,9 @@ export const createApp = (): Application => {
app.use(morgan('combined'));
}

// Request ID tracing
app.use(requestId);

// Rate limiting
app.use(rateLimiter);

Expand Down
10 changes: 1 addition & 9 deletions Backend/src/jobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -198,12 +198,4 @@ export const initializeOutboxProcessor = async (): Promise<void> => {
await runOutboxProcessor();
};

export const stopOutboxProcessorSafe = async (): Promise<void> => {
if (!outboxProcessorRunning) {
return;
}
outboxProcessorRunning = false;
await stopOutboxProcessor();
};

export const getBootstrappedWorkers = () => workerRegistry;
4 changes: 2 additions & 2 deletions Backend/src/jobs/outbox.processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions Backend/src/middleware/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 16 additions & 0 deletions Backend/src/middleware/requestId.ts
Original file line number Diff line number Diff line change
@@ -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();
};
51 changes: 30 additions & 21 deletions Backend/src/routes/health.routes.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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;
6 changes: 3 additions & 3 deletions Backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 18 additions & 18 deletions Backend/src/services/vault.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export class VaultService {
});

await prisma.outboxEvent.create({
data: {
data: {
eventType: OutboxEventType.VAULT_CLOSE,
aggregateId: createdVault.id,
Expand Down Expand Up @@ -140,7 +139,6 @@ export class VaultService {
});

await prisma.outboxEvent.create({
data: {
data: {
eventType: OutboxEventType.VAULT_DEPOSIT,
aggregateId: transaction.id,
Expand Down Expand Up @@ -218,7 +216,6 @@ export class VaultService {
});

await prisma.outboxEvent.create({
data: {
data: {
eventType: OutboxEventType.VAULT_WITHDRAWAL,
aggregateId: transaction.id,
Expand Down Expand Up @@ -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(),
}),
},
});
});

Expand Down Expand Up @@ -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() }),
},
});
});

Expand Down Expand Up @@ -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() }),
},
});
});

Expand Down
111 changes: 92 additions & 19 deletions Backend/tests/integration/health.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading