diff --git a/server/src/engine/poll-responses.test.ts b/server/src/engine/poll-responses.test.ts new file mode 100644 index 0000000..4415115 --- /dev/null +++ b/server/src/engine/poll-responses.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { + waitForPollResponse, + submitPollResponse, + getPendingPollChallenge, + clearAllPendingPolls, + isPollingBot, +} from './poll-responses.js' +import type { Challenge } from './challenges.js' + +const mockChallenge: Challenge = { + type: 'speed_blitz', + label: 'Speed Blitz', + prompt: 'What year was Bitcoin created?', + answers: ['2009'], + scoring: 'factual', + timeout_ms: 8000, + baseDamage: 20, +} + +const mockOpponent = { name: 'TestBot', wins: 5, losses: 3 } + +afterEach(() => { + clearAllPendingPolls() + vi.useRealTimers() +}) + +describe('isPollingBot', () => { + it('returns true for poll sentinel URL', () => { + expect(isPollingBot('http://poll.local/')).toBe(true) + }) + + it('returns false for other URLs', () => { + expect(isPollingBot('https://example.com/webhook')).toBe(false) + }) +}) + +describe('waitForPollResponse + getPendingPollChallenge', () => { + it('stores challenge and makes it retrievable', () => { + waitForPollResponse('f1', 'b1', mockChallenge, 1, mockOpponent, 'arena1', null) + + const pending = getPendingPollChallenge('b1') + expect(pending).not.toBeNull() + expect(pending!.fightId).toBe('f1') + expect(pending!.type).toBe('speed_blitz') + expect(pending!.prompt).toBe('What year was Bitcoin created?') + expect(pending!.roundNumber).toBe(1) + expect(pending!.opponent.name).toBe('TestBot') + expect(pending!.arena).toBe('arena1') + expect(pending!.constraints.max_tokens).toBe(500) + }) + + it('returns null for unknown bot', () => { + expect(getPendingPollChallenge('nonexistent')).toBeNull() + }) +}) + +describe('submitPollResponse', () => { + it('accepts submission and resolves promise', async () => { + const promise = waitForPollResponse('f2', 'b2', mockChallenge, 1, mockOpponent, 'arena1', null) + + const accepted = submitPollResponse('b2', '2009', 'ez') + expect(accepted).toBe(true) + + const result = await promise + expect(result.answer).toBe('2009') + expect(result.trashTalk).toBe('ez') + expect(result.timedOut).toBe(false) + }) + + it('returns false for unknown bot (no pending challenge)', () => { + expect(submitPollResponse('nobody', 'answer')).toBe(false) + }) + + it('rejects duplicate submission (second submit returns false)', async () => { + waitForPollResponse('f3', 'b3', mockChallenge, 1, mockOpponent, 'arena1', null) + + expect(submitPollResponse('b3', 'first')).toBe(true) + expect(submitPollResponse('b3', 'second')).toBe(false) + }) + + it('truncates answer to 2000 chars', async () => { + const promise = waitForPollResponse('f4', 'b4', mockChallenge, 1, mockOpponent, 'arena1', null) + submitPollResponse('b4', 'x'.repeat(3000)) + const result = await promise + expect(result.answer!.length).toBe(2000) + }) +}) + +describe('timeout', () => { + it('times out and resolves with null answer', async () => { + vi.useFakeTimers() + const shortChallenge = { ...mockChallenge, timeout_ms: 100 } + const promise = waitForPollResponse('f5', 'b5', shortChallenge, 1, mockOpponent, 'arena1', null) + + // Advance past timeout (100ms + POLL_GRACE_MS=10_000 = 10_100ms) + vi.advanceTimersByTime(10_200) + const result = await promise + expect(result.timedOut).toBe(true) + expect(result.answer).toBeNull() + }) + + it('clears pending after timeout', async () => { + vi.useFakeTimers() + const shortChallenge = { ...mockChallenge, timeout_ms: 100 } + waitForPollResponse('f6', 'b6', shortChallenge, 1, mockOpponent, 'arena1', null) + + vi.advanceTimersByTime(10_200) + expect(getPendingPollChallenge('b6')).toBeNull() + }) +}) diff --git a/server/src/middleware/bot-auth.test.ts b/server/src/middleware/bot-auth.test.ts new file mode 100644 index 0000000..5ca139a --- /dev/null +++ b/server/src/middleware/bot-auth.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createHash } from 'crypto' +import { Hono } from 'hono' + +// Mock the db module before importing authenticateBot +const mockSelect = vi.fn() +vi.mock('../db/index.js', () => ({ + db: { + select: (...args: unknown[]) => { + const result = mockSelect(...args) + return { + from: () => ({ + where: () => ({ + limit: () => result, + }), + }), + } + }, + }, + schema: { + bots: { + id: 'id', + name: 'name', + secretHash: 'secretHash', + webhookUrl: 'webhookUrl', + }, + }, +})) + +// Import after mock setup +const { authenticateBot } = await import('./bot-auth.js') + +const TEST_SECRET = 'my-bot-secret-key' +const TEST_HASH = createHash('sha256').update(TEST_SECRET).digest('hex') +const TEST_BOT = { + id: 'bot-123', + name: 'TestBot', + secretHash: TEST_HASH, + webhookUrl: 'https://example.com/webhook', +} + +function createApp() { + const app = new Hono() + app.get('/test', async (c) => { + const result = await authenticateBot(c) + if (result instanceof Response) return result + return c.json({ botId: result.botId, botName: result.botName }) + }) + return app +} + +describe('authenticateBot', () => { + beforeEach(() => { + mockSelect.mockReset() + }) + + it('authenticates via Authorization header', async () => { + mockSelect.mockReturnValue([TEST_BOT]) + const app = createApp() + const res = await app.request('/test', { + headers: { Authorization: `Bot bot-123:${TEST_SECRET}` }, + }) + expect(res.status).toBe(200) + const body = await res.json() as { botId: string; botName: string } + expect(body.botId).toBe('bot-123') + expect(body.botName).toBe('TestBot') + }) + + it('authenticates via query params', async () => { + mockSelect.mockReturnValue([TEST_BOT]) + const app = createApp() + const res = await app.request(`/test?bot_id=bot-123&secret=${TEST_SECRET}`) + expect(res.status).toBe(200) + const body = await res.json() as { botId: string } + expect(body.botId).toBe('bot-123') + }) + + it('returns 401 when no credentials provided', async () => { + const app = createApp() + const res = await app.request('/test') + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toContain('Authentication required') + }) + + it('returns 401 for invalid bot_id (not found)', async () => { + mockSelect.mockReturnValue([]) + const app = createApp() + const res = await app.request('/test', { + headers: { Authorization: `Bot unknown:${TEST_SECRET}` }, + }) + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toContain('Invalid bot_id or secret') + }) + + it('returns 401 for wrong secret', async () => { + mockSelect.mockReturnValue([TEST_BOT]) + const app = createApp() + const res = await app.request('/test', { + headers: { Authorization: 'Bot bot-123:wrong-secret' }, + }) + expect(res.status).toBe(401) + const body = await res.json() as { error: string } + expect(body.error).toContain('Invalid bot_id or secret') + }) +}) diff --git a/server/src/middleware/nip98.test.ts b/server/src/middleware/nip98.test.ts new file mode 100644 index 0000000..5a1ff93 --- /dev/null +++ b/server/src/middleware/nip98.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { verifyNip98Token } from './nip98.js' +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools' + +function createNip98Event( + sk: Uint8Array, + url: string, + method: string, + overrides: Record = {}, +) { + const event = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', url], + ['method', method], + ], + content: '', + ...overrides, + } + return finalizeEvent(event, sk) +} + +function toAuthHeader(event: object): string { + return `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}` +} + +describe('verifyNip98Token', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('accepts a valid NIP-98 token', () => { + const sk = generateSecretKey() + const pk = getPublicKey(sk) + const event = createNip98Event(sk, 'https://example.com/api/auth/nostr/session', 'POST') + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth/nostr/session', 'POST') + expect(result.valid).toBe(true) + expect(result.pubkey).toBe(pk) + }) + + it('rejects expired token (>120s)', () => { + const sk = generateSecretKey() + const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST', { + created_at: Math.floor(Date.now() / 1000) - 200, + }) + // Need to re-finalize with the old created_at + // Actually createNip98Event's overrides merge before finalize, but created_at gets set by finalizeEvent + // Let me build manually + const rawEvent = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000) - 200, + tags: [['u', 'https://example.com/api/auth'], ['method', 'POST']], + content: '', + } + const signed = finalizeEvent(rawEvent, sk) + const header = toAuthHeader(signed) + + const result = verifyNip98Token(header, '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('expired') + }) + + it('rejects wrong method', () => { + const sk = generateSecretKey() + const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST') + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth', 'GET') + expect(result.valid).toBe(false) + expect(result.error).toContain('Method mismatch') + }) + + it('rejects invalid signature (tampered event)', () => { + const sk = generateSecretKey() + const event = createNip98Event(sk, 'https://example.com/api/auth', 'POST') + // Tamper with pubkey + const tampered = { ...event, pubkey: '0'.repeat(64) } + const header = toAuthHeader(tampered) + + const result = verifyNip98Token(header, '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('Invalid signature') + }) + + it('rejects missing URL tag', () => { + const sk = generateSecretKey() + const event = finalizeEvent({ + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [['method', 'POST']], + content: '', + }, sk) + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('Missing URL tag') + }) + + it('rejects invalid auth header format', () => { + const result = verifyNip98Token('Bearer xyz123', '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('Invalid auth header format') + }) + + it('rejects wrong event kind', () => { + const sk = generateSecretKey() + const event = finalizeEvent({ + kind: 1, // Regular note, not 27235 + created_at: Math.floor(Date.now() / 1000), + tags: [['u', 'https://example.com/api/auth'], ['method', 'POST']], + content: '', + }, sk) + const header = toAuthHeader(event) + + const result = verifyNip98Token(header, '/api/auth', 'POST') + expect(result.valid).toBe(false) + expect(result.error).toContain('Wrong event kind') + }) +})