diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index 3d770e2..239d26c 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -85,6 +85,24 @@ function parseRecipients(recipients, next) { return result.data; } +/** + * Strictly parses a numeric amount string. + * Accepts only strings matching an optional sign, digits, and an optional decimal part. + * Rejects empty strings, comma-formatted numbers ("1,000"), and strings with trailing + * non-numeric content ("100USD", "50 units") — all of which parseFloat would silently + * truncate or misparse. + * + * @param {string} raw - The raw string value from the CSV cell. + * @returns {number|null} The parsed number, or null if the string is invalid. + */ +function strictParseAmount(raw) { + if (typeof raw !== 'string' || raw.trim() === '') return null; + const trimmed = raw.trim(); + // Only allow an optional sign followed by digits with an optional decimal part — nothing else. + if (!/^-?\d+(\.\d+)?$/.test(trimmed)) return null; + return Number(trimmed); +} + async function parseCSV(buffer) { const results = []; let rowCount = 0; @@ -102,10 +120,19 @@ async function parseCSV(buffer) { } const address = data.address || data.Address || data.ADDRESS; - const amount = parseFloat(data.amount || data.Amount || data.AMOUNT); - if (address && !Number.isNaN(amount)) { - results.push({ address, amount }); + const rawAmount = data.amount || data.Amount || data.AMOUNT; + const amount = strictParseAmount(rawAmount); + + if (amount === null) { + throw new AppError( + 'VALIDATION_ERROR', + `recipient ${rowCount}: amount is missing or invalid — got ${JSON.stringify(rawAmount ?? '')}; ` + + 'value must be a plain number (e.g. "100" or "1.5") with no commas or extra characters', + 400 + ); } + + results.push({ address, amount }); } }); diff --git a/test/airdrops.test.js b/test/airdrops.test.js index 9839baf..c7149f9 100644 --- a/test/airdrops.test.js +++ b/test/airdrops.test.js @@ -383,6 +383,88 @@ describe('POST /api/v1/airdrops/:id/recipients', () => { expect(addResponse.body.added).toBe(2); }); + // --- issue #134: strict amount parsing --- + + test('rejects a CSV row with a missing amount field', async () => { + const createResponse = await request(app) + .post('/api/v1/airdrops') + .send({ + name: 'Test Airdrop', + asset: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + total_amount: 100, + expiry_ledger: 123456, + }); + + // Row 1 has an empty amount cell; row 2 is valid — the whole upload must be rejected. + const csvContent = `address,amount\n${validAddress1},\n${validAddress2},50\n`; + const response = await request(app) + .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) + .attach('file', Buffer.from(csvContent), 'recipients.csv'); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + }); + expect(response.body.error.message).toMatch(/recipient 1/); + expect(response.body.error.message).toMatch(/amount is missing or invalid/); + expect(mockRedis.rpush).not.toHaveBeenCalled(); + }); + + test('rejects a CSV row with a comma-formatted amount (e.g. "1,000") rather than silently truncating to 1', async () => { + const createResponse = await request(app) + .post('/api/v1/airdrops') + .send({ + name: 'Test Airdrop', + asset: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + total_amount: 1000, + expiry_ledger: 123456, + }); + + // A spreadsheet-exported CSV that uses thousands-separator commas — was silently becoming 1. + const csvContent = `address,amount\n${validAddress1},"1,000"\n`; + const response = await request(app) + .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) + .attach('file', Buffer.from(csvContent), 'recipients.csv'); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + }); + expect(response.body.error.message).toMatch(/recipient 1/); + expect(response.body.error.message).toMatch(/amount is missing or invalid/); + expect(mockRedis.rpush).not.toHaveBeenCalled(); + }); + + test('rejects a CSV row with trailing non-numeric garbage in the amount (e.g. "100USD") rather than silently truncating to 100', async () => { + const createResponse = await request(app) + .post('/api/v1/airdrops') + .send({ + name: 'Test Airdrop', + asset: 'USDC', + asset_issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335AX2OBFLDTQLNUEHRGPTM6RIA', + total_amount: 100, + expiry_ledger: 123456, + }); + + // e.g. "100USD", "50 units" — was silently becoming 100 / 50 via parseFloat. + const csvContent = `address,amount\n${validAddress1},100USD\n`; + const response = await request(app) + .post(`/api/v1/airdrops/${createResponse.body.id}/recipients`) + .attach('file', Buffer.from(csvContent), 'recipients.csv'); + + expect(response.status).toBe(400); + expect(response.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + }); + expect(response.body.error.message).toMatch(/recipient 1/); + expect(response.body.error.message).toMatch(/amount is missing or invalid/); + expect(mockRedis.rpush).not.toHaveBeenCalled(); + }); + + // --- end issue #134 --- + test('rejects a CSV larger than the configured upload limit', async () => { const createResponse = await request(app) .post('/api/v1/airdrops')