Enhance security and reliability across SFTP workflows

This commit is contained in:
liumangmang
2026-03-10 16:15:46 +08:00
parent 56c40410dc
commit 0c443b029d
23 changed files with 1477 additions and 394 deletions

View File

@@ -0,0 +1,78 @@
package com.sftp.manager.controller;
import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.dto.DisconnectRequest;
import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.model.Connection;
import com.sftp.manager.service.ConnectionService;
import com.sftp.manager.service.SessionManager;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import java.util.Map;
public class ConnectionControllerTest {
private ConnectionController controller;
private ConnectionService connectionService;
private SessionManager sessionManager;
@BeforeEach
public void setUp() {
controller = new ConnectionController();
connectionService = Mockito.mock(ConnectionService.class);
sessionManager = Mockito.mock(SessionManager.class);
ReflectionTestUtils.setField(controller, "connectionService", connectionService);
ReflectionTestUtils.setField(controller, "sessionManager", sessionManager);
}
@Test
public void disconnect_shouldThrowWhenSessionIdEmpty() {
DisconnectRequest request = new DisconnectRequest();
request.setSessionId(" ");
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class,
() -> controller.disconnect(request));
Assertions.assertEquals("会话ID不能为空", ex.getMessage());
}
@Test
public void listConnections_shouldReturnSuccessResponse() {
Mockito.when(connectionService.listConnections()).thenReturn(Collections.<Connection>emptyList());
ApiResponse<java.util.List<Connection>> response = controller.listConnections();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("查询成功", response.getMessage());
}
@Test
public void getActiveConnections_shouldReturnSuccessResponse() {
Mockito.when(sessionManager.getAllActiveConnections()).thenReturn(Collections.<String, Connection>emptyMap());
ApiResponse<Map<String, Connection>> response = controller.getActiveConnections();
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("查询成功", response.getMessage());
}
@Test
public void connect_shouldDelegateToService() throws Exception {
ConnectionRequest request = new ConnectionRequest();
request.setHost("127.0.0.1");
request.setUsername("root");
Mockito.when(connectionService.connect(request)).thenReturn("sftp-1");
ApiResponse<String> response = controller.connect(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("sftp-1", response.getData());
}
}

View File

