diff --git a/README.md b/README.md index 69111c3..ccea1c2 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,18 @@ Retrieves recent immutable audit trail records for mutating admin actions (`POST Mutating admin requests are intercepted by `auditLogMiddleware` and recorded asynchronously upon response completion. Sensitive keys (`password`, `secret`, `apiKey`, `token`, `signature`, `privateKey`, `seed`) are deeply redacted before persistence. +### `GET /admin/webhooks/health` +Aggregates webhook delivery health so operators can spot broken merchant integrations. +- **Query Parameters:** + - `username` (optional) – Scope the aggregates to one merchant's webhooks. +- **Headers:** `x-api-key` (required) – must match `ADMIN_API_KEY`. +- **Returns:** JSON object with `success: true`, a `summary` (`total`, `healthy`, `failing`, `successRate24h`), and `failingOver24h` — the webhooks that have been failing continuously for more than 24 hours. +- **Status Codes:** + - `200 OK`: Health snapshot retrieved successfully. + - `401 Unauthorized`: Missing or invalid API key. + +A webhook is "failing" while its `failingSince` timestamp is set (cleared on the next successful delivery). `successRate24h` is the share of webhooks with a delivery attempt in the last 24h that are currently healthy; it is `null` when nothing has been active in that window. + ### `GET /metrics` Prometheus scrape endpoint, served in the Prometheus text format. Exempt from the diff --git a/stellar-payment-platform/src/routes/v1/adminRoutes.js b/stellar-payment-platform/src/routes/v1/adminRoutes.js index 3d10cf2..942cb32 100644 --- a/stellar-payment-platform/src/routes/v1/adminRoutes.js +++ b/stellar-payment-platform/src/routes/v1/adminRoutes.js @@ -173,6 +173,42 @@ module.exports = (redisClient) => { }), ); + // ── GET /admin/webhooks/health ─────────────────────────────────────────── + // Aggregates webhook delivery health so ops can spot broken merchant + // integrations: total/healthy/failing counts, a rolling 24h success rate, + // and the URLs that have been failing for more than 24h. + router.get('/admin/webhooks/health', adminAuth, asyncHandler(async (req, res) => { + const prisma = getPrisma(); + const username = typeof req.query.username === 'string' ? req.query.username.trim() : ''; + const where = username ? { username } : {}; + const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const [total, failing, activeLast24h, failingLast24h, failingOver24h] = await Promise.all([ + prisma.webhook.count({ where }), + prisma.webhook.count({ where: { ...where, failingSince: { not: null } } }), + prisma.webhook.count({ where: { ...where, lastSentAt: { gte: dayAgo } } }), + prisma.webhook.count({ where: { ...where, lastSentAt: { gte: dayAgo }, failingSince: { not: null } } }), + prisma.webhook.findMany({ + where: { ...where, failingSince: { lte: dayAgo } }, + select: { id: true, username: true, url: true, failingSince: true }, + orderBy: { failingSince: 'asc' }, + }), + ]); + + return res.status(200).json({ + success: true, + summary: { + total, + healthy: total - failing, + failing, + successRate24h: activeLast24h + ? Number((((activeLast24h - failingLast24h) / activeLast24h) * 100).toFixed(2)) + : null, + }, + failingOver24h, + }); + })); + return router; }; diff --git a/stellar-payment-platform/tests/admin-webhook-health.test.js b/stellar-payment-platform/tests/admin-webhook-health.test.js new file mode 100644 index 0000000..4ac7f88 --- /dev/null +++ b/stellar-payment-platform/tests/admin-webhook-health.test.js @@ -0,0 +1,150 @@ +'use strict'; + +/** + * tests/admin-webhook-health.test.js + * + * Tests for GET /admin/webhooks/health (issue: webhook delivery observability). + */ + +const request = require('supertest'); + +// ── mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('redis', () => ({ createClient: jest.fn(() => null) })); +jest.mock('../src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() })); +jest.mock('../src/soft-delete-purge-cron', () => ({ scheduleSoftDeletePurgeJob: jest.fn() })); + +const mockWebhookCount = jest.fn(); +const mockWebhookFindMany = jest.fn(); + +jest.mock('../prismaClient', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + payment: { findMany: jest.fn().mockResolvedValue([]) }, + webhook: { + count: mockWebhookCount, + findMany: mockWebhookFindMany, + }, + auditLog: { create: jest.fn().mockResolvedValue({}) }, + $transaction: jest.fn().mockResolvedValue([0, []]), + $queryRaw: jest.fn().mockResolvedValue([]), + $metrics: { json: jest.fn().mockResolvedValue({ counters: [], gauges: [], histograms: [] }) }, + }, + isPrismaConnectionError: () => false, +})); + +jest.mock('@stellar/stellar-sdk', () => ({ + Horizon: { Server: jest.fn().mockImplementation(() => ({ payments: jest.fn() })) }, + StrKey: { isValidEd25519PublicKey: jest.fn((v) => typeof v === 'string' && v.startsWith('G')) }, + Keypair: { fromPublicKey: jest.fn(() => ({ verify: jest.fn(() => true) })) }, +})); +jest.mock('pdfkit', () => jest.fn()); + +process.env.NODE_ENV = 'test'; +process.env.ADMIN_API_KEY = 'test-admin-key'; + +// Load app after mocks are in place. +const { app } = require('../server'); + +describe('GET /admin/webhooks/health', () => { + beforeEach(() => { + mockWebhookCount.mockReset(); + mockWebhookFindMany.mockReset(); + }); + + it('returns 401 without API key', async () => { + const res = await request(app).get('/api/v1/admin/webhooks/health'); + expect(res.status).toBe(401); + }); + + it('returns 401 with wrong API key', async () => { + const res = await request(app) + .get('/api/v1/admin/webhooks/health') + .set('x-api-key', 'wrong-key'); + expect(res.status).toBe(401); + }); + + it('aggregates totals, success rate, and stale failures', async () => { + // total, failing, activeLast24h, failingLast24h + mockWebhookCount + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(3) + .mockResolvedValueOnce(4) + .mockResolvedValueOnce(1); + + const staleFailure = { + id: 'wh-1', + username: 'alice', + url: 'https://broken.example.com/hook', + failingSince: new Date('2026-01-01T00:00:00Z'), + }; + mockWebhookFindMany.mockResolvedValueOnce([staleFailure]); + + const res = await request(app) + .get('/api/v1/admin/webhooks/health') + .set('x-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + success: true, + summary: { + total: 10, + healthy: 7, + failing: 3, + successRate24h: 75, + }, + failingOver24h: [ + { + id: 'wh-1', + username: 'alice', + url: 'https://broken.example.com/hook', + failingSince: '2026-01-01T00:00:00.000Z', + }, + ], + }); + }); + + it('returns null successRate24h when nothing has been active', async () => { + mockWebhookCount + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0); + mockWebhookFindMany.mockResolvedValueOnce([]); + + const res = await request(app) + .get('/api/v1/admin/webhooks/health') + .set('x-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(res.body.summary.successRate24h).toBeNull(); + }); + + it('scopes all queries by username when provided', async () => { + mockWebhookCount + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(0); + mockWebhookFindMany.mockResolvedValueOnce([]); + + const res = await request(app) + .get('/api/v1/admin/webhooks/health?username=alice') + .set('x-api-key', 'test-admin-key'); + + expect(res.status).toBe(200); + expect(mockWebhookCount).toHaveBeenCalledWith({ where: { username: 'alice' } }); + expect(mockWebhookFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ username: 'alice' }), + }), + ); + }); +});