From e6c38944437dba8ddaab5328ae6ce4996827b1b3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Mar 2026 23:48:12 +0000 Subject: [PATCH] =?UTF-8?q?test:=20add=20orchestrator=20test=20suite=20?= =?UTF-8?q?=E2=80=94=20utility=20functions=20and=20SSRF=20protection=20(9?= =?UTF-8?q?=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- server/src/engine/orchestrator.test.ts | 105 +++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 server/src/engine/orchestrator.test.ts diff --git a/server/src/engine/orchestrator.test.ts b/server/src/engine/orchestrator.test.ts new file mode 100644 index 0000000..59c47e5 --- /dev/null +++ b/server/src/engine/orchestrator.test.ts @@ -0,0 +1,105 @@ +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, +} = 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('isAllowedWebhookUrl — SSRF protection', () => { + it('blocks localhost', () => { + expect(isAllowedWebhookUrl('http://localhost/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://127.0.0.1/webhook')).toBe(false) + }) + + it('blocks private IP ranges', () => { + expect(isAllowedWebhookUrl('http://10.0.0.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://192.168.1.1/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://172.16.0.1/webhook')).toBe(false) + }) + + it('blocks .local and .internal TLDs', () => { + expect(isAllowedWebhookUrl('http://myapp.local/webhook')).toBe(false) + expect(isAllowedWebhookUrl('http://service.internal/webhook')).toBe(false) + }) + + it('allows public URLs', () => { + expect(isAllowedWebhookUrl('https://example.com/webhook')).toBe(true) + expect(isAllowedWebhookUrl('https://api.mybot.dev/fight')).toBe(true) + }) + + it('blocks file:// and other schemes', () => { + expect(isAllowedWebhookUrl('file:///etc/passwd')).toBe(false) + }) + + // Note: IPv6 loopback (::1) is not currently blocked — tracked for Phase 5 security hardening +})