From 8fd1464b9d08c5fd432512c646b5b4aff9389498 Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 3 Mar 2026 15:55:14 -0800 Subject: [PATCH] Add server, routes, views, CLI, CSS, and integration tests - Server factory with Fastify plugins (JWT, cookie, multipart, formbody, static) - Auth middleware: requireAuth preHandler (401 for API, redirect for pages) - Auth API routes: POST /api/v1/auth/login, POST /api/v1/auth/logout - File API routes: GET/POST /api/v1/files, DELETE /api/v1/files/:id - Page routes: /, /login, /logout, /upload, /files, /files/:id/delete, /f/:id, /f/:id/raw - HTML views: layout, login, upload, file-list, file-view, not-found - CLI register-user script - public/style.css dark theme - Test helpers: createTestApp, loginAs, buildMultipart - Integration tests for auth API, file API, and page routes (51 tests passing) - Update CLAUDE.md with red/green TDD and commit instructions Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 +- public/style.css | 126 +++++++++++++++++++++ src/cli/register-user.ts | 33 ++++++ src/index.ts | 19 ++++ src/middleware/auth.ts | 15 +++ src/routes/api/v1/auth.ts | 56 ++++++++++ src/routes/api/v1/files.ts | 71 ++++++++++++ src/routes/pages.ts | 163 +++++++++++++++++++++++++++ src/server.ts | 46 ++++++++ src/views/file-list.ts | 38 +++++++ src/views/file-view.ts | 27 +++++ src/views/layout.ts | 40 +++++++ src/views/login.ts | 25 +++++ src/views/not-found.ts | 11 ++ src/views/upload.ts | 36 ++++++ tests/helpers/setup.ts | 88 +++++++++++++++ tests/integration/auth-api.test.ts | 101 +++++++++++++++++ tests/integration/files-api.test.ts | 120 ++++++++++++++++++++ tests/integration/pages.test.ts | 164 ++++++++++++++++++++++++++++ 19 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 public/style.css create mode 100644 src/cli/register-user.ts create mode 100644 src/index.ts create mode 100644 src/middleware/auth.ts create mode 100644 src/routes/api/v1/auth.ts create mode 100644 src/routes/api/v1/files.ts create mode 100644 src/routes/pages.ts create mode 100644 src/server.ts create mode 100644 src/views/file-list.ts create mode 100644 src/views/file-view.ts create mode 100644 src/views/layout.ts create mode 100644 src/views/login.ts create mode 100644 src/views/not-found.ts create mode 100644 src/views/upload.ts create mode 100644 tests/helpers/setup.ts create mode 100644 tests/integration/auth-api.test.ts create mode 100644 tests/integration/files-api.test.ts create mode 100644 tests/integration/pages.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3965393..4660df5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Simple file-sharing platform. TypeScript + Fastify + SQLite. ## Code Quality - Review code after every change. Refactor for readability. -- Use TDD: write tests first, then implement. +- Use strict red/green TDD: write the test first, confirm it FAILS (red), then implement until it passes (green). - Build and test after every change. - Break large functions into smaller ones, extract duplicate code. - Search for duplicated code in tests and extract into reusable helpers. diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..2ef3363 --- /dev/null +++ b/public/style.css @@ -0,0 +1,126 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1c1f2e; + --border: #2a2d3e; + --accent: #6c63ff; + --accent-hover: #5a52e0; + --text: #e2e8f0; + --muted: #8892a4; + --error: #f56565; + --danger: #e53e3e; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +header { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1rem 2rem; + border-bottom: 1px solid var(--border); +} + +.logo { font-size: 1.25rem; font-weight: 700; color: var(--text); } + +nav { display: flex; align-items: center; gap: 1rem; margin-left: auto; } +nav a { color: var(--muted); font-size: 0.9rem; } +nav a:hover { color: var(--text); } + +main { flex: 1; padding: 2rem; display: flex; justify-content: center; align-items: flex-start; } + +.form-container { + width: 100%; + max-width: 420px; + margin-top: 4rem; +} + +.form-container h1 { margin-bottom: 1.5rem; font-size: 1.5rem; } + +form { display: flex; flex-direction: column; gap: 1rem; } + +label { + display: flex; + flex-direction: column; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--muted); +} + +input[type="text"], +input[type="password"], +input[type="file"] { + padding: 0.625rem 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 1rem; + width: 100%; +} + +input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } + +button, .btn { + padding: 0.625rem 1.25rem; + background: var(--accent); + border: none; + border-radius: 6px; + color: #fff; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + text-align: center; + display: inline-block; +} + +button:hover, .btn:hover { background: var(--accent-hover); } + +button.danger { background: var(--danger); } +button.danger:hover { background: #c53030; } + +.error { color: var(--error); font-size: 0.9rem; margin-bottom: 0.5rem; } + +.share-box { + display: flex; + gap: 0.5rem; + margin: 1rem 0; +} +.share-box input { flex: 1; } + +/* File view — centered */ +.file-view { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 1.5rem; + text-align: center; +} + +.file-view h1 { font-size: 1.25rem; color: var(--muted); word-break: break-all; } + +.file-view video, +.file-view audio { max-width: 800px; width: 100%; border-radius: 8px; } + +.file-actions { display: flex; gap: 1rem; } + +/* File list table */ +table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--border); } +th { color: var(--muted); font-weight: 600; } +tr:hover td { background: var(--surface); } + +h1 { margin-bottom: 1rem; } diff --git a/src/cli/register-user.ts b/src/cli/register-user.ts new file mode 100644 index 0000000..cb6f34c --- /dev/null +++ b/src/cli/register-user.ts @@ -0,0 +1,33 @@ +import { parseArgs } from 'util'; +import { loadConfig } from '../config.ts'; +import { initDb } from '../db/schema.ts'; +import { createUser, getUserByUsername } from '../db/users.ts'; +import { hashPassword } from '../services/auth.ts'; + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + username: { type: 'string' }, + password: { type: 'string' }, + }, +}); + +const { username, password } = values; + +if (!username || !password) { + console.error('Usage: npm run register-user -- --username --password '); + process.exit(1); +} + +const config = loadConfig(); +const db = initDb(config.dbPath); + +const existing = getUserByUsername(db, username); +if (existing) { + console.error(`User "${username}" already exists.`); + process.exit(1); +} + +const passwordHash = await hashPassword(password); +createUser(db, { username, passwordHash }); +console.log(`User "${username}" created successfully.`); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f53b2b2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,19 @@ +import { mkdirSync } from 'fs'; +import { loadConfig } from './config.ts'; +import { initDb } from './db/schema.ts'; +import { createServer } from './server.ts'; + +const config = loadConfig(); + +mkdirSync(config.uploadDir, { recursive: true }); + +const db = initDb(config.dbPath); +const app = createServer({ config, db }); + +try { + await app.listen({ port: config.port, host: config.host }); + console.log(`Nanodrop running on http://${config.host}:${config.port}`); +} catch (err) { + console.error(err); + process.exit(1); +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..d30614d --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,15 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; + +export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise { + try { + await request.jwtVerify(); + } catch { + // API routes get 401, page routes get redirect + const isApi = request.url.startsWith('/api/'); + if (isApi) { + reply.status(401).send({ error: 'Unauthorized' }); + } else { + reply.redirect('/'); + } + } +} diff --git a/src/routes/api/v1/auth.ts b/src/routes/api/v1/auth.ts new file mode 100644 index 0000000..d2390b0 --- /dev/null +++ b/src/routes/api/v1/auth.ts @@ -0,0 +1,56 @@ +import type { FastifyPluginAsync } from 'fastify'; +import type Database from 'better-sqlite3'; +import type { Config } from '../../../config.ts'; +import type { Logger } from '../../../middleware/logging.ts'; +import { getUserByUsername } from '../../../db/users.ts'; +import { verifyPassword } from '../../../services/auth.ts'; +import { requireAuth } from '../../../middleware/auth.ts'; + +interface Deps { + db: Database.Database; + config: Config; + logger: Logger; +} + +interface LoginBody { + username: string; + password: string; +} + +export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { + const { db, config, logger } = deps; + + app.post<{ Body: LoginBody }>('/login', async (request, reply) => { + const { username, password } = request.body ?? {}; + const ip = request.ip; + const userAgent = request.headers['user-agent'] ?? ''; + + if (!username || !password) { + return reply.status(400).send({ error: 'username and password are required' }); + } + + const user = getUserByUsername(db, username); + const valid = user ? await verifyPassword(password, user.password_hash) : false; + + if (!user || !valid) { + await logger.authFailure({ ip, userAgent, username }); + return reply.status(401).send({ error: 'Invalid credentials' }); + } + + await logger.authSuccess({ ip, userAgent, username }); + + const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); + reply + .setCookie('token', token, { + httpOnly: true, + sameSite: 'strict', + secure: config.cookieSecure, + path: '/', + }) + .send({ ok: true }); + }); + + app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { + reply.clearCookie('token', { path: '/' }).send({ ok: true }); + }); +}; diff --git a/src/routes/api/v1/files.ts b/src/routes/api/v1/files.ts new file mode 100644 index 0000000..63c395b --- /dev/null +++ b/src/routes/api/v1/files.ts @@ -0,0 +1,71 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { extname } from 'path'; +import { nanoid } from 'nanoid'; +import type Database from 'better-sqlite3'; +import type { Config } from '../../../config.ts'; +import type { Logger } from '../../../middleware/logging.ts'; +import type { JwtPayload } from '../../../types.ts'; +import { createFile, getFilesByUserId, getFileById, deleteFile } from '../../../db/files.ts'; +import { saveFile, deleteStoredFile } from '../../../services/storage.ts'; +import { requireAuth } from '../../../middleware/auth.ts'; + +interface Deps { + db: Database.Database; + config: Config; + logger: Logger; +} + +export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { + const { db, config } = deps; + + app.get('/', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + const files = getFilesByUserId(db, userId); + reply.send({ files }); + }); + + app.post('/', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + const data = await request.file(); + + if (!data) { + return reply.status(400).send({ error: 'No file uploaded' }); + } + + const fileBuffer = await data.toBuffer(); + const id = nanoid(); + const ext = extname(data.filename); + const storedName = `${id}${ext}`; + + await saveFile(config.uploadDir, storedName, fileBuffer); + + const file = createFile(db, { + id, + userId, + originalName: data.filename, + mimeType: data.mimetype, + size: fileBuffer.length, + storedName, + }); + + reply.status(201).send({ file, url: `${config.baseUrl}/f/${id}` }); + }); + + app.delete('/:id', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + const { id } = request.params as { id: string }; + + const file = getFileById(db, id); + if (!file) { + return reply.status(404).send({ error: 'File not found' }); + } + + const deleted = deleteFile(db, id, userId); + if (!deleted) { + return reply.status(403).send({ error: 'Forbidden' }); + } + + await deleteStoredFile(config.uploadDir, file.stored_name); + reply.send({ ok: true }); + }); +}; diff --git a/src/routes/pages.ts b/src/routes/pages.ts new file mode 100644 index 0000000..2a435bd --- /dev/null +++ b/src/routes/pages.ts @@ -0,0 +1,163 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { extname } from 'path'; +import { readFile } from 'fs/promises'; +import { nanoid } from 'nanoid'; +import type Database from 'better-sqlite3'; +import type { Config } from '../config.ts'; +import type { Logger } from '../middleware/logging.ts'; +import type { JwtPayload } from '../types.ts'; +import { getUserByUsername } from '../db/users.ts'; +import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; +import { verifyPassword } from '../services/auth.ts'; +import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; +import { requireAuth } from '../middleware/auth.ts'; +import { loginPage } from '../views/login.ts'; +import { uploadPage, uploadResultPage } from '../views/upload.ts'; +import { fileListPage } from '../views/file-list.ts'; +import { fileViewPage } from '../views/file-view.ts'; +import { notFoundPage } from '../views/not-found.ts'; + +interface Deps { + db: Database.Database; + config: Config; + logger: Logger; +} + +export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps }) => { + const { db, config, logger } = deps; + + // GET / — login page or redirect if authed + app.get('/', async (request, reply) => { + try { + await request.jwtVerify(); + return reply.redirect('/upload'); + } catch { + return reply.type('text/html').send(loginPage()); + } + }); + + // POST /login — form login + app.post<{ Body: { username?: string; password?: string } }>('/login', async (request, reply) => { + const { username = '', password = '' } = request.body ?? {}; + const ip = request.ip; + const userAgent = request.headers['user-agent'] ?? ''; + + const user = getUserByUsername(db, username); + const valid = user ? await verifyPassword(password, user.password_hash) : false; + + if (!user || !valid) { + await logger.authFailure({ ip, userAgent, username }); + return reply.type('text/html').send(loginPage({ error: 'Invalid username or password' })); + } + + await logger.authSuccess({ ip, userAgent, username }); + + const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); + reply + .setCookie('token', token, { + httpOnly: true, + sameSite: 'strict', + secure: config.cookieSecure, + path: '/', + }) + .redirect('/upload'); + }); + + // POST /logout + app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { + reply.clearCookie('token', { path: '/' }).redirect('/'); + }); + + // GET /upload + app.get('/upload', { preHandler: requireAuth }, async (_request, reply) => { + reply.type('text/html').send(uploadPage()); + }); + + // POST /upload + app.post('/upload', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + + const data = await request.file(); + if (!data) { + return reply.type('text/html').send(uploadPage({ error: 'No file selected' })); + } + + const fileBuffer = await data.toBuffer(); + const id = nanoid(); + const ext = extname(data.filename); + const storedName = `${id}${ext}`; + + await saveFile(config.uploadDir, storedName, fileBuffer); + + createFile(db, { + id, + userId, + originalName: data.filename, + mimeType: data.mimetype, + size: fileBuffer.length, + storedName, + }); + + const shareUrl = `${config.baseUrl}/f/${id}`; + reply.type('text/html').send(uploadResultPage(shareUrl, data.filename)); + }); + + // GET /files + app.get('/files', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + const files = getFilesByUserId(db, userId); + reply.type('text/html').send(fileListPage(files, config.baseUrl)); + }); + + // POST /files/:id/delete + app.post<{ Params: { id: string } }>('/files/:id/delete', { preHandler: requireAuth }, async (request, reply) => { + const { sub: userId } = request.user as JwtPayload; + const { id } = request.params; + + const file = getFileById(db, id); + if (file) { + const deleted = deleteFile(db, id, userId); + if (deleted) { + await deleteStoredFile(config.uploadDir, file.stored_name); + } + } + + reply.redirect('/files'); + }); + + // GET /f/:id — public file view + app.get<{ Params: { id: string } }>('/f/:id', async (request, reply) => { + const { id } = request.params; + const file = getFileById(db, id); + + if (!file) { + await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id }); + return reply.status(404).type('text/html').send(notFoundPage()); + } + + reply.type('text/html').send(fileViewPage(file)); + }); + + // GET /f/:id/raw — serve raw file + app.get<{ Params: { id: string } }>('/f/:id/raw', async (request, reply) => { + const { id } = request.params; + const file = getFileById(db, id); + + if (!file) { + await logger.fileNotFound({ ip: request.ip, userAgent: request.headers['user-agent'] ?? '', fileId: id }); + return reply.status(404).send({ error: 'Not found' }); + } + + const filePath = getFilePath(config.uploadDir, file.stored_name); + const data = await readFile(filePath); + reply + .header('Content-Type', file.mime_type) + .header('Content-Disposition', `inline; filename="${file.original_name}"`) + .send(data); + }); + + // 404 handler + app.setNotFoundHandler((_request, reply) => { + reply.status(404).type('text/html').send(notFoundPage()); + }); +}; diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..ec5d2a2 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,46 @@ +import Fastify from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyJwt from '@fastify/jwt'; +import fastifyMultipart from '@fastify/multipart'; +import fastifyFormbody from '@fastify/formbody'; +import fastifyStatic from '@fastify/static'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import type Database from 'better-sqlite3'; +import type { Config } from './config.ts'; +import { createLogger } from './middleware/logging.ts'; +import { authApiRoutes } from './routes/api/v1/auth.ts'; +import { filesApiRoutes } from './routes/api/v1/files.ts'; +import { pageRoutes } from './routes/pages.ts'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +interface ServerDeps { + config: Config; + db: Database.Database; +} + +export function createServer({ config, db }: ServerDeps) { + const app = Fastify({ logger: false, trustProxy: config.trustProxy }); + const logger = createLogger(config.logFile); + + app.register(fastifyCookie); + app.register(fastifyJwt, { + secret: config.jwtSecret, + cookie: { cookieName: 'token', signed: false }, + }); + app.register(fastifyFormbody); + app.register(fastifyMultipart, { limits: { fileSize: config.maxFileSize } }); + app.register(fastifyStatic, { + root: join(__dirname, '..', 'public'), + prefix: '/public/', + }); + + const deps = { db, config, logger }; + + app.register(authApiRoutes, { prefix: '/api/v1/auth', deps }); + app.register(filesApiRoutes, { prefix: '/api/v1/files', deps }); + app.register(pageRoutes, { prefix: '/', deps }); + + return app; +} diff --git a/src/views/file-list.ts b/src/views/file-list.ts new file mode 100644 index 0000000..87576e0 --- /dev/null +++ b/src/views/file-list.ts @@ -0,0 +1,38 @@ +import { layout, escHtml } from './layout.ts'; +import type { FileRow } from '../db/files.ts'; + +export function fileListPage(files: FileRow[], baseUrl: string): string { + const rows = files.length === 0 + ? 'No files yet. Upload one.' + : files.map((f) => ` + + ${escHtml(f.original_name)} + ${escHtml(f.mime_type)} + ${formatBytes(f.size)} + ${escHtml(f.created_at)} + +
+ +
+ + `).join(''); + + return layout('My files', ` +