@@ -0,0 +1,83 @@
package com.sftp.manager.controller;
import com.sftp.manager.dto.ApiResponse;
import com.sftp.manager.dto.BatchDeleteRequest;
import com.sftp.manager.dto.BatchDeleteResult;
import com.sftp.manager.dto.DirectoryRequest;
import com.sftp.manager.dto.FileListRequest;
import com.sftp.manager.service.LocalFileService;
import com.sftp.manager.service.SessionManager;
import com.sftp.manager.service.SftpService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
public class FileControllerTest {
private FileController controller;
private LocalFileService localFileService;
private SftpService sftpService;
@BeforeEach
public void setUp() {
controller = new FileController();
localFileService = Mockito.mock(LocalFileService.class);
sftpService = Mockito.mock(SftpService.class);
SessionManager sessionManager = Mockito.mock(SessionManager.class);
ReflectionTestUtils.setField(controller, "localFileService", localFileService);
ReflectionTestUtils.setField(controller, "sftpService", sftpService);
ReflectionTestUtils.setField(controller, "sessionManager", sessionManager);
}
@Test
public void listFiles_shouldThrowWhenSessionIdEmpty() {
FileListRequest request = new FileListRequest();
request.setSessionId(" ");
request.setPath("/tmp");
IllegalArgumentException ex = Assertions.assertThrows(IllegalArgumentException.class,
() -> controller.listFiles(request));
Assertions.assertEquals("会话ID不能为空", ex.getMessage());
}
@Test
public void createDirectory_local_shouldCallLocalService() throws Exception {
DirectoryRequest request = new DirectoryRequest();
request.setSessionId("local");
request.setPath("/tmp/new-dir");
Mockito.when(localFileService.createDirectory("/tmp/new-dir")).thenReturn(true);
ApiResponse<Void> response = controller.createDirectory(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertEquals("创建成功", response.getMessage());
Mockito.verify(localFileService).createDirectory("/tmp/new-dir");
}
@Test
public void batchDelete_nullPaths_shouldUseEmptyList() {
BatchDeleteRequest request = new BatchDeleteRequest();
request.setSessionId("local");
request.setPaths(null);
BatchDeleteResult result = new BatchDeleteResult();
result.setSuccessCount(0);
result.setFailCount(0);
result.setFailedFiles(Collections.<String>emptyList());
Mockito.when(localFileService.batchDelete(Mockito.anyList())).thenReturn(result);
ApiResponse<BatchDeleteResult> response = controller.batchDelete(request);
Assertions.assertTrue(response.isSuccess());
Assertions.assertNotNull(response.getData());
Mockito.verify(localFileService).batchDelete(Mockito.anyList());
}
}

View File

@@ -0,0 +1,115 @@
package com.sftp.manager.service;
import com.sftp.manager.dto.ConnectionRequest;
import com.sftp.manager.model.Connection;
import com.sftp.manager.repository.ConnectionRepository;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
public class ConnectionServiceTest {
private ConnectionService connectionService;
private ConnectionRepository connectionRepository;
@BeforeEach
public void setUp() {
connectionService = new ConnectionService();
connectionRepository = Mockito.mock(ConnectionRepository.class);
ReflectionTestUtils.setField(connectionService, "connectionRepository", connectionRepository);
ReflectionTestUtils.setField(connectionService, "sessionManager", Mockito.mock(SessionManager.class));
}
@Test
public void buildEffectiveRequest_shouldLoadSavedCredentialsById() throws Exception {
Connection saved = new Connection();
saved.setId(1L);
saved.setName("saved-conn");
saved.setHost("10.0.0.1");
saved.setPort(null);
saved.setUsername("saved-user");
saved.setPassword("saved-pass");
saved.setPrivateKeyPath("/tmp/id_rsa");
saved.setPassPhrase("pp");
saved.setRootPath("/home/saved");
Mockito.when(connectionRepository.findById(1L)).thenReturn(Optional.of(saved));
ConnectionRequest request = new ConnectionRequest();
request.setId(1L);
request.setHost("10.0.0.2");
ConnectionRequest effective = invokeBuildEffectiveRequest(request);
Assertions.assertEquals("10.0.0.2", effective.getHost());
Assertions.assertEquals("saved-user", effective.getUsername());
Assertions.assertEquals("saved-pass", effective.getPassword());
Assertions.assertEquals(Integer.valueOf(22), effective.getPort());
}
@Test
public void buildEffectiveRequest_shouldFailWhenNoPasswordAndNoPrivateKey() {
ConnectionRequest request = new ConnectionRequest();
request.setHost("127.0.0.1");
request.setUsername("root");
Exception ex = Assertions.assertThrows(Exception.class, () -> invokeBuildEffectiveRequest(request));
Assertions.assertEquals("密码和私钥不能同时为空", ex.getMessage());
}
@Test
public void configureKnownHosts_shouldFailWhenDefaultKnownHostsMissing() {
String oldUserHome = System.getProperty("user.home");
try {
Path tempHome = Files.createTempDirectory("sftp-manager-home");
System.setProperty("user.home", tempHome.toString());
ReflectionTestUtils.setField(connectionService, "knownHostsPath", "");
Exception ex = Assertions.assertThrows(Exception.class, this::invokeConfigureKnownHosts);
Assertions.assertTrue(ex.getMessage().contains("未找到 known_hosts"));
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
if (oldUserHome != null) {
System.setProperty("user.home", oldUserHome);
}
}
}
private ConnectionRequest invokeBuildEffectiveRequest(ConnectionRequest request) throws Exception {
Method method = ConnectionService.class.getDeclaredMethod("buildEffectiveRequest", ConnectionRequest.class);
method.setAccessible(true);
try {
return (ConnectionRequest) method.invoke(connectionService, request);
} catch (InvocationTargetException e) {
Throwable target = e.getTargetException();
if (target instanceof Exception) {
throw (Exception) target;
}
throw new RuntimeException(target);
}
}
private void invokeConfigureKnownHosts() throws Exception {
Method method = ConnectionService.class.getDeclaredMethod("configureKnownHosts", com.jcraft.jsch.JSch.class);
method.setAccessible(true);
try {
method.invoke(connectionService, new com.jcraft.jsch.JSch());
} catch (InvocationTargetException e) {
Throwable target = e.getTargetException();
if (target instanceof Exception) {
throw (Exception) target;
}
throw new RuntimeException(target);
}
}
}

View File

@@ -0,0 +1,77 @@
package com.sftp.manager.service;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class LocalFileServiceTest {
private final LocalFileService localFileService = new LocalFileService();
@TempDir
Path tempDir;
@Test
public void createDirectory_shouldRejectParentTraversalPath() {
String badPath = tempDir.resolve("../escape-dir").toString();
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.createDirectory(badPath));
Assertions.assertTrue(ex.getMessage().contains("上级目录引用"));
}
@Test
public void deleteFile_shouldRejectRootPath() {
File[] roots = File.listRoots();
Assertions.assertNotNull(roots);
Assertions.assertTrue(roots.length > 0);
String rootPath = roots[0].getPath();
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.deleteFile(rootPath));
Assertions.assertTrue(ex.getMessage().contains("根目录禁止删除"));
}
@Test
public void renameFile_shouldRejectCrossDirectoryRename() throws Exception {
Path source = tempDir.resolve("source.txt");
Files.write(source, "data".getBytes(StandardCharsets.UTF_8));
Path subDir = tempDir.resolve("sub");
Files.createDirectories(subDir);
Path target = subDir.resolve("target.txt");
Exception ex = Assertions.assertThrows(Exception.class,
() -> localFileService.renameFile(source.toString(), target.toString()));
Assertions.assertTrue(ex.getMessage().contains("仅支持同目录重命名"));
}
@Test
public void renameFile_shouldAllowRenameInSameDirectory() throws Exception {
Path source = tempDir.resolve("old-name.txt");
Files.write(source, "data".getBytes(StandardCharsets.UTF_8));
Path target = tempDir.resolve("new-name.txt");
boolean result = localFileService.renameFile(source.toString(), target.toString());
Assertions.assertTrue(result);
Assertions.assertFalse(Files.exists(source));
Assertions.assertTrue(Files.exists(target));
}
@Test
public void fileExists_shouldReturnFalseWhenPathContainsTraversal() {
boolean exists = localFileService.fileExists("../sensitive-path");
Assertions.assertFalse(exists);
}
}

