Files
sftp-proxy/src/__tests__/e2e-ftp.test.ts
2026-02-20 17:14:35 +11:00

248 lines
7.1 KiB
TypeScript

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<ftp.Client> {
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()
}
})
})