本文已同步至个人微信公众号【不能止步】,链接为通过Fake SFTP服务器测试文件服务器相关业务逻辑
在开发过程中,我们经常会同文件服务器打交道,比如上传、下载和更新文件等。为了通过单元测试与文件服务器交互的相关代码,通常我们有两种做法:
前者相当于集成测试,能够测试代码实现业务的正确性,包括目录、文件名等,但是其存在测试效率低和脆弱测试的可能性,此外,可能会影响我们放在文件服务器的正常文件。后者只能测试我们传递给文件服务器参数的正确性,为了测试代码,可能需要Mock大量的文件服务器接口并且为了测试可能需要改写代码(如将局部变量改为函数参数,否则非静态函数无法Mock),此外无法确定文件服务器行为的正确性。因此,这两种测试方法都不是一种好的测试文件服务器的方法。
本文介绍了一种在测试运行过程中启动一个基于内存文件系统的文件服务器的方法,即可以实现测试的稳定性,又无需Mock文件服务接口,同时还能够测试文件服务器行为的正确性。文章中采用的是更安全的SFTP文件服务器及其客户端SDK,希望对大家测试文件服务器相关的业务代码时有帮助。
SFTP(SSH文件传输协议)是一种安全的文件传输协议。它运行在SSH 协议之上,并支持 SSH 全部的安全和身份验证功能。
SFTP 几乎已经取代了传统的文件传输协议FTP,并且正在迅速取代FTP/S。它提供了这些协议提供的所有功能,但更安全、更可靠,配置更简单。基本上没有理由再使用遗留协议。
SFTP 还可以防止密码嗅探和中间人攻击。它使用加密和加密散列函数保护数据的完整性,并对服务器和用户进行验证。
SFTP 使用的端口号是SSH 端口 22。它实际上只是一个 SSH 服务器。服务器上没有开放单独的 SFTP 端口,无需在防火墙中配置另一个漏洞。
SFTP本身没有单独的守护进程,它必须使用sshd守护进程(端口号默认是22)来完成相应的连接操作,所以从某种意义上来说,SFTP并不像一个服务器程序,而更像是一个客户端程序。
为了测试与文件服务器相关的代码,本文使用了以下技术栈。
本文演示了基于SFTP文件服务器的文件上传场景。在该场景中,业务测上传文件到文件服务器的指定目录。
import lombok.Getter;
@Getter
public class SftpProperties {
private final String host;
private final String user;
private final String password;
private final int port;
public SftpProperties(String host, String user, String password, int port) {
this.host = host;
this.user = user;
this.password = password;
this.port = port;
}
}
import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import org.apache.commons.io.IOUtils;
import java.nio.charset.Charset;
public class SftpClientDemo {
private final SftpProperties sftpProperties;
public SftpClientDemo(SftpProperties sftpProperties) {
this.sftpProperties = sftpProperties;
}
public void uploadFile(String destination, String content) throws JSchException {
JSch jsch=new JSch();
Session session = jsch.getSession(sftpProperties.getUser(), sftpProperties.getHost(), sftpProperties.getPort());
session.setConfig("StrictHostKeyChecking", "no");
session.setPassword(sftpProperties.getPassword());
session.connect();
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
channel.connect();
try (var input = IOUtils.toInputStream(content, Charset.defaultCharset())) {
channel.put(input, destination);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (channel.isConnected()) {
channel.exit();
}
if (session.isConnected()) {
session.disconnect();
}
}
}
}
为了测试文件服务器相关的代码,需要在测试过程中启动一个基于内存文件系统的文件服务器。
import com.github.marschall.memoryfilesystem.MemoryFileSystemBuilder;
import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
import org.apache.sshd.common.session.SessionContext;
import org.apache.sshd.server.SshServer;
import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
import org.apache.sshd.sftp.server.SftpSubsystemFactory;
import java.io.IOException;
import java.nio.file.FileStore;
import java.nio.file.FileSystem;
import java.nio.file.FileVisitResult;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.WatchService;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.UserPrincipalLookupService;
import java.nio.file.spi.FileSystemProvider;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import static java.nio.file.FileVisitResult.CONTINUE;
import static java.nio.file.Files.delete;
import static java.nio.file.Files.exists;
import static java.nio.file.Files.isDirectory;
import static java.nio.file.Files.readAllBytes;
import static java.nio.file.Files.walkFileTree;
import static java.util.Collections.singletonList;
public class FakeSftpServer {
private static final SimpleFileVisitor<Path> cleanupVisitor = new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
delete(file);
return CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
if (dir.getParent() != null)
delete(dir);
return super.postVisitDirectory(dir, exc);
}
};
/* 这里采用static volatile的原因是避免并发运行测试用例时同时启动多个文件服务器,
* 从而导致一部分测试运行失败
*/
private static volatile SshServer sshServer;
private static volatile FileSystem fileSystem;
private FakeSftpServer(SshServer sshServer, boolean startup, NotCloseableFileSystemFactory fileSystemFactory) throws IOException {
if (FakeSftpServer.sshServer == null) {
FakeSftpServer.sshServer = sshServer;
}
if (fileSystem == null) {
fileSystem = fileSystemFactory.fileSystem;
}
if (startup) {
start();
}
}
public void start() throws IOException {
if (sshServer != null && !sshServer.isStarted()) {
sshServer.start();
}
}
public void stop() throws IOException {
if (sshServer != null && sshServer.isStarted()) {
sshServer.stop();
}
}
public void reset() throws IOException {
deleteAllFilesAndDirectories();
}
public boolean existsFile(String path) {
if (fileSystem != null) {
Path filePath = fileSystem.getPath(path);
return exists(filePath) && !isDirectory(filePath);
}
return false;
}
public byte[] getFileContent(String path) throws IOException {
Path pathAsObject = fileSystem.getPath(path);
return readAllBytes(pathAsObject);
}
public List<Path> getFiles() {
return StreamSupport.stream(fileSystem.getRootDirectories().spliterator(), false)
.flatMap((path) -> getFiles(path).stream())
.collect(Collectors.toList());
}
public List<Path> getFiles(Path path) {
ArrayList<Path> files = new ArrayList<>();
SimpleFileVisitor<Path> listFiles = fileListingVisitor(files::add);
try {
walkFileTree(path, listFiles);
} catch (IOException e) {
throw new RuntimeException(e);
}
return files;
}
private SimpleFileVisitor<Path> fileListingVisitor(Consumer<Path> fileConsumer) {
return new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
fileConsumer.accept(file);
return CONTINUE;
}
};
}
private void deleteAllFilesAndDirectories() throws IOException {
if (fileSystem != null) {
for (Path directory : fileSystem.getRootDirectories())
walkFileTree(directory, cleanupVisitor);
}
}
public static class Builder {
private String host;
private Integer port;
private String username;
private String password;
private boolean startup = false;
public Builder host(String host) {
this.host = host;
return this;
}
public Builder port(Integer port) {
this.port = port;
return this;
}
public Builder username(String username) {
this.username = username;
return this;
}
public Builder password(String password) {
this.password = password;
return this;
}
public Builder startup(boolean startup) {
this.startup = startup;
return this;
}
public FakeSftpServer build() throws IOException {
SshServer sshServer = SshServer.setUpDefaultServer();
sshServer.setHost(host);
sshServer.setPort(port);
/*
* SFTP是SSHD的子系统,该函数是开启SFTP子系统
*/
sshServer.setSubsystemFactories(singletonList(new SftpSubsystemFactory()));
// 初始化文件系统
NotCloseableFileSystemFactory fileSystemFactory = new NotCloseableFileSystemFactory(MemoryFileSystemBuilder.newLinux().build("SftpServer"));
sshServer.setFileSystemFactory(fileSystemFactory);
// Auth
sshServer.setKeyPairProvider(new SimpleGeneratorHostKeyProvider());
sshServer.setPasswordAuthenticator((user, pass, session) -> user.equals(username) && password.equals(pass));
return new FakeSftpServer(sshServer, startup, fileSystemFactory);
}
}
private static class NotCloseableFileSystemFactory extends VirtualFileSystemFactory {
private final FileSystem fileSystem;
public NotCloseableFileSystemFactory(FileSystem fileSystem) {
super(fileSystem.getPath("/"));
this.fileSystem = new NotCloseableFileSystem(fileSystem);
}
@Override
public FileSystem createFileSystem(SessionContext session) throws IOException {
Path dir = getUserHomeDir(session);
if (dir == null) {
throw new InvalidPathException(session.getUsername(), "Cannot resolve home directory");
}
return fileSystem;
}
}
private static class NotCloseableFileSystem extends FileSystem {
private final FileSystem fileSystem;
NotCloseableFileSystem(FileSystem fileSystem) {
this.fileSystem = fileSystem;
}
@Override
public FileSystemProvider provider() {
return fileSystem.provider();
}
/* 客户端端口链接后,文件系统会关闭并清理内部的所有文件,
* 从而导致无法从文件系统系统中提取文件以测试文件服务器行为的正确性,如文件目录和文件名等。
* 因此需要覆写close方法避免文件系统关闭和清理文件。
*/
@Override
public void close() {
//will not be closed, otherwise we can not get files from file system after disconnecting sftp connection
}
@Override
public boolean isOpen() {
return fileSystem.isOpen();
}
@Override
public boolean isReadOnly() {
return fileSystem.isReadOnly();
}
@Override
public String getSeparator() {
return fileSystem.getSeparator();
}
@Override
public Iterable<Path> getRootDirectories() {
return fileSystem.getRootDirectories();
}
@Override
public Iterable<FileStore> getFileStores() {
return fileSystem.getFileStores();
}
@Override
public Set<String> supportedFileAttributeViews() {
return fileSystem.supportedFileAttributeViews();
}
@Override
public Path getPath(String first, String... more) {
return fileSystem.getPath(first, more);
}
@Override
public PathMatcher getPathMatcher(
String syntaxAndPattern) {
return fileSystem.getPathMatcher(syntaxAndPattern);
}
@Override
public UserPrincipalLookupService getUserPrincipalLookupService() {
return fileSystem.getUserPrincipalLookupService();
}
@Override
public WatchService newWatchService() throws IOException {
return fileSystem.newWatchService();
}
}
}
import com.jcraft.jsch.JSchException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SftpClientDemoTest {
private static FakeSftpServer fakeSftpServer;
private static final SftpProperties FTP_PROPERTIES = new SftpProperties("localhost", "test", "test", 9999);
@BeforeAll
static void init() throws IOException {
fakeSftpServer = new FakeSftpServer.Builder()
.host(FTP_PROPERTIES.getHost())
.port(FTP_PROPERTIES.getPort())
.username(FTP_PROPERTIES.getUser())
.password(FTP_PROPERTIES.getPassword())
.startup(true)
.build();
}
@AfterAll
static void afterAll() throws IOException {
if (fakeSftpServer != null) {
fakeSftpServer.stop();
}
}
@AfterEach
void afterEach() throws IOException {
fakeSftpServer.reset();
}
@Test
public void shouldUploadFileSuccess() throws IOException, JSchException {
var destination = "/hello-world.txt";
var content = "hello world";
var ftpClientDemo = new SftpClientDemo(FTP_PROPERTIES);
ftpClientDemo.uploadFile(destination, content);
List<Path> files = fakeSftpServer.getFiles();
assertThat(fakeSftpServer.existsFile(destination)).isTrue();
assertThat(files.size()).isEqualTo(1);
assertThat(new String(fakeSftpServer.getFileContent(files.get(0).toString()))).isEqualTo(content);
}
}
SSH File Transfer Protocol (SFTP): Get SFTP client & server ↩︎
JSch ↩︎
mina-sshd ↩︎
memoryfilesystem ↩︎