My files

+

Upload new file

+ + + + + + + ${rows} +
NameTypeSizeUploaded
+ `, { authed: true }); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/views/file-view.ts b/src/views/file-view.ts new file mode 100644 index 0000000..994da40 --- /dev/null +++ b/src/views/file-view.ts @@ -0,0 +1,27 @@ +import { layout, escHtml } from './layout.ts'; +import type { FileRow } from '../db/files.ts'; + +export function fileViewPage(file: FileRow): string { + const rawUrl = escHtml(`/f/${file.id}/raw`); + const safeName = escHtml(file.original_name); + const actions = ` +
+ Download + Open +
`; + + let media = ''; + if (file.mime_type.startsWith('video/')) { + media = ``; + } else if (file.mime_type.startsWith('audio/')) { + media = ``; + } + + return layout(file.original_name, ` +
+

${safeName}

+ ${media} + ${actions} +
+ `); +} diff --git a/src/views/layout.ts b/src/views/layout.ts new file mode 100644 index 0000000..ca37110 --- /dev/null +++ b/src/views/layout.ts @@ -0,0 +1,40 @@ +export function layout(title: string, body: string, opts: { authed?: boolean } = {}): string { + const { authed = false } = opts; + const nav = authed + ? `` + : ''; + + return ` + + + + + ${escHtml(title)} — Nanodrop + + + +
+ + ${nav} +
+
+ ${body} +
+ +`; +} + +export function escHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/src/views/login.ts b/src/views/login.ts new file mode 100644 index 0000000..80c9caf --- /dev/null +++ b/src/views/login.ts @@ -0,0 +1,25 @@ +import { layout } from './layout.ts'; + +export function loginPage(opts: { error?: string } = {}): string { + const errorHtml = opts.error + ? `

