import { describe, it, expect, beforeEach, vi } from 'vitest'; import { mkdtempSync, rmSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import type Database from 'better-sqlite3'; import { initDb } from '../../src/db/schema.ts'; import { createUser } from '../../src/db/users.ts'; import { hashPassword } from '../../src/services/auth.ts'; import { createLogger } from '../../src/middleware/logging.ts'; import { createLockoutService } from '../../src/services/lockout.ts'; import { attemptLogin } from '../../src/services/login-handler.ts'; import { _resetDummyHashForTests } from '../../src/services/dummy-hash.ts'; import type { Config } from '../../src/config.ts'; function makeConfig(overrides: Partial = {}): Config { return { port: 0, host: '127.0.0.1', jwtSecret: 'x', dbPath: ':memory:', uploadDir: '/tmp', logFile: '/tmp/x.log', maxFileSize: 0, baseUrl: '', cookieSecure: false, trustProxy: false, lockoutThreshold: 2, lockoutBaseSeconds: 60, lockoutMaxSeconds: 600, loginMinResponseMs: 50, loginRateLimitMax: 0, loginRateLimitWindowSeconds: 0, ...overrides, }; } describe('login handler', () => { let db: Database.Database; let logDir: string; let logFile: string; let config: Config; beforeEach(async () => { _resetDummyHashForTests(); db = initDb(':memory:'); logDir = mkdtempSync(join(tmpdir(), 'nanodrop-handler-')); logFile = join(logDir, 'test.log'); config = makeConfig({ logFile }); const passwordHash = await hashPassword('correct-pw'); createUser(db, { username: 'alice', passwordHash }); // Warm dummy hash so timing assertions don't include the first cold compute. const { verifyAgainstDummy } = await import('../../src/services/dummy-hash.ts'); await verifyAgainstDummy('warmup'); }); function buildDeps() { const logger = createLogger(logFile); const lockout = createLockoutService({ db, config }); return { db, config, logger, lockout }; } it('returns success for valid credentials and resets lockout', async () => { const deps = buildDeps(); const result = await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1.1.1.1', userAgent: 'ua', }); expect(result.kind).toBe('success'); if (result.kind === 'success') { expect(result.user.username).toBe('alice'); } const log = readFileSync(logFile, 'utf-8'); expect(log).toMatch(/AUTH_SUCCESS/); }); it('returns bad_credentials and logs failure on wrong password', async () => { const deps = buildDeps(); const result = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua', }); expect(result.kind).toBe('bad_credentials'); const log = readFileSync(logFile, 'utf-8'); expect(log).toMatch(/AUTH_FAILURE/); expect(log).toMatch(/username="alice"/); }); it('returns bad_credentials for unknown user (still runs bcrypt against dummy)', async () => { const deps = buildDeps(); const start = Date.now(); const result = await attemptLogin(deps, { username: 'ghost', password: 'whatever', ip: '1.1.1.1', userAgent: 'ua', }); const elapsed = Date.now() - start; expect(result.kind).toBe('bad_credentials'); // Must spend bcrypt-comparable time even for unknown user. expect(elapsed).toBeGreaterThan(20); const log = readFileSync(logFile, 'utf-8'); expect(log).toMatch(/AUTH_FAILURE/); expect(log).toMatch(/username="ghost"/); }, 10_000); it('canonicalizes username (lowercase + trim) before lookup and lockout', async () => { const deps = buildDeps(); const result = await attemptLogin(deps, { username: ' ALICE ', password: 'correct-pw', ip: '1.1.1.1', userAgent: 'ua', }); expect(result.kind).toBe('success'); }); it('returns bad_request when username or password is empty', async () => { const deps = buildDeps(); const r1 = await attemptLogin(deps, { username: '', password: 'x', ip: '1', userAgent: 'ua' }); expect(r1.kind).toBe('bad_request'); const r2 = await attemptLogin(deps, { username: 'alice', password: '', ip: '1', userAgent: 'ua' }); expect(r2.kind).toBe('bad_request'); }); it('triggers lockout at threshold and logs AUTH_LOCKOUT_TRIGGERED', async () => { const deps = buildDeps(); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' }); const second = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' }); expect(second.kind).toBe('bad_credentials'); const log = readFileSync(logFile, 'utf-8'); expect(log).toMatch(/AUTH_LOCKOUT_TRIGGERED/); expect(log).toMatch(/duration_seconds=60/); }); it('returns locked + retry-after on subsequent attempt; correct password still rejected', async () => { const deps = buildDeps(); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' }); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1.1.1.1', userAgent: 'ua' }); const blocked = await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1.1.1.1', userAgent: 'ua', }); expect(blocked.kind).toBe('locked'); if (blocked.kind === 'locked') { expect(blocked.retryAfterSeconds).toBeGreaterThan(0); expect(blocked.retryAfterSeconds).toBeLessThanOrEqual(60); } const log = readFileSync(logFile, 'utf-8'); expect(log).toMatch(/AUTH_LOCKED_ATTEMPT/); // Even though password was correct, no AUTH_SUCCESS for this attempt. const successCount = (log.match(/AUTH_SUCCESS/g) ?? []).length; expect(successCount).toBe(0); }); it('does NOT call bcrypt when account is locked (short-circuits)', async () => { const deps = buildDeps(); // Lock the account. await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' }); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' }); // Locked attempt with constant-time clamp at 50ms — should be roughly clamp duration, // not bcrypt time (250ms+). const start = Date.now(); await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' }); const elapsed = Date.now() - start; // Clamp is 50ms; allow generous slack but assert well under bcrypt cost. expect(elapsed).toBeLessThan(150); }); it('respects loginMinResponseMs clamp on bad_credentials', async () => { config = makeConfig({ logFile, loginMinResponseMs: 200 }); const deps = buildDeps(); const start = Date.now(); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' }); const elapsed = Date.now() - start; expect(elapsed).toBeGreaterThanOrEqual(190); }, 10_000); it('successful login between failures resets the counter', async () => { const deps = buildDeps(); await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' }); await attemptLogin(deps, { username: 'alice', password: 'correct-pw', ip: '1', userAgent: 'ua' }); const r = await attemptLogin(deps, { username: 'alice', password: 'wrong', ip: '1', userAgent: 'ua' }); // After reset, this is failure #1 — well under threshold of 2, so no lock yet. expect(r.kind).toBe('bad_credentials'); // No AUTH_LOCKOUT_TRIGGERED in log. const log = readFileSync(logFile, 'utf-8'); expect(log).not.toMatch(/AUTH_LOCKOUT_TRIGGERED/); }); it('unknown username also accumulates lockout', async () => { const deps = buildDeps(); await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' }); await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' }); const r = await attemptLogin(deps, { username: 'ghost', password: 'x', ip: '1', userAgent: 'ua' }); expect(r.kind).toBe('locked'); }, 10_000); // Cleanup beforeEach(() => { return () => rmSync(logDir, { recursive: true, force: true }); }); });