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); + } + ); + }); +});