import { describe, test, expect, beforeEach } from 'bun:test' import { Database } from 'bun:sqlite' import { hashPasswordAsync, verifyPasswordAsync, hashPassword, verifyPassword, } from '../auth/password' import { issueSessionToken, resolveSessionToken, reapExpiredSessions, } from '../auth/session' import { migrateDatabase } from '../db/sqlite' function freshDb(): Database { const db = new Database(':memory:') migrateDatabase(db) db.exec( "INSERT OR IGNORE INTO users (id, username, password_hash, role, created_at) VALUES ('usr_test1', 'testuser', 'hash', 'admin', '2026-01-01T00:00:00Z')", ) return db } describe('hashPasswordAsync', () => { test('returns a Promise containing argon2id identifier', async () => { const result = hashPasswordAsync('my-password') expect(result).toBeInstanceOf(Promise) const hash = await result expect(typeof hash).toBe('string') expect(hash).toContain('argon2id') }, 15000) test('produces same format as sync hashPassword', async () => { const password = 'test-pw' const asyncHash = await hashPasswordAsync(password) // Both should be valid PHC format and verify correctly expect(verifyPassword(password, asyncHash)).toBe(true) }, 30000) test('verifyPasswordAsync works with sync-generated hashes', async () => { const password = 'cross-compat' const syncHash = hashPassword(password) expect(await verifyPasswordAsync(password, syncHash)).toBe(true) expect(await verifyPasswordAsync('wrong', syncHash)).toBe(false) }, 30000) test('verifyPasswordAsync returns false for empty password', async () => { const hash = hashPassword('pw') expect(await verifyPasswordAsync('', hash)).toBe(false) }, 15000) }) describe('resolveSessionToken kind parameter', () => { test('defaults to access kind — resolves access tokens', () => { const db = freshDb() const { accessToken } = issueSessionToken('usr_test1', db) // Default kind='access' should resolve access tokens expect(resolveSessionToken(accessToken, db)).toBe('usr_test1') }) test('defaults to access kind — rejects refresh tokens', () => { const db = freshDb() const { refreshToken } = issueSessionToken('usr_test1', db) // Default kind='access' should NOT resolve refresh tokens expect(resolveSessionToken(refreshToken, db)).toBeNull() }) test('kind=refresh resolves refresh tokens', () => { const db = freshDb() const { refreshToken } = issueSessionToken('usr_test1', db) expect(resolveSessionToken(refreshToken, db, 'refresh')).toBe('usr_test1') }) test('kind=refresh rejects access tokens', () => { const db = freshDb() const { accessToken } = issueSessionToken('usr_test1', db) expect(resolveSessionToken(accessToken, db, 'refresh')).toBeNull() }) test('kind=access explicitly resolves access tokens', () => { const db = freshDb() const { accessToken } = issueSessionToken('usr_test1', db) expect(resolveSessionToken(accessToken, db, 'access')).toBe('usr_test1') }) }) describe('reapExpiredSessions', () => { test('deletes expired tokens and returns count', () => { const db = freshDb() // Issue tokens (access expires in 1h, refresh in 30d) issueSessionToken('usr_test1', db) // Manually expire all tokens db.exec("UPDATE session_tokens SET expires_at = '2020-01-01T00:00:00Z'") const deleted = reapExpiredSessions(db) expect(deleted).toBe(2) // access + refresh // Verify tokens are gone const remaining = db .query('SELECT COUNT(*) as cnt FROM session_tokens') .get() as { cnt: number } expect(remaining.cnt).toBe(0) }) test('does not delete non-expired tokens', () => { const db = freshDb() issueSessionToken('usr_test1', db) const deleted = reapExpiredSessions(db) expect(deleted).toBe(0) const remaining = db .query('SELECT COUNT(*) as cnt FROM session_tokens') .get() as { cnt: number } expect(remaining.cnt).toBe(2) }) test('returns 0 when no tokens exist', () => { const db = freshDb() expect(reapExpiredSessions(db)).toBe(0) }) }) describe('schema indexes', () => { test('indexes are created after migration', () => { const db = freshDb() const indexes = db .query( "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'", ) .all() as Array<{ name: string }> const names = indexes.map(i => i.name) expect(names).toContain('idx_session_owners_owner') expect(names).toContain('idx_session_owners_session') expect(names).toContain('idx_team_members_user') expect(names).toContain('idx_team_members_team') expect(names).toContain('idx_session_shares_user') expect(names).toContain('idx_session_shares_team') expect(names).toContain('idx_session_shares_session') expect(names).toContain('idx_sessions_visibility') expect(names).toContain('idx_sessions_environment') expect(names).toContain('idx_session_tokens_user') expect(names).toContain('idx_environments_team') }) })