248 lines
7.1 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|