diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 2a995ca..c275a39 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -70,12 +70,12 @@ jobs: # ── Security scanning ───────────────────────────────────────────────────── # Issue #114: npm audit for known vulnerabilities # Fails on high or critical CVEs to prevent merging vulnerable dependencies - # + # # Policy: Builds fail on HIGH or CRITICAL vulnerabilities # - HIGH/CRITICAL: Must be fixed before merge (blocking) # - MODERATE: Review required, fix in follow-up PR (non-blocking via Dependabot) # - LOW: Tracked via Dependabot, fix during regular maintenance - # + # # Dependabot automatically creates PRs for vulnerable dependencies security-scan: name: Security audit diff --git a/Dockerfile b/Dockerfile index 49e4c3f..e782cd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,42 +1,32 @@ -# ── Stage 1: build ──────────────────────────────────────────────────────────── FROM node:20-alpine AS builder WORKDIR /app -# Install dependencies (including devDependencies needed for tsc + prisma generate) COPY package*.json ./ RUN npm ci -# Generate Prisma client (requires schema but not a live DB) COPY prisma ./prisma RUN npx prisma generate -# Compile TypeScript COPY tsconfig.json ./ COPY src ./src RUN npm run build -# Prune dev dependencies — only production deps go into the runtime image -RUN npm ci --omit=dev - -# ── Stage 2: runtime ────────────────────────────────────────────────────────── FROM node:20-alpine AS runtime -# Least-privilege user RUN addgroup -S app && adduser -S app -G app WORKDIR /app -# Copy compiled output, production node_modules, and Prisma artefacts +COPY --from=builder /app/package*.json ./ +RUN npm ci --omit=dev + COPY --from=builder /app/dist ./dist -COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/prisma ./prisma -COPY package.json ./ +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma USER app EXPOSE 3001 -# Run migrations then start the server. -# In Kubernetes use an initContainer for the migrate step so rollout is atomic. CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/package.json b/package.json index d6708b8..b87316a 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "lint": "npm run lint:types && npm run lint:style", "lint:types": "tsc --noEmit", "lint:style": "eslint \"src/**/*.ts\" \"prisma/**/*.ts\"", - "format": "prettier --write .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/helpers/testDb.ts tests/integration/stellar/events.test.ts tests/unit/nlp/parser.test.ts tests/unit/whatsapp/handler.test.ts", - "format:check": "prettier --check .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/helpers/testDb.ts tests/integration/stellar/events.test.ts tests/unit/nlp/parser.test.ts tests/unit/whatsapp/handler.test.ts", + "format": "prettier --write .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/unit/stellar/dlq-alerts.test.ts", + "format:check": "prettier --check .github/workflows/node-ci.yml package.json .prettierrc.json eslint.config.mjs src/nlp/parser.ts src/stellar/dlq.ts src/whatsapp/handler.ts src/whatsapp/userManager.ts tests/unit/stellar/dlq-alerts.test.ts", "prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate" }, @@ -71,4 +71,4 @@ "ts-node": "^10.9.2", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/src/stellar/dlq.ts b/src/stellar/dlq.ts index c209757..93039a4 100644 --- a/src/stellar/dlq.ts +++ b/src/stellar/dlq.ts @@ -13,7 +13,7 @@ import { xdr } from '@stellar/stellar-sdk' import { logger } from '../utils/logger' import db from '../db' import { updateDlqSize } from '../utils/metrics' -import config from '../config' +import { config } from '../config' import { alertingService, type DLQAlertPayload } from '../services/alerting' export type DeadLetterEventStatus = 'PENDING' | 'RETRIED' | 'RESOLVED' diff --git a/tests/unit/stellar/dlq-alerts.test.ts b/tests/unit/stellar/dlq-alerts.test.ts index e5b74d3..d4ae49b 100644 --- a/tests/unit/stellar/dlq-alerts.test.ts +++ b/tests/unit/stellar/dlq-alerts.test.ts @@ -3,207 +3,210 @@ * Tests threshold logic, cooldown behavior, and alert formatting */ -import { alertingService, type DLQAlertPayload } from '../../../src/services/alerting' +import { + alertingService, + type DLQAlertPayload, +} from '../../../src/services/alerting' describe('DLQ Alerting', () => { - beforeEach(() => { - jest.clearAllMocks() - // Reset alert states by creating a fresh instance - jest.resetModules() + beforeEach(() => { + jest.clearAllMocks() + // Reset alert states by creating a fresh instance + jest.resetModules() + }) + + describe('AlertingService', () => { + it('should create singleton instance', () => { + const instance1 = alertingService + const instance2 = alertingService + expect(instance1).toBe(instance2) }) - describe('AlertingService', () => { - it('should create singleton instance', () => { - const instance1 = alertingService - const instance2 = alertingService - expect(instance1).toBe(instance2) - }) - - it('should identify enabled channels based on environment', () => { - const channels = alertingService.getEnabledChannels() - expect(channels).toContain('LOG') - }) - - describe('Cooldown Logic', () => { - it('should suppress alert within cooldown window', async () => { - const payload: DLQAlertPayload = { - title: 'Test Alert', - description: 'Test Description', - severity: 'critical', - component: 'dlq', - dlqSize: 100, - statusBreakdown: { pending: 80, retried: 15, resolved: 5 }, - } - - // First alert should succeed - const result1 = await alertingService.emit(payload, 'test-alert-key') - expect(result1.sent).toBe(true) - - // Second alert within cooldown should be suppressed - const result2 = await alertingService.emit(payload, 'test-alert-key') - expect(result2.sent).toBe(false) - expect(result2.reason).toContain('cooldown') - }) - - it('should allow alert after cooldown expires', async () => { - jest.useFakeTimers() - - const payload: DLQAlertPayload = { - title: 'Test Alert', - description: 'Test Description', - severity: 'critical', - component: 'dlq', - dlqSize: 100, - statusBreakdown: { pending: 80, retried: 15, resolved: 5 }, - } - - // First alert - const result1 = await alertingService.emit(payload, 'test-cooldown') - expect(result1.sent).toBe(true) - - // Within cooldown - const result2 = await alertingService.emit(payload, 'test-cooldown') - expect(result2.sent).toBe(false) - - // After cooldown (advance 16 minutes) - jest.advanceTimersByTime(16 * 60 * 1000) - const result3 = await alertingService.emit(payload, 'test-cooldown') - expect(result3.sent).toBe(true) - - jest.useRealTimers() - }) - }) - - describe('DLQ Alert Payload', () => { - it('should format DLQ alert with all metadata', async () => { - const payload: DLQAlertPayload = { - title: 'DLQ Critical Alert', - description: 'Queue exceeded threshold', - severity: 'critical', - component: 'dlq', - dlqSize: 75, - statusBreakdown: { - pending: 60, - retried: 10, - resolved: 5, - }, - oldestPendingAge: { - eventId: 'evt-123', - ageMs: 3600000, // 1 hour - ageHumanReadable: '1 hour', - }, - adminLink: 'https://admin.example.com/dlq', - metadata: { - threshold: 50, - timestamp: new Date().toISOString(), - }, - } - - const result = await alertingService.emitDLQAlert(payload) - // Should at least attempt to emit (may fail if no external service configured) - expect(result).toBeDefined() - }) - - it('should handle alert without optional metadata', async () => { - const payload: DLQAlertPayload = { - title: 'DLQ Alert', - description: 'Queue size increased', - severity: 'warning', - component: 'dlq', - dlqSize: 25, - statusBreakdown: { pending: 20, retried: 5, resolved: 0 }, - } - - const result = await alertingService.emitDLQAlert(payload) - expect(result).toBeDefined() - }) - }) - - describe('Alert Channels', () => { - it('should always send to LOG channel', async () => { - const payload: DLQAlertPayload = { - title: 'Test', - description: 'Test alert', - severity: 'critical', - component: 'dlq', - dlqSize: 50, - statusBreakdown: { pending: 40, retried: 5, resolved: 5 }, - } - - // LOG channel should always be present - const channels = alertingService.getEnabledChannels() - expect(channels).toContain('LOG') - - const result = await alertingService.emit(payload, 'log-test') - expect(result.sent).toBe(true) - }) - }) - - describe('Clear DLQ State', () => { - it('should clear alert state when queue normalizes', async () => { - const payload: DLQAlertPayload = { - title: 'Test', - description: 'Test', - severity: 'critical', - component: 'dlq', - dlqSize: 100, - statusBreakdown: { pending: 100, retried: 0, resolved: 0 }, - } - - // Emit alert - await alertingService.emitDLQAlert(payload) - - // Clear state - alertingService.clearDLQAlertState() - - // Next alert should succeed (no cooldown) - const result = await alertingService.emit(payload, 'dlq:threshold') - expect(result.sent).toBe(true) - }) - }) + it('should identify enabled channels based on environment', () => { + const channels = alertingService.getEnabledChannels() + expect(channels).toContain('LOG') }) - describe('Alert Severity Levels', () => { - it('should handle critical severity', async () => { - const payload: DLQAlertPayload = { - title: 'Critical Alert', - description: 'Immediate action required', - severity: 'critical', - component: 'dlq', - dlqSize: 100, - statusBreakdown: { pending: 90, retried: 5, resolved: 5 }, - } - - const result = await alertingService.emit(payload, 'critical-test') - expect(result.sent).toBe(true) - }) - - it('should handle warning severity', async () => { - const payload: DLQAlertPayload = { - title: 'Warning Alert', - description: 'Investigate soon', - severity: 'warning', - component: 'dlq', - dlqSize: 30, - statusBreakdown: { pending: 20, retried: 5, resolved: 5 }, - } - - const result = await alertingService.emit(payload, 'warning-test') - expect(result.sent).toBe(true) - }) - - it('should handle info severity', async () => { - const payload: DLQAlertPayload = { - title: 'Info Alert', - description: 'Monitor trend', - severity: 'info', - component: 'dlq', - dlqSize: 10, - statusBreakdown: { pending: 5, retried: 3, resolved: 2 }, - } - - const result = await alertingService.emit(payload, 'info-test') - expect(result.sent).toBe(true) - }) + describe('Cooldown Logic', () => { + it('should suppress alert within cooldown window', async () => { + const payload: DLQAlertPayload = { + title: 'Test Alert', + description: 'Test Description', + severity: 'critical', + component: 'dlq', + dlqSize: 100, + statusBreakdown: { pending: 80, retried: 15, resolved: 5 }, + } + + // First alert should succeed + const result1 = await alertingService.emit(payload, 'test-alert-key') + expect(result1.sent).toBe(true) + + // Second alert within cooldown should be suppressed + const result2 = await alertingService.emit(payload, 'test-alert-key') + expect(result2.sent).toBe(false) + expect(result2.reason).toContain('cooldown') + }) + + it('should allow alert after cooldown expires', async () => { + jest.useFakeTimers() + + const payload: DLQAlertPayload = { + title: 'Test Alert', + description: 'Test Description', + severity: 'critical', + component: 'dlq', + dlqSize: 100, + statusBreakdown: { pending: 80, retried: 15, resolved: 5 }, + } + + // First alert + const result1 = await alertingService.emit(payload, 'test-cooldown') + expect(result1.sent).toBe(true) + + // Within cooldown + const result2 = await alertingService.emit(payload, 'test-cooldown') + expect(result2.sent).toBe(false) + + // After cooldown (advance 16 minutes) + jest.advanceTimersByTime(16 * 60 * 1000) + const result3 = await alertingService.emit(payload, 'test-cooldown') + expect(result3.sent).toBe(true) + + jest.useRealTimers() + }) }) + + describe('DLQ Alert Payload', () => { + it('should format DLQ alert with all metadata', async () => { + const payload: DLQAlertPayload = { + title: 'DLQ Critical Alert', + description: 'Queue exceeded threshold', + severity: 'critical', + component: 'dlq', + dlqSize: 75, + statusBreakdown: { + pending: 60, + retried: 10, + resolved: 5, + }, + oldestPendingAge: { + eventId: 'evt-123', + ageMs: 3600000, // 1 hour + ageHumanReadable: '1 hour', + }, + adminLink: 'https://admin.example.com/dlq', + metadata: { + threshold: 50, + timestamp: new Date().toISOString(), + }, + } + + const result = await alertingService.emitDLQAlert(payload) + // Should at least attempt to emit (may fail if no external service configured) + expect(result).toBeDefined() + }) + + it('should handle alert without optional metadata', async () => { + const payload: DLQAlertPayload = { + title: 'DLQ Alert', + description: 'Queue size increased', + severity: 'warning', + component: 'dlq', + dlqSize: 25, + statusBreakdown: { pending: 20, retried: 5, resolved: 0 }, + } + + const result = await alertingService.emitDLQAlert(payload) + expect(result).toBeDefined() + }) + }) + + describe('Alert Channels', () => { + it('should always send to LOG channel', async () => { + const payload: DLQAlertPayload = { + title: 'Test', + description: 'Test alert', + severity: 'critical', + component: 'dlq', + dlqSize: 50, + statusBreakdown: { pending: 40, retried: 5, resolved: 5 }, + } + + // LOG channel should always be present + const channels = alertingService.getEnabledChannels() + expect(channels).toContain('LOG') + + const result = await alertingService.emit(payload, 'log-test') + expect(result.sent).toBe(true) + }) + }) + + describe('Clear DLQ State', () => { + it('should clear alert state when queue normalizes', async () => { + const payload: DLQAlertPayload = { + title: 'Test', + description: 'Test', + severity: 'critical', + component: 'dlq', + dlqSize: 100, + statusBreakdown: { pending: 100, retried: 0, resolved: 0 }, + } + + // Emit alert + await alertingService.emitDLQAlert(payload) + + // Clear state + alertingService.clearDLQAlertState() + + // Next alert should succeed (no cooldown) + const result = await alertingService.emit(payload, 'dlq:threshold') + expect(result.sent).toBe(true) + }) + }) + }) + + describe('Alert Severity Levels', () => { + it('should handle critical severity', async () => { + const payload: DLQAlertPayload = { + title: 'Critical Alert', + description: 'Immediate action required', + severity: 'critical', + component: 'dlq', + dlqSize: 100, + statusBreakdown: { pending: 90, retried: 5, resolved: 5 }, + } + + const result = await alertingService.emit(payload, 'critical-test') + expect(result.sent).toBe(true) + }) + + it('should handle warning severity', async () => { + const payload: DLQAlertPayload = { + title: 'Warning Alert', + description: 'Investigate soon', + severity: 'warning', + component: 'dlq', + dlqSize: 30, + statusBreakdown: { pending: 20, retried: 5, resolved: 5 }, + } + + const result = await alertingService.emit(payload, 'warning-test') + expect(result.sent).toBe(true) + }) + + it('should handle info severity', async () => { + const payload: DLQAlertPayload = { + title: 'Info Alert', + description: 'Monitor trend', + severity: 'info', + component: 'dlq', + dlqSize: 10, + statusBreakdown: { pending: 5, retried: 3, resolved: 2 }, + } + + const result = await alertingService.emit(payload, 'info-test') + expect(result.sent).toBe(true) + }) + }) })