test: pentest payment edge cases with 10 attack vector tests

Cover amount=0, over-max (999999999), zap non-winner, zap unfinished
fight, invalid wallet method, empty cashu token, claim nonexistent
payment, confirm already-confirmed, confirm outbound payment, claim
payment with no token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-13 09:32:19 +00:00
co-authored by Claude Opus 4.6
parent 2fea2bf8c9
commit c5744f5984
+191
View File
@@ -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')
})
})