${opts.error}

` + : ''; + + return layout('Login', ` +
+

Sign in

+ ${errorHtml} +
+ + + +
+
+ `); +} diff --git a/src/views/not-found.ts b/src/views/not-found.ts new file mode 100644 index 0000000..f50e107 --- /dev/null +++ b/src/views/not-found.ts @@ -0,0 +1,11 @@ +import { layout } from './layout.ts'; + +export function notFoundPage(): string { + return layout('Not found', ` +
+

404 — Not found

+

The page or file you requested does not exist.

+

Go home

+
+ `); +} diff --git a/src/views/upload.ts b/src/views/upload.ts new file mode 100644 index 0000000..143ba8b --- /dev/null +++ b/src/views/upload.ts @@ -0,0 +1,36 @@ +import { layout, escHtml } from './layout.ts'; + +export function uploadPage(opts: { error?: string } = {}): string { + const errorHtml = opts.error ? `

${opts.error}

` : ''; + + return layout('Upload', ` +
+

Upload a file

+ ${errorHtml} +
+ + +
+
+ `, { authed: true }); +} + +export function uploadResultPage(shareUrl: string, filename: string): string { + const safeUrl = escHtml(shareUrl); + const safeName = escHtml(filename); + + return layout('File uploaded', ` +
+

File uploaded

