Files
nanodrop/tests/unit/logging.test.ts
Brendan Chen ad36b23061
All checks were successful
Deploy to Homelab / deploy (push) Successful in 18s
feat(auth): add shared login handler with constant-time clamp
Single attemptLogin() orchestrates lockout check, bcrypt verify (against
real or dummy hash), success/failure logging, and a configurable minimum
response-time clamp. Both login routes will share this — no duplication.

Adds three logger events for the operator's escalation pipeline:
AUTH_LOCKOUT_TRIGGERED, AUTH_LOCKED_ATTEMPT, and AUTH_RATE_LIMITED.
fail2ban filters can pick these up to escalate persistent attackers from
in-app lockout to IP ban.

Constant-time defense: unknown users still pay bcrypt cost (via dummy hash)
and the clamp ensures locked-vs-unknown-vs-wrong-password aren't
distinguishable by response time. Username is canonicalized (lowercase + trim)
before lookup so attackers can't bypass lockout via case variation.
2026-05-03 03:34:15 -07:00

103 lines
3.7 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { createLogger } from '../../src/middleware/logging.ts';
describe('middleware/logging', () => {
let logDir: string;
let logFile: string;
beforeEach(() => {
logDir = mkdtempSync(join(tmpdir(), 'nanodrop-log-test-'));
logFile = join(logDir, 'test.log');
});
afterEach(() => {
rmSync(logDir, { recursive: true, force: true });
});
it('writes AUTH_FAILURE log entry', async () => {
const logger = createLogger(logFile);
await logger.authFailure({ ip: '1.2.3.4', userAgent: 'TestAgent/1.0', username: 'admin' });
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_FAILURE/);
expect(content).toMatch(/ip=1\.2\.3\.4/);
expect(content).toMatch(/username="admin"/);
expect(content).toMatch(/user-agent="TestAgent\/1\.0"/);
});
it('writes AUTH_SUCCESS log entry', async () => {
const logger = createLogger(logFile);
await logger.authSuccess({ ip: '1.2.3.4', userAgent: 'TestAgent/1.0', username: 'bob' });
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_SUCCESS/);
expect(content).toMatch(/username="bob"/);
});
it('writes FILE_NOT_FOUND log entry', async () => {
const logger = createLogger(logFile);
await logger.fileNotFound({ ip: '5.6.7.8', userAgent: 'curl/7.0', fileId: 'abc123' });
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/FILE_NOT_FOUND/);
expect(content).toMatch(/file_id="abc123"/);
});
it('creates log file if it does not exist', async () => {
expect(existsSync(logFile)).toBe(false);
const logger = createLogger(logFile);
await logger.authSuccess({ ip: '1.1.1.1', userAgent: 'x', username: 'u' });
expect(existsSync(logFile)).toBe(true);
});
it('writes AUTH_LOCKOUT_TRIGGERED log entry with duration', async () => {
const logger = createLogger(logFile);
await logger.authLockoutTriggered({
ip: '1.2.3.4',
userAgent: 'TestAgent/1.0',
username: 'alice',
durationSeconds: 60,
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_LOCKOUT_TRIGGERED/);
expect(content).toMatch(/ip=1\.2\.3\.4/);
expect(content).toMatch(/username="alice"/);
expect(content).toMatch(/duration_seconds=60/);
});
it('writes AUTH_LOCKED_ATTEMPT log entry with retry-after', async () => {
const logger = createLogger(logFile);
await logger.authLockedAttempt({
ip: '1.2.3.4',
userAgent: 'TestAgent/1.0',
username: 'alice',
retryAfterSeconds: 25,
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_LOCKED_ATTEMPT/);
expect(content).toMatch(/username="alice"/);
expect(content).toMatch(/retry_after_seconds=25/);
});
it('writes AUTH_RATE_LIMITED log entry with route', async () => {
const logger = createLogger(logFile);
await logger.authRateLimited({
ip: '9.9.9.9',
userAgent: 'curl/7.0',
route: '/api/v1/auth/login',
});
const content = readFileSync(logFile, 'utf-8');
expect(content).toMatch(/AUTH_RATE_LIMITED/);
expect(content).toMatch(/ip=9\.9\.9\.9/);
expect(content).toMatch(/route="\/api\/v1\/auth\/login"/);
});
it('appends multiple entries', async () => {
const logger = createLogger(logFile);
await logger.authSuccess({ ip: '1.1.1.1', userAgent: 'a', username: 'u1' });
await logger.authFailure({ ip: '2.2.2.2', userAgent: 'b', username: 'u2' });
const lines = readFileSync(logFile, 'utf-8').trim().split('\n');
expect(lines).toHaveLength(2);
});
});