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
33 changes: 30 additions & 3 deletions src/routes/airdrops.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 });
}
});

Expand Down
82 changes: 82 additions & 0 deletions test/airdrops.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down