Add 3 tests for cleanupOrphanedFights: verifies db.update sets status='cancelled' with endedAt on stale live fights, returns 0 on success, and propagates DB errors correctly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
209 lines
7.2 KiB
TypeScript
209 lines
7.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock DB
|
|
vi.mock('../db/index.js', () => ({
|
|
db: {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockResolvedValue([]),
|
|
}),
|
|
}),
|
|
}),
|
|
insert: vi.fn().mockReturnValue({
|
|
values: vi.fn().mockReturnValue({ run: vi.fn() }),
|
|
}),
|
|
update: vi.fn().mockReturnValue({
|
|
set: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({ run: vi.fn() }),
|
|
}),
|
|
}),
|
|
},
|
|
schema: {
|
|
bots: { id: 'id', name: 'name', webhookUrl: 'webhookUrl', eloRating: 'eloRating', isActive: 'isActive', publicKey: 'publicKey', secretHash: 'secretHash' },
|
|
fights: { id: 'id', status: 'status', startedAt: 'startedAt' },
|
|
rounds: {},
|
|
},
|
|
sqlite: { transaction: vi.fn((fn: any) => fn()) },
|
|
}))
|
|
|
|
// Mock external modules
|
|
vi.mock('../engine/betting.js', () => ({
|
|
lockBets: vi.fn(),
|
|
settleBets: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../engine/payments.js', () => ({
|
|
payWinner: vi.fn(),
|
|
refundEntry: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../engine/nostr-publish.js', () => ({
|
|
publishFightResult: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('../engine/queue.js', () => ({
|
|
setCooldown: vi.fn(),
|
|
}))
|
|
|
|
const {
|
|
isInFight,
|
|
getActiveFightId,
|
|
getActiveFighterCount,
|
|
isMockBot,
|
|
isAllowedWebhookUrl,
|
|
cleanupOrphanedFights,
|
|
} = await import('./orchestrator.js')
|
|
|
|
describe('orchestrator utility functions', () => {
|
|
it('isInFight returns false for unknown bot', () => {
|
|
expect(isInFight('unknown-bot-xyz')).toBe(false)
|
|
})
|
|
|
|
it('getActiveFightId returns undefined for unknown bot', () => {
|
|
expect(getActiveFightId('unknown-bot-xyz')).toBeUndefined()
|
|
})
|
|
|
|
it('getActiveFighterCount returns a number', () => {
|
|
expect(typeof getActiveFighterCount()).toBe('number')
|
|
})
|
|
|
|
it('isMockBot identifies mock webhook URLs', () => {
|
|
expect(isMockBot('http://mock.local/bot-1')).toBe(true)
|
|
expect(isMockBot('http://mock.local')).toBe(true)
|
|
expect(isMockBot('https://example.com/webhook')).toBe(false)
|
|
expect(isMockBot('http://human.local/')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('cleanupOrphanedFights', () => {
|
|
it('calls db.update to cancel stale live fights', async () => {
|
|
const { db } = await import('../db/index.js')
|
|
const mockWhere = vi.fn().mockResolvedValue(undefined)
|
|
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
|
|
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet })
|
|
|
|
await cleanupOrphanedFights()
|
|
|
|
// Verify db.update was called
|
|
expect(db.update).toHaveBeenCalled()
|
|
// Verify set was called with cancelled status and endedAt
|
|
expect(mockSet).toHaveBeenCalledWith(
|
|
expect.objectContaining({ status: 'cancelled', endedAt: expect.any(String) }),
|
|
)
|
|
// Verify where clause was applied (filters live + old)
|
|
expect(mockWhere).toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns 0 (placeholder) on success', async () => {
|
|
const { db } = await import('../db/index.js')
|
|
const mockWhere = vi.fn().mockResolvedValue(undefined)
|
|
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
|
|
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet })
|
|
|
|
const result = await cleanupOrphanedFights()
|
|
expect(result).toBe(0)
|
|
})
|
|
|
|
it('throws if db.update fails', async () => {
|
|
const { db } = await import('../db/index.js')
|
|
const mockWhere = vi.fn().mockRejectedValue(new Error('DB locked'));
|
|
const mockSet = vi.fn().mockReturnValue({ where: mockWhere });
|
|
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({ set: mockSet })
|
|
|
|
await expect(cleanupOrphanedFights()).rejects.toThrow('DB locked')
|
|
})
|
|
})
|
|
|
|
describe('isAllowedWebhookUrl — SSRF protection', () => {
|
|
it('blocks localhost variants', () => {
|
|
expect(isAllowedWebhookUrl('http://localhost/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://127.0.0.1/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://127.0.0.2/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://0.0.0.0/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks private IPv4 ranges', () => {
|
|
// 10.x.x.x
|
|
expect(isAllowedWebhookUrl('http://10.0.0.1/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://10.255.255.255/webhook')).toBe(false)
|
|
// 192.168.x.x
|
|
expect(isAllowedWebhookUrl('http://192.168.1.1/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://192.168.0.1/webhook')).toBe(false)
|
|
// 172.16-31.x.x
|
|
expect(isAllowedWebhookUrl('http://172.16.0.1/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://172.31.255.255/webhook')).toBe(false)
|
|
// 172.15 and 172.32 should be ALLOWED (outside private range)
|
|
expect(isAllowedWebhookUrl('http://172.15.0.1/webhook')).toBe(true)
|
|
expect(isAllowedWebhookUrl('http://172.32.0.1/webhook')).toBe(true)
|
|
})
|
|
|
|
it('blocks IPv6 loopback ::1', () => {
|
|
expect(isAllowedWebhookUrl('http://[::1]/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks IPv6 all-zeros [::]', () => {
|
|
expect(isAllowedWebhookUrl('http://[::]/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks IPv6 link-local fe80::', () => {
|
|
expect(isAllowedWebhookUrl('http://[fe80::1]/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://[fe80::abcd:1234]/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks IPv6-mapped IPv4 localhost', () => {
|
|
expect(isAllowedWebhookUrl('http://[::ffff:127.0.0.1]/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks IPv6 private ranges fc/fd', () => {
|
|
expect(isAllowedWebhookUrl('http://[fc00::1]/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://[fd00::1]/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks .local, .internal, .localhost TLDs', () => {
|
|
expect(isAllowedWebhookUrl('http://myapp.local/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://service.internal/webhook')).toBe(false)
|
|
expect(isAllowedWebhookUrl('http://evil.localhost/webhook')).toBe(false)
|
|
})
|
|
|
|
it('blocks file:// scheme', () => {
|
|
expect(isAllowedWebhookUrl('file:///etc/passwd')).toBe(false)
|
|
})
|
|
|
|
it('blocks gopher:// scheme', () => {
|
|
expect(isAllowedWebhookUrl('gopher://evil.com/')).toBe(false)
|
|
})
|
|
|
|
it('blocks data: scheme', () => {
|
|
expect(isAllowedWebhookUrl('data:text/html,<h1>hi</h1>')).toBe(false)
|
|
})
|
|
|
|
it('blocks link-local metadata IP', () => {
|
|
expect(isAllowedWebhookUrl('http://169.254.169.254/latest/meta-data')).toBe(false)
|
|
})
|
|
|
|
it('blocks octal/decimal IP bypass (URL parser normalizes)', () => {
|
|
// Node URL parser normalizes 0177.0.0.1 to 127.0.0.1
|
|
expect(isAllowedWebhookUrl('http://0177.0.0.1/')).toBe(false)
|
|
// Decimal IP for 127.0.0.1
|
|
expect(isAllowedWebhookUrl('http://2130706433/')).toBe(false)
|
|
})
|
|
|
|
it('blocks invalid/empty URLs', () => {
|
|
expect(isAllowedWebhookUrl('')).toBe(false)
|
|
expect(isAllowedWebhookUrl('not-a-url')).toBe(false)
|
|
expect(isAllowedWebhookUrl('javascript:alert(1)')).toBe(false)
|
|
})
|
|
|
|
it('blocks overly long URLs (>2048 chars)', () => {
|
|
expect(isAllowedWebhookUrl('https://example.com/' + 'a'.repeat(2100))).toBe(false)
|
|
})
|
|
|
|
it('allows valid public URLs', () => {
|
|
expect(isAllowedWebhookUrl('https://example.com/webhook')).toBe(true)
|
|
expect(isAllowedWebhookUrl('https://api.mybot.dev/fight')).toBe(true)
|
|
expect(isAllowedWebhookUrl('http://bot.ngrok.io/challenge')).toBe(true)
|
|
expect(isAllowedWebhookUrl('https://1.2.3.4/webhook')).toBe(true)
|
|
})
|
|
})
|