Files
botfights/server/src/engine/answers-edge.test.ts
T
DorianandClaude Opus 4.6 9b0d251d1c fix: sanitize error responses to prevent internal detail leakage
Add sanitizeError() helper that strips file paths, stack traces, SQLite
errors, and system errors from messages before returning them to clients.
Applied to all route-level catch blocks in payments, queue, fights, and
admin routes. Includes 12 tests for the sanitizer and static analysis
test verifying no route files leak raw err.message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:46:38 +00:00

185 lines
8.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { checkAnswer } from './answers.js'
describe('checkAnswer edge cases', () => {
it('unicode answers — exact accented match works', () => {
expect(checkAnswer('café', ['café'])).toBeGreaterThanOrEqual(0.9)
})
// FINDING: accent-stripped matching NOT supported — "cafe" != "café"
it('unicode — accent stripping not supported (known limitation)', () => {
expect(checkAnswer('cafe', ['café'])).toBe(0)
})
it('special characters in answers', () => {
expect(checkAnswer('C++', ['C++'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('c++', ['C++'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('AT&T', ['AT&T'])).toBeGreaterThanOrEqual(0.9)
})
it('numeric word forms vs digits', () => {
expect(checkAnswer('42', ['forty-two'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('forty-two', ['42'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('twenty one', ['21'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('21', ['twenty-one'])).toBeGreaterThanOrEqual(0.8)
})
it('extra whitespace handling', () => {
expect(checkAnswer(' Bitcoin ', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('Bitcoin\t\n', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer(' ', ['Bitcoin'])).toBe(0)
})
it('substring of correct answer — reverse containment', () => {
// FINDING: "Bit" (3 chars) matches "Bitcoin" via reverse containment (answer contains response)
// This is a known false positive for short substrings at >= 3 chars
expect(checkAnswer('Bit', ['Bitcoin'])).toBe(0.8)
expect(checkAnswer('Bitcoin', ['Bitcoin Core'])).toBeGreaterThanOrEqual(0.8)
})
it('answer containing correct plus extra text', () => {
expect(checkAnswer('The answer is Bitcoin obviously', ['Bitcoin'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('I think it is 42 satoshis', ['42'])).toBeGreaterThanOrEqual(0.8)
})
it('empty and null inputs', () => {
expect(checkAnswer(null, ['Bitcoin'])).toBe(0)
expect(checkAnswer('', ['Bitcoin'])).toBe(0)
expect(checkAnswer('Bitcoin', [])).toBe(0)
})
it('case variations', () => {
expect(checkAnswer('BITCOIN', ['bitcoin'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('bItCoIn', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9)
})
it('plural/singular matching', () => {
expect(checkAnswer('blocks', ['block'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('node', ['nodes'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer('halving', ['halvings'])).toBeGreaterThanOrEqual(0.8)
})
it('contraction matching', () => {
expect(checkAnswer("can't", ['cannot'])).toBeGreaterThanOrEqual(0.8)
expect(checkAnswer("don't", ['do not'])).toBeGreaterThanOrEqual(0.8)
})
it('multiple valid answers — returns max confidence across all', () => {
// FINDING: checkAnswer iterates answers in order, returns first match
// "Satoshi" matches "Satoshi Nakamoto" first via reverse containment (0.8)
// rather than exact-matching "Satoshi" (1.0). Returns first, not best.
const score = checkAnswer('Satoshi', ['Satoshi Nakamoto', 'Satoshi'])
expect(score).toBeGreaterThanOrEqual(0.8) // gets 0.8 from first match
expect(checkAnswer('Nakamoto', ['Satoshi Nakamoto', 'Satoshi'])).toBeGreaterThanOrEqual(0.8)
})
it('adversarial input — very long string', () => {
const longStr = 'a'.repeat(10000) + ' Bitcoin ' + 'b'.repeat(10000)
// Should still find the answer without catastrophic backtracking
const start = performance.now()
checkAnswer(longStr, ['Bitcoin'])
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(50) // should be very fast
})
it('true/false matching', () => {
expect(checkAnswer('true', ['True'])).toBeGreaterThanOrEqual(0.9)
// FINDING: "yes" matches "True" because true/false keyword check is broad
// Treats yes/true as equivalent. Not necessarily wrong for trivia.
expect(checkAnswer('yes', ['True'])).toBe(0.9)
expect(checkAnswer('false', ['False'])).toBeGreaterThanOrEqual(0.9)
})
it('boolean-like answers', () => {
expect(checkAnswer('True', ['true'])).toBeGreaterThanOrEqual(0.9)
expect(checkAnswer('FALSE', ['false'])).toBeGreaterThanOrEqual(0.9)
})
})
describe('checkAnswer adversarial profiling — target <1ms per check', () => {
const TARGET_MS = 1
it('2000-char response', () => {
const longAnswer = 'x'.repeat(2000)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin', '42', 'Satoshi Nakamoto'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('2000-char response containing the answer buried deep', () => {
const longAnswer = 'z'.repeat(1900) + ' Bitcoin ' + 'z'.repeat(91)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('regex metacharacters in response — no backtracking', () => {
const regexBomb = '(a+)+$'.repeat(200) + '.*?.*?.*?' + '['.repeat(100)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(regexBomb, ['42', 'Bitcoin'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('regex metacharacters in accepted answer — escaped safely', () => {
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer('42', ['(a+)+$', '.*+', '[test]'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('unicode heavy response — CJK, emoji, combining chars', () => {
const unicode = '比特币₿🚀'.repeat(300) + ' Bitcoin ' + '漢字'.repeat(100)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(unicode, ['Bitcoin', '比特币'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('unicode combining characters and diacritics', () => {
// Zalgo text: base char + many combining marks
const zalgo = 'B' + '\u0300\u0301\u0302\u0303\u0304'.repeat(50) + 'itcoin'
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(zalgo, ['Bitcoin'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('many accepted answers (20) with long response', () => {
const answers = Array.from({ length: 20 }, (_, i) => `answer_variant_${i}_satoshi`)
const response = 'a'.repeat(1000) + ' answer_variant_19_satoshi ' + 'b'.repeat(1000)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(response, answers)
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('pathological whitespace — tabs, newlines, mixed', () => {
const ws = '\t\n\r '.repeat(500) + 'Bitcoin' + ' \t\n'.repeat(500)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(ws, ['Bitcoin'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('numeric answer in huge response — boundary regex safe', () => {
// Tests the dynamic RegExp(numStr) path with number buried in text
const response = 'word '.repeat(400) + '21000000' + ' word'.repeat(400)
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(response, ['21000000'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
it('contraction-heavy 2000-char response', () => {
const contractions = "can't don't won't doesn't isn't aren't wasn't weren't hasn't haven't hadn't couldn't shouldn't wouldn't it's that's they're we're you're "
const response = contractions.repeat(15) // ~2000 chars
const start = performance.now()
for (let i = 0; i < 100; i++) checkAnswer(response, ['cannot', 'will not', 'they are'])
const avg = (performance.now() - start) / 100
expect(avg).toBeLessThan(TARGET_MS)
})
})