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 <noreply@anthropic.com>
This commit is contained in:
40
src/middleware/logging.ts
Normal file
40
src/middleware/logging.ts
Normal file
@@ -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<void>;
|
||||||
|
authFailure(params: AuthLogParams): Promise<void>;
|
||||||
|
fileNotFound(params: FileNotFoundParams): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLogger(logFile: string): Logger {
|
||||||
|
function timestamp(): string {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(line: string): Promise<void> {
|
||||||
|
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}"`),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
src/services/auth.ts
Normal file
11
src/services/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
|
const SALT_ROUNDS = 12;
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
21
src/services/storage.ts
Normal file
21
src/services/storage.ts
Normal file
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await unlink(join(uploadDir, storedName));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/types.ts
Normal file
4
src/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface JwtPayload {
|
||||||
|
sub: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
60
tests/unit/logging.test.ts
Normal file
60
tests/unit/logging.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
tests/unit/storage-service.test.ts
Normal file
42
tests/unit/storage-service.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user