2026-03-13 09:16:04 +00:00
|
|
|
import { describe, it, expect } from 'vitest'
|
|
|
|
|
import {
|
|
|
|
|
pubkeySchema,
|
|
|
|
|
botNameSchema,
|
|
|
|
|
satsSchema,
|
|
|
|
|
idSchema,
|
|
|
|
|
httpUrlSchema,
|
|
|
|
|
loginSchema,
|
|
|
|
|
registerSchema,
|
|
|
|
|
registerHumanSchema,
|
|
|
|
|
updateBotSchema,
|
|
|
|
|
respondSchema,
|
|
|
|
|
reactSchema,
|
|
|
|
|
placeBetSchema,
|
|
|
|
|
depositSchema,
|
|
|
|
|
withdrawSchema,
|
|
|
|
|
connectWalletSchema,
|
|
|
|
|
createInvoiceSchema,
|
|
|
|
|
submitCashuSchema,
|
|
|
|
|
zapSchema,
|
|
|
|
|
disconnectWalletSchema,
|
|
|
|
|
createTournamentSchema,
|
|
|
|
|
joinTournamentSchema,
|
|
|
|
|
startTournamentSchema,
|
|
|
|
|
joinRankedSchema,
|
|
|
|
|
testWebhookSchema,
|
2026-03-13 09:46:38 +00:00
|
|
|
sanitizeError,
|
2026-03-13 09:16:04 +00:00
|
|
|
} from './validators.js'
|
|
|
|
|
|
|
|
|
|
// --- Primitive schemas ---
|
|
|
|
|
|
|
|
|
|
describe('pubkeySchema', () => {
|
|
|
|
|
const valid = 'a'.repeat(64)
|
|
|
|
|
it('accepts valid 64-char hex', () => { expect(pubkeySchema.safeParse(valid).success).toBe(true) })
|
|
|
|
|
it('rejects too short', () => { expect(pubkeySchema.safeParse('abc').success).toBe(false) })
|
|
|
|
|
it('rejects too long', () => { expect(pubkeySchema.safeParse('a'.repeat(65)).success).toBe(false) })
|
|
|
|
|
it('rejects non-hex', () => { expect(pubkeySchema.safeParse('g'.repeat(64)).success).toBe(false) })
|
|
|
|
|
it('rejects uppercase', () => { expect(pubkeySchema.safeParse('A'.repeat(64)).success).toBe(false) })
|
|
|
|
|
it('rejects empty', () => { expect(pubkeySchema.safeParse('').success).toBe(false) })
|
|
|
|
|
it('rejects number', () => { expect(pubkeySchema.safeParse(12345).success).toBe(false) })
|
|
|
|
|
it('rejects null', () => { expect(pubkeySchema.safeParse(null).success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('botNameSchema', () => {
|
|
|
|
|
it('accepts valid names', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('ab').success).toBe(true)
|
|
|
|
|
expect(botNameSchema.safeParse('test-bot_1').success).toBe(true)
|
|
|
|
|
expect(botNameSchema.safeParse('ABCDEFGHIJKL').success).toBe(true) // 12 chars
|
|
|
|
|
})
|
|
|
|
|
it('rejects too short', () => { expect(botNameSchema.safeParse('a').success).toBe(false) })
|
|
|
|
|
it('rejects too long', () => { expect(botNameSchema.safeParse('a'.repeat(13)).success).toBe(false) })
|
|
|
|
|
it('rejects special chars', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('bot name').success).toBe(false) // spaces
|
|
|
|
|
expect(botNameSchema.safeParse('bot@name').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('<script>').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects empty', () => { expect(botNameSchema.safeParse('').success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('satsSchema', () => {
|
|
|
|
|
it('accepts valid amounts', () => {
|
|
|
|
|
expect(satsSchema.safeParse(1).success).toBe(true)
|
|
|
|
|
expect(satsSchema.safeParse(1_000_000).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects zero', () => { expect(satsSchema.safeParse(0).success).toBe(false) })
|
|
|
|
|
it('rejects negative', () => { expect(satsSchema.safeParse(-1).success).toBe(false) })
|
|
|
|
|
it('rejects over max', () => { expect(satsSchema.safeParse(1_000_001).success).toBe(false) })
|
|
|
|
|
it('rejects non-integer', () => { expect(satsSchema.safeParse(1.5).success).toBe(false) })
|
|
|
|
|
it('rejects string', () => { expect(satsSchema.safeParse('100').success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('idSchema', () => {
|
|
|
|
|
it('accepts valid IDs', () => {
|
|
|
|
|
expect(idSchema.safeParse('abc123').success).toBe(true)
|
|
|
|
|
expect(idSchema.safeParse('x'.repeat(24)).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects empty', () => { expect(idSchema.safeParse('').success).toBe(false) })
|
|
|
|
|
it('rejects too long', () => { expect(idSchema.safeParse('x'.repeat(25)).success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('httpUrlSchema', () => {
|
|
|
|
|
it('accepts http/https URLs', () => {
|
|
|
|
|
expect(httpUrlSchema.safeParse('https://example.com').success).toBe(true)
|
|
|
|
|
expect(httpUrlSchema.safeParse('http://api.bot.dev/hook').success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects file:// URLs', () => { expect(httpUrlSchema.safeParse('file:///etc/passwd').success).toBe(false) })
|
|
|
|
|
it('rejects ftp:// URLs', () => { expect(httpUrlSchema.safeParse('ftp://files.example.com').success).toBe(false) })
|
|
|
|
|
it('rejects non-URLs', () => { expect(httpUrlSchema.safeParse('not a url').success).toBe(false) })
|
|
|
|
|
it('rejects javascript: URLs', () => { expect(httpUrlSchema.safeParse('javascript:alert(1)').success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Auth schemas ---
|
|
|
|
|
|
|
|
|
|
describe('loginSchema', () => {
|
|
|
|
|
const validPk = 'a'.repeat(64)
|
|
|
|
|
it('accepts valid login', () => { expect(loginSchema.safeParse({ pubkey: validPk }).success).toBe(true) })
|
|
|
|
|
it('rejects missing pubkey', () => { expect(loginSchema.safeParse({}).success).toBe(false) })
|
|
|
|
|
it('rejects bad pubkey', () => { expect(loginSchema.safeParse({ pubkey: 'short' }).success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('registerSchema', () => {
|
|
|
|
|
const base = { pubkey: 'a'.repeat(64), name: 'testbot' }
|
|
|
|
|
it('accepts minimal registration', () => { expect(registerSchema.safeParse(base).success).toBe(true) })
|
|
|
|
|
it('accepts full registration', () => {
|
|
|
|
|
expect(registerSchema.safeParse({
|
|
|
|
|
...base,
|
|
|
|
|
webhookUrl: 'https://mybot.com/hook',
|
|
|
|
|
archetype: 'warrior',
|
|
|
|
|
profilePicUrl: 'https://img.com/pic.png',
|
|
|
|
|
customization: { color: 'red' },
|
|
|
|
|
}).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('accepts empty webhook (poll mode)', () => {
|
|
|
|
|
expect(registerSchema.safeParse({ ...base, webhookUrl: '' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects bad name', () => {
|
|
|
|
|
expect(registerSchema.safeParse({ ...base, name: 'x' }).success).toBe(false) // too short
|
|
|
|
|
expect(registerSchema.safeParse({ ...base, name: 'x'.repeat(13) }).success).toBe(false) // too long
|
|
|
|
|
expect(registerSchema.safeParse({ ...base, name: 'bad name!' }).success).toBe(false) // special chars
|
|
|
|
|
})
|
|
|
|
|
it('rejects bad pubkey', () => {
|
|
|
|
|
expect(registerSchema.safeParse({ ...base, pubkey: 'bad' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('registerHumanSchema', () => {
|
|
|
|
|
const base = { pubkey: 'a'.repeat(64), name: 'human01' }
|
|
|
|
|
it('accepts valid human registration', () => { expect(registerHumanSchema.safeParse(base).success).toBe(true) })
|
|
|
|
|
it('accepts with avatar seed', () => {
|
|
|
|
|
expect(registerHumanSchema.safeParse({ ...base, avatarSeed: 'seed123' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('updateBotSchema', () => {
|
2026-07-31 12:06:36 -04:00
|
|
|
const base = {}
|
|
|
|
|
it('accepts empty body (no updates)', () => { expect(updateBotSchema.safeParse(base).success).toBe(true) })
|
2026-03-13 09:16:04 +00:00
|
|
|
it('accepts webhook update', () => {
|
|
|
|
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'https://new.com/hook' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects file:// webhook', () => {
|
|
|
|
|
expect(updateBotSchema.safeParse({ ...base, webhookUrl: 'file:///etc/passwd' }).success).toBe(false)
|
|
|
|
|
})
|
2026-07-31 12:06:36 -04:00
|
|
|
// SECURITY REGRESSION: pubkey must never be a schema field here. POST
|
|
|
|
|
// /api/auth/update derives identity from the verified JWT
|
|
|
|
|
// (extractPubkeyFromAuth), not from client body — see server/src/routes/auth.ts.
|
|
|
|
|
// A pubkey field in this schema previously let an unauthenticated caller
|
|
|
|
|
// claim any bot as their own and hijack its webhook/customization.
|
|
|
|
|
it('does not declare a pubkey field (identity comes from the JWT, not the body)', () => {
|
|
|
|
|
expect('pubkey' in updateBotSchema.shape).toBe(false)
|
|
|
|
|
})
|
2026-03-13 09:16:04 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Fight schemas ---
|
|
|
|
|
|
|
|
|
|
describe('respondSchema', () => {
|
|
|
|
|
it('accepts valid response', () => {
|
|
|
|
|
expect(respondSchema.safeParse({ answer: 'hello' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('accepts with trash talk', () => {
|
|
|
|
|
expect(respondSchema.safeParse({ answer: 'hello', trashTalk: 'rekt' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects answer > 2000 chars', () => {
|
|
|
|
|
expect(respondSchema.safeParse({ answer: 'x'.repeat(2001) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects trash talk > 200 chars', () => {
|
|
|
|
|
expect(respondSchema.safeParse({ answer: 'ok', trashTalk: 'x'.repeat(201) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('reactSchema', () => {
|
|
|
|
|
it('accepts valid emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥' }).success).toBe(true) })
|
|
|
|
|
it('rejects empty emoji', () => { expect(reactSchema.safeParse({ emoji: '' }).success).toBe(false) })
|
|
|
|
|
it('rejects oversized emoji', () => { expect(reactSchema.safeParse({ emoji: '🔥'.repeat(5) }).success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Bet schemas ---
|
|
|
|
|
|
|
|
|
|
describe('placeBetSchema', () => {
|
|
|
|
|
const valid = {
|
|
|
|
|
fightId: 'fight123',
|
|
|
|
|
pubkey: 'a'.repeat(64),
|
|
|
|
|
botId: 'bot456',
|
|
|
|
|
amountSats: 1000,
|
|
|
|
|
cashuToken: 'cashuAey...',
|
|
|
|
|
}
|
|
|
|
|
it('accepts valid bet', () => { expect(placeBetSchema.safeParse(valid).success).toBe(true) })
|
|
|
|
|
it('rejects zero amount', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects negative amount', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: -100 }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects over-max amount', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 999999999 }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects non-integer amount', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ ...valid, amountSats: 1.5 }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects missing fields', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ fightId: 'x' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects empty cashu token', () => {
|
|
|
|
|
expect(placeBetSchema.safeParse({ ...valid, cashuToken: '' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('depositSchema', () => {
|
|
|
|
|
it('accepts valid deposit', () => {
|
|
|
|
|
expect(depositSchema.safeParse({ amountSats: 500, pubkey: 'a'.repeat(64) }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects under minimum (100)', () => {
|
|
|
|
|
expect(depositSchema.safeParse({ amountSats: 50, pubkey: 'a'.repeat(64) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('withdrawSchema', () => {
|
|
|
|
|
it('accepts valid withdrawal', () => {
|
|
|
|
|
expect(withdrawSchema.safeParse({ cashuToken: 'tok', bolt11: 'lnbc...' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects missing bolt11', () => {
|
|
|
|
|
expect(withdrawSchema.safeParse({ cashuToken: 'tok' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Payment schemas ---
|
|
|
|
|
|
|
|
|
|
describe('connectWalletSchema', () => {
|
|
|
|
|
const valid = { pubkey: 'a'.repeat(64), method: 'nwc' as const, connectionData: 'nostr+walletconnect://...' }
|
|
|
|
|
it('accepts valid connection', () => { expect(connectWalletSchema.safeParse(valid).success).toBe(true) })
|
|
|
|
|
it('rejects invalid method', () => {
|
|
|
|
|
expect(connectWalletSchema.safeParse({ ...valid, method: 'paypal' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects empty connectionData', () => {
|
|
|
|
|
expect(connectWalletSchema.safeParse({ ...valid, connectionData: '' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('zapSchema', () => {
|
|
|
|
|
const valid = { winnerId: 'bot1', fightId: 'fight1', amountSats: 100 }
|
|
|
|
|
it('accepts valid zap', () => { expect(zapSchema.safeParse(valid).success).toBe(true) })
|
|
|
|
|
it('rejects zero sats', () => { expect(zapSchema.safeParse({ ...valid, amountSats: 0 }).success).toBe(false) })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Tournament schemas ---
|
|
|
|
|
|
|
|
|
|
describe('createTournamentSchema', () => {
|
|
|
|
|
const valid = { pubkey: 'a'.repeat(64), name: 'Lightning Cup' }
|
|
|
|
|
it('accepts minimal tournament', () => { expect(createTournamentSchema.safeParse(valid).success).toBe(true) })
|
|
|
|
|
it('accepts full tournament', () => {
|
|
|
|
|
expect(createTournamentSchema.safeParse({
|
|
|
|
|
...valid, format: 'round_robin', size: 16, entrySats: 500,
|
|
|
|
|
}).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects invalid size', () => {
|
|
|
|
|
expect(createTournamentSchema.safeParse({ ...valid, size: 64 }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects empty name', () => {
|
|
|
|
|
expect(createTournamentSchema.safeParse({ ...valid, name: '' }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects name > 100 chars', () => {
|
|
|
|
|
expect(createTournamentSchema.safeParse({ ...valid, name: 'x'.repeat(101) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// --- Queue schemas ---
|
|
|
|
|
|
|
|
|
|
describe('joinRankedSchema', () => {
|
|
|
|
|
it('accepts valid join', () => {
|
|
|
|
|
expect(joinRankedSchema.safeParse({ paymentId: 'pay123' }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('accepts with pubkey', () => {
|
|
|
|
|
expect(joinRankedSchema.safeParse({ paymentId: 'pay123', pubkey: 'a'.repeat(64) }).success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects missing paymentId', () => {
|
|
|
|
|
expect(joinRankedSchema.safeParse({}).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-13 09:46:38 +00:00
|
|
|
// --- sanitizeError ---
|
|
|
|
|
|
|
|
|
|
describe('sanitizeError', () => {
|
|
|
|
|
it('returns fallback for non-Error values', () => {
|
|
|
|
|
expect(sanitizeError('string error', 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(null, 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(undefined, 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(42, 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('passes through safe error messages', () => {
|
|
|
|
|
expect(sanitizeError(new Error('Bot not found'), 'fallback')).toBe('Bot not found')
|
|
|
|
|
expect(sanitizeError(new Error('Payment already confirmed'), 'fallback')).toBe('Payment already confirmed')
|
|
|
|
|
expect(sanitizeError(new Error('already in a fight'), 'fallback')).toBe('already in a fight')
|
|
|
|
|
expect(sanitizeError(new Error('Invoice creation failed'), 'fallback')).toBe('Invoice creation failed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with TypeScript file paths', () => {
|
|
|
|
|
expect(sanitizeError(new Error('TypeError at /src/engine/payments.ts:42'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('Cannot read property of null at file.ts:10'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with JavaScript file paths', () => {
|
|
|
|
|
expect(sanitizeError(new Error('ReferenceError in module.js:5'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('Error in handler.mjs '), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with /src/ paths', () => {
|
|
|
|
|
expect(sanitizeError(new Error('Failed to load /src/config/keys'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with node_modules paths', () => {
|
|
|
|
|
expect(sanitizeError(new Error('Error in /node_modules/drizzle-orm/dist/index.js'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with stack trace fragments', () => {
|
|
|
|
|
expect(sanitizeError(new Error('at Object.runInContext (vm.js:130)'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('at Module._compile (internal/modules)'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('at async Router.handle'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with SQLite errors', () => {
|
|
|
|
|
expect(sanitizeError(new Error('SQLITE_CONSTRAINT: UNIQUE constraint failed'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('SQLITE_ERROR: no such table: users'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with system errors', () => {
|
|
|
|
|
expect(sanitizeError(new Error('ENOENT: no such file or directory'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('ECONNREFUSED 127.0.0.1:5432'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('EACCES: permission denied'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('strips messages with absolute paths', () => {
|
|
|
|
|
expect(sanitizeError(new Error('Cannot open /Users/deploy/app/db.sqlite'), 'fallback')).toBe('fallback')
|
|
|
|
|
expect(sanitizeError(new Error('File not found: /home/app/config.json'), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns fallback for empty message', () => {
|
|
|
|
|
expect(sanitizeError(new Error(''), 'fallback')).toBe('fallback')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-13 09:16:04 +00:00
|
|
|
// --- Attack inputs ---
|
|
|
|
|
|
|
|
|
|
describe('attack inputs', () => {
|
|
|
|
|
it('rejects SQL injection in pubkey', () => {
|
|
|
|
|
expect(pubkeySchema.safeParse("' OR 1=1 --").success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects XSS in bot name', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('<img onerror=alert(1)>').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects prototype pollution in customization keys', () => {
|
|
|
|
|
// The schema allows unknown keys, but Zod strips __proto__ in strict mode
|
|
|
|
|
const result = registerSchema.safeParse({
|
|
|
|
|
pubkey: 'a'.repeat(64),
|
|
|
|
|
name: 'test01',
|
|
|
|
|
customization: { __proto__: { admin: true } },
|
|
|
|
|
})
|
|
|
|
|
// This should still parse (customization is z.record(z.unknown()))
|
|
|
|
|
// The actual protection is that we never spread customization into objects unsafely
|
|
|
|
|
expect(result.success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
it('rejects absurdly long answer', () => {
|
|
|
|
|
expect(respondSchema.safeParse({ answer: 'x'.repeat(10000) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects negative sats in deposit', () => {
|
|
|
|
|
expect(depositSchema.safeParse({ amountSats: -1, pubkey: 'a'.repeat(64) }).success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
it('rejects data: URL in webhook', () => {
|
|
|
|
|
expect(httpUrlSchema.safeParse('data:text/html,<h1>hi</h1>').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-13 10:11:12 +00:00
|
|
|
|
|
|
|
|
describe('auth & registration edge cases', () => {
|
|
|
|
|
it('unicode bot name rejected', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('ビットコイン').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('café').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('bot🚀').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('böt').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('bot name with spaces rejected', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('my bot').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('bot name with special chars rejected', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('bot!@#').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('bot.name').success).toBe(false)
|
|
|
|
|
expect(botNameSchema.safeParse('bot/name').success).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('valid bot names accepted', () => {
|
|
|
|
|
expect(botNameSchema.safeParse('SatoshiBot').success).toBe(true)
|
|
|
|
|
expect(botNameSchema.safeParse('bot-1').success).toBe(true)
|
|
|
|
|
expect(botNameSchema.safeParse('bot_2').success).toBe(true)
|
|
|
|
|
expect(botNameSchema.safeParse('A1').success).toBe(true)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('two bots with same webhook URL: both pass validation (uniqueness enforced at DB level)', () => {
|
|
|
|
|
const pub1 = 'a'.repeat(64)
|
|
|
|
|
const pub2 = 'b'.repeat(64)
|
|
|
|
|
const sharedUrl = 'https://example.com/webhook'
|
|
|
|
|
const r1 = registerSchema.safeParse({ pubkey: pub1, name: 'Bot1', webhookUrl: sharedUrl })
|
|
|
|
|
const r2 = registerSchema.safeParse({ pubkey: pub2, name: 'Bot2', webhookUrl: sharedUrl })
|
|
|
|
|
expect(r1.success).toBe(true)
|
|
|
|
|
expect(r2.success).toBe(true)
|
|
|
|
|
// Note: same webhook URL is allowed — uniqueness is on name, not URL
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('pubkey must be exactly 64 hex chars', () => {
|
|
|
|
|
expect(pubkeySchema.safeParse('a'.repeat(63)).success).toBe(false) // too short
|
|
|
|
|
expect(pubkeySchema.safeParse('a'.repeat(65)).success).toBe(false) // too long
|
|
|
|
|
expect(pubkeySchema.safeParse('g'.repeat(64)).success).toBe(false) // non-hex
|
|
|
|
|
expect(pubkeySchema.safeParse('A'.repeat(64)).success).toBe(false) // uppercase (not lowercase hex)
|
|
|
|
|
expect(pubkeySchema.safeParse('a'.repeat(64)).success).toBe(true) // valid
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('register rejects missing required fields', () => {
|
|
|
|
|
expect(registerSchema.safeParse({}).success).toBe(false)
|
|
|
|
|
expect(registerSchema.safeParse({ pubkey: 'a'.repeat(64) }).success).toBe(false) // missing name
|
|
|
|
|
expect(registerSchema.safeParse({ name: 'Bot1' }).success).toBe(false) // missing pubkey
|
|
|
|
|
})
|
|
|
|
|
})
|