View File

@@ -0,0 +1,81 @@
package com.sftp.manager.service;
import com.jcraft.jsch.ChannelSftp;
import com.sftp.manager.model.Connection;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Map;
public class SessionManagerTest {
@Test
public void cleanupExpiredSessions_shouldRemoveDisconnectedSession() {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(false);
Connection connection = new Connection();
connection.setName("test");
String sessionId = sessionManager.addSession(channel, connection);
sessionManager.cleanupExpiredSessions();
Assertions.assertNull(sessionManager.getSession(sessionId));
Assertions.assertNull(sessionManager.getConnection(sessionId));
}
@Test
public void cleanupExpiredSessions_shouldRemoveTimeoutSession() {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 100L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(true);
Connection connection = new Connection();
connection.setName("timeout-test");
String sessionId = sessionManager.addSession(channel, connection);
@SuppressWarnings("unchecked")
Map<String, Long> accessMap = (Map<String, Long>) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime");
Assertions.assertNotNull(accessMap);
accessMap.put(sessionId, System.currentTimeMillis() - 10_000L);
sessionManager.cleanupExpiredSessions();
Assertions.assertNull(sessionManager.getSession(sessionId));
Assertions.assertNull(sessionManager.getConnection(sessionId));
}
@Test
public void getSession_shouldRefreshLastAccessTime() throws Exception {
SessionManager sessionManager = new SessionManager();
ReflectionTestUtils.setField(sessionManager, "sessionTimeout", 60_000L);
ChannelSftp channel = Mockito.mock(ChannelSftp.class);
Mockito.when(channel.isConnected()).thenReturn(true);
Connection connection = new Connection();
connection.setName("access-test");
String sessionId = sessionManager.addSession(channel, connection);
@SuppressWarnings("unchecked")
Map<String, Long> accessMap = (Map<String, Long>) ReflectionTestUtils.getField(sessionManager, "sessionLastAccessTime");
Assertions.assertNotNull(accessMap);
accessMap.put(sessionId, 1L);
sessionManager.getSession(sessionId);
Long refreshedTime = accessMap.get(sessionId);
Assertions.assertNotNull(refreshedTime);
Assertions.assertTrue(refreshedTime > 1L);
}
}