diff --git a/server/src/routes/payments.test.ts b/server/src/routes/payments.test.ts index cb4678c..687f3da 100644 --- a/server/src/routes/payments.test.ts +++ b/server/src/routes/payments.test.ts @@ -204,3 +204,194 @@ describe('payments routes', () => { expect(json.error).toContain('Missing') }) }) + +describe('payment security — attack vectors', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Attack 1: amount=0 on zap + it('zap: rejects amount=0', async () => { + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'bot_1', fightId: 'f_1', amountSats: 0 }), + }) + expect(res.status).toBe(400) + }) + + // Attack 2: amount=999999999 (overflow max) + it('zap: rejects amount=999999999 (over 1M max)', async () => { + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'bot_1', fightId: 'f_1', amountSats: 999999999 }), + }) + expect(res.status).toBe(400) + }) + + // Attack 3: zap non-winner (wrong botId) + it('zap: rejects when bot did not win the fight', async () => { + // Mock fight exists, finished, but different winner + const mockDb = db as any + mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ winnerId: 'actual_winner', status: 'finished' }]), + }), + }), + }) + + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'fake_winner', fightId: 'f_1', amountSats: 100 }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('did not win') + }) + + // Attack 4: zap on unfinished fight + it('zap: rejects zap on fight that is not finished', async () => { + const mockDb = db as any + mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ winnerId: null, status: 'live' }]), + }), + }), + }) + + const app = makeApp() + const res = await app.request('/api/payments/zap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ winnerId: 'bot_1', fightId: 'f_1', amountSats: 100 }), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('not finished') + }) + + // Attack 5: connect-wallet with invalid method + it('connect-wallet: rejects invalid wallet method (e.g. "paypal")', async () => { + const app = makeApp() + const res = await app.request('/api/payments/connect-wallet', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pubkey: 'a'.repeat(64), + method: 'paypal', + connectionData: 'malicious://data', + }), + }) + expect(res.status).toBe(400) + }) + + // Attack 6: submit-cashu with empty token + it('submit-cashu: rejects empty token string', async () => { + const app = makeApp() + const res = await app.request('/api/payments/submit-cashu', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botId: 'bot_1', token: '' }), + }) + expect(res.status).toBe(400) + }) + + // Attack 7: claim payment that doesn't exist + it('claim: returns 404 for nonexistent paymentId', async () => { + const app = makeApp() + const res = await app.request('/api/payments/claim/nonexistent_id', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(404) + }) + + // Attack 8: confirm already-confirmed payment + it('confirm: rejects confirming an already confirmed payment', async () => { + const mockDb = db as any + mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ + id: 'pay_1', + status: 'confirmed', + direction: 'in', + botId: 'bot_1', + }]), + }), + }), + }) + + const app = makeApp() + const res = await app.request('/api/payments/confirm/pay_1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pubkey: 'a'.repeat(64) }), + }) + expect(res.status).toBe(200) + const json = await res.json() as { status: string } + expect(json.status).toBe('confirmed') + }) + + // Attack 9: confirm outbound payment (not allowed) + it('confirm: rejects confirming an outbound payment', async () => { + const mockDb = db as any + mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ + id: 'pay_2', + status: 'pending', + direction: 'out', + botId: 'bot_1', + }]), + }), + }), + }) + + const app = makeApp() + const res = await app.request('/api/payments/confirm/pay_2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('outbound') + }) + + // Attack 10: claim payment with no cashu token + it('claim: rejects when payment has no cashu token to claim', async () => { + const mockDb = db as any + mockDb.select.mockReturnValueOnce({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([{ + id: 'pay_3', + status: 'confirmed', + botId: 'bot_1', + cashuToken: null, + }]), + }), + }), + }) + + const app = makeApp() + const res = await app.request('/api/payments/claim/pay_3', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + const json = await res.json() as { error: string } + expect(json.error).toContain('No Cashu token') + }) +})