import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' import * as ftp from 'basic-ftp' import { FTPServer } from '../adapters/server/ftp.js' import type { BaseClient } from '../adapters/client/base.js' import { createMockClient, populateCache, HOME_TREE, HOME_FILES, STORAGE_TREE, STORAGE_FILES, } from './helpers.js' vi.mock('../logger.js', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), }, })) const TEST_PORT = 2391 const TEST_USER = 'testuser' const TEST_PASS = 'testpass' /** Creates a connected + authenticated FTP client. */ async function connectFTP(): Promise { const client = new ftp.Client() await client.access({ host: '127.0.0.1', port: TEST_PORT, user: TEST_USER, password: TEST_PASS, secure: false, }) return client } describe('E2E: FTP Server', () => { let server: FTPServer let homeClient: BaseClient let storageClient: BaseClient beforeAll(async () => { homeClient = createMockClient('/home', HOME_TREE, HOME_FILES) storageClient = createMockClient('/storage', STORAGE_TREE, STORAGE_FILES) await populateCache(homeClient, HOME_TREE) await populateCache(storageClient, STORAGE_TREE) server = new FTPServer( [homeClient, storageClient], { port: TEST_PORT, pasv_url: '127.0.0.1', pasv_min: 3000, pasv_max: 3100 }, { username: TEST_USER, password: TEST_PASS }, ) await server.start() }) afterAll(async () => { await server.stop() }) // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- it('should reject invalid credentials', async () => { const client = new ftp.Client() await expect( client.access({ host: '127.0.0.1', port: TEST_PORT, user: 'wrong', password: 'wrong', secure: false, }) ).rejects.toThrow() client.close() }) // --------------------------------------------------------------------------- // Directory listing // --------------------------------------------------------------------------- it('should list the virtual root', async () => { const client = await connectFTP() try { const list = await client.list('/') const names = list.map(e => e.name) expect(names).toContain('home') expect(names).toContain('storage') } finally { client.close() } }) it('should list mount point contents', async () => { const client = await connectFTP() try { const list = await client.list('/home') const names = list.map(e => e.name) expect(names).toContain('docs') expect(names).toContain('readme.txt') } finally { client.close() } }) it('should list nested directory contents', async () => { const client = await connectFTP() try { const list = await client.list('/home/docs') const names = list.map(e => e.name) expect(names).toContain('hello.txt') expect(names).toContain('world.txt') } finally { client.close() } }) it('should list storage mount contents', async () => { const client = await connectFTP() try { const list = await client.list('/storage/backups') const names = list.map(e => e.name) expect(names).toContain('db.sql') } finally { client.close() } }) // --------------------------------------------------------------------------- // Directory navigation // --------------------------------------------------------------------------- it('should change directory to mount point', async () => { const client = await connectFTP() try { await client.cd('/home') const pwd = await client.pwd() expect(pwd).toBe('/home') } finally { client.close() } }) it('should change directory to nested path', async () => { const client = await connectFTP() try { await client.cd('/home/docs') const pwd = await client.pwd() expect(pwd).toBe('/home/docs') } finally { client.close() } }) // --------------------------------------------------------------------------- // File download // --------------------------------------------------------------------------- it('should download a file', async () => { const client = await connectFTP() try { const chunks: Buffer[] = [] const writable = new (await import('node:stream')).PassThrough() writable.on('data', (chunk: Buffer) => chunks.push(chunk)) await client.downloadTo(writable, '/home/readme.txt') const content = Buffer.concat(chunks).toString() expect(content).toBe('Hello from home!') } finally { client.close() } }) it('should download a nested file', async () => { const client = await connectFTP() try { const chunks: Buffer[] = [] const writable = new (await import('node:stream')).PassThrough() writable.on('data', (chunk: Buffer) => chunks.push(chunk)) await client.downloadTo(writable, '/home/docs/hello.txt') const content = Buffer.concat(chunks).toString() expect(content).toBe('Hello, World!') } finally { client.close() } }) // --------------------------------------------------------------------------- // File upload // --------------------------------------------------------------------------- it('should upload a file', async () => { const client = await connectFTP() try { const { Readable } = await import('node:stream') const readable = Readable.from(Buffer.from('Uploaded content')) await client.uploadFrom(readable, '/home/uploaded.txt') expect(homeClient.write).toHaveBeenCalled() } finally { client.close() } }) // --------------------------------------------------------------------------- // Directory creation // --------------------------------------------------------------------------- it('should create a directory', async () => { const client = await connectFTP() try { // Use raw MKD command — ensureDir does CWD+MKD which fails on virtual paths await client.send('MKD /home/new-dir') expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir') } finally { client.close() } }) // --------------------------------------------------------------------------- // Delete // --------------------------------------------------------------------------- it('should delete a file', async () => { const client = await connectFTP() try { await client.remove('/home/readme.txt') expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt') } finally { client.close() } }) // --------------------------------------------------------------------------- // Rename // --------------------------------------------------------------------------- it('should rename a file', async () => { const client = await connectFTP() try { await client.rename('/home/readme.txt', '/home/renamed.txt') expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt') } finally { client.close() } }) })