From 7fe3a9a24d7913104a1cca2c238f5cd6bc9c24d4 Mon Sep 17 00:00:00 2001 From: Olagoke22 <115514757+Olagoke22@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:17:58 +0100 Subject: [PATCH 1/2] Add GET /api/v1/admin/webhooks/health endpoint (#1) Gives ops visibility into webhook delivery reliability: total/healthy/failing counts, a rolling 24h success rate, and the URLs that have been failing for more than 24 hours, optionally scoped to one username. Uses DB-side count aggregation instead of loading all webhook rows into memory. Co-authored-by: Claude Sonnet 5 --- README.md | 12 ++ .../src/routes/v1/adminRoutes.js | 36 +++++ .../tests/admin-webhook-health.test.js | 150 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 stellar-payment-platform/tests/admin-webhook-health.test.js diff --git a/README.md b/README.md index 69111c32..ccea1c25 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 3d10cf21..942cb320 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 00000000..4ac7f88b --- /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' }), + }), + ); + }); +}); From fbfbb9ad38c5cf7af120e5034700658206b5f95c Mon Sep 17 00:00:00 2001 From: Olagoke22 <115514757+Olagoke22@users.noreply.github.com> Date: Sun, 30 Aug 2026 18:44:07 +0100 Subject: [PATCH 2/2] new --- .husky/pre-commit | 10 +++++++++- CONTRIBUTING.md | 16 ++++++++++++++++ stellar-payment-platform/package.json | 1 + 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 CONTRIBUTING.md diff --git a/.husky/pre-commit b/.husky/pre-commit index d0a77842..2d3d8db4 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,9 @@ -npx lint-staged \ No newline at end of file +npx lint-staged + +if git diff --cached --name-only --diff-filter=ACM | grep -q '\.prisma$'; then + echo "Prisma schema changed — running prisma validate..." + npm --prefix stellar-payment-platform run prisma:validate || { + echo "Prisma schema is invalid. Fix the errors above before committing." + exit 1 + } +fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..088a280b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,16 @@ +# Contributing + +## Pre-commit hooks + +This repo uses [Husky](https://typicode.github.io/husky/) to run checks before each commit (`.husky/pre-commit`): + +- **ESLint** via `lint-staged` on staged `.js`/`.jsx` files. +- **`prisma validate`** whenever a staged file ends in `.prisma` (e.g. [stellar-payment-platform/prisma/schema.prisma](stellar-payment-platform/prisma/schema.prisma)). This catches schema syntax errors locally instead of failing in CI. The check is skipped entirely if no `.prisma` files are staged. + +If `prisma validate` fails, the commit is blocked and the error is printed to the terminal. Fix the schema and re-commit. + +You can run the same check manually at any time: + +```bash +npm --prefix stellar-payment-platform run prisma:validate +``` diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index af206fea..ac26aee1 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -13,6 +13,7 @@ "listener": "node horizonListener.js", "db:seed": "node scripts/seed.js", "prisma:generate": "prisma generate", + "prisma:validate": "prisma validate", "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:studio": "prisma studio",