import { describe, test, expect, beforeEach, mock } from 'bun:test' import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { mockConfigModule } from './helpers/mock-config' // Mock config before importing modules that depend on it mock.module('../config', () => mockConfigModule()) import { Hono } from 'hono' import { Database } from 'bun:sqlite' // Dynamic imports — modules may not exist yet (TDD) let authRoutes: Hono | undefined let migrateDatabase: ((db: Database) => void) | undefined let initDatabase: ((path?: string) => Database) | undefined let resetDbSingleton: (() => void) | undefined let getDb: (() => Database) | undefined try { const dbMod = await import('../db/sqlite') migrateDatabase = dbMod.migrateDatabase getDb = dbMod.getDb initDatabase = dbMod.initDatabase resetDbSingleton = dbMod.resetDbSingleton } catch { // Module not implemented yet } try { const routesMod = await import('../routes/web/auth-routes') authRoutes = routesMod.default } catch { // Module not implemented yet } function createApp(): Hono { const app = new Hono() if (authRoutes) { app.route('/web/auth', authRoutes) } return app } // Helper: create a fresh in-memory DB for testing. // Must call initDatabase(uniquePath) so the singleton never resolves to // the production default (tmpdir()/rcs-dev.db) — otherwise tests pollute // that file and real server startups read stale users/tokens. function setupTestDb(): void { if (resetDbSingleton) { try { resetDbSingleton() } catch { // ignore } } if (initDatabase) { const uniquePath = resolve( tmpdir(), `rcs-auth-login-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`, ) initDatabase(uniquePath) } } describe('POST /web/auth/setup', () => { let app: Hono beforeEach(() => { setupTestDb() app = createApp() }) test('creates admin user and issues token when users table is empty', async () => { // Precondition: users table must be empty for setup to succeed const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'secure-password-123', }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.accessToken || body.access_token).toBeDefined() }) test('returns 409 when users table is not empty', async () => { // First setup to create a user await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'secure-password-123', }), }) // Second setup should fail const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin2', password: 'another-password-123', }), }) expect(res.status).toBe(409) }) test('returns 401 for wrong API key', async () => { const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'wrong-api-key', username: 'admin', password: 'secure-password-123', }), }) expect(res.status).toBe(401) }) test('returns 400 for missing username', async () => { const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', password: 'secure-password-123', }), }) expect(res.status).toBe(400) }) test('returns 400 for missing password', async () => { const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', }), }) expect(res.status).toBe(400) }) test('returns 400 for password shorter than 8 characters', async () => { const res = await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'short', }), }) expect(res.status).toBe(400) }) }) describe('POST /web/auth/login', () => { let app: Hono beforeEach(async () => { setupTestDb() app = createApp() // Create an admin user via setup await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) }) test('returns 200 with access and refresh cookie for valid credentials', async () => { const res = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'admin-password-123', }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.accessToken || body.access_token).toBeDefined() // Check Set-Cookie headers const setCookies = res.headers.getSetCookie() expect(setCookies.length).toBeGreaterThanOrEqual(1) }) test('returns 401 for wrong password', async () => { const res = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'wrong-password', }), }) expect(res.status).toBe(401) }) test('returns 401 for non-existent user (does not leak user existence)', async () => { const res = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'nonexistent', password: 'some-password-123', }), }) expect(res.status).toBe(401) // The error message should be the same as wrong-password // to avoid leaking user existence }) test('returns 400 for missing fields', async () => { const res = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', }), }) expect(res.status).toBe(400) }) }) describe('POST /web/auth/refresh', () => { let app: Hono let refreshToken: string beforeEach(async () => { setupTestDb() app = createApp() // Create admin user and login to get tokens await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) const loginRes = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'admin-password-123', }), }) const loginBody = await loginRes.json() refreshToken = loginBody.refreshToken || loginBody.refresh_token || '' }) test('returns new access token for valid refresh token', async () => { const res = await app.request('/web/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rcs_refresh=${refreshToken}`, }, body: JSON.stringify({ refreshToken }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.accessToken || body.access_token).toBeDefined() }) test('returns 401 for invalid refresh token', async () => { const res = await app.request('/web/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: 'invalid-refresh-token' }), }) expect(res.status).toBe(401) }) test('returns 401 for expired refresh token', async () => { // Manually expire the refresh token in DB if (getDb) { const db = getDb() db.exec( "UPDATE session_tokens SET expires_at = '2020-01-01T00:00:00Z' WHERE kind = 'refresh'", ) } const res = await app.request('/web/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) expect(res.status).toBe(401) }) test('returns 401 for revoked refresh token', async () => { // Revoke the refresh token in DB if (getDb) { const db = getDb() db.exec( "UPDATE session_tokens SET revoked_at = '2026-01-01T00:00:00Z' WHERE kind = 'refresh'", ) } const res = await app.request('/web/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), }) expect(res.status).toBe(401) }) }) describe('POST /web/auth/logout', () => { let app: Hono let accessToken: string beforeEach(async () => { setupTestDb() app = createApp() // Setup + login await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) const loginRes = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'admin-password-123', }), }) const loginBody = await loginRes.json() accessToken = loginBody.accessToken || loginBody.access_token || '' }) test('revokes token and returns 200 with cookie clearing', async () => { const res = await app.request('/web/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, }) expect(res.status).toBe(200) // Should clear cookies (Set-Cookie with Max-Age=0 or expires in past) const setCookies = res.headers.getSetCookie() for (const cookie of setCookies) { const lower = cookie.toLowerCase() // Cookies being cleared have Max-Age=0 or an expired date if (lower.includes('max-age')) { const match = lower.match(/max-age=(\d+)/) if (match) { expect(parseInt(match[1], 10)).toBe(0) } } } }) test('returns 200 when no token provided (idempotent)', async () => { const res = await app.request('/web/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) expect(res.status).toBe(200) }) }) describe('POST /web/auth/join', () => { let app: Hono let inviteToken: string beforeEach(async () => { setupTestDb() app = createApp() // Setup admin await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) // Create an invitation (this endpoint may not exist yet) // For now, directly insert into DB if possible if (getDb) { const db = getDb() const crypto = await import('node:crypto') const token = `inv_test123` const tokenHash = crypto.createHash('sha256').update(token).digest('hex') const futureExpiry = new Date(Date.now() + 86400000).toISOString() try { // Get the actual admin user ID created by setup const adminRow = db .query("SELECT id FROM users WHERE username = 'admin'") .get() as { id: string } | null const createdBy = adminRow?.id || 'usr_admin' // Ensure the referenced user exists (for FK constraint) if (!adminRow) { db.exec( "INSERT OR IGNORE INTO users (id, username, password_hash, role, created_at) VALUES ('usr_admin', '_admin_placeholder', 'hash', 'admin', '2026-01-01T00:00:00Z')", ) } db.exec( `INSERT OR IGNORE INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by) VALUES ('${tokenHash}', 'member', '${futureExpiry}', 1, 0, '${createdBy}')`, ) inviteToken = token } catch { // Table may not exist yet inviteToken = 'inv_test123' } } else { inviteToken = 'inv_test123' } }) test('creates user and joins team with valid invitation', async () => { const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'newuser', password: 'newuser-password-123', }), }) expect(res.status).toBe(200) const body = await res.json() expect(body.accessToken || body.access_token).toBeDefined() }) test('returns 400 for expired invitation', async () => { // Insert an expired invitation if (getDb) { const db = getDb() const crypto = await import('node:crypto') const token = 'inv_expired' const tokenHash = crypto.createHash('sha256').update(token).digest('hex') const pastExpiry = new Date(Date.now() - 86400000).toISOString() try { db.exec( `INSERT OR REPLACE INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by) VALUES ('${tokenHash}', 'member', '${pastExpiry}', 1, 0, 'usr_admin')`, ) inviteToken = token } catch { inviteToken = 'inv_expired' } } else { inviteToken = 'inv_expired' } const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'newuser2', password: 'newuser-password-123', }), }) expect(res.status).toBe(400) }) test('returns 400 for already-used invitation', async () => { // Insert a consumed invitation if (getDb) { const db = getDb() const crypto = await import('node:crypto') const token = 'inv_used' const tokenHash = crypto.createHash('sha256').update(token).digest('hex') const futureExpiry = new Date(Date.now() + 86400000).toISOString() try { db.exec( `INSERT OR REPLACE INTO invitations (token_hash, role, expires_at, max_uses, uses, created_by, consumed_at) VALUES ('${tokenHash}', 'member', '${futureExpiry}', 1, 1, 'usr_admin', '2026-01-01T00:00:00Z')`, ) inviteToken = token } catch { inviteToken = 'inv_used' } } else { inviteToken = 'inv_used' } const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken, username: 'newuser3', password: 'newuser-password-123', }), }) expect(res.status).toBe(400) }) test('returns 400 for invalid invitation', async () => { const res = await app.request('/web/auth/join', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inviteToken: 'inv_completely_invalid', username: 'newuser4', password: 'newuser-password-123', }), }) expect(res.status).toBe(400) }) }) describe('GET /web/auth/me', () => { let app: Hono let accessToken: string beforeEach(async () => { setupTestDb() app = createApp() // Setup + login await app.request('/web/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ apiKey: 'test-api-key', username: 'admin', password: 'admin-password-123', }), }) const loginRes = await app.request('/web/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'admin-password-123', }), }) const loginBody = await loginRes.json() accessToken = loginBody.accessToken || loginBody.access_token || '' }) test('returns 200 with user info when logged in', async () => { const res = await app.request('/web/auth/me', { headers: { Authorization: `Bearer ${accessToken}`, }, }) expect(res.status).toBe(200) const body = await res.json() expect(body.username).toBe('admin') expect(body.role).toBeDefined() }) test('returns 401 when not logged in', async () => { const res = await app.request('/web/auth/me') expect(res.status).toBe(401) }) })