From 2a25455a157a615e697ad57329261f3423b79d9d Mon Sep 17 00:00:00 2001 From: Tomike Tolulope Date: Wed, 24 Jun 2026 12:31:13 +0000 Subject: [PATCH] feat: add Zod validation, metrics tests, monitoring assets, and smoke alias Closes #146 Closes #185 Closes #186 Closes #187 --- README.md | 10 ++ .../grafana/dashboards/agent-loop.json | 59 ++++++++++ deploy/monitoring/grafana/dashboards/dlq.json | 42 +++++++ .../grafana/dashboards/latency.json | 44 ++++++++ .../grafana/dashboards/system-overview.json | 89 +++++++++++++++ .../grafana/provisioning/dashboards.yaml | 8 ++ .../grafana/provisioning/datasources.yaml | 9 ++ deploy/monitoring/prometheus/alert-rules.yaml | 105 ++++++++++++++++++ docs/DEPLOYMENT.md | 30 +++++ package.json | 1 + readme.md | 10 ++ src/controllers/auth-controller.ts | 16 +-- src/routes/whatsapp.ts | 4 +- src/validators/auth-validators.ts | 6 +- src/validators/webhook-validators.ts | 6 + tests/metrics-endpoint.test.ts | 89 +++++++++++++++ tests/validation-public-routes.test.ts | 81 ++++++++++++++ 17 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 deploy/monitoring/grafana/dashboards/agent-loop.json create mode 100644 deploy/monitoring/grafana/dashboards/dlq.json create mode 100644 deploy/monitoring/grafana/dashboards/latency.json create mode 100644 deploy/monitoring/grafana/dashboards/system-overview.json create mode 100644 deploy/monitoring/grafana/provisioning/dashboards.yaml create mode 100644 deploy/monitoring/grafana/provisioning/datasources.yaml create mode 100644 deploy/monitoring/prometheus/alert-rules.yaml create mode 100644 src/validators/webhook-validators.ts create mode 100644 tests/metrics-endpoint.test.ts create mode 100644 tests/validation-public-routes.test.ts diff --git a/README.md b/README.md index 7980623..94315dd 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,13 @@ npm run dev ```bash npm test ``` + +## Smoke test + +Run a health-check smoke test against a running server: + +```bash +npm run smoke +``` + +`smoke:health` is also available as a named alias for the startup health check. Both run the same script. diff --git a/deploy/monitoring/grafana/dashboards/agent-loop.json b/deploy/monitoring/grafana/dashboards/agent-loop.json new file mode 100644 index 0000000..bb88c20 --- /dev/null +++ b/deploy/monitoring/grafana/dashboards/agent-loop.json @@ -0,0 +1,59 @@ +{ + "title": "NeuroWealth — Agent Loop", + "uid": "nw-agent-loop", + "schemaVersion": 38, + "refresh": "30s", + "time": { "from": "now-3h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Agent Loop Status", + "type": "stat", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "targets": [{ "expr": "agent_loop_status" }], + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "STOPPED", "color": "red" }, "1": { "text": "RUNNING", "color": "green" }, "2": { "text": "DEGRADED", "color": "yellow" } } } + ] + } + } + }, + { + "id": 2, + "title": "Heartbeat Age (s)", + "type": "gauge", + "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, + "targets": [{ "expr": "time() - agent_loop_heartbeat_timestamp" }], + "fieldConfig": { + "defaults": { + "unit": "s", + "min": 0, + "max": 600, + "thresholds": { "steps": [{ "value": 0, "color": "green" }, { "value": 120, "color": "yellow" }, { "value": 300, "color": "red" }] } + } + } + }, + { + "id": 3, + "title": "Rebalance Checks", + "type": "timeseries", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(agent_rebalance_checks_total[5m])", "legendFormat": "{{status}}" }] + }, + { + "id": 4, + "title": "Rebalances Triggered", + "type": "timeseries", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(agent_rebalances_triggered_total[5m])", "legendFormat": "triggered" }] + }, + { + "id": 5, + "title": "Snapshot Duration P95 (s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 10, "w": 24, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(agent_snapshot_duration_seconds_bucket[5m]))", "legendFormat": "p95" }] + } + ] +} diff --git a/deploy/monitoring/grafana/dashboards/dlq.json b/deploy/monitoring/grafana/dashboards/dlq.json new file mode 100644 index 0000000..62c9672 --- /dev/null +++ b/deploy/monitoring/grafana/dashboards/dlq.json @@ -0,0 +1,42 @@ +{ + "title": "NeuroWealth — DLQ", + "uid": "nw-dlq", + "schemaVersion": 38, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "DLQ Size", + "type": "stat", + "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, + "targets": [{ "expr": "dlq_size" }], + "fieldConfig": { + "defaults": { + "thresholds": { "steps": [{ "value": 0, "color": "green" }, { "value": 20, "color": "yellow" }, { "value": 50, "color": "red" }] } + } + } + }, + { + "id": 2, + "title": "DLQ Size Over Time", + "type": "timeseries", + "gridPos": { "x": 0, "y": 4, "w": 24, "h": 6 }, + "targets": [{ "expr": "dlq_size", "legendFormat": "dlq size" }] + }, + { + "id": 3, + "title": "DLQ Retry Rate", + "type": "timeseries", + "gridPos": { "x": 0, "y": 10, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(dlq_retry_total[5m])", "legendFormat": "{{status}}" }] + }, + { + "id": 4, + "title": "Cursor Lag (ledgers)", + "type": "timeseries", + "gridPos": { "x": 12, "y": 10, "w": 12, "h": 6 }, + "targets": [{ "expr": "cursor_lag_ledgers", "legendFormat": "lag" }] + } + ] +} diff --git a/deploy/monitoring/grafana/dashboards/latency.json b/deploy/monitoring/grafana/dashboards/latency.json new file mode 100644 index 0000000..1e614e6 --- /dev/null +++ b/deploy/monitoring/grafana/dashboards/latency.json @@ -0,0 +1,44 @@ +{ + "title": "NeuroWealth — Latency", + "uid": "nw-latency", + "schemaVersion": 38, + "refresh": "30s", + "time": { "from": "now-3h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "HTTP P50 Latency (s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 0, "w": 12, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.50, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p50" }] + }, + { + "id": 2, + "title": "HTTP P95 Latency (s)", + "type": "timeseries", + "gridPos": { "x": 12, "y": 0, "w": 12, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95" }] + }, + { + "id": 3, + "title": "DB Operation P95 Latency (s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 6, "w": 12, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(db_operation_duration_seconds_bucket[5m]))", "legendFormat": "{{operation}}" }] + }, + { + "id": 4, + "title": "Event Processing P95 Duration (s)", + "type": "timeseries", + "gridPos": { "x": 12, "y": 6, "w": 12, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(events_processing_duration_seconds_bucket[5m]))", "legendFormat": "{{event_type}}" }] + }, + { + "id": 5, + "title": "Analytics API P95 Latency (s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 12, "w": 24, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(analytics_request_duration_seconds_bucket[5m]))", "legendFormat": "{{endpoint}}" }] + } + ] +} diff --git a/deploy/monitoring/grafana/dashboards/system-overview.json b/deploy/monitoring/grafana/dashboards/system-overview.json new file mode 100644 index 0000000..0f24c2c --- /dev/null +++ b/deploy/monitoring/grafana/dashboards/system-overview.json @@ -0,0 +1,89 @@ +{ + "title": "NeuroWealth — System Overview", + "uid": "nw-system-overview", + "schemaVersion": 38, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "panels": [ + { + "id": 1, + "title": "Agent Loop Status", + "type": "stat", + "gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 }, + "targets": [{ "expr": "agent_loop_status", "legendFormat": "status" }], + "options": { "reduceOptions": { "calcs": ["lastNotNull"] } }, + "fieldConfig": { + "defaults": { + "mappings": [ + { "type": "value", "options": { "0": { "text": "STOPPED", "color": "red" }, "1": { "text": "RUNNING", "color": "green" }, "2": { "text": "DEGRADED", "color": "yellow" } } } + ] + } + } + }, + { + "id": 2, + "title": "Heartbeat Age (s)", + "type": "stat", + "gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 }, + "targets": [{ "expr": "time() - agent_loop_heartbeat_timestamp", "legendFormat": "age" }], + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": { "steps": [{ "value": 0, "color": "green" }, { "value": 120, "color": "yellow" }, { "value": 300, "color": "red" }] } + } + } + }, + { + "id": 3, + "title": "Cursor Lag (ledgers)", + "type": "stat", + "gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 }, + "targets": [{ "expr": "cursor_lag_ledgers", "legendFormat": "lag" }], + "fieldConfig": { + "defaults": { + "thresholds": { "steps": [{ "value": 0, "color": "green" }, { "value": 50, "color": "yellow" }, { "value": 100, "color": "red" }] } + } + } + }, + { + "id": 4, + "title": "DLQ Size", + "type": "stat", + "gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 }, + "targets": [{ "expr": "dlq_size", "legendFormat": "dlq" }], + "fieldConfig": { + "defaults": { + "thresholds": { "steps": [{ "value": 0, "color": "green" }, { "value": 20, "color": "yellow" }, { "value": 50, "color": "red" }] } + } + } + }, + { + "id": 5, + "title": "HTTP Request Rate", + "type": "timeseries", + "gridPos": { "x": 0, "y": 4, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(http_requests_total[1m])", "legendFormat": "{{method}} {{route}} {{status_code}}" }] + }, + { + "id": 6, + "title": "HTTP P95 Latency (s)", + "type": "timeseries", + "gridPos": { "x": 12, "y": 4, "w": 12, "h": 6 }, + "targets": [{ "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))", "legendFormat": "p95" }] + }, + { + "id": 7, + "title": "Event Processing Rate", + "type": "timeseries", + "gridPos": { "x": 0, "y": 10, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(events_processed_total[1m])", "legendFormat": "{{event_type}} {{status}}" }] + }, + { + "id": 8, + "title": "Failure Rate", + "type": "timeseries", + "gridPos": { "x": 12, "y": 10, "w": 12, "h": 6 }, + "targets": [{ "expr": "rate(failures_total[1m])", "legendFormat": "{{component}} {{error_type}}" }] + } + ] +} diff --git a/deploy/monitoring/grafana/provisioning/dashboards.yaml b/deploy/monitoring/grafana/provisioning/dashboards.yaml new file mode 100644 index 0000000..9666c67 --- /dev/null +++ b/deploy/monitoring/grafana/provisioning/dashboards.yaml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: neurowealth + type: file + disableDeletion: false + updateIntervalSeconds: 30 + options: + path: /etc/grafana/dashboards diff --git a/deploy/monitoring/grafana/provisioning/datasources.yaml b/deploy/monitoring/grafana/provisioning/datasources.yaml new file mode 100644 index 0000000..7378e45 --- /dev/null +++ b/deploy/monitoring/grafana/provisioning/datasources.yaml @@ -0,0 +1,9 @@ +apiVersion: 1 +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + jsonData: + timeInterval: "15s" diff --git a/deploy/monitoring/prometheus/alert-rules.yaml b/deploy/monitoring/prometheus/alert-rules.yaml new file mode 100644 index 0000000..4072dcd --- /dev/null +++ b/deploy/monitoring/prometheus/alert-rules.yaml @@ -0,0 +1,105 @@ +groups: + - name: neurowealth_critical + interval: 30s + rules: + - alert: AgentLoopStopped + expr: agent_loop_status == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Agent loop is not running" + description: "Agent loop has been stopped for more than 1 minute" + + - alert: AgentLoopHeartbeatStale + expr: time() - agent_loop_heartbeat_timestamp > 300 + for: 1m + labels: + severity: critical + annotations: + summary: "Agent loop heartbeat is stale" + description: "Agent loop heartbeat not updated for 5 minutes" + + - alert: CursorLagCritical + expr: cursor_lag_ledgers > 100 + for: 2m + labels: + severity: critical + annotations: + summary: "Event cursor lag critical" + description: "Cursor lag is {{ $value }} ledgers (> 100)" + + - alert: DLQSizeCritical + expr: dlq_size > 50 + for: 1m + labels: + severity: critical + annotations: + summary: "Dead Letter Queue critically large" + description: "DLQ has {{ $value }} events (> 50)" + + - alert: HighFailureRate + expr: rate(failures_total[5m]) > 10 + for: 5m + labels: + severity: critical + annotations: + summary: "High failure rate detected" + description: "Failure rate is {{ $value }}/min (> 10)" + + - name: neurowealth_warning + interval: 30s + rules: + - alert: AgentLoopDegraded + expr: agent_loop_status == 2 + for: 5m + labels: + severity: warning + annotations: + summary: "Agent loop in degraded state" + description: "Agent loop has been degraded for more than 5 minutes" + + - alert: CursorLagWarning + expr: cursor_lag_ledgers > 50 + for: 5m + labels: + severity: warning + annotations: + summary: "Event cursor lag elevated" + description: "Cursor lag is {{ $value }} ledgers (> 50)" + + - alert: DLQSizeWarning + expr: dlq_size > 20 + for: 5m + labels: + severity: warning + annotations: + summary: "Dead Letter Queue growing" + description: "DLQ has {{ $value }} events (> 20)" + + - alert: EventProcessingSlow + expr: histogram_quantile(0.95, rate(events_processing_duration_seconds_bucket[5m])) > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "Event processing slow" + description: "P95 event processing duration is {{ $value }}s (> 2s)" + + - alert: DatabaseOperationsSlow + expr: histogram_quantile(0.95, rate(db_operation_duration_seconds_bucket[5m])) > 1 + for: 5m + labels: + severity: warning + annotations: + summary: "Database operations slow" + description: "P95 DB operation duration is {{ $value }}s (> 1s)" + + - alert: HTTPRequestsSlow + expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 5 + for: 5m + labels: + severity: warning + annotations: + summary: "HTTP requests slow" + description: "P95 HTTP request duration is {{ $value }}s (> 5s)" diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 37f9df0..804fde7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -204,6 +204,36 @@ There is **no leader election**. Running multiple replicas will: - **Request tracing:** clients may send `X-Request-ID` or `X-Correlation-ID`; the server echoes `X-Request-ID` on every response and includes `correlationId` in structured logs - **DLQ:** monitor `dead_letter_events` count and `event_cursors.lastProcessedLedger` lag — see `docs/OBSERVABILITY.md` and `docs/RUNBOOK.md` +### Monitoring assets + +Pre-built alert rules and Grafana dashboards live under `deploy/monitoring/`: + +| Path | Purpose | +|------|---------| +| `deploy/monitoring/prometheus/alert-rules.yaml` | Prometheus alert rules (critical + warning) | +| `deploy/monitoring/grafana/dashboards/system-overview.json` | System overview dashboard | +| `deploy/monitoring/grafana/dashboards/agent-loop.json` | Agent loop health dashboard | +| `deploy/monitoring/grafana/dashboards/dlq.json` | DLQ and cursor lag dashboard | +| `deploy/monitoring/grafana/dashboards/latency.json` | HTTP, DB, and event latency dashboard | +| `deploy/monitoring/grafana/provisioning/datasources.yaml` | Grafana datasource provisioning | +| `deploy/monitoring/grafana/provisioning/dashboards.yaml` | Grafana dashboard provisioning | + +**Prometheus:** add the alert rules file to your Prometheus configuration: + +```yaml +rule_files: + - /etc/prometheus/rules/alert-rules.yaml +``` + +**Grafana:** copy the provisioning files and dashboards to your Grafana instance: + +```bash +cp deploy/monitoring/grafana/provisioning/* /etc/grafana/provisioning/ +cp deploy/monitoring/grafana/dashboards/*.json /etc/grafana/dashboards/ +``` + +Grafana will auto-load the dashboards on next restart. + --- ## CI validation diff --git a/package.json b/package.json index 71adde9..bf76efb 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "nodemon", "build": "tsc", "start": "node dist/index.js", + "smoke": "bash scripts/smoke-health.sh", "smoke:health": "bash scripts/smoke-health.sh", "wallet:rotate": "npx ts-node scripts/rotate-wallet-key.ts", "wallet:rotate:dry-run": "npx ts-node scripts/rotate-wallet-key.ts --dry-run", diff --git a/readme.md b/readme.md index 7980623..94315dd 100644 --- a/readme.md +++ b/readme.md @@ -46,3 +46,13 @@ npm run dev ```bash npm test ``` + +## Smoke test + +Run a health-check smoke test against a running server: + +```bash +npm run smoke +``` + +`smoke:health` is also available as a named alias for the startup health check. Both run the same script. diff --git a/src/controllers/auth-controller.ts b/src/controllers/auth-controller.ts index ad7c2cf..7e5b30d 100644 --- a/src/controllers/auth-controller.ts +++ b/src/controllers/auth-controller.ts @@ -20,12 +20,7 @@ import { stellarVerification } from '../utils/stellar/stellar-verification'; * correctly across multiple app instances. */ export async function challenge(req: Request, res: Response): Promise { - const { stellarPubKey } = req.body as { stellarPubKey?: string }; - - if (!stellarPubKey) { - res.status(400).json({ error: 'stellarPubKey is required' }); - return; - } + const { stellarPubKey } = req.body as { stellarPubKey: string }; // Validate the public key format try { @@ -69,15 +64,10 @@ export async function challenge(req: Request, res: Response): Promise { */ export async function verify(req: Request, res: Response): Promise { const { stellarPubKey, signature } = req.body as { - stellarPubKey?: string; - signature?: string; + stellarPubKey: string; + signature: string; }; - if (!stellarPubKey || !signature) { - res.status(400).json({ error: 'stellarPubKey and signature are required' }); - return; - } - // 1. Look up nonce in DB const stored = await db.authNonce.findUnique({ where: { stellarPubKey } }); if (!stored) { diff --git a/src/routes/whatsapp.ts b/src/routes/whatsapp.ts index 655cfd2..2410487 100644 --- a/src/routes/whatsapp.ts +++ b/src/routes/whatsapp.ts @@ -2,6 +2,8 @@ import express, { Request, Response } from 'express' import { validateRequest, twiml } from 'twilio' import { handleWhatsAppMessage } from '../whatsapp/handler' import { logger } from '../utils/logger' +import { validate } from '../middleware/validate' +import { whatsappWebhookSchema } from '../validators/webhook-validators' const router = express.Router() @@ -20,7 +22,7 @@ router.get('/webhook', (_req: Request, res: Response) => { * spoofed calls even on staging/dev where NODE_ENV is not 'production'. * https://www.twilio.com/docs/usage/security#validating-requests */ -router.post('/webhook', async (req: Request, res: Response) => { +router.post('/webhook', validate({ body: whatsappWebhookSchema }), async (req: Request, res: Response) => { const authToken = process.env.TWILIO_AUTH_TOKEN if (!authToken) { diff --git a/src/validators/auth-validators.ts b/src/validators/auth-validators.ts index 0125475..1e122ea 100644 --- a/src/validators/auth-validators.ts +++ b/src/validators/auth-validators.ts @@ -1,10 +1,10 @@ import { z } from 'zod'; export const authChallengeSchema = z.object({ - stellarPubKey: z.string().trim().optional(), + stellarPubKey: z.string().trim().min(1, 'stellarPubKey is required'), }); export const authVerifySchema = z.object({ - stellarPubKey: z.string().trim().optional(), - signature: z.string().trim().optional(), + stellarPubKey: z.string().trim().min(1, 'stellarPubKey is required'), + signature: z.string().trim().min(1, 'signature is required'), }); diff --git a/src/validators/webhook-validators.ts b/src/validators/webhook-validators.ts new file mode 100644 index 0000000..9aaa5ba --- /dev/null +++ b/src/validators/webhook-validators.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const whatsappWebhookSchema = z.object({ + From: z.string().min(1, 'From is required'), + Body: z.string().min(1, 'Body is required'), +}); diff --git a/tests/metrics-endpoint.test.ts b/tests/metrics-endpoint.test.ts new file mode 100644 index 0000000..22f0aa0 --- /dev/null +++ b/tests/metrics-endpoint.test.ts @@ -0,0 +1,89 @@ +/** + * Integration tests for GET /metrics + * Covers: authorization behavior, response status codes, and Prometheus text format. + * Issue #187 + */ + +import request from 'supertest' +import app from '../src/index' + +describe('GET /metrics', () => { + describe('unauthorized access', () => { + it('returns 404 with no credentials', async () => { + const res = await request(app).get('/metrics') + expect(res.status).toBe(404) + expect(res.body.error).toBeDefined() + }) + + it('returns 404 with invalid X-Internal-Token', async () => { + const res = await request(app) + .get('/metrics') + .set('X-Internal-Token', 'bad-token') + expect(res.status).toBe(404) + }) + + it('returns 404 with invalid Bearer token', async () => { + const res = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer bad-token') + expect(res.status).toBe(404) + }) + }) + + describe('authorized access via X-Internal-Token', () => { + const token = process.env.INTERNAL_SERVICE_TOKEN + + beforeAll(() => { + if (!token) console.log('Skipping authorized tests: INTERNAL_SERVICE_TOKEN not set') + }) + + it('returns 200 with valid X-Internal-Token', async () => { + if (!token) return + const res = await request(app) + .get('/metrics') + .set('X-Internal-Token', token) + expect(res.status).toBe(200) + }) + + it('returns text/plain content-type', async () => { + if (!token) return + const res = await request(app) + .get('/metrics') + .set('X-Internal-Token', token) + expect(res.headers['content-type']).toMatch(/text\/plain/) + }) + + it('returns Prometheus-compatible exposition format', async () => { + if (!token) return + const res = await request(app) + .get('/metrics') + .set('X-Internal-Token', token) + // Prometheus text format starts with # HELP or # TYPE lines + expect(res.text).toMatch(/^#\s+(HELP|TYPE)\s+\w+/m) + }) + }) + + describe('authorized access via Bearer (admin) token', () => { + const token = process.env.ADMIN_API_TOKEN + + beforeAll(() => { + if (!token) console.log('Skipping authorized tests: ADMIN_API_TOKEN not set') + }) + + it('returns 200 with valid ADMIN_API_TOKEN as Bearer', async () => { + if (!token) return + const res = await request(app) + .get('/metrics') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + }) + + it('returns Prometheus-compatible text with admin token', async () => { + if (!token) return + const res = await request(app) + .get('/metrics') + .set('Authorization', `Bearer ${token}`) + expect(res.text).toMatch(/^#\s+(HELP|TYPE)\s+\w+/m) + }) + }) +}) diff --git a/tests/validation-public-routes.test.ts b/tests/validation-public-routes.test.ts new file mode 100644 index 0000000..0cb6cf4 --- /dev/null +++ b/tests/validation-public-routes.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for Zod validation on public routes. + * Verifies that invalid payloads return 400 with a descriptive error body. + * Issue #146 + */ + +import request from 'supertest' +import app from '../src/index' + +describe('Zod validation on public routes', () => { + describe('POST /api/auth/challenge', () => { + it('returns 400 when stellarPubKey is missing', async () => { + const res = await request(app).post('/api/auth/challenge').send({}) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + expect(res.body.details).toBeDefined() + }) + + it('returns 400 when stellarPubKey is empty string', async () => { + const res = await request(app) + .post('/api/auth/challenge') + .send({ stellarPubKey: '' }) + expect(res.status).toBe(400) + expect(res.body.details).toBeDefined() + }) + + it('passes validation with a non-empty stellarPubKey', async () => { + // The key is invalid Stellar format so we get 400 from the controller, + // but the Zod middleware layer should not be the one rejecting it. + const res = await request(app) + .post('/api/auth/challenge') + .send({ stellarPubKey: 'GNOTAVALIDKEY' }) + // 400 from controller (invalid Stellar key format), not from validation layer + expect(res.status).toBe(400) + expect(res.body.error).toBe('Invalid Stellar public key') + }) + }) + + describe('POST /api/auth/verify', () => { + it('returns 400 when both fields are missing', async () => { + const res = await request(app).post('/api/auth/verify').send({}) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + expect(res.body.details).toBeDefined() + }) + + it('returns 400 when signature is missing', async () => { + const res = await request(app) + .post('/api/auth/verify') + .send({ stellarPubKey: 'GXXXXXX' }) + expect(res.status).toBe(400) + expect(res.body.details).toBeDefined() + }) + + it('returns 400 when stellarPubKey is missing', async () => { + const res = await request(app) + .post('/api/auth/verify') + .send({ signature: 'abc123' }) + expect(res.status).toBe(400) + expect(res.body.details).toBeDefined() + }) + }) + + describe('POST /api/whatsapp/webhook', () => { + it('returns 400 when From is missing', async () => { + const res = await request(app) + .post('/api/whatsapp/webhook') + .send({ Body: 'hello' }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) + + it('returns 400 when Body is missing', async () => { + const res = await request(app) + .post('/api/whatsapp/webhook') + .send({ From: '+1234567890' }) + expect(res.status).toBe(400) + expect(res.body.error).toBeDefined() + }) + }) +})