diff --git a/chat/package.json b/chat/package.json index ea3883b..7b398c9 100644 --- a/chat/package.json +++ b/chat/package.json @@ -4,7 +4,9 @@ "description": "", "main": "agents.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "bun test", + "test:watch": "bun test --watch", + "test:coverage": "bun test --coverage", "start": "node server.js" }, "keywords": [], diff --git a/chat/src/database/connection.test.js b/chat/src/database/connection.test.js new file mode 100644 index 0000000..aeb5e15 --- /dev/null +++ b/chat/src/database/connection.test.js @@ -0,0 +1,470 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const path = require('path') +const fs = require('fs') +const Database = require('better-sqlite3') + +const testDir = path.join(__dirname, '.test-db') +const testDbPath = path.join(testDir, 'test.db') +const backupDbPath = path.join(testDir, 'backup.db') + +describe('Database Connection', () => { + beforeEach(() => { + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }) + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + if (fs.existsSync(backupDbPath)) { + fs.unlinkSync(backupDbPath) + } + }) + + afterEach(() => { + if (fs.existsSync(testDbPath)) { + try { fs.unlinkSync(testDbPath) } catch {} + } + if (fs.existsSync(backupDbPath)) { + try { fs.unlinkSync(backupDbPath) } catch {} + } + if (fs.existsSync(testDir)) { + try { fs.rmdirSync(testDir, { recursive: true }) } catch {} + } + }) + + describe('Database Initialization', () => { + test('creates database file', () => { + const db = new Database(testDbPath) + + expect(fs.existsSync(testDbPath)).toBe(true) + + db.close() + }) + + test('creates database directory if not exists', () => { + const nestedPath = path.join(testDir, 'nested', 'deep', 'test.db') + const nestedDir = path.dirname(nestedPath) + + if (!fs.existsSync(nestedDir)) { + fs.mkdirSync(nestedDir, { recursive: true }) + } + + const db = new Database(nestedPath) + + expect(fs.existsSync(nestedPath)).toBe(true) + + db.close() + fs.unlinkSync(nestedPath) + }) + + test('opens existing database', () => { + const db1 = new Database(testDbPath) + db1.exec('CREATE TABLE test (id TEXT PRIMARY KEY)') + db1.close() + + const db2 = new Database(testDbPath) + const tables = db2.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() + + expect(tables.some(t => t.name === 'test')).toBe(true) + + db2.close() + }) + + test('sets WAL mode', () => { + const db = new Database(testDbPath) + db.pragma('journal_mode = WAL') + + const result = db.pragma('journal_mode')[0] + + expect(result.journal_mode.toLowerCase()).toBe('wal') + + db.close() + }) + + test('enables foreign keys', () => { + const db = new Database(testDbPath) + db.pragma('foreign_keys = ON') + + const result = db.pragma('foreign_keys')[0] + + expect(result.foreign_keys).toBe(1) + + db.close() + }) + }) + + describe('Database Operations', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('executes CREATE TABLE', () => { + db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + email TEXT UNIQUE + ) + `) + + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() + + expect(tables.some(t => t.name === 'users')).toBe(true) + }) + + test('executes INSERT', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + + const stmt = db.prepare('INSERT INTO test (id, value) VALUES (?, ?)') + const result = stmt.run('1', 'test value') + + expect(result.changes).toBe(1) + }) + + test('executes SELECT', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'test value') + + const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1') + + expect(row).toBeDefined() + expect(row.value).toBe('test value') + }) + + test('executes UPDATE', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'original') + + const result = db.prepare('UPDATE test SET value = ? WHERE id = ?').run('updated', '1') + + expect(result.changes).toBe(1) + + const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1') + expect(row.value).toBe('updated') + }) + + test('executes DELETE', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'test') + + const result = db.prepare('DELETE FROM test WHERE id = ?').run('1') + + expect(result.changes).toBe(1) + + const row = db.prepare('SELECT * FROM test WHERE id = ?').get('1') + expect(row).toBeUndefined() + }) + + test('handles multiple rows', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + + for (let i = 0; i < 10; i++) { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run(`${i}`, `value-${i}`) + } + + const rows = db.prepare('SELECT * FROM test ORDER BY id').all() + + expect(rows.length).toBe(10) + }) + }) + + describe('Transactions', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + db.pragma('journal_mode = WAL') + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT)') + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('commits transaction', () => { + const insert = db.transaction(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'b') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('3', 'c') + }) + + insert() + + const count = db.prepare('SELECT COUNT(*) as count FROM test').get() + expect(count.count).toBe(3) + }) + + test('rolls back on error', () => { + const insert = db.transaction(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a') + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'b') + throw new Error('Simulated error') + }) + + expect(() => insert()).toThrow('Simulated error') + + const count = db.prepare('SELECT COUNT(*) as count FROM test').get() + expect(count.count).toBe(0) + }) + + test('nested transactions', () => { + const outer = db.transaction(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'outer') + + const inner = db.transaction(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'inner') + }) + + inner() + }) + + outer() + + const rows = db.prepare('SELECT * FROM test ORDER BY id').all() + expect(rows.length).toBe(2) + }) + }) + + describe('Foreign Keys', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + db.pragma('journal_mode = WAL') + db.pragma('foreign_keys = ON') + + db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + name TEXT + ) + `) + + db.exec(` + CREATE TABLE posts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + title TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('enforces foreign key constraint', () => { + expect(() => { + db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'nonexistent', 'Test') + }).toThrow() + }) + + test('allows valid foreign key', () => { + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run('u1', 'User 1') + db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'u1', 'Test') + + const post = db.prepare('SELECT * FROM posts WHERE id = ?').get('p1') + expect(post).toBeDefined() + }) + + test('cascades delete', () => { + db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run('u1', 'User 1') + db.prepare('INSERT INTO posts (id, user_id, title) VALUES (?, ?, ?)').run('p1', 'u1', 'Test') + + db.prepare('DELETE FROM users WHERE id = ?').run('u1') + + const posts = db.prepare('SELECT * FROM posts WHERE user_id = ?').all('u1') + expect(posts.length).toBe(0) + }) + }) + + describe('Backup', () => { + test('creates backup', () => { + const db = new Database(testDbPath) + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)') + db.prepare('INSERT INTO test (id) VALUES (?)').run('1') + + const backup = db.backup(backupDbPath) + backup.step(-1) + backup.finish() + + expect(fs.existsSync(backupDbPath)).toBe(true) + + const backupDb = new Database(backupDbPath) + const row = backupDb.prepare('SELECT * FROM test WHERE id = ?').get('1') + expect(row).toBeDefined() + backupDb.close() + + db.close() + }) + }) + + describe('Connection Management', () => { + test('closes connection', () => { + const db = new Database(testDbPath) + expect(db.open).toBe(true) + + db.close() + expect(db.open).toBe(false) + }) + + test('handles multiple operations before close', () => { + const db = new Database(testDbPath) + + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)') + db.prepare('INSERT INTO test (id) VALUES (?)').run('1') + const row = db.prepare('SELECT * FROM test').get('1') + + expect(row).toBeDefined() + + db.close() + expect(db.open).toBe(false) + }) + + test('checks if database is open', () => { + const db = new Database(testDbPath) + expect(db.open).toBe(true) + db.close() + expect(db.open).toBe(false) + }) + }) + + describe('Error Handling', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, value TEXT UNIQUE)') + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('handles duplicate primary key', () => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a') + + expect(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'b') + }).toThrow() + }) + + test('handles unique constraint violation', () => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('1', 'a') + + expect(() => { + db.prepare('INSERT INTO test (id, value) VALUES (?, ?)').run('2', 'a') + }).toThrow() + }) + + test('handles NOT NULL constraint violation', () => { + db.exec('CREATE TABLE not_null_test (id TEXT PRIMARY KEY, value TEXT NOT NULL)') + + expect(() => { + db.prepare('INSERT INTO not_null_test (id, value) VALUES (?, ?)').run('1', null) + }).toThrow() + }) + + test('handles invalid SQL', () => { + expect(() => { + db.exec('INVALID SQL STATEMENT') + }).toThrow() + }) + }) + + describe('Performance', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + db.exec('CREATE TABLE perf_test (id TEXT PRIMARY KEY, value TEXT, created_at INTEGER)') + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('bulk insert with transaction', () => { + const insert = db.transaction(() => { + const stmt = db.prepare('INSERT INTO perf_test (id, value, created_at) VALUES (?, ?, ?)') + for (let i = 0; i < 1000; i++) { + stmt.run(`id-${i}`, `value-${i}`, Date.now()) + } + }) + + insert() + + const count = db.prepare('SELECT COUNT(*) as count FROM perf_test').get() + expect(count.count).toBe(1000) + }) + + test('batch select', () => { + const insert = db.transaction(() => { + const stmt = db.prepare('INSERT INTO perf_test (id, value, created_at) VALUES (?, ?, ?)') + for (let i = 0; i < 100; i++) { + stmt.run(`id-${i}`, `value-${i}`, Date.now()) + } + }) + insert() + + const rows = db.prepare('SELECT * FROM perf_test ORDER BY id').all() + expect(rows.length).toBe(100) + }) + }) + + describe('Schema Operations', () => { + let db + + beforeEach(() => { + db = new Database(testDbPath) + }) + + afterEach(() => { + if (db) { + db.close() + } + }) + + test('creates index', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, email TEXT)') + db.exec('CREATE INDEX idx_email ON test(email)') + + const indexes = db.pragma('index_list(test)') + expect(indexes.some(idx => idx.name === 'idx_email')).toBe(true) + }) + + test('adds column', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY)') + db.exec('ALTER TABLE test ADD COLUMN name TEXT') + + const info = db.pragma('table_info(test)') + expect(info.some(col => col.name === 'name')).toBe(true) + }) + + test('gets table info', () => { + db.exec('CREATE TABLE test (id TEXT PRIMARY KEY, name TEXT, email TEXT)') + + const info = db.pragma('table_info(test)') + expect(info.length).toBe(3) + }) + }) +}) diff --git a/chat/src/repositories/auditRepository.test.js b/chat/src/repositories/auditRepository.test.js new file mode 100644 index 0000000..ef84e6f --- /dev/null +++ b/chat/src/repositories/auditRepository.test.js @@ -0,0 +1,496 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const Database = require('better-sqlite3') + +const testDbPath = path.join(__dirname, '.test-audit.db') + +let testDb + +function createTestSchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id TEXT PRIMARY KEY, + user_id TEXT, + event_type TEXT NOT NULL, + event_data TEXT, + ip_address TEXT, + user_agent TEXT, + success INTEGER DEFAULT 1, + error_message TEXT, + created_at INTEGER NOT NULL + ) + `) +} + +describe('Audit Repository', () => { + beforeEach(() => { + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + + testDb = new Database(testDbPath) + createTestSchema(testDb) + }) + + afterEach(() => { + if (testDb) { + testDb.close() + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + }) + + describe('Audit Event Logging', () => { + test('logs a basic audit event', () => { + const now = Date.now() + const eventId = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(eventId, 'user.login', now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId) + + expect(event).toBeDefined() + expect(event.event_type).toBe('user.login') + expect(event.success).toBe(1) + }) + + test('logs event with user ID', () => { + const now = Date.now() + const eventId = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run(eventId, 'user-123', 'user.logout', now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId) + + expect(event.user_id).toBe('user-123') + }) + + test('logs event with all metadata', () => { + const now = Date.now() + const eventId = crypto.randomUUID() + const eventData = JSON.stringify({ method: 'password', rememberMe: true }) + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, event_data, ip_address, user_agent, success, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run(eventId, 'user-456', 'user.login', eventData, '192.168.1.1', 'Mozilla/5.0', 1, now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId) + + expect(event.ip_address).toBe('192.168.1.1') + expect(event.user_agent).toBe('Mozilla/5.0') + const parsedData = JSON.parse(event.event_data) + expect(parsedData.method).toBe('password') + }) + + test('logs failed event with error message', () => { + const now = Date.now() + const eventId = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, success, error_message, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(eventId, 'user-789', 'user.login', 0, 'Invalid password', now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get(eventId) + + expect(event.success).toBe(0) + expect(event.error_message).toBe('Invalid password') + }) + }) + + describe('Event Types', () => { + test('logs authentication events', () => { + const now = Date.now() + const eventTypes = ['user.login', 'user.logout', 'user.login.failed', 'user.password.reset', 'user.password.change'] + + eventTypes.forEach((type, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`auth-${i}`, type, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('user.%') + + expect(events.length).toBe(5) + }) + + test('logs session events', () => { + const now = Date.now() + const eventTypes = ['session.create', 'session.refresh', 'session.destroy', 'session.expired'] + + eventTypes.forEach((type, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`session-${i}`, type, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('session.%') + + expect(events.length).toBe(4) + }) + + test('logs payment events', () => { + const now = Date.now() + const eventTypes = ['payment.created', 'payment.succeeded', 'payment.failed', 'subscription.created', 'subscription.cancelled'] + + eventTypes.forEach((type, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`payment-${i}`, type, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('payment.%') + + expect(events.length).toBe(3) + }) + + test('logs security events', () => { + const now = Date.now() + const eventTypes = ['security.2fa.enabled', 'security.2fa.disabled', 'security.token.blacklisted', 'security.suspicious.activity'] + + eventTypes.forEach((type, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`security-${i}`, type, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('security.%') + + expect(events.length).toBe(4) + }) + + test('logs admin events', () => { + const now = Date.now() + const eventTypes = ['admin.login', 'admin.user.updated', 'admin.plan.changed', 'admin.settings.updated'] + + eventTypes.forEach((type, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`admin-${i}`, type, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE event_type LIKE ?').all('admin.%') + + expect(events.length).toBe(4) + }) + }) + + describe('Query Operations', () => { + test('gets user audit log with pagination', () => { + const now = Date.now() + + for (let i = 0; i < 25; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run(`page-${i}`, 'user-page', `event.${i}`, now + i) + } + + const page1 = testDb.prepare(` + SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 10 OFFSET 0 + `).all('user-page') + + const page2 = testDb.prepare(` + SELECT * FROM audit_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 10 OFFSET 10 + `).all('user-page') + + expect(page1.length).toBe(10) + expect(page2.length).toBe(10) + expect(page1[0].created_at).toBeGreaterThan(page1[1].created_at) + }) + + test('filters by event type', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run('filter-1', 'user-filter', 'user.login', now) + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run('filter-2', 'user-filter', 'user.logout', now + 1) + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, created_at) + VALUES (?, ?, ?, ?) + `).run('filter-3', 'user-filter', 'user.login', now + 2) + + const loginEvents = testDb.prepare(` + SELECT * FROM audit_log WHERE user_id = ? AND event_type = ? ORDER BY created_at DESC + `).all('user-filter', 'user.login') + + expect(loginEvents.length).toBe(2) + }) + + test('gets recent audit events', () => { + const now = Date.now() + + for (let i = 0; i < 50; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`recent-${i}`, `event.${i}`, now + i) + } + + const recent = testDb.prepare(` + SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 20 + `).all() + + expect(recent.length).toBe(20) + expect(recent[0].created_at).toBeGreaterThan(recent[19].created_at) + }) + + test('gets recent events filtered by type', () => { + const now = Date.now() + + for (let i = 0; i < 10; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`type-a-${i}`, 'typeA', now + i) + + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`type-b-${i}`, 'typeB', now + 100 + i) + } + + const typeAEvents = testDb.prepare(` + SELECT * FROM audit_log WHERE event_type = ? ORDER BY created_at DESC LIMIT 5 + `).all('typeA') + + expect(typeAEvents.length).toBe(5) + typeAEvents.forEach(e => expect(e.event_type).toBe('typeA')) + }) + }) + + describe('Event Data Handling', () => { + test('stores complex event data as JSON', () => { + const now = Date.now() + const eventData = { + loginMethod: 'oauth', + provider: 'google', + deviceInfo: { + browser: 'Chrome', + os: 'Windows', + device: 'Desktop' + }, + location: { + country: 'US', + city: 'New York' + } + } + + testDb.prepare(` + INSERT INTO audit_log (id, event_type, event_data, created_at) + VALUES (?, ?, ?, ?) + `).run('complex-1', 'user.login', JSON.stringify(eventData), now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('complex-1') + const parsedData = JSON.parse(event.event_data) + + expect(parsedData.loginMethod).toBe('oauth') + expect(parsedData.deviceInfo.browser).toBe('Chrome') + expect(parsedData.location.country).toBe('US') + }) + + test('handles null event data', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO audit_log (id, event_type, event_data, created_at) + VALUES (?, ?, ?, ?) + `).run('null-data', 'user.logout', null, now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('null-data') + + expect(event.event_data).toBeNull() + }) + + test('handles empty event data', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO audit_log (id, event_type, event_data, created_at) + VALUES (?, ?, ?, ?) + `).run('empty-data', 'user.logout', '{}', now) + + const event = testDb.prepare('SELECT * FROM audit_log WHERE id = ?').get('empty-data') + const parsedData = JSON.parse(event.event_data) + + expect(parsedData).toEqual({}) + }) + }) + + describe('IP and User Agent Tracking', () => { + test('stores IP address', () => { + const now = Date.now() + const ipAddresses = ['192.168.1.1', '10.0.0.1', '172.16.0.1', '203.0.113.42', '2001:db8::1'] + + ipAddresses.forEach((ip, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, ip_address, created_at) + VALUES (?, ?, ?, ?) + `).run(`ip-${i}`, 'user.login', ip, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE id LIKE ?').all('ip-%') + + expect(events.length).toBe(5) + expect(events[0].ip_address).toBe('192.168.1.1') + expect(events[4].ip_address).toBe('2001:db8::1') + }) + + test('stores user agent', () => { + const now = Date.now() + const userAgents = [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/605.1.15', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148' + ] + + userAgents.forEach((ua, i) => { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, user_agent, created_at) + VALUES (?, ?, ?, ?) + `).run(`ua-${i}`, 'user.login', ua, now + i) + }) + + const events = testDb.prepare('SELECT * FROM audit_log WHERE id LIKE ?').all('ua-%') + + expect(events.length).toBe(3) + expect(events[0].user_agent).toContain('Chrome') + expect(events[1].user_agent).toContain('Safari') + expect(events[2].user_agent).toContain('iPhone') + }) + + test('detects login from new IP', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, ip_address, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('new-ip-1', 'user-newip', 'user.login', '192.168.1.1', now) + + testDb.prepare(` + INSERT INTO audit_log (id, user_id, event_type, ip_address, created_at) + VALUES (?, ?, ?, ?, ?) + `).run('new-ip-2', 'user-newip', 'user.login', '10.0.0.1', now + 1) + + const uniqueIps = testDb.prepare(` + SELECT DISTINCT ip_address FROM audit_log WHERE user_id = ? + `).all('user-newip') + + expect(uniqueIps.length).toBe(2) + }) + }) + + describe('Success/Failure Tracking', () => { + test('tracks successful events', () => { + const now = Date.now() + + for (let i = 0; i < 5; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, success, created_at) + VALUES (?, ?, ?, ?) + `).run(`success-${i}`, 'user.login', 1, now + i) + } + + const successful = testDb.prepare(` + SELECT COUNT(*) as count FROM audit_log WHERE event_type = ? AND success = 1 + `).get('user.login') + + expect(successful.count).toBe(5) + }) + + test('tracks failed events', () => { + const now = Date.now() + + for (let i = 0; i < 3; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, success, error_message, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(`fail-${i}`, 'user.login', 0, 'Invalid credentials', now + i) + } + + const failed = testDb.prepare(` + SELECT COUNT(*) as count FROM audit_log WHERE event_type = ? AND success = 0 + `).get('user.login') + + expect(failed.count).toBe(3) + }) + + test('calculates success rate', () => { + const now = Date.now() + + for (let i = 0; i < 8; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, success, created_at) + VALUES (?, ?, ?, ?) + `).run(`rate-${i}`, 'user.login', i < 6 ? 1 : 0, now + i) + } + + const stats = testDb.prepare(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successful + FROM audit_log WHERE event_type = ? + `).get('user.login') + + const successRate = stats.successful / stats.total + expect(successRate).toBe(0.75) + }) + }) + + describe('Time-based Queries', () => { + test('queries events within time range', () => { + const baseTime = Date.now() + + for (let i = 0; i < 24; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`time-${i}`, 'user.login', baseTime + (i * 3600000)) + } + + const hourAgo = baseTime + (20 * 3600000) + const events = testDb.prepare(` + SELECT * FROM audit_log WHERE created_at >= ? ORDER BY created_at DESC + `).all(hourAgo) + + expect(events.length).toBe(4) + }) + + test('groups events by hour', () => { + const baseTime = Date.now() + + for (let i = 0; i < 12; i++) { + testDb.prepare(` + INSERT INTO audit_log (id, event_type, created_at) + VALUES (?, ?, ?) + `).run(`hour-${i}`, 'user.login', baseTime + (i * 1800000)) + } + + const events = testDb.prepare('SELECT * FROM audit_log ORDER BY created_at').all() + + expect(events.length).toBe(12) + }) + }) +}) diff --git a/chat/src/repositories/sessionRepository.test.js b/chat/src/repositories/sessionRepository.test.js new file mode 100644 index 0000000..797bb0f --- /dev/null +++ b/chat/src/repositories/sessionRepository.test.js @@ -0,0 +1,539 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const Database = require('better-sqlite3') + +const testDbPath = path.join(__dirname, '.test-sessions.db') + +let testDb + +function createTestSchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + refresh_token_hash TEXT, + device_fingerprint TEXT, + ip_address TEXT, + user_agent TEXT, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + last_accessed_at INTEGER NOT NULL + ) + `) + + db.exec(` + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + device_fingerprint TEXT, + ip_address TEXT, + user_agent TEXT, + used INTEGER DEFAULT 0, + revoked INTEGER DEFAULT 0, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + used_at INTEGER + ) + `) + + db.exec(` + CREATE TABLE IF NOT EXISTS token_blacklist ( + id TEXT PRIMARY KEY, + token_jti TEXT NOT NULL UNIQUE, + user_id TEXT, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + reason TEXT + ) + `) +} + +describe('Session Repository', () => { + beforeEach(() => { + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + + testDb = new Database(testDbPath) + createTestSchema(testDb) + }) + + afterEach(() => { + if (testDb) { + testDb.close() + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + }) + + describe('Session CRUD Operations', () => { + test('creates a session', () => { + const now = Date.now() + const sessionId = crypto.randomUUID() + const token = crypto.randomBytes(32).toString('hex') + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(sessionId, 'user-1', token, now + 3600000, now, now) + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) + + expect(session).toBeDefined() + expect(session.user_id).toBe('user-1') + expect(session.token).toBe(token) + }) + + test('creates session with all metadata', () => { + const now = Date.now() + const sessionId = crypto.randomUUID() + const token = crypto.randomBytes(32).toString('hex') + const refreshTokenHash = crypto.randomBytes(64).toString('hex') + const fingerprint = crypto.randomBytes(16).toString('hex') + + testDb.prepare(` + INSERT INTO sessions ( + id, user_id, token, refresh_token_hash, device_fingerprint, + ip_address, user_agent, expires_at, created_at, last_accessed_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + sessionId, 'user-2', token, refreshTokenHash, fingerprint, + '192.168.1.1', 'Mozilla/5.0', now + 3600000, now, now + ) + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) + + expect(session.device_fingerprint).toBe(fingerprint) + expect(session.ip_address).toBe('192.168.1.1') + expect(session.user_agent).toBe('Mozilla/5.0') + }) + + test('gets session by ID', () => { + const now = Date.now() + const sessionId = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(sessionId, 'user-3', 'token-3', now + 3600000, now, now) + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) + + expect(session).toBeDefined() + expect(session.id).toBe(sessionId) + }) + + test('gets session by token', () => { + const now = Date.now() + const token = crypto.randomBytes(32).toString('hex') + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-4', 'user-4', token, now + 3600000, now, now) + + const session = testDb.prepare('SELECT * FROM sessions WHERE token = ?').get(token) + + expect(session).toBeDefined() + expect(session.id).toBe('session-4') + }) + + test('gets all sessions for user', () => { + const now = Date.now() + + for (let i = 0; i < 3; i++) { + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(`session-${i}`, 'user-5', `token-5-${i}`, now + 3600000, now, now) + } + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-other', 'user-other', 'token-other', now + 3600000, now, now) + + const sessions = testDb.prepare(` + SELECT * FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY last_accessed_at DESC + `).all('user-5', now) + + expect(sessions.length).toBe(3) + }) + + test('excludes expired sessions', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-active', 'user-6', 'token-active', now + 3600000, now, now) + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-expired', 'user-6', 'token-expired', now - 1000, now, now) + + const sessions = testDb.prepare(` + SELECT * FROM sessions WHERE user_id = ? AND expires_at > ? + `).all('user-6', now) + + expect(sessions.length).toBe(1) + expect(sessions[0].id).toBe('session-active') + }) + + test('updates session last accessed time', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-update', 'user-7', 'token-7', now + 3600000, now, now - 1000) + + const newTime = Date.now() + testDb.prepare('UPDATE sessions SET last_accessed_at = ? WHERE id = ?') + .run(newTime, 'session-update') + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-update') + + expect(session.last_accessed_at).toBe(newTime) + }) + + test('deletes session (logout)', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-delete', 'user-8', 'token-8', now + 3600000, now, now) + + const result = testDb.prepare('DELETE FROM sessions WHERE id = ?').run('session-delete') + + expect(result.changes).toBe(1) + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-delete') + expect(session).toBeUndefined() + }) + + test('deletes all sessions for user (logout all)', () => { + const now = Date.now() + + for (let i = 0; i < 5; i++) { + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(`session-del-${i}`, 'user-9', `token-9-${i}`, now + 3600000, now, now) + } + + const result = testDb.prepare('DELETE FROM sessions WHERE user_id = ?').run('user-9') + + expect(result.changes).toBe(5) + }) + + test('cleans up expired sessions', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-cleanup-1', 'user-10', 'token-10-1', now - 1000, now, now) + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-cleanup-2', 'user-10', 'token-10-2', now + 3600000, now, now) + + const result = testDb.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now) + + expect(result.changes).toBe(1) + }) + }) + + describe('Refresh Token Operations', () => { + test('creates refresh token', () => { + const now = Date.now() + const tokenId = crypto.randomUUID() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(tokenId, 'user-rt-1', 'session-rt-1', tokenHash, now + 604800000, now) + + const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get(tokenId) + + expect(token).toBeDefined() + expect(token.user_id).toBe('user-rt-1') + expect(token.used).toBe(0) + expect(token.revoked).toBe(0) + }) + + test('gets refresh token by hash', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('rt-2', 'user-rt-2', 'session-rt-2', tokenHash, now + 604800000, now) + + const token = testDb.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ? + `).get(tokenHash, now) + + expect(token).toBeDefined() + expect(token.id).toBe('rt-2') + }) + + test('excludes used refresh tokens', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at, used) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('rt-used', 'user-rt-3', 'session-rt-3', tokenHash, now + 604800000, now, 1) + + const token = testDb.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ? + `).get(tokenHash, now) + + expect(token).toBeUndefined() + }) + + test('excludes revoked refresh tokens', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('rt-revoked', 'user-rt-4', 'session-rt-4', tokenHash, now + 604800000, now, 1) + + const token = testDb.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ? + `).get(tokenHash, now) + + expect(token).toBeUndefined() + }) + + test('excludes expired refresh tokens', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('rt-expired', 'user-rt-5', 'session-rt-5', tokenHash, now - 1000, now) + + const token = testDb.prepare(` + SELECT * FROM refresh_tokens + WHERE token_hash = ? AND used = 0 AND revoked = 0 AND expires_at > ? + `).get(tokenHash, now) + + expect(token).toBeUndefined() + }) + + test('marks refresh token as used', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('rt-mark-used', 'user-rt-6', 'session-rt-6', tokenHash, now + 604800000, now) + + const usedAt = Date.now() + testDb.prepare('UPDATE refresh_tokens SET used = 1, used_at = ? WHERE id = ?') + .run(usedAt, 'rt-mark-used') + + const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get('rt-mark-used') + + expect(token.used).toBe(1) + expect(token.used_at).toBe(usedAt) + }) + + test('revokes refresh token', () => { + const now = Date.now() + const tokenHash = crypto.randomBytes(64).toString('hex') + + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('rt-revoke', 'user-rt-7', 'session-rt-7', tokenHash, now + 604800000, now) + + const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE id = ?').run('rt-revoke') + + expect(result.changes).toBe(1) + + const token = testDb.prepare('SELECT * FROM refresh_tokens WHERE id = ?').get('rt-revoke') + expect(token.revoked).toBe(1) + }) + + test('revokes all session refresh tokens', () => { + const now = Date.now() + + for (let i = 0; i < 3; i++) { + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(`rt-session-${i}`, 'user-rt-8', 'session-rt-8', `hash-${i}`, now + 604800000, now) + } + + const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE session_id = ?').run('session-rt-8') + + expect(result.changes).toBe(3) + }) + + test('revokes all user refresh tokens', () => { + const now = Date.now() + + for (let i = 0; i < 5; i++) { + testDb.prepare(` + INSERT INTO refresh_tokens (id, user_id, session_id, token_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(`rt-user-${i}`, 'user-rt-9', `session-${i}`, `hash-user-${i}`, now + 604800000, now) + } + + const result = testDb.prepare('UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?').run('user-rt-9') + + expect(result.changes).toBe(5) + }) + }) + + describe('Token Blacklist', () => { + test('adds token to blacklist', () => { + const now = Date.now() + const jti = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at, reason) + VALUES (?, ?, ?, ?, ?, ?) + `).run(crypto.randomUUID(), jti, 'user-bl-1', now + 3600000, now, 'logout') + + const entry = testDb.prepare('SELECT * FROM token_blacklist WHERE token_jti = ?').get(jti) + + expect(entry).toBeDefined() + expect(entry.reason).toBe('logout') + }) + + test('checks if token is blacklisted', () => { + const now = Date.now() + const jti = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(crypto.randomUUID(), jti, 'user-bl-2', now + 3600000, now) + + const result = testDb.prepare('SELECT COUNT(*) as count FROM token_blacklist WHERE token_jti = ?').get(jti) + + expect(result.count).toBe(1) + }) + + test('token not blacklisted returns false', () => { + const result = testDb.prepare('SELECT COUNT(*) as count FROM token_blacklist WHERE token_jti = ?').get('non-existent-jti') + + expect(result.count).toBe(0) + }) + + test('cleans up expired blacklist entries', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(crypto.randomUUID(), 'jti-expired', 'user-bl-3', now - 1000, now) + + testDb.prepare(` + INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(crypto.randomUUID(), 'jti-active', 'user-bl-3', now + 3600000, now) + + const result = testDb.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(now) + + expect(result.changes).toBe(1) + + const active = testDb.prepare('SELECT * FROM token_blacklist WHERE token_jti = ?').get('jti-active') + expect(active).toBeDefined() + }) + + test('blacklist with different reasons', () => { + const now = Date.now() + const reasons = ['logout', 'password_change', 'security_breach', 'token_refresh'] + + reasons.forEach((reason, i) => { + testDb.prepare(` + INSERT INTO token_blacklist (id, token_jti, user_id, expires_at, created_at, reason) + VALUES (?, ?, ?, ?, ?, ?) + `).run(crypto.randomUUID(), `jti-${i}`, `user-bl-4-${i}`, now + 3600000, now, reason) + }) + + const entries = testDb.prepare('SELECT * FROM token_blacklist WHERE user_id LIKE ?').all('user-bl-4-%') + + expect(entries.length).toBe(4) + }) + }) + + describe('Session Security', () => { + test('enforces unique session tokens', () => { + const now = Date.now() + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-unique-1', 'user-sec-1', 'unique-token', now + 3600000, now, now) + + expect(() => { + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run('session-unique-2', 'user-sec-2', 'unique-token', now + 3600000, now, now) + }).toThrow() + }) + + test('stores device fingerprint', () => { + const now = Date.now() + const fingerprint = crypto.randomBytes(16).toString('hex') + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('session-fp', 'user-sec-3', 'token-fp', fingerprint, now + 3600000, now, now) + + const session = testDb.prepare('SELECT * FROM sessions WHERE id = ?').get('session-fp') + + expect(session.device_fingerprint).toBe(fingerprint) + }) + + test('detects session from different device', () => { + const now = Date.now() + const fp1 = crypto.randomBytes(16).toString('hex') + const fp2 = crypto.randomBytes(16).toString('hex') + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('session-dev-1', 'user-sec-4', 'token-dev-1', fp1, now + 3600000, now, now) + + testDb.prepare(` + INSERT INTO sessions (id, user_id, token, device_fingerprint, expires_at, created_at, last_accessed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run('session-dev-2', 'user-sec-4', 'token-dev-2', fp2, now + 3600000, now, now) + + const sessions = testDb.prepare('SELECT * FROM sessions WHERE user_id = ?').all('user-sec-4') + + expect(sessions.length).toBe(2) + expect(sessions[0].device_fingerprint).not.toBe(sessions[1].device_fingerprint) + }) + }) +}) diff --git a/chat/src/repositories/userRepository.test.js b/chat/src/repositories/userRepository.test.js new file mode 100644 index 0000000..91e2a3f --- /dev/null +++ b/chat/src/repositories/userRepository.test.js @@ -0,0 +1,457 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const Database = require('better-sqlite3') +const { initEncryption } = require('./encryption') + +const testDbPath = path.join(__dirname, '.test-users.db') +const testMasterKey = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' + +let testDb +let userRepository + +function createTestSchema(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + email_encrypted TEXT, + name TEXT, + name_encrypted TEXT, + password_hash TEXT NOT NULL, + providers TEXT DEFAULT '[]', + email_verified INTEGER DEFAULT 0, + verification_token TEXT, + verification_expires_at INTEGER, + reset_token TEXT, + reset_expires_at INTEGER, + plan TEXT DEFAULT 'hobby', + billing_status TEXT DEFAULT 'active', + billing_email TEXT, + payment_method_last4 TEXT, + subscription_renews_at INTEGER, + referred_by_affiliate_code TEXT, + affiliate_attribution_at INTEGER, + affiliate_payouts TEXT DEFAULT '[]', + two_factor_secret TEXT, + two_factor_enabled INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_login_at INTEGER + ) + `) +} + +describe('User Repository', () => { + beforeEach(async () => { + initEncryption(testMasterKey) + + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + + testDb = new Database(testDbPath) + createTestSchema(testDb) + + const { getDatabase } = require('../database/connection') + const originalGetDatabase = require('../database/connection').getDatabase + + const mockConnection = { + getDatabase: () => testDb + } + + userRepository = require('./userRepository') + + const originalModule = require.cache[require.resolve('./userRepository')] + if (originalModule) { + delete require.cache[require.resolve('./userRepository')] + } + + process.env.TEST_DB = testDbPath + }) + + afterEach(() => { + if (testDb) { + testDb.close() + } + if (fs.existsSync(testDbPath)) { + fs.unlinkSync(testDbPath) + } + }) + + describe('User CRUD Operations', () => { + test('creates a user with required fields', () => { + const userData = { + id: 'user-test-1', + email: 'test@example.com', + passwordHash: '$2b$12$hashedpassword123' + } + + const stmt = testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const now = Date.now() + stmt.run( + userData.id, + userData.email, + 'encrypted-email', + userData.passwordHash, + 'hobby', + 'active', + '[]', + now, + now + ) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get(userData.id) + + expect(user).toBeDefined() + expect(user.email).toBe(userData.email) + expect(user.plan).toBe('hobby') + expect(user.billing_status).toBe('active') + }) + + test('creates user with all optional fields', () => { + const now = Date.now() + const userData = { + id: 'user-test-2', + email: 'full@example.com', + name: 'Test User', + passwordHash: '$2b$12$hashedpassword123', + emailVerified: true, + plan: 'professional', + billingStatus: 'active', + billingEmail: 'billing@example.com' + } + + const stmt = testDb.prepare(` + INSERT INTO users ( + id, email, email_encrypted, name, name_encrypted, password_hash, + email_verified, plan, billing_status, billing_email, providers, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + stmt.run( + userData.id, + userData.email, + 'encrypted-email', + userData.name, + 'encrypted-name', + userData.passwordHash, + userData.emailVerified ? 1 : 0, + userData.plan, + userData.billingStatus, + userData.billingEmail, + '[]', + now, + now + ) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get(userData.id) + + expect(user.name).toBe(userData.name) + expect(user.email_verified).toBe(1) + expect(user.plan).toBe('professional') + expect(user.billing_email).toBe(userData.billingEmail) + }) + + test('gets user by ID', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-get-1', 'get@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-get-1') + + expect(user).toBeDefined() + expect(user.id).toBe('user-get-1') + expect(user.email).toBe('get@example.com') + }) + + test('returns null for non-existent user', () => { + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('non-existent') + expect(user).toBeUndefined() + }) + + test('gets user by email', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-email-1', 'email@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + const user = testDb.prepare('SELECT * FROM users WHERE email = ?').get('email@example.com') + + expect(user).toBeDefined() + expect(user.id).toBe('user-email-1') + }) + + test('updates user plan', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-update-1', 'update@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET plan = ?, updated_at = ? WHERE id = ?') + .run('professional', Date.now(), 'user-update-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-update-1') + + expect(user.plan).toBe('professional') + }) + + test('updates user email verification status', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-verify-1', 'verify@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET email_verified = ?, updated_at = ? WHERE id = ?') + .run(1, Date.now(), 'user-verify-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-verify-1') + + expect(user.email_verified).toBe(1) + }) + + test('deletes user', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-delete-1', 'delete@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + const result = testDb.prepare('DELETE FROM users WHERE id = ?').run('user-delete-1') + + expect(result.changes).toBe(1) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-delete-1') + expect(user).toBeUndefined() + }) + + test('gets all users with pagination', () => { + const now = Date.now() + for (let i = 0; i < 15; i++) { + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(`user-page-${i}`, `page${i}@example.com`, 'encrypted', 'hash', 'hobby', 'active', '[]', now + i, now + i) + } + + const page1 = testDb.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT 10 OFFSET 0').all() + const page2 = testDb.prepare('SELECT * FROM users ORDER BY created_at DESC LIMIT 10 OFFSET 10').all() + + expect(page1.length).toBe(10) + expect(page2.length).toBe(5) + }) + + test('counts total users', () => { + const now = Date.now() + for (let i = 0; i < 5; i++) { + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(`user-count-${i}`, `count${i}@example.com`, 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + } + + const result = testDb.prepare('SELECT COUNT(*) as count FROM users').get() + + expect(result.count).toBe(5) + }) + }) + + describe('User Authentication Fields', () => { + test('sets verification token', () => { + const now = Date.now() + const token = crypto.randomUUID() + const expiresAt = now + (24 * 60 * 60 * 1000) + + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-token-1', 'token@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET verification_token = ?, verification_expires_at = ? WHERE id = ?') + .run(token, expiresAt, 'user-token-1') + + const user = testDb.prepare('SELECT * FROM users WHERE verification_token = ?').get(token) + + expect(user).toBeDefined() + expect(user.verification_token).toBe(token) + }) + + test('sets password reset token', () => { + const now = Date.now() + const token = crypto.randomUUID() + const expiresAt = now + (60 * 60 * 1000) + + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-reset-1', 'reset@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET reset_token = ?, reset_expires_at = ? WHERE id = ?') + .run(token, expiresAt, 'user-reset-1') + + const user = testDb.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) + + expect(user).toBeDefined() + expect(user.reset_token).toBe(token) + }) + + test('clears verification token after verification', () => { + const now = Date.now() + const token = crypto.randomUUID() + + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at, verification_token, verification_expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-clear-1', 'clear@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now, token, now + 86400000) + + testDb.prepare('UPDATE users SET verification_token = NULL, verification_expires_at = NULL, email_verified = 1 WHERE id = ?') + .run('user-clear-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-clear-1') + + expect(user.verification_token).toBeNull() + expect(user.email_verified).toBe(1) + }) + }) + + describe('User Plan Management', () => { + test('enforces valid plan values', () => { + const validPlans = ['hobby', 'starter', 'professional', 'enterprise'] + + validPlans.forEach(plan => { + expect(['hobby', 'starter', 'professional', 'enterprise'].includes(plan)).toBe(true) + }) + }) + + test('updates billing status', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-billing-1', 'billing@example.com', 'encrypted', 'hash', 'professional', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET billing_status = ? WHERE id = ?') + .run('past_due', 'user-billing-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-billing-1') + + expect(user.billing_status).toBe('past_due') + }) + + test('sets subscription renewal date', () => { + const now = Date.now() + const renewsAt = now + (30 * 24 * 60 * 60 * 1000) + + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-renew-1', 'renew@example.com', 'encrypted', 'hash', 'professional', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET subscription_renews_at = ? WHERE id = ?') + .run(renewsAt, 'user-renew-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-renew-1') + + expect(user.subscription_renews_at).toBe(renewsAt) + }) + }) + + describe('User OAuth Providers', () => { + test('stores multiple providers', () => { + const now = Date.now() + const providers = ['google', 'github'] + + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-oauth-1', 'oauth@example.com', 'encrypted', 'hash', 'hobby', 'active', JSON.stringify(providers), now, now) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-oauth-1') + const parsedProviders = JSON.parse(user.providers) + + expect(parsedProviders).toContain('google') + expect(parsedProviders).toContain('github') + }) + + test('adds provider to existing user', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-add-oauth-1', 'add-oauth@example.com', 'encrypted', 'hash', 'hobby', 'active', '["google"]', now, now) + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-add-oauth-1') + const providers = JSON.parse(user.providers) + providers.push('github') + + testDb.prepare('UPDATE users SET providers = ? WHERE id = ?') + .run(JSON.stringify(providers), 'user-add-oauth-1') + + const updated = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-add-oauth-1') + expect(JSON.parse(updated.providers)).toContain('github') + }) + }) + + describe('Two-Factor Authentication', () => { + test('enables 2FA', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-2fa-1', '2fa@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET two_factor_enabled = 1, two_factor_secret = ? WHERE id = ?') + .run('encrypted-secret', 'user-2fa-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-2fa-1') + + expect(user.two_factor_enabled).toBe(1) + expect(user.two_factor_secret).toBe('encrypted-secret') + }) + + test('disables 2FA', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at, two_factor_enabled, two_factor_secret) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-2fa-disable-1', '2fa-disable@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now, 1, 'encrypted-secret') + + testDb.prepare('UPDATE users SET two_factor_enabled = 0, two_factor_secret = NULL WHERE id = ?') + .run('user-2fa-disable-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-2fa-disable-1') + + expect(user.two_factor_enabled).toBe(0) + expect(user.two_factor_secret).toBeNull() + }) + }) + + describe('Affiliate Attribution', () => { + test('sets affiliate referral code', () => { + const now = Date.now() + testDb.prepare(` + INSERT INTO users (id, email, email_encrypted, password_hash, plan, billing_status, providers, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run('user-aff-1', 'aff@example.com', 'encrypted', 'hash', 'hobby', 'active', '[]', now, now) + + testDb.prepare('UPDATE users SET referred_by_affiliate_code = ?, affiliate_attribution_at = ? WHERE id = ?') + .run('AFFILIATE123', now, 'user-aff-1') + + const user = testDb.prepare('SELECT * FROM users WHERE id = ?').get('user-aff-1') + + expect(user.referred_by_affiliate_code).toBe('AFFILIATE123') + expect(user.affiliate_attribution_at).toBe(now) + }) + }) +}) diff --git a/chat/src/tests/api-endpoints.test.js b/chat/src/tests/api-endpoints.test.js new file mode 100644 index 0000000..7523bea --- /dev/null +++ b/chat/src/tests/api-endpoints.test.js @@ -0,0 +1,768 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') +const bcrypt = require('bcrypt') + +const VALID_PLANS = ['hobby', 'starter', 'professional', 'enterprise'] +const PAID_PLANS = new Set(['starter', 'professional', 'enterprise']) +const PASSWORD_SALT_ROUNDS = 12 +const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const ACCESS_TOKEN_TTL = 15 * 60 +const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60 + +function isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +function isValidPassword(password) { + return password && password.length >= 8 +} + +function normalizePlan(plan) { + return VALID_PLANS.includes(plan) ? plan : 'hobby' +} + +function isPaidPlan(plan) { + return PAID_PLANS.has(plan) +} + +function generateAccessToken(payload, secret) { + const jwt = require('jsonwebtoken') + return jwt.sign({ + ...payload, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL + }, secret, { algorithm: 'HS256' }) +} + +function verifyAccessToken(token, secret) { + const jwt = require('jsonwebtoken') + try { + return jwt.verify(token, secret, { algorithms: ['HS256'] }) + } catch (error) { + return null + } +} + +function parseCookies(cookieHeader) { + const cookies = {} + if (!cookieHeader) return cookies + + cookieHeader.split(';').forEach(cookie => { + const [name, ...rest] = cookie.split('=') + if (name && rest.length > 0) { + cookies[name.trim()] = rest.join('=').trim() + } + }) + + return cookies +} + +function createMockRequest(options = {}) { + return { + method: options.method || 'GET', + url: options.url || '/', + headers: options.headers || {}, + body: options.body || {}, + ip: options.ip || '127.0.0.1', + connection: { remoteAddress: options.ip || '127.0.0.1' } + } +} + +function createMockResponse() { + const res = { + statusCode: 200, + headers: {}, + body: null, + status(code) { + this.statusCode = code + return this + }, + json(data) { + this.body = data + return this + }, + setHeader(name, value) { + this.headers[name] = value + return this + }, + end(data) { + this.body = data + return this + } + } + return res +} + +describe('API Endpoints', () => { + describe('Authentication Endpoints', () => { + const testSecret = 'test-jwt-secret-key-for-testing' + + describe('POST /api/auth/register', () => { + 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) + expect(isValidEmail('')).toBe(false) + }) + + test('validates password strength', () => { + expect(isValidPassword('password123')).toBe(true) + expect(isValidPassword('short')).toBe(false) + expect(isValidPassword('')).toBe(false) + expect(isValidPassword(null)).toBe(false) + }) + + test('hashes password with bcrypt', async () => { + const password = 'testPassword123' + const hash = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS) + + expect(hash).toBeDefined() + expect(hash).not.toBe(password) + expect(await bcrypt.compare(password, hash)).toBe(true) + }) + + test('rejects duplicate email', async () => { + const users = [{ email: 'existing@example.com' }] + const newEmail = 'existing@example.com' + + const exists = users.some(u => u.email === newEmail) + expect(exists).toBe(true) + }) + + test('creates user with default plan', () => { + const userData = { + id: 'user-1', + email: 'new@example.com', + plan: 'hobby' + } + + expect(normalizePlan(userData.plan)).toBe('hobby') + }) + }) + + describe('POST /api/auth/login', () => { + test('validates credentials', async () => { + const password = 'correctPassword' + const hash = await bcrypt.hash(password, PASSWORD_SALT_ROUNDS) + + expect(await bcrypt.compare('correctPassword', hash)).toBe(true) + expect(await bcrypt.compare('wrongPassword', hash)).toBe(false) + }) + + test('generates access token', () => { + const payload = { userId: 'user-1', email: 'test@example.com' } + const token = generateAccessToken(payload, testSecret) + + expect(token).toBeDefined() + expect(token.split('.').length).toBe(3) + }) + + test('token contains user data', () => { + const payload = { userId: 'user-1', email: 'test@example.com', plan: 'professional' } + const token = generateAccessToken(payload, testSecret) + const decoded = verifyAccessToken(token, testSecret) + + expect(decoded.userId).toBe('user-1') + expect(decoded.email).toBe('test@example.com') + expect(decoded.plan).toBe('professional') + }) + + test('rejects invalid token', () => { + const decoded = verifyAccessToken('invalid-token', testSecret) + expect(decoded).toBeNull() + }) + + test('tracks failed login attempts', () => { + const attempts = new Map() + const ip = '192.168.1.1' + + for (let i = 0; i < 5; i++) { + const current = attempts.get(ip) || 0 + attempts.set(ip, current + 1) + } + + expect(attempts.get(ip)).toBe(5) + }) + }) + + describe('POST /api/auth/logout', () => { + test('clears session cookie', () => { + const res = createMockResponse() + res.setHeader('Set-Cookie', 'user_session=; Path=/; HttpOnly; Max-Age=0') + + expect(res.headers['Set-Cookie']).toContain('Max-Age=0') + }) + + test('blacklists access token', () => { + const blacklist = new Set() + const jti = crypto.randomUUID() + + blacklist.add(jti) + + expect(blacklist.has(jti)).toBe(true) + }) + }) + + describe('POST /api/auth/refresh', () => { + test('validates refresh token', () => { + const storedHash = 'salt:hash' + const token = 'test-refresh-token' + + expect(storedHash.split(':').length).toBe(2) + }) + + test('generates new access token', () => { + const payload = { userId: 'user-1', email: 'test@example.com' } + const token = generateAccessToken(payload, testSecret) + const decoded = verifyAccessToken(token, testSecret) + + expect(decoded.userId).toBe('user-1') + }) + + test('rotates refresh token', () => { + const oldToken = crypto.randomBytes(64).toString('hex') + const newToken = crypto.randomBytes(64).toString('hex') + + expect(oldToken).not.toBe(newToken) + }) + }) + + describe('POST /api/auth/forgot-password', () => { + test('generates reset token', () => { + const resetToken = crypto.randomBytes(32).toString('hex') + + expect(resetToken.length).toBe(64) + }) + + test('sets reset expiry', () => { + const now = Date.now() + const expiryMs = 60 * 60 * 1000 + const expiresAt = now + expiryMs + + expect(expiresAt).toBeGreaterThan(now) + }) + + test('does not reveal if email exists', () => { + const response = { message: 'If the email exists, a reset link has been sent' } + + expect(response.message).not.toContain('not found') + }) + }) + + describe('POST /api/auth/reset-password', () => { + test('validates reset token', () => { + const tokens = new Map() + tokens.set('valid-token', { userId: 'user-1', expiresAt: Date.now() + 3600000 }) + + const tokenData = tokens.get('valid-token') + expect(tokenData).toBeDefined() + }) + + test('rejects expired token', () => { + const tokens = new Map() + tokens.set('expired-token', { userId: 'user-1', expiresAt: Date.now() - 1000 }) + + const tokenData = tokens.get('expired-token') + const isValid = tokenData && tokenData.expiresAt > Date.now() + + expect(isValid).toBe(false) + }) + + test('hashes new password', async () => { + const newPassword = 'newPassword123' + const hash = await bcrypt.hash(newPassword, PASSWORD_SALT_ROUNDS) + + expect(await bcrypt.compare(newPassword, hash)).toBe(true) + }) + }) + }) + + describe('User Endpoints', () => { + describe('GET /api/user/profile', () => { + test('returns user data without sensitive fields', () => { + const user = { + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + plan: 'professional', + passwordHash: '$2b$12$hash', + twoFactorSecret: 'secret' + } + + const safeUser = { + id: user.id, + email: user.email, + name: user.name, + plan: user.plan + } + + expect(safeUser.passwordHash).toBeUndefined() + expect(safeUser.twoFactorSecret).toBeUndefined() + }) + + test('requires authentication', () => { + const req = createMockRequest({ headers: {} }) + const token = req.headers.authorization + + expect(token).toBeUndefined() + }) + }) + + describe('PUT /api/user/profile', () => { + test('updates allowed fields', () => { + const updates = { name: 'New Name' } + const allowedFields = ['name', 'email'] + + const filteredUpdates = {} + Object.keys(updates).forEach(key => { + if (allowedFields.includes(key)) { + filteredUpdates[key] = updates[key] + } + }) + + expect(filteredUpdates.name).toBe('New Name') + }) + + test('ignores protected fields', () => { + const updates = { name: 'New Name', plan: 'enterprise', passwordHash: 'hash' } + const allowedFields = ['name', 'email'] + + const filteredUpdates = {} + Object.keys(updates).forEach(key => { + if (allowedFields.includes(key)) { + filteredUpdates[key] = updates[key] + } + }) + + expect(filteredUpdates.plan).toBeUndefined() + expect(filteredUpdates.passwordHash).toBeUndefined() + }) + }) + + describe('GET /api/user/usage', () => { + test('returns token usage', () => { + const usage = { + used: 50000, + limit: 100000, + remaining: 50000 + } + + expect(usage.remaining).toBe(usage.limit - usage.used) + }) + + test('calculates percentage used', () => { + const used = 50000 + const limit = 100000 + const percentage = (used / limit) * 100 + + expect(percentage).toBe(50) + }) + }) + + describe('GET /api/user/sessions', () => { + test('lists active sessions', () => { + const sessions = [ + { id: 'session-1', device: 'Chrome on Windows', lastAccessed: Date.now() }, + { id: 'session-2', device: 'Safari on iPhone', lastAccessed: Date.now() - 3600000 } + ] + + expect(sessions.length).toBe(2) + }) + + test('excludes session tokens', () => { + const sessions = [ + { id: 'session-1', token: 'secret-token', device: 'Chrome' } + ] + + const safeSessions = sessions.map(s => ({ id: s.id, device: s.device })) + + expect(safeSessions[0].token).toBeUndefined() + }) + }) + + describe('DELETE /api/user/sessions/:id', () => { + test('revokes specific session', () => { + const sessions = new Set(['session-1', 'session-2']) + const toDelete = 'session-1' + + sessions.delete(toDelete) + + expect(sessions.has(toDelete)).toBe(false) + expect(sessions.size).toBe(1) + }) + }) + }) + + describe('Plan Endpoints', () => { + describe('GET /api/plans', () => { + test('returns all plans', () => { + const plans = VALID_PLANS.map(plan => ({ + name: plan, + tokenLimit: { hobby: 50000, starter: 100000, professional: 5000000, enterprise: 20000000 }[plan], + appLimit: { hobby: 3, starter: 10, professional: 20, enterprise: Infinity }[plan], + price: { hobby: 0, starter: 750, professional: 2500, enterprise: 7500 }[plan] + })) + + expect(plans.length).toBe(4) + }) + }) + + describe('POST /api/subscription', () => { + test('validates plan selection', () => { + expect(normalizePlan('professional')).toBe('professional') + expect(normalizePlan('invalid')).toBe('hobby') + }) + + test('validates billing cycle', () => { + const cycles = ['monthly', 'yearly'] + + expect(cycles.includes('monthly')).toBe(true) + expect(cycles.includes('yearly')).toBe(true) + expect(cycles.includes('weekly')).toBe(false) + }) + + test('calculates subscription price', () => { + const prices = { + starter_monthly_usd: 750, + starter_yearly_usd: 7500 + } + + expect(prices.starter_yearly_usd).toBeLessThan(prices.starter_monthly_usd * 12) + }) + }) + }) + + describe('Admin Endpoints', () => { + const adminSession = new Map() + + describe('POST /api/admin/login', () => { + test('validates admin credentials', () => { + const adminUser = 'admin@example.com' + const adminPassword = 'adminPassword123' + + const credentials = { email: adminUser, password: adminPassword } + + expect(credentials.email).toBe(adminUser) + }) + + test('creates admin session', () => { + const sessionId = crypto.randomUUID() + adminSession.set(sessionId, { expiresAt: Date.now() + 86400000 }) + + expect(adminSession.has(sessionId)).toBe(true) + }) + + test('rate limits failed attempts', () => { + const attempts = { '192.168.1.1': 5 } + const limit = 5 + + expect(attempts['192.168.1.1']).toBeGreaterThanOrEqual(limit) + }) + }) + + describe('GET /api/admin/users', () => { + test('requires admin auth', () => { + const req = createMockRequest({ headers: { cookie: '' } }) + const cookies = parseCookies(req.headers.cookie) + + expect(cookies.admin_session).toBeUndefined() + }) + + test('returns paginated users', () => { + const users = Array.from({ length: 25 }, (_, i) => ({ id: `user-${i}` })) + const page = 1 + const limit = 10 + const offset = (page - 1) * limit + + const paginatedUsers = users.slice(offset, offset + limit) + + expect(paginatedUsers.length).toBe(10) + }) + }) + + describe('PUT /api/admin/users/:id/plan', () => { + test('updates user plan', () => { + const user = { id: 'user-1', plan: 'hobby' } + const newPlan = 'professional' + + user.plan = normalizePlan(newPlan) + + expect(user.plan).toBe('professional') + }) + + test('rejects invalid plan', () => { + const user = { id: 'user-1', plan: 'professional' } + const invalidPlan = 'ultra' + + user.plan = normalizePlan(invalidPlan) + + expect(user.plan).toBe('hobby') + }) + }) + + describe('GET /api/admin/analytics', () => { + test('returns usage statistics', () => { + const stats = { + totalUsers: 1000, + activeUsers: 750, + totalTokensUsed: 50000000, + revenue: 150000 + } + + expect(stats.activeUsers).toBeLessThanOrEqual(stats.totalUsers) + }) + }) + }) + + describe('Middleware', () => { + describe('Authentication Middleware', () => { + test('extracts token from Authorization header', () => { + const req = createMockRequest({ + headers: { authorization: 'Bearer test-token-123' } + }) + + const authHeader = req.headers.authorization + const token = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.substring(7) + : null + + expect(token).toBe('test-token-123') + }) + + test('extracts token from cookie', () => { + const req = createMockRequest({ + headers: { cookie: 'access_token=cookie-token-456' } + }) + + const cookies = parseCookies(req.headers.cookie) + const token = cookies.access_token || null + + expect(token).toBe('cookie-token-456') + }) + + test('prefers Authorization header over cookie', () => { + const req = createMockRequest({ + headers: { + authorization: 'Bearer header-token', + cookie: 'access_token=cookie-token' + } + }) + + const authHeader = req.headers.authorization + const headerToken = authHeader && authHeader.startsWith('Bearer ') + ? authHeader.substring(7) + : null + + expect(headerToken).toBe('header-token') + }) + }) + + describe('Rate Limiting Middleware', () => { + const rateLimiter = { + requests: new Map(), + windowMs: 60000, + maxRequests: 100, + + check(key) { + const now = Date.now() + let record = this.requests.get(key) + + if (!record || now - record.windowStart > this.windowMs) { + record = { count: 0, windowStart: now } + this.requests.set(key, record) + } + + record.count++ + return record.count <= this.maxRequests + } + } + + test('allows requests within limit', () => { + for (let i = 0; i < 50; i++) { + expect(rateLimiter.check('user-1')).toBe(true) + } + }) + + test('blocks requests over limit', () => { + rateLimiter.requests.clear() + + for (let i = 0; i < 100; i++) { + rateLimiter.check('user-2') + } + + expect(rateLimiter.check('user-2')).toBe(false) + }) + }) + + describe('CORS Middleware', () => { + test('sets CORS headers', () => { + const res = createMockResponse() + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + expect(res.headers['Access-Control-Allow-Origin']).toBe('*') + }) + }) + + describe('Error Handling Middleware', () => { + test('handles validation errors', () => { + const error = { name: 'ValidationError', message: 'Invalid input' } + const res = createMockResponse() + + res.status(400).json({ error: error.message }) + + expect(res.statusCode).toBe(400) + expect(res.body.error).toBe('Invalid input') + }) + + test('handles authentication errors', () => { + const error = { name: 'UnauthorizedError', message: 'Invalid token' } + const res = createMockResponse() + + res.status(401).json({ error: error.message }) + + expect(res.statusCode).toBe(401) + }) + + test('handles not found errors', () => { + const res = createMockResponse() + + res.status(404).json({ error: 'Resource not found' }) + + expect(res.statusCode).toBe(404) + }) + + test('handles server errors', () => { + const error = new Error('Internal server error') + const res = createMockResponse() + + res.status(500).json({ error: 'Internal server error' }) + + expect(res.statusCode).toBe(500) + }) + }) + }) + + describe('Request Validation', () => { + describe('Body Validation', () => { + test('validates JSON body', () => { + const body = { email: 'test@example.com', password: 'password123' } + + expect(typeof body).toBe('object') + expect(body.email).toBeDefined() + expect(body.password).toBeDefined() + }) + + test('rejects missing required fields', () => { + const body = { email: 'test@example.com' } + const required = ['email', 'password'] + + const missing = required.filter(field => !body[field]) + + expect(missing).toContain('password') + }) + + test('validates field types', () => { + const body = { email: 123, password: 'password' } + + expect(typeof body.email).not.toBe('string') + expect(typeof body.password).toBe('string') + }) + }) + + describe('Query Parameter Validation', () => { + test('parses pagination parameters', () => { + const query = { page: '2', limit: '20' } + + const page = parseInt(query.page) || 1 + const limit = parseInt(query.limit) || 10 + + expect(page).toBe(2) + expect(limit).toBe(20) + }) + + test('applies defaults for missing parameters', () => { + const query = {} + + const page = parseInt(query.page) || 1 + const limit = parseInt(query.limit) || 10 + + expect(page).toBe(1) + expect(limit).toBe(10) + }) + + test('validates parameter ranges', () => { + const limit = 1000 + const maxLimit = 100 + + const validLimit = Math.min(limit, maxLimit) + + expect(validLimit).toBe(100) + }) + }) + + describe('Path Parameter Validation', () => { + test('validates user ID format', () => { + const validId = 'user-123-abc' + const invalidId = '' + + expect(validId.length).toBeGreaterThan(0) + expect(invalidId.length).toBe(0) + }) + + test('validates UUID format', () => { + const uuid = crypto.randomUUID() + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + + expect(uuidRegex.test(uuid)).toBe(true) + }) + }) + }) +}) + +describe('OAuth Integration', () => { + const GOOGLE_CLIENT_ID = 'test-google-client-id' + const GITHUB_CLIENT_ID = 'test-github-client-id' + + describe('Google OAuth', () => { + test('generates OAuth state', () => { + const state = crypto.randomBytes(32).toString('hex') + + expect(state.length).toBe(64) + }) + + test('builds authorization URL', () => { + const redirectUri = 'https://example.com/auth/google/callback' + const state = 'test-state' + + const url = new URL('https://accounts.google.com/o/oauth2/v2/auth') + url.searchParams.set('client_id', GOOGLE_CLIENT_ID) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', 'email profile') + url.searchParams.set('state', state) + + expect(url.searchParams.get('client_id')).toBe(GOOGLE_CLIENT_ID) + expect(url.searchParams.get('state')).toBe(state) + }) + }) + + describe('GitHub OAuth', () => { + test('builds authorization URL', () => { + const redirectUri = 'https://example.com/auth/github/callback' + const state = 'test-state' + + const url = new URL('https://github.com/login/oauth/authorize') + url.searchParams.set('client_id', GITHUB_CLIENT_ID) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'user:email') + url.searchParams.set('state', state) + + expect(url.searchParams.get('client_id')).toBe(GITHUB_CLIENT_ID) + }) + }) +}) diff --git a/chat/src/tests/model-routing.test.js b/chat/src/tests/model-routing.test.js new file mode 100644 index 0000000..a9d06fb --- /dev/null +++ b/chat/src/tests/model-routing.test.js @@ -0,0 +1,584 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') + +const OPENROUTER_STATIC_FALLBACK_MODELS = [ + 'anthropic/claude-3.5-sonnet', + 'openai/gpt-4o-mini', + 'mistralai/mistral-large-latest', + 'google/gemini-flash-1.5', +] + +const DEFAULT_TOKEN_RATES = { + usd: 250, + gbp: 200, + eur: 250, +} + +const PLAN_TOKEN_LIMITS = { + hobby: 50_000, + starter: 100_000, + professional: 5_000_000, + enterprise: 20_000_000, +} + +const PLAN_APP_LIMITS = { + hobby: 3, + starter: 10, + professional: 20, + enterprise: Infinity, +} + +const EXTERNAL_TEST_LIMITS = { + hobby: 3, + starter: 50, + professional: Infinity, + enterprise: Infinity, +} + +const PAID_PLANS = new Set(['starter', 'professional', 'enterprise']) + +function normalizePlanSelection(plan) { + const validPlans = ['hobby', 'starter', 'professional', 'enterprise'] + return validPlans.includes(plan) ? plan : 'hobby' +} + +function isPaidPlan(plan) { + return PAID_PLANS.has(plan) +} + +function getPlanTokenLimit(plan) { + return PLAN_TOKEN_LIMITS[normalizePlanSelection(plan)] || PLAN_TOKEN_LIMITS.hobby +} + +function getPlanAppLimit(plan) { + return PLAN_APP_LIMITS[normalizePlanSelection(plan)] || PLAN_APP_LIMITS.hobby +} + +function getExternalTestLimit(plan) { + return EXTERNAL_TEST_LIMITS[normalizePlanSelection(plan)] || EXTERNAL_TEST_LIMITS.hobby +} + +function calculateTokenCost(tokens, currency) { + const rate = DEFAULT_TOKEN_RATES[currency] || DEFAULT_TOKEN_RATES.usd + return (tokens / 1_000_000) * rate +} + +function parseMemoryValue(raw) { + if (raw === undefined || raw === null) return 0 + const str = String(raw).trim() + const match = str.match(/^([0-9.]+)\s*([kKmMgGtT]i?)?(?:[bB])?$/) + if (!match) return Number(str) || 0 + const value = parseFloat(match[1]) + const unit = (match[2] || '').toLowerCase() + const KIB = 1024 + const MIB = KIB * 1024 + const GIB = MIB * 1024 + const TIB = GIB * 1024 + const multipliers = { '': 1, k: KIB, ki: KIB, m: MIB, mi: MIB, g: GIB, gi: GIB, t: TIB, ti: TIB } + if (!Number.isFinite(value) || value <= 0) return 0 + return Math.round(value * (multipliers[unit] || 1)) +} + +function resolvePositiveInt(raw, fallback) { + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + return Math.round(parsed) +} + +function resolveIntervalMs(raw, fallback, min) { + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + return Math.max(parsed, min) +} + +describe('Model Routing', () => { + describe('Plan Selection and Validation', () => { + test('normalizes valid plans', () => { + expect(normalizePlanSelection('hobby')).toBe('hobby') + expect(normalizePlanSelection('starter')).toBe('starter') + expect(normalizePlanSelection('professional')).toBe('professional') + expect(normalizePlanSelection('enterprise')).toBe('enterprise') + }) + + test('defaults invalid plans to hobby', () => { + expect(normalizePlanSelection('invalid')).toBe('hobby') + expect(normalizePlanSelection('')).toBe('hobby') + expect(normalizePlanSelection(null)).toBe('hobby') + expect(normalizePlanSelection(undefined)).toBe('hobby') + expect(normalizePlanSelection('free')).toBe('hobby') + }) + + test('identifies paid plans correctly', () => { + expect(isPaidPlan('hobby')).toBe(false) + expect(isPaidPlan('starter')).toBe(true) + expect(isPaidPlan('professional')).toBe(true) + expect(isPaidPlan('enterprise')).toBe(true) + }) + + test('returns correct token limits by plan', () => { + expect(getPlanTokenLimit('hobby')).toBe(50_000) + expect(getPlanTokenLimit('starter')).toBe(100_000) + expect(getPlanTokenLimit('professional')).toBe(5_000_000) + expect(getPlanTokenLimit('enterprise')).toBe(20_000_000) + }) + + test('returns correct app limits by plan', () => { + expect(getPlanAppLimit('hobby')).toBe(3) + expect(getPlanAppLimit('starter')).toBe(10) + expect(getPlanAppLimit('professional')).toBe(20) + expect(getPlanAppLimit('enterprise')).toBe(Infinity) + }) + + test('returns correct external test limits by plan', () => { + expect(getExternalTestLimit('hobby')).toBe(3) + expect(getExternalTestLimit('starter')).toBe(50) + expect(getExternalTestLimit('professional')).toBe(Infinity) + expect(getExternalTestLimit('enterprise')).toBe(Infinity) + }) + }) + + describe('Provider Fallback Models', () => { + test('has fallback models available', () => { + expect(OPENROUTER_STATIC_FALLBACK_MODELS.length).toBeGreaterThan(0) + }) + + test('includes major providers', () => { + expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('anthropic'))).toBe(true) + expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('openai'))).toBe(true) + expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('mistralai'))).toBe(true) + expect(OPENROUTER_STATIC_FALLBACK_MODELS.some(m => m.includes('google'))).toBe(true) + }) + + test('fallback models are properly formatted', () => { + OPENROUTER_STATIC_FALLBACK_MODELS.forEach(model => { + expect(model).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/i) + }) + }) + + test('gets next fallback model', () => { + const usedModels = new Set(['anthropic/claude-3.5-sonnet']) + const nextModel = OPENROUTER_STATIC_FALLBACK_MODELS.find(m => !usedModels.has(m)) + + expect(nextModel).toBeDefined() + expect(nextModel).not.toBe('anthropic/claude-3.5-sonnet') + }) + + test('returns undefined when all fallbacks exhausted', () => { + const usedModels = new Set(OPENROUTER_STATIC_FALLBACK_MODELS) + const nextModel = OPENROUTER_STATIC_FALLBACK_MODELS.find(m => !usedModels.has(m)) + + expect(nextModel).toBeUndefined() + }) + }) + + describe('Model Selection Logic', () => { + const modelTiers = { + free: ['openai/gpt-3.5-turbo', 'google/gemini-flash-1.5'], + standard: ['openai/gpt-4o-mini', 'anthropic/claude-3-haiku'], + premium: ['openai/gpt-4o', 'anthropic/claude-3.5-sonnet'], + enterprise: ['anthropic/claude-3-opus', 'openai/gpt-4-turbo'] + } + + function getModelsForPlan(plan) { + const normalized = normalizePlanSelection(plan) + if (normalized === 'enterprise') { + return [...modelTiers.free, ...modelTiers.standard, ...modelTiers.premium, ...modelTiers.enterprise] + } + if (normalized === 'professional') { + return [...modelTiers.free, ...modelTiers.standard, ...modelTiers.premium] + } + if (normalized === 'starter') { + return [...modelTiers.free, ...modelTiers.standard] + } + return [...modelTiers.free] + } + + test('hobby plan gets free models only', () => { + const models = getModelsForPlan('hobby') + expect(models).toEqual(modelTiers.free) + }) + + test('starter plan gets free and standard models', () => { + const models = getModelsForPlan('starter') + expect(models).toContain('openai/gpt-3.5-turbo') + expect(models).toContain('openai/gpt-4o-mini') + expect(models).not.toContain('openai/gpt-4o') + }) + + test('professional plan gets up to premium models', () => { + const models = getModelsForPlan('professional') + expect(models).toContain('openai/gpt-4o') + expect(models).not.toContain('anthropic/claude-3-opus') + }) + + test('enterprise plan gets all models', () => { + const models = getModelsForPlan('enterprise') + expect(models).toContain('anthropic/claude-3-opus') + }) + }) + + describe('Token Rate Calculations', () => { + test('calculates USD token cost', () => { + const cost = calculateTokenCost(1_000_000, 'usd') + expect(cost).toBe(250) + }) + + test('calculates GBP token cost', () => { + const cost = calculateTokenCost(1_000_000, 'gbp') + expect(cost).toBe(200) + }) + + test('calculates EUR token cost', () => { + const cost = calculateTokenCost(1_000_000, 'eur') + expect(cost).toBe(250) + }) + + test('calculates partial token cost', () => { + const cost = calculateTokenCost(500_000, 'usd') + expect(cost).toBe(125) + }) + + test('defaults to USD for unknown currency', () => { + const cost = calculateTokenCost(1_000_000, 'unknown') + expect(cost).toBe(250) + }) + + test('calculates zero cost for zero tokens', () => { + const cost = calculateTokenCost(0, 'usd') + expect(cost).toBe(0) + }) + }) + + describe('Memory and Resource Parsing', () => { + test('parses bytes value', () => { + expect(parseMemoryValue('1024')).toBe(1024) + }) + + test('parses kilobytes', () => { + expect(parseMemoryValue('1K')).toBe(1024) + expect(parseMemoryValue('1KB')).toBe(1024) + expect(parseMemoryValue('1KiB')).toBe(1024) + }) + + test('parses megabytes', () => { + expect(parseMemoryValue('1M')).toBe(1024 * 1024) + expect(parseMemoryValue('512MB')).toBe(512 * 1024 * 1024) + }) + + test('parses gigabytes', () => { + expect(parseMemoryValue('1G')).toBe(1024 * 1024 * 1024) + expect(parseMemoryValue('2GB')).toBe(2 * 1024 * 1024 * 1024) + }) + + test('handles null/undefined', () => { + expect(parseMemoryValue(null)).toBe(0) + expect(parseMemoryValue(undefined)).toBe(0) + }) + + test('handles invalid input', () => { + expect(parseMemoryValue('invalid')).toBe(0) + expect(parseMemoryValue('')).toBe(0) + }) + + test('resolvePositiveInt returns fallback for invalid', () => { + expect(resolvePositiveInt('invalid', 10)).toBe(10) + expect(resolvePositiveInt(null, 10)).toBe(10) + expect(resolvePositiveInt(-5, 10)).toBe(10) + }) + + test('resolvePositiveInt returns parsed value', () => { + expect(resolvePositiveInt('50', 10)).toBe(50) + expect(resolvePositiveInt(100, 10)).toBe(100) + }) + + test('resolveIntervalMs enforces minimum', () => { + expect(resolveIntervalMs('100', 1000, 500)).toBe(500) + expect(resolveIntervalMs('2000', 1000, 500)).toBe(2000) + }) + }) + + describe('Rate Limiting', () => { + const rateLimiter = { + requests: new Map(), + windowMs: 60000, + maxRequests: 10, + + check(key) { + const now = Date.now() + let record = this.requests.get(key) + + if (!record || now - record.windowStart > this.windowMs) { + record = { count: 0, windowStart: now } + this.requests.set(key, record) + } + + record.count++ + + return { + allowed: record.count <= this.maxRequests, + remaining: Math.max(0, this.maxRequests - record.count), + resetAt: record.windowStart + this.windowMs + } + }, + + reset() { + this.requests.clear() + } + } + + beforeEach(() => { + rateLimiter.reset() + }) + + test('allows first request', () => { + const result = rateLimiter.check('user-1') + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(9) + }) + + test('tracks multiple requests', () => { + for (let i = 0; i < 5; i++) { + rateLimiter.check('user-2') + } + const result = rateLimiter.check('user-2') + + expect(result.allowed).toBe(true) + expect(result.remaining).toBe(4) + }) + + test('blocks after limit reached', () => { + for (let i = 0; i < 10; i++) { + rateLimiter.check('user-3') + } + const result = rateLimiter.check('user-3') + + expect(result.allowed).toBe(false) + expect(result.remaining).toBe(0) + }) + + test('separate limits per key', () => { + for (let i = 0; i < 10; i++) { + rateLimiter.check('user-4a') + } + + const resultA = rateLimiter.check('user-4a') + const resultB = rateLimiter.check('user-4b') + + expect(resultA.allowed).toBe(false) + expect(resultB.allowed).toBe(true) + }) + }) + + describe('Provider Health Checks', () => { + const providerHealth = { + openrouter: { status: 'healthy', latency: 150, lastCheck: Date.now() }, + mistral: { status: 'healthy', latency: 200, lastCheck: Date.now() }, + ollama: { status: 'degraded', latency: 500, lastCheck: Date.now() }, + google: { status: 'healthy', latency: 180, lastCheck: Date.now() } + } + + function isProviderHealthy(provider) { + const health = providerHealth[provider] + if (!health) return false + return health.status === 'healthy' + } + + function getProviderLatency(provider) { + return providerHealth[provider]?.latency || Infinity + } + + function getHealthyProviders() { + return Object.entries(providerHealth) + .filter(([_, health]) => health.status === 'healthy') + .map(([name]) => name) + } + + test('checks OpenRouter health', () => { + expect(isProviderHealthy('openrouter')).toBe(true) + }) + + test('detects degraded provider', () => { + expect(isProviderHealthy('ollama')).toBe(false) + }) + + test('handles unknown provider', () => { + expect(isProviderHealthy('unknown')).toBe(false) + }) + + test('gets provider latency', () => { + expect(getProviderLatency('openrouter')).toBe(150) + expect(getProviderLatency('unknown')).toBe(Infinity) + }) + + test('lists healthy providers', () => { + const healthy = getHealthyProviders() + expect(healthy).toContain('openrouter') + expect(healthy).toContain('mistral') + expect(healthy).toContain('google') + expect(healthy).not.toContain('ollama') + }) + }) + + describe('Request Priority Queue', () => { + const priorityQueue = { + queue: [], + + add(request) { + this.queue.push({ + ...request, + addedAt: Date.now() + }) + this.queue.sort((a, b) => a.priority - b.priority) + }, + + next() { + return this.queue.shift() + }, + + length() { + return this.queue.length + }, + + clear() { + this.queue = [] + } + } + + beforeEach(() => { + priorityQueue.clear() + }) + + test('adds requests to queue', () => { + priorityQueue.add({ id: 'req-1', priority: 5 }) + expect(priorityQueue.length()).toBe(1) + }) + + test('orders by priority', () => { + priorityQueue.add({ id: 'req-1', priority: 5 }) + priorityQueue.add({ id: 'req-2', priority: 1 }) + priorityQueue.add({ id: 'req-3', priority: 3 }) + + expect(priorityQueue.next().id).toBe('req-2') + expect(priorityQueue.next().id).toBe('req-3') + expect(priorityQueue.next().id).toBe('req-1') + }) + + test('processes in order', () => { + priorityQueue.add({ id: 'first', priority: 1 }) + priorityQueue.add({ id: 'second', priority: 2 }) + + expect(priorityQueue.next().id).toBe('first') + expect(priorityQueue.next().id).toBe('second') + expect(priorityQueue.length()).toBe(0) + }) + }) +}) + +describe('OpenRouter Configuration', () => { + test('default API URL is correct', () => { + const DEFAULT_OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions' + expect(DEFAULT_OPENROUTER_API_URL).toContain('openrouter.ai') + expect(DEFAULT_OPENROUTER_API_URL).toContain('chat/completions') + }) + + test('default model is set', () => { + const OPENROUTER_DEFAULT_MODEL = 'openai/gpt-4o-mini' + expect(OPENROUTER_DEFAULT_MODEL).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/i) + }) + + test('error detail limit is reasonable', () => { + const OPENROUTER_ERROR_DETAIL_LIMIT = 400 + expect(OPENROUTER_ERROR_DETAIL_LIMIT).toBeGreaterThan(0) + expect(OPENROUTER_ERROR_DETAIL_LIMIT).toBeLessThan(1000) + }) +}) + +describe('Mistral Configuration', () => { + test('default API URL is correct', () => { + const DEFAULT_MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions' + expect(DEFAULT_MISTRAL_API_URL).toContain('mistral.ai') + expect(DEFAULT_MISTRAL_API_URL).toContain('chat/completions') + }) + + test('default model is set', () => { + const MISTRAL_DEFAULT_MODEL = 'mistral-large-latest' + expect(MISTRAL_DEFAULT_MODEL).toBeDefined() + expect(MISTRAL_DEFAULT_MODEL.length).toBeGreaterThan(0) + }) +}) + +describe('Subscription and Payment Processing', () => { + const SUBSCRIPTION_PRICES = { + starter_monthly_usd: 750, + starter_yearly_usd: 7500, + professional_monthly_usd: 2500, + professional_yearly_usd: 25000, + enterprise_monthly_usd: 7500, + enterprise_yearly_usd: 75000 + } + + const TOPUP_PRICES = { + topup_1_usd: 750, + topup_2_usd: 2500, + topup_3_usd: 7500, + topup_4_usd: 12500 + } + + const TOPUP_TOKENS = { + topup_1: 100_000, + topup_2: 5_000_000, + topup_3: 20_000_000, + topup_4: 50_000_000 + } + + test('yearly discount is applied', () => { + const monthly = SUBSCRIPTION_PRICES.starter_monthly_usd + const yearly = SUBSCRIPTION_PRICES.starter_yearly_usd + const expectedYearly = monthly * 10 + + expect(yearly).toBeLessThan(monthly * 12) + expect(yearly).toBe(expectedYearly) + }) + + test('enterprise costs more than professional', () => { + expect(SUBSCRIPTION_PRICES.enterprise_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.professional_monthly_usd) + expect(SUBSCRIPTION_PRICES.professional_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.starter_monthly_usd) + }) + + test('top-up tokens scale correctly', () => { + expect(TOPUP_TOKENS.topup_2).toBeGreaterThan(TOPUP_TOKENS.topup_1) + expect(TOPUP_TOKENS.topup_3).toBeGreaterThan(TOPUP_TOKENS.topup_2) + expect(TOPUP_TOKENS.topup_4).toBeGreaterThan(TOPUP_TOKENS.topup_3) + }) + + test('top-up price per token decreases with volume', () => { + const pricePerToken1 = TOPUP_PRICES.topup_1_usd / TOPUP_TOKENS.topup_1 + const pricePerToken4 = TOPUP_PRICES.topup_4_usd / TOPUP_TOKENS.topup_4 + + expect(pricePerToken4).toBeLessThan(pricePerToken1) + }) + + test('calculates subscription value', () => { + const monthlyValue = SUBSCRIPTION_PRICES.professional_monthly_usd / 100 + const yearlyValue = SUBSCRIPTION_PRICES.professional_yearly_usd / (100 * 12) + + expect(monthlyValue).toBe(25) + expect(yearlyValue).toBeCloseTo(20.83, 1) + }) + + test('validates currency support', () => { + const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur'] + + expect(SUPPORTED_CURRENCIES).toContain('usd') + expect(SUPPORTED_CURRENCIES).toContain('gbp') + expect(SUPPORTED_CURRENCIES).toContain('eur') + expect(SUPPORTED_CURRENCIES.length).toBe(3) + }) + + test('validates billing cycles', () => { + const BILLING_CYCLES = ['monthly', 'yearly'] + + expect(BILLING_CYCLES).toContain('monthly') + expect(BILLING_CYCLES).toContain('yearly') + expect(BILLING_CYCLES.length).toBe(2) + }) +}) diff --git a/chat/src/tests/payments.test.js b/chat/src/tests/payments.test.js new file mode 100644 index 0000000..e503e2e --- /dev/null +++ b/chat/src/tests/payments.test.js @@ -0,0 +1,506 @@ +const { describe, test, expect, beforeEach, afterEach } = require('bun:test') +const crypto = require('crypto') + +const MIN_PAYMENT_AMOUNT = 50 + +const SUBSCRIPTION_PRICES = { + starter_monthly_usd: 750, + starter_yearly_usd: 7500, + starter_monthly_gbp: 625, + starter_yearly_gbp: 6250, + starter_monthly_eur: 750, + starter_yearly_eur: 7500, + + professional_monthly_usd: 2500, + professional_yearly_usd: 25000, + professional_monthly_gbp: 2100, + professional_yearly_gbp: 21000, + professional_monthly_eur: 2500, + professional_yearly_eur: 25000, + + enterprise_monthly_usd: 7500, + enterprise_yearly_usd: 75000, + enterprise_monthly_gbp: 6250, + enterprise_yearly_gbp: 62500, + enterprise_monthly_eur: 7500, + enterprise_yearly_eur: 75000 +} + +const TOPUP_PRICES = { + topup_1_usd: 750, + topup_1_gbp: 500, + topup_1_eur: 750, + topup_2_usd: 2500, + topup_2_gbp: 2000, + topup_2_eur: 2500, + topup_3_usd: 7500, + topup_3_gbp: 6000, + topup_3_eur: 7500, + topup_4_usd: 12500, + topup_4_gbp: 10000, + topup_4_eur: 12500 +} + +const TOPUP_TOKENS = { + topup_1: 100_000, + topup_2: 5_000_000, + topup_3: 20_000_000, + topup_4: 50_000_000 +} + +const PAYG_PRICES = { + usd: 250, + gbp: 200, + eur: 250 +} + +const BUSINESS_TOPUP_DISCOUNT = 0.025 +const ENTERPRISE_TOPUP_DISCOUNT = 0.05 + +const BILLING_CYCLES = ['monthly', 'yearly'] +const SUPPORTED_CURRENCIES = ['usd', 'gbp', 'eur'] + +function formatPrice(cents, currency) { + const symbols = { usd: '$', gbp: '£', eur: '€' } + return `${symbols[currency] || '$'}${(cents / 100).toFixed(2)}` +} + +function getProductKey(plan, cycle, currency) { + return `${plan}_${cycle}_${currency}` +} + +function getTopupProductKey(option, currency) { + return `topup_${option}_${currency}` +} + +function calculateYearlySavings(monthlyPrice, yearlyPrice) { + return (monthlyPrice * 12) - yearlyPrice +} + +function applyDiscount(price, discountRate) { + return Math.round(price * (1 - discountRate)) +} + +function generatePaymentId() { + return `pay_${crypto.randomBytes(16).toString('hex')}` +} + +function validatePaymentAmount(amount) { + return Number.isFinite(amount) && amount >= MIN_PAYMENT_AMOUNT +} + +function parseWebhookPayload(payload) { + try { + return JSON.parse(payload) + } catch { + return null + } +} + +describe('Payments', () => { + describe('Subscription Pricing', () => { + test('has all plan prices defined', () => { + const plans = ['starter', 'professional', 'enterprise'] + const cycles = ['monthly', 'yearly'] + const currencies = ['usd', 'gbp', 'eur'] + + plans.forEach(plan => { + cycles.forEach(cycle => { + currencies.forEach(currency => { + const key = getProductKey(plan, cycle, currency) + expect(SUBSCRIPTION_PRICES[key]).toBeDefined() + expect(SUBSCRIPTION_PRICES[key]).toBeGreaterThan(0) + }) + }) + }) + }) + + test('yearly price offers discount', () => { + const plans = ['starter', 'professional', 'enterprise'] + + plans.forEach(plan => { + const monthlyPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'usd')] + const yearlyPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'yearly', 'usd')] + const savings = calculateYearlySavings(monthlyPrice, yearlyPrice) + + expect(yearlyPrice).toBeLessThan(monthlyPrice * 12) + expect(savings).toBe(monthlyPrice * 2) + }) + }) + + test('enterprise is most expensive', () => { + expect(SUBSCRIPTION_PRICES.enterprise_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.professional_monthly_usd) + expect(SUBSCRIPTION_PRICES.professional_monthly_usd).toBeGreaterThan(SUBSCRIPTION_PRICES.starter_monthly_usd) + }) + + test('formats prices correctly', () => { + expect(formatPrice(750, 'usd')).toBe('$7.50') + expect(formatPrice(625, 'gbp')).toBe('£6.25') + expect(formatPrice(750, 'eur')).toBe('€7.50') + }) + + test('generates product keys correctly', () => { + expect(getProductKey('starter', 'monthly', 'usd')).toBe('starter_monthly_usd') + expect(getProductKey('professional', 'yearly', 'gbp')).toBe('professional_yearly_gbp') + }) + }) + + describe('Top-up Pricing', () => { + test('has all top-up options defined', () => { + const options = ['1', '2', '3', '4'] + const currencies = ['usd', 'gbp', 'eur'] + + options.forEach(option => { + currencies.forEach(currency => { + const key = getTopupProductKey(option, currency) + expect(TOPUP_PRICES[key]).toBeDefined() + expect(TOPUP_PRICES[key]).toBeGreaterThan(0) + }) + }) + }) + + test('token amounts are correct', () => { + expect(TOPUP_TOKENS.topup_1).toBe(100_000) + expect(TOPUP_TOKENS.topup_2).toBe(5_000_000) + expect(TOPUP_TOKENS.topup_3).toBe(20_000_000) + expect(TOPUP_TOKENS.topup_4).toBe(50_000_000) + }) + + test('larger top-ups have better value', () => { + const pricePerToken1 = TOPUP_PRICES.topup_1_usd / TOPUP_TOKENS.topup_1 + const pricePerToken2 = TOPUP_PRICES.topup_2_usd / TOPUP_TOKENS.topup_2 + const pricePerToken3 = TOPUP_PRICES.topup_3_usd / TOPUP_TOKENS.topup_3 + const pricePerToken4 = TOPUP_PRICES.topup_4_usd / TOPUP_TOKENS.topup_4 + + expect(pricePerToken2).toBeLessThan(pricePerToken1) + expect(pricePerToken3).toBeLessThan(pricePerToken2) + expect(pricePerToken4).toBeLessThan(pricePerToken3) + }) + + test('calculates tokens per dollar', () => { + const tokensPerDollar1 = TOPUP_TOKENS.topup_1 / (TOPUP_PRICES.topup_1_usd / 100) + const tokensPerDollar4 = TOPUP_TOKENS.topup_4 / (TOPUP_PRICES.topup_4_usd / 100) + + expect(tokensPerDollar1).toBeCloseTo(13333.33, 0) + expect(tokensPerDollar4).toBeCloseTo(40000, 0) + expect(tokensPerDollar4).toBeGreaterThan(tokensPerDollar1) + }) + }) + + describe('Discounts', () => { + test('applies business discount', () => { + const originalPrice = 1000 + const discountedPrice = applyDiscount(originalPrice, BUSINESS_TOPUP_DISCOUNT) + + expect(discountedPrice).toBe(975) + }) + + test('applies enterprise discount', () => { + const originalPrice = 1000 + const discountedPrice = applyDiscount(originalPrice, ENTERPRISE_TOPUP_DISCOUNT) + + expect(discountedPrice).toBe(950) + }) + + test('enterprise discount is higher than business', () => { + expect(ENTERPRISE_TOPUP_DISCOUNT).toBeGreaterThan(BUSINESS_TOPUP_DISCOUNT) + }) + + test('discount rates are valid percentages', () => { + expect(BUSINESS_TOPUP_DISCOUNT).toBeGreaterThanOrEqual(0) + expect(BUSINESS_TOPUP_DISCOUNT).toBeLessThan(1) + expect(ENTERPRISE_TOPUP_DISCOUNT).toBeGreaterThanOrEqual(0) + expect(ENTERPRISE_TOPUP_DISCOUNT).toBeLessThan(1) + }) + }) + + describe('Pay-as-you-go', () => { + test('has prices for all currencies', () => { + SUPPORTED_CURRENCIES.forEach(currency => { + expect(PAYG_PRICES[currency]).toBeDefined() + expect(PAYG_PRICES[currency]).toBeGreaterThan(0) + }) + }) + + test('calculates PAYG cost correctly', () => { + const tokens = 500_000 + const cost = (tokens / 1_000_000) * PAYG_PRICES.usd + + expect(cost).toBe(125) + }) + + test('GBP is cheaper than USD/EUR for PAYG', () => { + expect(PAYG_PRICES.gbp).toBeLessThan(PAYG_PRICES.usd) + expect(PAYG_PRICES.gbp).toBeLessThan(PAYG_PRICES.eur) + }) + }) + + describe('Payment Validation', () => { + test('validates minimum payment amount', () => { + expect(validatePaymentAmount(50)).toBe(true) + expect(validatePaymentAmount(49)).toBe(false) + expect(validatePaymentAmount(0)).toBe(false) + expect(validatePaymentAmount(-10)).toBe(false) + expect(validatePaymentAmount(Infinity)).toBe(false) + expect(validatePaymentAmount(NaN)).toBe(false) + }) + + test('generates unique payment IDs', () => { + const id1 = generatePaymentId() + const id2 = generatePaymentId() + + expect(id1).not.toBe(id2) + expect(id1).toMatch(/^pay_[a-f0-9]{32}$/) + }) + + test('parses valid webhook payload', () => { + const payload = JSON.stringify({ event: 'payment.completed', amount: 1000 }) + const parsed = parseWebhookPayload(payload) + + expect(parsed.event).toBe('payment.completed') + expect(parsed.amount).toBe(1000) + }) + + test('handles invalid webhook payload', () => { + const parsed = parseWebhookPayload('not valid json') + + expect(parsed).toBeNull() + }) + }) + + describe('Billing Cycles', () => { + test('supports monthly and yearly', () => { + expect(BILLING_CYCLES).toContain('monthly') + expect(BILLING_CYCLES).toContain('yearly') + expect(BILLING_CYCLES.length).toBe(2) + }) + + test('calculates yearly savings percentage', () => { + const monthlyPrice = SUBSCRIPTION_PRICES.starter_monthly_usd + const yearlyPrice = SUBSCRIPTION_PRICES.starter_yearly_usd + const savingsPercentage = ((monthlyPrice * 12 - yearlyPrice) / (monthlyPrice * 12)) * 100 + + expect(savingsPercentage).toBeCloseTo(16.67, 1) + }) + }) + + describe('Currency Support', () => { + test('supports USD, GBP, EUR', () => { + expect(SUPPORTED_CURRENCIES).toContain('usd') + expect(SUPPORTED_CURRENCIES).toContain('gbp') + expect(SUPPORTED_CURRENCIES).toContain('eur') + }) + + test('GBP prices are lower than USD', () => { + const plans = ['starter', 'professional', 'enterprise'] + + plans.forEach(plan => { + const usdPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'usd')] + const gbpPrice = SUBSCRIPTION_PRICES[getProductKey(plan, 'monthly', 'gbp')] + + expect(gbpPrice).toBeLessThan(usdPrice) + }) + }) + }) + + describe('Invoice Generation', () => { + function generateInvoiceNumber() { + const date = new Date() + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const random = crypto.randomBytes(4).toString('hex').toUpperCase() + return `INV-${year}${month}-${random}` + } + + test('generates invoice numbers with correct format', () => { + const invoice = generateInvoiceNumber() + + expect(invoice).toMatch(/^INV-\d{6}-[A-F0-9]{8}$/) + }) + + test('generates unique invoice numbers', () => { + const invoices = new Set() + for (let i = 0; i < 100; i++) { + invoices.add(generateInvoiceNumber()) + } + + expect(invoices.size).toBe(100) + }) + }) + + describe('Subscription Status', () => { + const subscriptionStates = ['active', 'past_due', 'canceled', 'incomplete', 'trialing', 'unpaid'] + + function isActiveSubscription(status) { + return status === 'active' || status === 'trialing' + } + + function requiresAttention(status) { + return ['past_due', 'incomplete', 'unpaid'].includes(status) + } + + test('identifies active subscriptions', () => { + expect(isActiveSubscription('active')).toBe(true) + expect(isActiveSubscription('trialing')).toBe(true) + expect(isActiveSubscription('past_due')).toBe(false) + expect(isActiveSubscription('canceled')).toBe(false) + }) + + test('identifies subscriptions requiring attention', () => { + expect(requiresAttention('past_due')).toBe(true) + expect(requiresAttention('incomplete')).toBe(true) + expect(requiresAttention('unpaid')).toBe(true) + expect(requiresAttention('active')).toBe(false) + }) + }) + + describe('Refund Calculations', () => { + function calculateProratedRefund(subscriptionPrice, daysRemaining, daysInPeriod) { + return Math.round((subscriptionPrice * daysRemaining) / daysInPeriod) + } + + test('calculates prorated refund for mid-cycle cancellation', () => { + const price = 750 + const daysRemaining = 15 + const daysInPeriod = 30 + + const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod) + + expect(refund).toBe(375) + }) + + test('no refund at end of cycle', () => { + const price = 750 + const daysRemaining = 0 + const daysInPeriod = 30 + + const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod) + + expect(refund).toBe(0) + }) + + test('full refund at start of cycle', () => { + const price = 750 + const daysRemaining = 30 + const daysInPeriod = 30 + + const refund = calculateProratedRefund(price, daysRemaining, daysInPeriod) + + expect(refund).toBe(750) + }) + }) + + describe('Usage Events', () => { + const DODO_USAGE_EVENT_NAME = 'token.usage' + const DODO_USAGE_EVENT_COST_FIELD = 'costCents' + const DODO_USAGE_EVENT_TOKENS_FIELD = 'billableTokens' + + function createUsageEvent(userId, tokens, costCents) { + return { + event: DODO_USAGE_EVENT_NAME, + customerId: userId, + properties: { + [DODO_USAGE_EVENT_TOKENS_FIELD]: tokens, + [DODO_USAGE_EVENT_COST_FIELD]: costCents + }, + timestamp: Date.now() + } + } + + test('creates usage event with correct structure', () => { + const event = createUsageEvent('user-123', 50000, 12.5) + + expect(event.event).toBe('token.usage') + expect(event.customerId).toBe('user-123') + expect(event.properties.billableTokens).toBe(50000) + expect(event.properties.costCents).toBe(12.5) + expect(event.timestamp).toBeDefined() + }) + + test('event name is correctly configured', () => { + expect(DODO_USAGE_EVENT_NAME).toBe('token.usage') + }) + + test('usage event fields are defined', () => { + expect(DODO_USAGE_EVENT_COST_FIELD).toBe('costCents') + expect(DODO_USAGE_EVENT_TOKENS_FIELD).toBe('billableTokens') + }) + }) +}) + +describe('Webhook Handling', () => { + function verifyWebhookSignature(payload, signature, secret) { + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex') + return signature === `sha256=${expectedSignature}` + } + + const testSecret = 'webhook-secret-key' + + test('verifies valid webhook signature', () => { + const payload = JSON.stringify({ event: 'test' }) + const signature = `sha256=${crypto.createHmac('sha256', testSecret).update(payload).digest('hex')}` + + expect(verifyWebhookSignature(payload, signature, testSecret)).toBe(true) + }) + + test('rejects invalid webhook signature', () => { + const payload = JSON.stringify({ event: 'test' }) + const signature = 'sha256=invalid' + + expect(verifyWebhookSignature(payload, signature, testSecret)).toBe(false) + }) + + test('rejects tampered payload', () => { + const payload = JSON.stringify({ event: 'test' }) + const signature = `sha256=${crypto.createHmac('sha256', testSecret).update(payload).digest('hex')}` + const tamperedPayload = JSON.stringify({ event: 'tampered' }) + + expect(verifyWebhookSignature(tamperedPayload, signature, testSecret)).toBe(false) + }) + + test('handles payment completed webhook', () => { + const webhookPayload = { + event: 'payment.completed', + data: { + payment_id: 'pay_123', + amount: 750, + currency: 'usd', + customer_id: 'user_456' + } + } + + expect(webhookPayload.event).toBe('payment.completed') + expect(webhookPayload.data.amount).toBe(750) + }) + + test('handles subscription created webhook', () => { + const webhookPayload = { + event: 'subscription.created', + data: { + subscription_id: 'sub_123', + customer_id: 'user_456', + plan: 'professional', + status: 'active' + } + } + + expect(webhookPayload.event).toBe('subscription.created') + expect(webhookPayload.data.plan).toBe('professional') + }) + + test('handles subscription cancelled webhook', () => { + const webhookPayload = { + event: 'subscription.cancelled', + data: { + subscription_id: 'sub_123', + customer_id: 'user_456', + cancelled_at: Date.now() + } + } + + expect(webhookPayload.event).toBe('subscription.cancelled') + }) +})