diff --git a/chat/admin/admin.test.js b/chat/admin/admin.test.js new file mode 100644 index 0000000..5c4b080 --- /dev/null +++ b/chat/admin/admin.test.js @@ -0,0 +1,530 @@ +const { describe, test, expect, beforeEach, afterEach, mock } = require('bun:test') + +describe('Admin Panel Functionality', () => { + let adminSessions + let adminLoginAttempts + + const ADMIN_COOKIE_NAME = 'admin_session' + const ADMIN_SESSION_TTL_MS = 24 * 60 * 60 * 1000 + const ADMIN_LOGIN_RATE_LIMIT = 5 + + beforeEach(() => { + adminSessions = new Map() + adminLoginAttempts = new Map() + }) + + afterEach(() => { + adminSessions.clear() + adminLoginAttempts.clear() + }) + + describe('Admin Session Management', () => { + function readAdminSessionToken(req) { + try { + const cookieHeader = req?.headers?.cookie || '' + if (!cookieHeader) return '' + const parts = cookieHeader.split(';').map((p) => p.trim()) + const match = parts.find((p) => p.startsWith(`${ADMIN_COOKIE_NAME}=`)) + if (!match) return '' + return decodeURIComponent(match.split('=').slice(1).join('=') || '') + } catch (_) { + return '' + } + } + + function getAdminSession(req) { + const token = readAdminSessionToken(req) + if (!token) return null + const session = adminSessions.get(token) + if (!session) return null + if (session.expiresAt && session.expiresAt < Date.now()) { + adminSessions.delete(token) + return null + } + return { token, expiresAt: session.expiresAt } + } + + function startAdminSession() { + const crypto = require('crypto') + const token = crypto.randomUUID() + const expiresAt = Date.now() + ADMIN_SESSION_TTL_MS + adminSessions.set(token, { expiresAt }) + return token + } + + function clearAdminSession(token) { + if (token) adminSessions.delete(token) + } + + function requireAdminAuth(req) { + const session = getAdminSession(req) + if (!session) return null + return session + } + + test('starts a new admin session', () => { + const token = startAdminSession() + + expect(token).toBeDefined() + expect(adminSessions.has(token)).toBe(true) + expect(adminSessions.get(token).expiresAt).toBeGreaterThan(Date.now()) + }) + + test('reads admin session token from cookie', () => { + const req = { + headers: { + cookie: `${ADMIN_COOKIE_NAME}=test-token-123; other=value` + } + } + + const token = readAdminSessionToken(req) + expect(token).toBe('test-token-123') + }) + + test('returns null for missing cookie', () => { + const req = { headers: {} } + const token = readAdminSessionToken(req) + expect(token).toBe('') + }) + + test('validates active session', () => { + const token = startAdminSession() + const req = { + headers: { cookie: `${ADMIN_COOKIE_NAME}=${token}` } + } + + const session = getAdminSession(req) + expect(session).not.toBeNull() + expect(session.token).toBe(token) + }) + + test('rejects expired session', () => { + const crypto = require('crypto') + const token = crypto.randomUUID() + adminSessions.set(token, { expiresAt: Date.now() - 1000 }) + + const req = { + headers: { cookie: `${ADMIN_COOKIE_NAME}=${token}` } + } + + const session = getAdminSession(req) + expect(session).toBeNull() + expect(adminSessions.has(token)).toBe(false) + }) + + test('rejects invalid token', () => { + const req = { + headers: { cookie: `${ADMIN_COOKIE_NAME}=invalid-token` } + } + + const session = getAdminSession(req) + expect(session).toBeNull() + }) + + test('clears admin session', () => { + const token = startAdminSession() + clearAdminSession(token) + + expect(adminSessions.has(token)).toBe(false) + }) + + test('requireAdminAuth returns session for valid auth', () => { + const token = startAdminSession() + const req = { + headers: { cookie: `${ADMIN_COOKIE_NAME}=${token}` } + } + + const session = requireAdminAuth(req) + expect(session).not.toBeNull() + }) + + test('requireAdminAuth returns null for invalid auth', () => { + const req = { headers: {} } + const session = requireAdminAuth(req) + expect(session).toBeNull() + }) + }) + + describe('Admin Login Rate Limiting', () => { + const LOGIN_LOCKOUT_MS = 900000 + const RATE_LIMIT_WINDOW_MS = 60000 + + function checkLoginRateLimit(ip, limit, attempts) { + const now = Date.now() + const record = attempts.get(ip) + + if (!record) { + attempts.set(ip, { count: 1, windowStart: now, lockedUntil: null }) + return { blocked: false, count: 1 } + } + + if (record.lockedUntil && record.lockedUntil > now) { + return { + blocked: true, + retryAfter: Math.ceil((record.lockedUntil - now) / 1000) + } + } + + if (now - record.windowStart > RATE_LIMIT_WINDOW_MS) { + attempts.set(ip, { count: 1, windowStart: now, lockedUntil: null }) + return { blocked: false, count: 1 } + } + + record.count++ + + if (record.count >= limit) { + record.lockedUntil = now + LOGIN_LOCKOUT_MS + return { blocked: true, retryAfter: Math.ceil(LOGIN_LOCKOUT_MS / 1000) } + } + + return { blocked: false, count: record.count } + } + + test('allows first login attempt', () => { + const result = checkLoginRateLimit('192.168.1.1', ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + + expect(result.blocked).toBe(false) + expect(result.count).toBe(1) + }) + + test('counts attempts within window', () => { + const ip = '192.168.1.1' + checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + const result = checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + + expect(result.blocked).toBe(false) + expect(result.count).toBe(3) + }) + + test('blocks after exceeding limit', () => { + const ip = '192.168.1.1' + for (let i = 0; i < ADMIN_LOGIN_RATE_LIMIT; i++) { + checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + } + const result = checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + + expect(result.blocked).toBe(true) + expect(result.retryAfter).toBeGreaterThan(0) + }) + + test('resets after lockout period', () => { + const ip = '192.168.1.1' + for (let i = 0; i < ADMIN_LOGIN_RATE_LIMIT; i++) { + checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + } + + const record = adminLoginAttempts.get(ip) + record.lockedUntil = Date.now() - 1000 + + const result = checkLoginRateLimit(ip, ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + expect(result.blocked).toBe(false) + }) + + test('tracks different IPs separately', () => { + checkLoginRateLimit('192.168.1.1', ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + checkLoginRateLimit('192.168.1.1', ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + checkLoginRateLimit('192.168.1.2', ADMIN_LOGIN_RATE_LIMIT, adminLoginAttempts) + + expect(adminLoginAttempts.get('192.168.1.1').count).toBe(2) + expect(adminLoginAttempts.get('192.168.1.2').count).toBe(1) + }) + }) + + describe('Admin User Management', () => { + let usersDb + + beforeEach(() => { + usersDb = [ + { id: 'user-1', email: 'user1@test.com', plan: 'hobby', billingStatus: 'active' }, + { id: 'user-2', email: 'user2@test.com', plan: 'starter', billingStatus: 'active' }, + { id: 'user-3', email: 'user3@test.com', plan: 'professional', billingStatus: 'active' } + ] + }) + + function findUserById(userId) { + return usersDb.find(u => u.id === userId) || null + } + + function normalizePlanSelection(plan) { + const validPlans = ['hobby', 'starter', 'professional', 'enterprise'] + return validPlans.includes(plan) ? plan : 'hobby' + } + + function isPaidPlan(plan) { + return ['starter', 'professional', 'enterprise'].includes(plan) + } + + test('finds user by ID', () => { + const user = findUserById('user-1') + expect(user).toBeDefined() + expect(user.email).toBe('user1@test.com') + }) + + test('returns null for non-existent user', () => { + const user = findUserById('non-existent') + expect(user).toBeNull() + }) + + test('normalizes valid plan selections', () => { + expect(normalizePlanSelection('hobby')).toBe('hobby') + expect(normalizePlanSelection('starter')).toBe('starter') + expect(normalizePlanSelection('professional')).toBe('professional') + expect(normalizePlanSelection('enterprise')).toBe('enterprise') + }) + + test('defaults invalid plan to hobby', () => { + expect(normalizePlanSelection('invalid')).toBe('hobby') + expect(normalizePlanSelection('')).toBe('hobby') + expect(normalizePlanSelection(null)).toBe('hobby') + }) + + test('identifies paid plans', () => { + expect(isPaidPlan('hobby')).toBe(false) + expect(isPaidPlan('starter')).toBe(true) + expect(isPaidPlan('professional')).toBe(true) + expect(isPaidPlan('enterprise')).toBe(true) + }) + + test('updates user plan', () => { + const user = findUserById('user-1') + user.plan = 'enterprise' + + expect(user.plan).toBe('enterprise') + }) + + test('lists all users', () => { + expect(usersDb.length).toBe(3) + }) + + test('deletes user', () => { + const initialCount = usersDb.length + const index = usersDb.findIndex(u => u.id === 'user-1') + usersDb.splice(index, 1) + + expect(usersDb.length).toBe(initialCount - 1) + expect(findUserById('user-1')).toBeNull() + }) + }) + + describe('Admin Token Management', () => { + let tokenUsage + let usersDb + + beforeEach(() => { + tokenUsage = {} + usersDb = [ + { id: 'user-1', plan: 'hobby' }, + { id: 'user-2', plan: 'professional' } + ] + }) + + function ensureTokenUsageBucket(userId) { + if (!tokenUsage[userId]) { + tokenUsage[userId] = { usage: 0, tokenOverride: null } + } + return tokenUsage[userId] + } + + function getPlanTokenLimits(plan, userId) { + const limits = { + hobby: 10000, + starter: 50000, + professional: 200000, + enterprise: 1000000 + } + const bucket = tokenUsage[userId] + if (bucket?.tokenOverride !== null && bucket?.tokenOverride !== undefined) { + return bucket.tokenOverride + } + return limits[plan] || limits.hobby + } + + test('creates token bucket for new user', () => { + const bucket = ensureTokenUsageBucket('new-user') + + expect(bucket).toBeDefined() + expect(bucket.usage).toBe(0) + expect(bucket.tokenOverride).toBeNull() + }) + + test('returns existing bucket', () => { + tokenUsage['existing-user'] = { usage: 5000, tokenOverride: 20000 } + const bucket = ensureTokenUsageBucket('existing-user') + + expect(bucket.usage).toBe(5000) + expect(bucket.tokenOverride).toBe(20000) + }) + + test('sets token limit override', () => { + const userId = 'user-1' + const bucket = ensureTokenUsageBucket(userId) + bucket.tokenOverride = 50000 + + expect(bucket.tokenOverride).toBe(50000) + }) + + test('clears override when set to 0', () => { + const userId = 'user-1' + const bucket = ensureTokenUsageBucket(userId) + bucket.tokenOverride = 50000 + bucket.tokenOverride = 0 ? null : bucket.tokenOverride + + bucket.tokenOverride = null + + expect(bucket.tokenOverride).toBeNull() + }) + + test('sets usage directly', () => { + const userId = 'user-1' + const bucket = ensureTokenUsageBucket(userId) + bucket.usage = 25000 + + expect(bucket.usage).toBe(25000) + }) + + test('calculates remaining tokens', () => { + const userId = 'user-1' + const bucket = ensureTokenUsageBucket(userId) + const limit = getPlanTokenLimits('hobby', userId) + bucket.usage = 5000 + const remaining = limit - bucket.usage + + expect(remaining).toBe(5000) + }) + + test('uses override limit when set', () => { + const userId = 'user-1' + const bucket = ensureTokenUsageBucket(userId) + bucket.tokenOverride = 50000 + + const limit = getPlanTokenLimits('hobby', userId) + expect(limit).toBe(50000) + }) + }) + + describe('Admin Model Management', () => { + let opencodeModels + let publicModels + + beforeEach(() => { + opencodeModels = [ + { id: 'model-1', name: 'gpt-4', label: 'GPT-4', tier: 'premium' }, + { id: 'model-2', name: 'claude-3', label: 'Claude 3', tier: 'premium' } + ] + publicModels = [ + { id: 'model-3', name: 'gpt-3.5', label: 'GPT-3.5', tier: 'standard' } + ] + }) + + function normalizeTier(tier) { + const validTiers = ['free', 'standard', 'premium', 'enterprise'] + return validTiers.includes(tier) ? tier : 'standard' + } + + function getTierMultiplier(tier) { + const multipliers = { + free: 0.5, + standard: 1, + premium: 2, + enterprise: 5 + } + return multipliers[tier] || 1 + } + + test('lists opencode models', () => { + expect(opencodeModels.length).toBe(2) + expect(opencodeModels[0].name).toBe('gpt-4') + }) + + test('lists public models', () => { + expect(publicModels.length).toBe(1) + expect(publicModels[0].name).toBe('gpt-3.5') + }) + + test('normalizes tier values', () => { + expect(normalizeTier('free')).toBe('free') + expect(normalizeTier('premium')).toBe('premium') + expect(normalizeTier('invalid')).toBe('standard') + }) + + test('gets tier multipliers', () => { + expect(getTierMultiplier('free')).toBe(0.5) + expect(getTierMultiplier('standard')).toBe(1) + expect(getTierMultiplier('premium')).toBe(2) + expect(getTierMultiplier('enterprise')).toBe(5) + }) + + test('adds new model to opencode', () => { + const newModel = { + id: 'model-4', + name: 'llama-3', + label: 'LLaMA 3', + tier: 'standard' + } + opencodeModels.push(newModel) + + expect(opencodeModels.length).toBe(3) + expect(opencodeModels.find(m => m.id === 'model-4')).toBeDefined() + }) + + test('updates existing model', () => { + const model = opencodeModels.find(m => m.id === 'model-1') + model.label = 'GPT-4 Turbo' + + expect(model.label).toBe('GPT-4 Turbo') + }) + + test('deletes model', () => { + const index = opencodeModels.findIndex(m => m.id === 'model-1') + opencodeModels.splice(index, 1) + + expect(opencodeModels.length).toBe(1) + expect(opencodeModels.find(m => m.id === 'model-1')).toBeUndefined() + }) + + test('reorders models', () => { + const newOrder = [ + { id: 'model-2' }, + { id: 'model-1' } + ] + + const reordered = [] + newOrder.forEach(m => { + const model = opencodeModels.find(pm => pm.id === m.id) + if (model) reordered.push(model) + }) + + opencodeModels = reordered + + expect(opencodeModels[0].id).toBe('model-2') + expect(opencodeModels[1].id).toBe('model-1') + }) + }) + + describe('Admin Authentication Validation', () => { + function checkHoneypot(body) { + return !!(body && body.website) + } + + function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) + } + + test('detects honeypot field', () => { + expect(checkHoneypot({ website: 'spam' })).toBe(true) + expect(checkHoneypot({ website: '' })).toBe(true) + expect(checkHoneypot({ other: 'value' })).toBe(false) + expect(checkHoneypot({})).toBe(false) + }) + + test('validates email format', () => { + expect(isValidEmail('test@example.com')).toBe(true) + expect(isValidEmail('user.name@domain.co.uk')).toBe(true) + expect(isValidEmail('invalid')).toBe(false) + expect(isValidEmail('no@domain')).toBe(false) + expect(isValidEmail('@nodomain.com')).toBe(false) + }) + }) +}) diff --git a/chat/security/prompt-sanitizer.test.js b/chat/security/prompt-sanitizer.test.js new file mode 100644 index 0000000..733349f --- /dev/null +++ b/chat/security/prompt-sanitizer.test.js @@ -0,0 +1,520 @@ +const { describe, test, expect } = require('bun:test') +const { + sanitizeUserInput, + wrapUserContent, + createHardenedSystemPrompt, + shouldBlockInput, + generateBoundary, + normalizeText, + hasAttackContext, + hasLegitimateContext, + isObfuscatedAttack, + CORE_ATTACK_PATTERNS +} = require('./prompt-sanitizer') + +describe('Prompt Sanitizer Security', () => { + describe('sanitizeUserInput', () => { + test('allows normal user input', () => { + const result = sanitizeUserInput('Create a WordPress plugin for contact forms') + + expect(result.blocked).toBe(false) + expect(result.sanitized).toBe('Create a WordPress plugin for contact forms') + }) + + test('handles empty input', () => { + const result = sanitizeUserInput('') + + expect(result.blocked).toBe(false) + expect(result.sanitized).toBe('') + expect(result.confidence).toBe('none') + }) + + test('handles null input', () => { + const result = sanitizeUserInput(null) + + expect(result.blocked).toBe(false) + expect(result.sanitized).toBe('') + }) + + test('handles undefined input', () => { + const result = sanitizeUserInput(undefined) + + expect(result.blocked).toBe(false) + expect(result.sanitized).toBe('') + }) + + test('truncates long input', () => { + const longInput = 'a'.repeat(60000) + const result = sanitizeUserInput(longInput, { maxLength: 50000 }) + + expect(result.sanitized.length).toBe(50000) + expect(result.warnings.length).toBeGreaterThan(0) + }) + + test('escapes HTML by default', () => { + const result = sanitizeUserInput('') + + expect(result.sanitized).toContain('<') + expect(result.sanitized).toContain('>') + expect(result.sanitized).not.toContain('