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:
co-authored by
Claude Opus 4.6
parent
2fea2bf8c9
commit
c5744f5984
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user