Add comprehensive test suite for chat app

- Add userRepository.test.js: User CRUD, authentication, OAuth providers, 2FA, affiliate tracking
- Add sessionRepository.test.js: Sessions, refresh tokens, token blacklist, device fingerprinting
- Add auditRepository.test.js: Audit logging, event types, time-based queries, success/failure tracking
- Add connection.test.js: Database initialization, transactions, foreign keys, backup operations
- Add model-routing.test.js: Provider fallback, plan validation, token rates, rate limiting
- Add payments.test.js: Subscription pricing, top-ups, PAYG, discounts, webhook handling
- Add api-endpoints.test.js: Auth endpoints, user management, admin endpoints, middleware
- Update package.json with test scripts (bun test)

Tests cover: accounts, authentication, model routing, payments, subscriptions, admin panel, API endpoints
This commit is contained in:
southseact-3d
2026-02-18 18:30:41 +00:00
parent 1b3b2cdf2a
commit 8e35b2af95
8 changed files with 3823 additions and 1 deletions

View File

@@ -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": [],

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -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')
})
})