From 58317647ea623c32df23b9f68bbb37a443a31a88 Mon Sep 17 00:00:00 2001 From: DARCSZN Date: Sun, 28 Jun 2026 09:37:11 +0100 Subject: [PATCH] fix: add mass-assignment protection to all PATCH/PUT endpoints Wire validatePayloadFields into the global middleware chain so routes registered in ROUTE_ALLOWED_FIELDS actively reject unknown fields (400 UNKNOWN_FIELDS) instead of silently ignoring them. Add explicit validateSchema declarations to every mutable-resource endpoint that previously relied only on manual destructuring: - wallet.js: updateWalletLabelSchema, updateWalletSchema, updateHomeDomainSchema, updateWalletLimitsSchema, updateLeaderboardVisibilitySchema; fix the broken inflationDestinationSchema (was a no-op JSON Schema object, now strips unknown fields correctly) - stream.js: updateScheduleSchema for PATCH /schedules/:id - admin/webhooks.js: updateWebhookStatusSchema for PATCH /:id (schema enum replaces manual inline status validation) - admin/geoRules.js: updateGeoRuleSchema for PATCH /:id - admin/pledges.js: cancelPledgeSchema for PATCH /:id/cancel Each schema is the single source of truth for that resource's updatable field set. validateSchema strips unknown keys before the handler runs, so protected fields (role, status, apiKeyId, createdAt, verified, publicKey) are never seen by service or database layers. Add tests/security/mass-assignment.test.js with two suites: 1. validatePayloadFields rejection: protected fields on registered routes receive 400 UNKNOWN_FIELDS. 2. validateSchema stripping: protected fields are removed from req.body before reaching the handler, verified by echoing the processed body. --- src/bootstrap/middleware.js | 4 + src/routes/admin/geoRules.js | 12 +- src/routes/admin/pledges.js | 10 + src/routes/admin/webhooks.js | 15 +- src/routes/stream.js | 11 +- src/routes/wallet.js | 77 +++-- tests/security/mass-assignment.test.js | 455 +++++++++++++++++++++++++ 7 files changed, 558 insertions(+), 26 deletions(-) create mode 100644 tests/security/mass-assignment.test.js diff --git a/src/bootstrap/middleware.js b/src/bootstrap/middleware.js index 096f4a10..f0274736 100644 --- a/src/bootstrap/middleware.js +++ b/src/bootstrap/middleware.js @@ -29,6 +29,7 @@ const { fieldFilterMiddleware } = require('../middleware/fieldFilter'); const { requestTimeout, TIMEOUTS } = require('../middleware/requestTimeout'); const apiVersionMiddleware = require('../middleware/apiVersion'); const requireApiKey = require('../middleware/apiKey'); +const { validatePayloadFields } = require('../middleware/validation'); const asyncHandler = require('../utils/asyncHandler'); const log = require('../utils/log'); const requestCounter = require('../utils/requestCounter'); @@ -146,6 +147,9 @@ function applyMiddleware(app) { return requestTimeout(GLOBAL_TIMEOUT_MS)(req, res, next); }); + // ─── Mass-assignment protection: reject unknown fields on registered routes ─── + app.use(validatePayloadFields); + // ─── Schema version negotiation ────────────────────────────────────────────── app.use(apiVersionMiddleware); } diff --git a/src/routes/admin/geoRules.js b/src/routes/admin/geoRules.js index 82c17e35..d5e9bd39 100644 --- a/src/routes/admin/geoRules.js +++ b/src/routes/admin/geoRules.js @@ -22,6 +22,16 @@ const GeoRuleService = require('../../services/GeoRuleService'); const AuditLogService = require('../../services/AuditLogService'); const log = require('../../utils/log'); const { geoBlockMiddleware } = require('../../middleware/geoBlock'); +const { validateSchema } = require('../../middleware/schemaValidation'); + +const updateGeoRuleSchema = validateSchema({ + body: { + fields: { + active: { type: 'boolean', required: false }, + description: { type: 'string', required: false, nullable: true }, + } + } +}); const router = express.Router(); @@ -124,7 +134,7 @@ router.post('/', ...auth, async (req, res, next) => { * Update a geo rule (toggle active, change description). * Body: { active?: boolean, description?: string } */ -router.patch('/:id', ...auth, async (req, res, next) => { +router.patch('/:id', ...auth, updateGeoRuleSchema, async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (!Number.isInteger(id) || id <= 0) { diff --git a/src/routes/admin/pledges.js b/src/routes/admin/pledges.js index 6a922b1d..356d23dd 100644 --- a/src/routes/admin/pledges.js +++ b/src/routes/admin/pledges.js @@ -22,6 +22,15 @@ const PledgeFulfillmentService = require('../../services/PledgeFulfillmentServic const WebhookService = require('../../services/WebhookService'); const AuditLogService = require('../../services/AuditLogService'); const log = require('../../utils/log'); +const { validateSchema } = require('../../middleware/schemaValidation'); + +const cancelPledgeSchema = validateSchema({ + body: { + fields: { + reason: { type: 'string', required: false, nullable: true }, + } + } +}); const VALID_STATUSES = ['pending', 'fulfilled', 'cancelled', 'expired']; @@ -150,6 +159,7 @@ router.patch( router.patch( '/:id/cancel', checkPermission(PERMISSIONS.ADMIN_ALL), + cancelPledgeSchema, asyncHandler(async (req, res, next) => { try { const { id } = req.params; diff --git a/src/routes/admin/webhooks.js b/src/routes/admin/webhooks.js index 5e90dd66..ac5f9dd4 100644 --- a/src/routes/admin/webhooks.js +++ b/src/routes/admin/webhooks.js @@ -18,6 +18,15 @@ const { payloadSizeLimiter, ENDPOINT_LIMITS } = require('../../middleware/payloa const { requireAdmin} = require('../../middleware/rbac'); const WebhookService = require('../../services/WebhookService'); const Database = require('../../utils/database'); +const { validateSchema } = require('../../middleware/schemaValidation'); + +const updateWebhookStatusSchema = validateSchema({ + body: { + fields: { + status: { type: 'string', required: true, enum: ['active', 'disabled'] }, + } + } +}); /** * GET /admin/webhooks @@ -160,15 +169,11 @@ router.post('/:id/retry', requireApiKey, requireAdmin(), payloadSizeLimiter(ENDP * Update webhook status (disable/enable). * Body: { status: "disabled" | "active" } */ -router.patch('/:id', requireApiKey, requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.webhook), asyncHandler(async (req, res, next) => { +router.patch('/:id', requireApiKey, requireAdmin(), updateWebhookStatusSchema, payloadSizeLimiter(ENDPOINT_LIMITS.webhook), asyncHandler(async (req, res, next) => { try { const webhookId = parseInt(req.params.id, 10); const { status } = req.body; - if (!status || !['active', 'disabled'].includes(status)) { - return res.status(400).json({ success: false, error: 'status must be "active" or "disabled"' }); - } - // Verify webhook exists const webhook = await Database.get('SELECT id FROM webhooks WHERE id = ?', [webhookId]); if (!webhook) { diff --git a/src/routes/stream.js b/src/routes/stream.js index 1fffeba8..f2465003 100644 --- a/src/routes/stream.js +++ b/src/routes/stream.js @@ -153,6 +153,15 @@ const streamScheduleIdSchema = validateSchema({ }, }); +const updateScheduleSchema = validateSchema({ + body: { + fields: { + amount: { types: ['number', 'numberString'], required: false }, + frequency: { type: 'string', required: false, enum: ['daily', 'weekly', 'monthly'] }, + } + } +}); + /** * POST /stream/create * Create a recurring donation schedule @@ -700,7 +709,7 @@ router.get('/schedules/:id/history', checkPermission(PERMISSIONS.STREAM_READ), s * Cancelled/suspended schedules cannot be updated (409). * Requires stream:write permission. */ -router.patch('/schedules/:id', checkPermission(PERMISSIONS.STREAM_UPDATE), streamScheduleIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.stream), asyncHandler(async (req, res, next) => { +router.patch('/schedules/:id', checkPermission(PERMISSIONS.STREAM_UPDATE), streamScheduleIdSchema, updateScheduleSchema, payloadSizeLimiter(ENDPOINT_LIMITS.stream), asyncHandler(async (req, res, next) => { try { const { amount, frequency } = req.body; diff --git a/src/routes/wallet.js b/src/routes/wallet.js index c1ea0821..4fbd94b0 100644 --- a/src/routes/wallet.js +++ b/src/routes/wallet.js @@ -74,22 +74,65 @@ const walletCreateSchema = validateSchema({ } }); -// Inflation destination schema for PATCH -const inflationDestinationSchema = { - type: 'object', - required: ['destination', 'signedXDR'], - properties: { - destination: { type: 'string' }, - signedXDR: { type: 'string' } +const updateWalletLabelSchema = validateSchema({ + body: { + fields: { + label: { type: 'string', required: false, nullable: true, maxLength: 100 }, + } + } +}); + +const updateWalletSchema = validateSchema({ + body: { + fields: { + label: { type: 'string', required: false, nullable: true, maxLength: 100 }, + ownerName: { type: 'string', required: false, nullable: true, maxLength: 200 }, + } } -}; +}); + +const updateHomeDomainSchema = validateSchema({ + body: { + fields: { + domain: { type: 'string', required: true }, + sourceSecret: { type: 'string', required: true }, + } + } +}); + +const updateWalletLimitsSchema = validateSchema({ + body: { + fields: { + daily_limit: { type: 'number', required: false, nullable: true }, + monthly_limit: { type: 'number', required: false, nullable: true }, + per_transaction_limit: { type: 'number', required: false, nullable: true }, + } + } +}); + +const updateLeaderboardVisibilitySchema = validateSchema({ + body: { + fields: { + visible: { type: 'boolean', required: true }, + } + } +}); + +const inflationDestinationSchema = validateSchema({ + body: { + fields: { + destination: { type: 'string', required: true }, + signedXDR: { type: 'string', required: true }, + } + } +}); // PATCH /wallets/:id/inflation-destination router.patch( '/:id/inflation-destination', requireAuth, requirePermission('wallets:write'), - validateSchema(inflationDestinationSchema), + inflationDestinationSchema, asyncHandler(async (req, res, next) => { try { const { id } = req.params; @@ -630,7 +673,7 @@ router.get('/:id', checkPermission(PERMISSIONS.WALLETS_READ), walletIdSchema, ca * Body: { "label": "string" } — empty string or null clears the label. * Requires wallets:write permission (not admin). */ -router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateWalletLabelSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { const { id } = req.params; const { label } = req.body; @@ -676,12 +719,8 @@ router.patch('/:id/label', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletId * PATCH /wallets/:id * Update wallet metadata (label, ownerName only — publicKey is immutable) */ -router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), updateWalletSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { - // publicKey is immutable — changing it would break all FK relationships - if (req.body.publicKey !== undefined) { - return res.status(400).json({ success: false, error: 'Public key cannot be changed' }); - } const { label, ownerName } = req.body; @@ -716,7 +755,7 @@ router.patch('/:id', checkPermission(PERMISSIONS.WALLETS_UPDATE), payloadSizeLim * Set the home domain on a wallet's Stellar account. * Body: { domain: string, sourceSecret: string } */ -router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateHomeDomainSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { const { domain, sourceSecret } = req.body; @@ -772,7 +811,7 @@ router.patch('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), wa * Idiomatic alias for PATCH — sets the home domain on a wallet's Stellar account. * Body: { domain: string, sourceSecret: string } */ -router.put('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.put('/:id/home-domain', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateHomeDomainSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { const { domain, sourceSecret } = req.body; @@ -1001,7 +1040,7 @@ router.get('/:publicKey/transactions', checkPermission(PERMISSIONS.WALLETS_READ) * Set per-wallet donation limits (admin only) * Body: { daily_limit, monthly_limit, per_transaction_limit } — all optional, positive number or null */ -router.patch('/:id/limits', requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.patch('/:id/limits', requireAdmin(), updateWalletLimitsSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { const userId = parseInt(req.params.id, 10); if (isNaN(userId) || userId < 1) { @@ -1066,7 +1105,7 @@ router.patch('/:id/limits', requireAdmin(), payloadSizeLimiter(ENDPOINT_LIMITS.w * Opt a wallet in or out of public leaderboard ranking. * Body: { visible: boolean } */ -router.patch('/:id/leaderboard-visibility', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { +router.patch('/:id/leaderboard-visibility', checkPermission(PERMISSIONS.WALLETS_UPDATE), walletIdSchema, updateLeaderboardVisibilitySchema, payloadSizeLimiter(ENDPOINT_LIMITS.wallet), asyncHandler(async (req, res, next) => { try { const { visible } = req.body || {}; if (typeof visible !== 'boolean') { diff --git a/tests/security/mass-assignment.test.js b/tests/security/mass-assignment.test.js new file mode 100644 index 00000000..aa3d4d69 --- /dev/null +++ b/tests/security/mass-assignment.test.js @@ -0,0 +1,455 @@ +'use strict'; + +/** + * Mass-Assignment Protection Tests + * + * Verifies that protected fields (status, role, apiKeyId, createdAt, verified, publicKey) + * cannot be injected into persisted records through PATCH/PUT endpoints. + * + * Two layers of protection are tested: + * 1. validatePayloadFields (global middleware) — REJECTS unknown fields with 400 for + * routes registered in ROUTE_ALLOWED_FIELDS. + * 2. validateSchema (per-route middleware) — STRIPS unknown fields from req.body before + * the handler sees them, so they are never passed to the database layer. + */ + +const request = require('supertest'); +const express = require('express'); +const { validatePayloadFields } = require('../../src/middleware/validation'); +const { validateSchema } = require('../../src/middleware/schemaValidation'); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Build a minimal Express app that: + * 1. Applies the provided schema middleware (which strips unknown fields). + * 2. Echoes req.body back so tests can inspect what reached the handler. + */ +function buildSchemaApp(method, path, schemaMiddleware) { + const app = express(); + app.use(express.json()); + app[method](path, schemaMiddleware, (req, res) => { + res.json({ success: true, received: req.body }); + }); + return app; +} + +/** + * Build a minimal Express app that applies the global validatePayloadFields + * middleware (which rejects unknown fields with 400 for registered routes). + */ +function buildPayloadFieldApp() { + const app = express(); + app.use(express.json()); + app.use(validatePayloadFields); + + app.patch('/api/v1/wallets/:id', (req, res) => res.json({ success: true })); + app.patch('/api/v1/donations/:id/status', (req, res) => res.json({ success: true })); + app.post('/api/v1/wallets', (req, res) => res.status(201).json({ success: true })); + app.post('/api/v1/donations', (req, res) => res.status(201).json({ success: true })); + app.post('/api/v1/api-keys', (req, res) => res.status(201).json({ success: true })); + + return app; +} + +// ─── Protected field sets ────────────────────────────────────────────────────── + +const PROTECTED_FIELDS = { + status: 'verified', + role: 'admin', + apiKeyId: 'key-999', + createdAt: '2000-01-01T00:00:00.000Z', + verified: true, + publicKey: 'GMALICIOUS0000000000000000000000000000000000000000000000', +}; + +// ─── Layer 1: validatePayloadFields (REJECTION) ──────────────────────────────── + +describe('Mass-Assignment — validatePayloadFields (reject unknown fields)', () => { + let app; + + beforeAll(() => { + app = buildPayloadFieldApp(); + }); + + describe('PATCH /wallets/:id', () => { + const allowed = { label: 'Legit Label', ownerName: 'Alice' }; + + it('accepts a valid wallet update payload', async () => { + const res = await request(app).patch('/api/v1/wallets/1').send(allowed); + expect(res.status).toBe(200); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'rejects payload containing protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/api/v1/wallets/1') + .send({ ...allowed, [field]: value }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('UNKNOWN_FIELDS'); + expect(res.body.error.unknownFields).toContain(field); + } + ); + }); + + describe('PATCH /donations/:id/status', () => { + const allowed = { status: 'confirmed', stellarTxId: 'abc', ledger: 1000, notes: 'ok', tags: [] }; + + it('accepts a valid donation status update payload', async () => { + const res = await request(app).patch('/api/v1/donations/1/status').send(allowed); + expect(res.status).toBe(200); + }); + + it.each([['role', 'admin'], ['apiKeyId', 'k1'], ['createdAt', '2000-01-01'], ['verified', true]])( + 'rejects payload containing protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/api/v1/donations/1/status') + .send({ status: 'confirmed', [field]: value }); + + expect(res.status).toBe(400); + expect(res.body.error.unknownFields).toContain(field); + } + ); + }); + + describe('POST /wallets', () => { + it('rejects payload containing protected field "role"', async () => { + const res = await request(app) + .post('/api/v1/wallets') + .send({ address: 'GXXX', label: 'Test', role: 'admin' }); + + expect(res.status).toBe(400); + expect(res.body.error.unknownFields).toContain('role'); + }); + }); + + describe('POST /api-keys', () => { + it('rejects payload containing protected field "apiKeyId"', async () => { + const res = await request(app) + .post('/api/v1/api-keys') + .send({ name: 'Key', role: 'user', apiKeyId: 'injected' }); + + expect(res.status).toBe(400); + expect(res.body.error.unknownFields).toContain('apiKeyId'); + }); + }); +}); + +// ─── Layer 2: validateSchema (strip unknown fields) ─────────────────────────── + +describe('Mass-Assignment — validateSchema (strip unknown fields)', () => { + + describe('PATCH /wallets/:id — updateWalletSchema', () => { + const updateWalletSchema = validateSchema({ + body: { + fields: { + label: { type: 'string', required: false, nullable: true, maxLength: 100 }, + ownerName: { type: 'string', required: false, nullable: true, maxLength: 200 }, + } + } + }); + const app = buildSchemaApp('patch', '/wallets/:id', updateWalletSchema); + + it('allows declared fields through to the handler', async () => { + const res = await request(app) + .patch('/wallets/1') + .send({ label: 'New Label', ownerName: 'Bob' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ label: 'New Label', ownerName: 'Bob' }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s" before it reaches the handler', + async (field, value) => { + const res = await request(app) + .patch('/wallets/1') + .send({ label: 'New Label', [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + expect(res.body.received.label).toBe('New Label'); + } + ); + }); + + describe('PATCH /wallets/:id/label — updateWalletLabelSchema', () => { + const updateWalletLabelSchema = validateSchema({ + body: { + fields: { + label: { type: 'string', required: false, nullable: true, maxLength: 100 }, + } + } + }); + const app = buildSchemaApp('patch', '/wallets/:id/label', updateWalletLabelSchema); + + it('allows the label field through', async () => { + const res = await request(app).patch('/wallets/1/label').send({ label: 'My Label' }); + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ label: 'My Label' }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/wallets/1/label') + .send({ label: 'Safe', [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + }); + + describe('PATCH /wallets/:id/limits — updateWalletLimitsSchema', () => { + const updateWalletLimitsSchema = validateSchema({ + body: { + fields: { + daily_limit: { type: 'number', required: false, nullable: true }, + monthly_limit: { type: 'number', required: false, nullable: true }, + per_transaction_limit: { type: 'number', required: false, nullable: true }, + } + } + }); + const app = buildSchemaApp('patch', '/wallets/:id/limits', updateWalletLimitsSchema); + + it('allows limit fields through', async () => { + const res = await request(app) + .patch('/wallets/1/limits') + .send({ daily_limit: 100, monthly_limit: 1000 }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ daily_limit: 100, monthly_limit: 1000 }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/wallets/1/limits') + .send({ daily_limit: 50, [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + expect(res.body.received.daily_limit).toBe(50); + } + ); + }); + + describe('PATCH /wallets/:id/leaderboard-visibility — updateLeaderboardVisibilitySchema', () => { + const updateLeaderboardVisibilitySchema = validateSchema({ + body: { + fields: { + visible: { type: 'boolean', required: true }, + } + } + }); + const app = buildSchemaApp('patch', '/wallets/:id/leaderboard-visibility', updateLeaderboardVisibilitySchema); + + it('allows the visible field through', async () => { + const res = await request(app) + .patch('/wallets/1/leaderboard-visibility') + .send({ visible: true }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ visible: true }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/wallets/1/leaderboard-visibility') + .send({ visible: false, [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + }); + + describe('PATCH /stream/schedules/:id — updateScheduleSchema', () => { + const updateScheduleSchema = validateSchema({ + body: { + fields: { + amount: { types: ['number', 'numberString'], required: false }, + frequency: { type: 'string', required: false, enum: ['daily', 'weekly', 'monthly'] }, + } + } + }); + const app = buildSchemaApp('patch', '/stream/schedules/:id', updateScheduleSchema); + + it('allows amount and frequency through', async () => { + const res = await request(app) + .patch('/stream/schedules/1') + .send({ amount: 10, frequency: 'weekly' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ amount: 10, frequency: 'weekly' }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/stream/schedules/1') + .send({ amount: 5, [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + expect(res.body.received.amount).toBe(5); + } + ); + }); + + describe('PATCH /admin/webhooks/:id — updateWebhookStatusSchema', () => { + const updateWebhookStatusSchema = validateSchema({ + body: { + fields: { + status: { type: 'string', required: true, enum: ['active', 'disabled'] }, + } + } + }); + const app = buildSchemaApp('patch', '/admin/webhooks/:id', updateWebhookStatusSchema); + + it('allows the status field through', async () => { + const res = await request(app) + .patch('/admin/webhooks/1') + .send({ status: 'active' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ status: 'active' }); + }); + + it.each([['role', 'admin'], ['apiKeyId', 'k1'], ['createdAt', '2000-01-01'], ['verified', true]])( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/admin/webhooks/1') + .send({ status: 'disabled', [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + + it('rejects an invalid status value', async () => { + const res = await request(app) + .patch('/admin/webhooks/1') + .send({ status: 'superadmin' }); + + expect(res.status).toBe(400); + }); + }); + + describe('PATCH /admin/geo-rules/:id — updateGeoRuleSchema', () => { + const updateGeoRuleSchema = validateSchema({ + body: { + fields: { + active: { type: 'boolean', required: false }, + description: { type: 'string', required: false, nullable: true }, + } + } + }); + const app = buildSchemaApp('patch', '/admin/geo-rules/:id', updateGeoRuleSchema); + + it('allows active and description through', async () => { + const res = await request(app) + .patch('/admin/geo-rules/1') + .send({ active: false, description: 'Updated rule' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ active: false, description: 'Updated rule' }); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/admin/geo-rules/1') + .send({ active: true, [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + }); + + describe('PATCH /admin/pledges/:id/cancel — cancelPledgeSchema', () => { + const cancelPledgeSchema = validateSchema({ + body: { + fields: { + reason: { type: 'string', required: false, nullable: true }, + } + } + }); + const app = buildSchemaApp('patch', '/admin/pledges/:id/cancel', cancelPledgeSchema); + + it('allows an optional reason through', async () => { + const res = await request(app) + .patch('/admin/pledges/1/cancel') + .send({ reason: 'Duplicate pledge' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ reason: 'Duplicate pledge' }); + }); + + it('accepts empty body (reason is optional)', async () => { + const res = await request(app) + .patch('/admin/pledges/1/cancel') + .send({}); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({}); + }); + + it.each(Object.entries(PROTECTED_FIELDS))( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/admin/pledges/1/cancel') + .send({ reason: 'Cancelled', [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + }); + + describe('Corporate matching status — updateStatusSchema', () => { + const updateStatusSchema = validateSchema({ + body: { + fields: { + status: { type: 'string', required: true, enum: ['active', 'paused', 'exhausted'] } + } + } + }); + const app = buildSchemaApp('patch', '/admin/corporate-matching/:id/status', updateStatusSchema); + + it('allows the status field through', async () => { + const res = await request(app) + .patch('/admin/corporate-matching/1/status') + .send({ status: 'paused' }); + + expect(res.status).toBe(200); + expect(res.body.received).toEqual({ status: 'paused' }); + }); + + it.each([['role', 'admin'], ['apiKeyId', 'k1'], ['createdAt', '2000-01-01']])( + 'strips protected field "%s"', + async (field, value) => { + const res = await request(app) + .patch('/admin/corporate-matching/1/status') + .send({ status: 'active', [field]: value }); + + expect(res.status).toBe(200); + expect(res.body.received).not.toHaveProperty(field); + } + ); + }); +});