Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions stellar-payment-platform/src/routes/v1/adminRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

150 changes: 150 additions & 0 deletions stellar-payment-platform/tests/admin-webhook-health.test.js
Original file line number Diff line number Diff line change
@@ -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' }),
}),
);
});
});
Loading