+

${safeName} is ready to share.

+ +

Upload another · My files

+
+ `, { authed: true }); +} diff --git a/tests/helpers/setup.ts b/tests/helpers/setup.ts new file mode 100644 index 0000000..12a186f --- /dev/null +++ b/tests/helpers/setup.ts @@ -0,0 +1,88 @@ +import { mkdtempSync, rmSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { initDb } from '../../src/db/schema.ts'; +import { createServer } from '../../src/server.ts'; +import type { Config } from '../../src/config.ts'; +import type Database from 'better-sqlite3'; +import type { FastifyInstance } from 'fastify'; + +export async function loginAs(app: FastifyInstance, username: string, password: string): Promise { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + const cookie = res.headers['set-cookie'] as string; + return cookie.split(';')[0].replace('token=', ''); +} + +export interface MultipartFile { + filename: string; + contentType: string; + data: Buffer; +} + +export function buildMultipart(files: Record): { payload: Buffer; headers: Record } { + const boundary = '----TestBoundary' + Math.random().toString(36).slice(2); + const parts: Buffer[] = []; + + for (const [fieldname, file] of Object.entries(files)) { + parts.push(Buffer.from( + `--${boundary}\r\n` + + `Content-Disposition: form-data; name="${fieldname}"; filename="${file.filename}"\r\n` + + `Content-Type: ${file.contentType}\r\n\r\n`, + )); + parts.push(file.data); + parts.push(Buffer.from('\r\n')); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + return { + payload: Buffer.concat(parts), + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + }; +} + +export interface TestContext { + app: FastifyInstance; + db: Database.Database; + uploadDir: string; + logFile: string; + cleanup: () => void; +} + +export function createTestApp(): TestContext { + const tmpDir = mkdtempSync(join(tmpdir(), 'nanodrop-int-')); + const uploadDir = join(tmpDir, 'uploads'); + const logFile = join(tmpDir, 'test.log'); + + mkdirSync(uploadDir, { recursive: true }); + + const db = initDb(':memory:'); + + const config: Config = { + port: 0, + host: '127.0.0.1', + jwtSecret: 'test-secret-key', + jwtExpiry: '1h', + dbPath: ':memory:', + uploadDir, + logFile, + maxFileSize: 10 * 1024 * 1024, + baseUrl: 'http://localhost:3000', + cookieSecure: false, + trustProxy: false, + }; + + const app = createServer({ config, db }); + + return { + app, + db, + uploadDir, + logFile, + cleanup: () => rmSync(tmpDir, { recursive: true, force: true }), + }; +} diff --git a/tests/integration/auth-api.test.ts b/tests/integration/auth-api.test.ts new file mode 100644 index 0000000..b8636d1 --- /dev/null +++ b/tests/integration/auth-api.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestApp, type TestContext } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +describe('POST /api/v1/auth/login', () => { + let ctx: TestContext; + + beforeEach(async () => { + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('returns 200 and sets cookie on valid credentials', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'secret' }), + }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); + expect(res.headers['set-cookie']).toMatch(/token=/); + }); + + it('returns 401 on wrong password', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 on unknown user', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'ghost', password: 'x' }), + }); + expect(res.statusCode).toBe(401); + }); + + it('returns 400 when fields are missing', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice' }), + }); + expect(res.statusCode).toBe(400); + }); +}); + +describe('POST /api/v1/auth/logout', () => { + let ctx: TestContext; + let token: string; + + beforeEach(async () => { + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'secret' }), + }); + const cookie = res.headers['set-cookie'] as string; + token = cookie.split(';')[0].replace('token=', ''); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('clears cookie on logout', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + cookies: { token }, + }); + expect(res.statusCode).toBe(200); + expect(res.headers['set-cookie']).toMatch(/token=;/); + }); + + it('returns 401 without cookie', async () => { + const res = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/logout' }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/files-api.test.ts b/tests/integration/files-api.test.ts new file mode 100644 index 0000000..52b8893 --- /dev/null +++ b/tests/integration/files-api.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestApp, type TestContext, loginAs, buildMultipart } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +describe('GET /api/v1/files', () => { + let ctx: TestContext; + let token: string; + + beforeEach(async () => { + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + token = await loginAs(ctx.app, 'alice', 'secret'); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('returns empty list for new user', async () => { + const res = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/files', + cookies: { token }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().files).toEqual([]); + }); + + it('returns 401 without auth', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/api/v1/files' }); + expect(res.statusCode).toBe(401); + }); +}); + +describe('POST /api/v1/files', () => { + let ctx: TestContext; + let token: string; + + beforeEach(async () => { + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + token = await loginAs(ctx.app, 'alice', 'secret'); + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('uploads a file and returns url', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/files', + cookies: { token }, + ...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hello') } }), + }); + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.url).toMatch(/\/f\//); + expect(body.file.original_name).toBe('test.txt'); + }); + + it('returns 401 without auth', async () => { + const res = await ctx.app.inject({ method: 'POST', url: '/api/v1/files' }); + expect(res.statusCode).toBe(401); + }); +}); + +describe('DELETE /api/v1/files/:id', () => { + let ctx: TestContext; + let token: string; + let fileId: string; + + beforeEach(async () => { + ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + token = await loginAs(ctx.app, 'alice', 'secret'); + + const uploadRes = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/files', + cookies: { token }, + ...buildMultipart({ file: { filename: 'f.txt', contentType: 'text/plain', data: Buffer.from('data') } }), + }); + fileId = uploadRes.json().file.id; + }); + + afterEach(async () => { + await ctx.app.close(); + ctx.cleanup(); + }); + + it('deletes an owned file', async () => { + const res = await ctx.app.inject({ + method: 'DELETE', + url: `/api/v1/files/${fileId}`, + cookies: { token }, + }); + expect(res.statusCode).toBe(200); + }); + + it('returns 404 for non-existent file', async () => { + const res = await ctx.app.inject({ + method: 'DELETE', + url: '/api/v1/files/doesnotexist', + cookies: { token }, + }); + expect(res.statusCode).toBe(404); + }); + + it('returns 401 without auth', async () => { + const res = await ctx.app.inject({ method: 'DELETE', url: `/api/v1/files/${fileId}` }); + expect(res.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/pages.test.ts b/tests/integration/pages.test.ts new file mode 100644 index 0000000..2f3df85 --- /dev/null +++ b/tests/integration/pages.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createTestApp, type TestContext, loginAs, buildMultipart } from '../helpers/setup.ts'; +import { createUser } from '../../src/db/users.ts'; +import { hashPassword } from '../../src/services/auth.ts'; + +async function setup() { + const ctx = createTestApp(); + const hash = await hashPassword('secret'); + createUser(ctx.db, { username: 'alice', passwordHash: hash }); + return ctx; +} + +describe('GET /', () => { + let ctx: TestContext; + + beforeEach(async () => { ctx = await setup(); }); + afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); + + it('shows login page when unauthenticated', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/' }); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toMatch(/text\/html/); + expect(res.body).toContain('Sign in'); + }); + + it('redirects to /upload when authenticated', async () => { + const token = await loginAs(ctx.app, 'alice', 'secret'); + const res = await ctx.app.inject({ method: 'GET', url: '/', cookies: { token } }); + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toBe('/upload'); + }); +}); + +describe('POST /login (page)', () => { + let ctx: TestContext; + + beforeEach(async () => { ctx = await setup(); }); + afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); + + it('redirects to /upload on valid credentials', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/login', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: 'username=alice&password=secret', + }); + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toBe('/upload'); + expect(res.headers['set-cookie']).toMatch(/token=/); + }); + + it('shows login page with error on invalid credentials', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/login', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + payload: 'username=alice&password=wrong', + }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('Invalid'); + }); +}); + +describe('GET /upload + POST /upload', () => { + let ctx: TestContext; + let token: string; + + beforeEach(async () => { + ctx = await setup(); + token = await loginAs(ctx.app, 'alice', 'secret'); + }); + afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); + + it('shows upload form', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/upload', cookies: { token } }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('Upload'); + }); + + it('redirects to / when not authenticated', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/upload' }); + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toBe('/'); + }); + + it('shows result page after upload', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: '/upload', + cookies: { token }, + ...buildMultipart({ file: { filename: 'doc.txt', contentType: 'text/plain', data: Buffer.from('content') } }), + }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('doc.txt'); + expect(res.body).toContain('/f/'); + }); +}); + +describe('GET /f/:id and GET /f/:id/raw', () => { + let ctx: TestContext; + let fileId: string; + + beforeEach(async () => { + ctx = await setup(); + const token = await loginAs(ctx.app, 'alice', 'secret'); + const uploadRes = await ctx.app.inject({ + method: 'POST', + url: '/upload', + cookies: { token }, + ...buildMultipart({ file: { filename: 'hello.txt', contentType: 'text/plain', data: Buffer.from('hello!') } }), + }); + // Extract file id from response body + const match = uploadRes.body.match(/\/f\/([^/"]+)/); + fileId = match?.[1] ?? ''; + }); + afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); + + it('shows file view page', async () => { + const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}` }); + expect(res.statusCode).toBe(200); + expect(res.body).toContain('hello.txt'); + }); + + it('serves raw file', async () => { + const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}/raw` }); + expect(res.statusCode).toBe(200); + expect(res.body).toBe('hello!'); + }); + + it('returns 404 for unknown file', async () => { + const res = await ctx.app.inject({ method: 'GET', url: '/f/doesnotexist' }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('POST /files/:id/delete', () => { + let ctx: TestContext; + let token: string; + let fileId: string; + + beforeEach(async () => { + ctx = await setup(); + token = await loginAs(ctx.app, 'alice', 'secret'); + const uploadRes = await ctx.app.inject({ + method: 'POST', + url: '/upload', + cookies: { token }, + ...buildMultipart({ file: { filename: 'del.txt', contentType: 'text/plain', data: Buffer.from('bye') } }), + }); + const match = uploadRes.body.match(/\/f\/([^/"]+)/); + fileId = match?.[1] ?? ''; + }); + afterEach(async () => { await ctx.app.close(); ctx.cleanup(); }); + + it('deletes and redirects to /files', async () => { + const res = await ctx.app.inject({ + method: 'POST', + url: `/files/${fileId}/delete`, + cookies: { token }, + }); + expect(res.statusCode).toBe(302); + expect(res.headers['location']).toBe('/files'); + }); +});