test: add poll-responses, NIP-98, and bot-auth test suites
- poll-responses.test.ts: 10 tests covering lifecycle, timeout, duplicate rejection - nip98.test.ts: 7 tests covering valid token, expiry, method, signature, tags - bot-auth.test.ts: 5 tests covering header auth, query params, invalid credentials Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a49cc124fe
commit
21f9650585
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user