import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import jwt from 'jsonwebtoken'; import { eq } from 'drizzle-orm'; import { fileURLToPath } from 'node:url'; import * as schema from '../db/schema.js'; import { setWorkerId } from '../utils/snowflake.js'; import { signJwt } from '../utils/auth.js'; setWorkerId(9); const __dirname = path.dirname(fileURLToPath(import.meta.url)); type TestDb = ReturnType>; let sqlite: Database.Database; let testDb: TestDb; let app: FastifyInstance; // Each test gets a fresh tmp dir so tus file I/O stays isolated. let tmpDir: string; vi.mock('../db/index.js', () => ({ getDb: () => testDb, getRawDb: () => sqlite, schema, })); // Mock heavy media-processing so tests don't need real files / ffmpeg. vi.mock('../utils/thumbnail.js', () => ({ generateThumbnail: vi.fn().mockResolvedValue(null), isResizableImage: vi.fn().mockReturnValue(false), probeImageDimensions: vi.fn().mockResolvedValue(null), probeMediaMeta: vi.fn().mockResolvedValue(null), generateVideoThumbnail: vi.fn().mockResolvedValue(null), })); function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); for (const f of files) { const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); const statements = sqlText.split(/-->\s*statement-breakpoint/); for (const stmt of statements) { const clean = stmt.trim(); if (clean) db.exec(clean); } } } // Override config paths to use the test-local tmpDir so tus file I/O is isolated. // We do this by mocking the config module. vi.mock('../config.js', async () => { // Grab the real config first (runs dotenv so JWT_SECRET etc. are set) const real = await import('../config.js'); // We'll patch the directory fields; the proxy below reads `tmpDir` at // call time, which is reassigned in each beforeEach. return { config: new Proxy(real.config, { get(target, prop: string) { if (prop === 'uploadDir') return tmpDir ?? target.uploadDir; if (prop === 'tusUploadDir') return tmpDir ? path.join(tmpDir, '.tus') : target.tusUploadDir; return (target as Record)[prop]; }, }), }; }); async function buildApp(): Promise { const { filesRoutes } = await import('./files.js'); const f = Fastify(); await f.register(filesRoutes); return f; } const USER_A_ID = 'user-a'; const USER_B_ID = 'user-b'; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'backspace-tus-test-')); sqlite = new Database(':memory:'); sqlite.pragma('foreign_keys = ON'); applyMigrations(sqlite); testDb = drizzle(sqlite, { schema }); // Seed instance_settings (ensureDefaults equivalent) testDb.insert(schema.instanceSettings).values({ id: 1, updatedAt: Date.now(), maxUploadSizeBytes: 10 * 1024 * 1024 * 1024, // 10 GB default for most tests }).run(); // Seed two users for ownership tests testDb.insert(schema.users).values([ { id: USER_A_ID, username: 'user_a', passwordHash: 'x', isAdmin: 0, createdAt: Date.now() }, { id: USER_B_ID, username: 'user_b', passwordHash: 'x', isAdmin: 0, createdAt: Date.now() }, ]).run(); app = await buildApp(); }); afterEach(async () => { await app.close(); // FileStore.checkOrCreateDirectory() fires an async fs.mkdir in its // constructor callback. Deleting the tree before that callback resolves // causes an ENOENT uncaught error. We drain one I/O tick to let the // callback settle (it will see EEXIST and be ignored), then clean up. await new Promise(resolve => setTimeout(resolve, 50)); fs.rmSync(tmpDir, { recursive: true, force: true }); }); // ─── Helper: build a tus-compatible Upload-Metadata header ────────────────── function tusMetadata(fields: Record): string { return Object.entries(fields) .map(([k, v]) => `${k} ${Buffer.from(v).toString('base64')}`) .join(','); } describe('POST /api/files — tus upload endpoint', () => { it('unauthenticated POST returns 401', async () => { const res = await app.inject({ method: 'POST', url: '/api/files', headers: { 'tus-resumable': '1.0.0', 'upload-length': '1024', 'content-length': '0', }, }); expect(res.statusCode).toBe(401); }); it('authenticated create returns 201 + Location + Upload-Expires', async () => { const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); const res = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-length': '512', 'upload-metadata': tusMetadata({ originalName: 'test.png' }), 'content-length': '0', }, }); expect(res.statusCode).toBe(201); expect(res.headers['location']).toBeTruthy(); expect(res.headers['upload-expires']).toBeTruthy(); }); it('non-owner PATCH returns 403', async () => { const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); // User A creates the upload const createRes = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${tokenA}`, 'tus-resumable': '1.0.0', 'upload-length': '4', 'upload-metadata': tusMetadata({ originalName: 'secret.txt' }), 'content-length': '0', }, }); expect(createRes.statusCode).toBe(201); const location = createRes.headers['location'] as string; // Extract the upload ID path from the full Location URL const uploadPath = location.replace(/^https?:\/\/[^/]+/, ''); // User B tries to PATCH — must be rejected const patchRes = await app.inject({ method: 'PATCH', url: uploadPath, headers: { 'authorization': `Bearer ${tokenB}`, 'tus-resumable': '1.0.0', 'upload-offset': '0', 'content-type': 'application/offset+octet-stream', 'content-length': '4', }, payload: Buffer.from('data'), }); expect(patchRes.statusCode).toBe(403); }); it('rejects DELETE from a non-owner with 403', async () => { const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); const createRes = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${tokenA}`, 'tus-resumable': '1.0.0', 'upload-length': '8', 'upload-metadata': tusMetadata({ originalName: 'mine.txt' }), 'content-length': '0', }, }); expect(createRes.statusCode).toBe(201); const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); const delRes = await app.inject({ method: 'DELETE', url: uploadPath, headers: { 'authorization': `Bearer ${tokenB}`, 'tus-resumable': '1.0.0', }, }); expect(delRes.statusCode).toBe(403); }); it('rejects HEAD from a non-owner with 403', async () => { const tokenA = signJwt({ userId: USER_A_ID, username: 'user_a' }); const tokenB = signJwt({ userId: USER_B_ID, username: 'user_b' }); const createRes = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${tokenA}`, 'tus-resumable': '1.0.0', 'upload-length': '8', 'upload-metadata': tusMetadata({ originalName: 'mine.txt' }), 'content-length': '0', }, }); expect(createRes.statusCode).toBe(201); const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); const headRes = await app.inject({ method: 'HEAD', url: uploadPath, headers: { 'authorization': `Bearer ${tokenB}`, 'tus-resumable': '1.0.0', }, }); expect(headRes.statusCode).toBe(403); }); it('rejects requests from a soft-deleted user', async () => { const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); // Mark user A as soft-deleted AFTER signing the token. testDb.update(schema.users) .set({ isDeleted: 1 }) .where(eq(schema.users.id, USER_A_ID)) .run(); const res = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-length': '4', 'upload-metadata': tusMetadata({ originalName: 'x.bin' }), 'content-length': '0', }, }); expect(res.statusCode).toBe(401); }); it("rejects tokens issued before the user's password was changed", async () => { // Sign a token with iat = T1 (1000s ago). const t1 = Math.floor(Date.now() / 1000) - 1000; const { config } = await import('../config.js'); const token = jwt.sign( { userId: USER_A_ID, username: 'user_a', iat: t1 }, config.jwtSecret, { algorithm: 'HS256', expiresIn: '7d' }, ); // Set passwordChangedAt to T2 (500s ago) in milliseconds — newer than iat. const t2Ms = (Math.floor(Date.now() / 1000) - 500) * 1000; testDb.update(schema.users) .set({ passwordChangedAt: t2Ms }) .where(eq(schema.users.id, USER_A_ID)) .run(); const res = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-length': '4', 'upload-metadata': tusMetadata({ originalName: 'x.bin' }), 'content-length': '0', }, }); expect(res.statusCode).toBe(401); }); it('completes an upload end-to-end and returns Attachment JSON in final PATCH response', async () => { const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); const body = Buffer.from('hello world\n'); // 12 bytes const createRes = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-length': String(body.length), 'upload-metadata': tusMetadata({ originalName: 'greeting.txt' }), 'content-length': '0', }, }); expect(createRes.statusCode).toBe(201); const uploadPath = (createRes.headers['location'] as string).replace(/^https?:\/\/[^/]+/, ''); const patchRes = await app.inject({ method: 'PATCH', url: uploadPath, headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-offset': '0', 'content-type': 'application/offset+octet-stream', 'content-length': String(body.length), }, payload: body, }); // tus returns 204 from the underlying handler, but onUploadFinish overrides // with a 200 + JSON body. Either way the body should contain the Attachment. expect([200, 204]).toContain(patchRes.statusCode); const json = JSON.parse(patchRes.body) as { id: string; filename: string; size: number; mimetype: string; }; expect(json.id).toBeTruthy(); expect(json.size).toBe(body.length); expect(json.mimetype).toBe('application/octet-stream'); // .txt isn't in EXT_MIMETYPES expect(json.filename.endsWith('.txt')).toBe(true); // The attachments row should exist in the DB. const row = testDb .select() .from(schema.attachments) .where(eq(schema.attachments.id, json.id)) .get(); expect(row).toBeTruthy(); expect(row?.size).toBe(body.length); expect(row?.uploaderId).toBe(USER_A_ID); // The committed file should exist in the upload dir. expect(fs.existsSync(path.join(tmpDir, json.filename))).toBe(true); }); it('oversize Upload-Length returns 413', async () => { // Set the instance limit to a small value testDb.update(schema.instanceSettings) .set({ maxUploadSizeBytes: 1024 }) // 1 KB limit .run(); const token = signJwt({ userId: USER_A_ID, username: 'user_a' }); const res = await app.inject({ method: 'POST', url: '/api/files', headers: { 'authorization': `Bearer ${token}`, 'tus-resumable': '1.0.0', 'upload-length': String(10 * 1024 * 1024 * 1024), // 10 GB > 1 KB 'upload-metadata': tusMetadata({ originalName: 'huge.bin' }), 'content-length': '0', }, }); expect(res.statusCode).toBe(413); }); });