Files
nanodrop/tests/unit/login-handler.test.ts
Brendan Chen a4355e1ef3 refactor(auth): drop JWT_EXPIRY env var (family TTL is canonical)
The JWT lifetime is now pinned to SESSION_TTL_SECONDS (30 days)
inside issueSessionCookie, so JWT_EXPIRY had no effect after the
prior commits. Removing the field from Config and the env read in
loadConfig lets the TypeScript compiler verify no caller was still
relying on it.

Per the family invariant ('coherence across apps is a feature'),
the per-app override is intentionally gone — a deploy with stale
JWT_EXPIRY in .env will now silently use the 30-day family default
regardless of the value.

Test fixtures (Config literals in setup.ts, login-handler.test.ts,
lockout-service.test.ts) and config.test.ts assertions updated to
match the new shape.
2026-05-09 10:17:04 -07:00

212 lines
8.1 KiB
TypeScript

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> = {}): 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 });
});
});