From 157d1e82300c2e388010cb72c1806779925b3ead Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 3 Mar 2026 15:49:22 -0800 Subject: [PATCH] Add auth service, storage service, types, and logging middleware - Auth service: hashPassword/verifyPassword via bcrypt - Storage service: saveFile, getFilePath, deleteStoredFile with ENOENT handling - Types: JwtPayload interface - Logging middleware: createLogger writing AUTH_FAILURE, AUTH_SUCCESS, FILE_NOT_FOUND - 27 tests passing Co-Authored-By: Claude Sonnet 4.6 --- src/middleware/logging.ts | 40 ++++++++++++++++++++ src/services/auth.ts | 11 ++++++ src/services/storage.ts | 21 +++++++++++ src/types.ts | 4 ++ tests/unit/logging.test.ts | 60 ++++++++++++++++++++++++++++++ tests/unit/storage-service.test.ts | 42 +++++++++++++++++++++ 6 files changed, 178 insertions(+) create mode 100644 src/middleware/logging.ts create mode 100644 src/services/auth.ts create mode 100644 src/services/storage.ts create mode 100644 src/types.ts create mode 100644 tests/unit/logging.test.ts create mode 100644 tests/unit/storage-service.test.ts diff --git a/src/middleware/logging.ts b/src/middleware/logging.ts new file mode 100644 index 0000000..87c180d --- /dev/null +++ b/src/middleware/logging.ts @@ -0,0 +1,40 @@ +import { appendFile } from 'fs/promises'; + +interface AuthLogParams { + ip: string; + userAgent: string; + username: string; +} + +interface FileNotFoundParams { + ip: string; + userAgent: string; + fileId: string; +} + +export interface Logger { + authSuccess(params: AuthLogParams): Promise; + authFailure(params: AuthLogParams): Promise; + fileNotFound(params: FileNotFoundParams): Promise; +} + +export function createLogger(logFile: string): Logger { + function timestamp(): string { + return new Date().toISOString(); + } + + async function write(line: string): Promise { + await appendFile(logFile, line + '\n'); + } + + function authLine(event: string, { ip, userAgent, username }: AuthLogParams): string { + return `[${timestamp()}] ${event} ip=${ip} user-agent="${userAgent}" username="${username}"`; + } + + return { + authSuccess: (params) => write(authLine('AUTH_SUCCESS', params)), + authFailure: (params) => write(authLine('AUTH_FAILURE', params)), + fileNotFound: ({ ip, userAgent, fileId }) => + write(`[${timestamp()}] FILE_NOT_FOUND ip=${ip} user-agent="${userAgent}" file_id="${fileId}"`), + }; +} diff --git a/src/services/auth.ts b/src/services/auth.ts new file mode 100644 index 0000000..fb6da05 --- /dev/null +++ b/src/services/auth.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} diff --git a/src/services/storage.ts b/src/services/storage.ts new file mode 100644 index 0000000..ce00119 --- /dev/null +++ b/src/services/storage.ts @@ -0,0 +1,21 @@ +import { writeFile, unlink } from 'fs/promises'; +import { join } from 'path'; + +export async function saveFile(uploadDir: string, storedName: string, data: Buffer): Promise { + await writeFile(join(uploadDir, storedName), data); + return storedName; +} + +export function getFilePath(uploadDir: string, storedName: string): string { + return join(uploadDir, storedName); +} + +export async function deleteStoredFile(uploadDir: string, storedName: string): Promise { + try { + await unlink(join(uploadDir, storedName)); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7408ccf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,4 @@ +export interface JwtPayload { + sub: number; + username: string; +} diff --git a/tests/unit/logging.test.ts b/tests/unit/logging.test.ts new file mode 100644 index 0000000..97cdd1d --- /dev/null +++ b/tests/unit/logging.test.ts @@ -0,0 +1,60 @@ +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('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); + }); +}); diff --git a/tests/unit/storage-service.test.ts b/tests/unit/storage-service.test.ts new file mode 100644 index 0000000..b14fe32 --- /dev/null +++ b/tests/unit/storage-service.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { saveFile, deleteStoredFile, getFilePath } from '../../src/services/storage.ts'; + +describe('services/storage', () => { + let uploadDir: string; + + beforeEach(() => { + uploadDir = mkdtempSync(join(tmpdir(), 'nanodrop-test-')); + }); + + afterEach(() => { + rmSync(uploadDir, { recursive: true, force: true }); + }); + + it('saves a file buffer and returns stored name', async () => { + const content = Buffer.from('hello world'); + const storedName = await saveFile(uploadDir, 'file1.txt', content); + expect(storedName).toBe('file1.txt'); + expect(existsSync(join(uploadDir, 'file1.txt'))).toBe(true); + expect(readFileSync(join(uploadDir, 'file1.txt'))).toEqual(content); + }); + + it('returns full path for a stored file', () => { + const path = getFilePath(uploadDir, 'file1.txt'); + expect(path).toBe(join(uploadDir, 'file1.txt')); + }); + + it('deletes a stored file', async () => { + const content = Buffer.from('to delete'); + await saveFile(uploadDir, 'todelete.txt', content); + expect(existsSync(join(uploadDir, 'todelete.txt'))).toBe(true); + await deleteStoredFile(uploadDir, 'todelete.txt'); + expect(existsSync(join(uploadDir, 'todelete.txt'))).toBe(false); + }); + + it('does not throw when deleting non-existent file', async () => { + await expect(deleteStoredFile(uploadDir, 'ghost.txt')).resolves.not.toThrow(); + }); +});