feat: prepare sellable source delivery edition

This commit is contained in:
liumangmang
2026-04-16 23:28:26 +08:00
parent f606d20000
commit 37dc4d8216
93 changed files with 7649 additions and 3096 deletions

View File

@@ -6,7 +6,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Configuration;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
@@ -15,34 +14,38 @@ public class ConfigurationValidator implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(ConfigurationValidator.class);
@Value("${SSHMANAGER_ENCRYPTION_KEY:}")
@Value("${sshmanager.encryption-key:}")
private String encryptionKey;
@Value("${SSHMANAGER_JWT_SECRET:}")
@Value("${sshmanager.jwt-secret:}")
private String jwtSecret;
@Value("${DATA_DIR:/app/data}")
private String dataDir;
@Override
public void run(String... args) {
log.info("Data directory resolved to: {}", dataDir);
Set<String> missingConfigs = new HashSet<>();
if (encryptionKey == null || encryptionKey.trim().isEmpty()) {
missingConfigs.add("SSHMANAGER_ENCRYPTION_KEY");
missingConfigs.add("sshmanager.encryption-key");
}
if (jwtSecret == null || jwtSecret.trim().isEmpty()) {
missingConfigs.add("SSHMANAGER_JWT_SECRET");
missingConfigs.add("sshmanager.jwt-secret");
}
if (!missingConfigs.isEmpty()) {
String missing = String.join(", ", missingConfigs);
log.error("Missing required environment variables: {}", missing);
log.error("Please set the following environment variables:");
log.error("Missing required configuration values: {}", missing);
log.error("Please provide them via Spring properties, environment variables or JVM -D arguments.");
missingConfigs.forEach(key -> log.error(" - {} (required)", key));
log.error("Application will not start without these configurations.");
System.exit(1);
}
if ("ssh-manager-jwt-secret-change-in-production".equals(jwtSecret)) {
log.error("Default JWT secret detected. Please set SSHMANAGER_JWT_SECRET to a secure random value.");
log.error("Default JWT secret detected. Please set sshmanager.jwt-secret to a secure random value.");
System.exit(1);
}

View File

@@ -1,69 +1,140 @@
package com.sshmanager.controller;
import com.sshmanager.dto.LoginRequest;
import com.sshmanager.dto.LoginResponse;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.security.JwtTokenProvider;
import org.springframework.http.ResponseEntity;
import com.sshmanager.dto.LoginRequest;
import com.sshmanager.dto.LoginResponse;
import com.sshmanager.dto.ChangePasswordRequest;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.security.JwtTokenProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider tokenProvider;
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider tokenProvider;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider tokenProvider,
UserRepository userRepository,
PasswordEncoder passwordEncoder) {
this.authenticationManager = authenticationManager;
this.tokenProvider = tokenProvider;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
public AuthController(AuthenticationManager authenticationManager,
JwtTokenProvider tokenProvider,
UserRepository userRepository) {
this.authenticationManager = authenticationManager;
this.tokenProvider = tokenProvider;
this.userRepository = userRepository;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String token = tokenProvider.generateToken(authentication);
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
LoginResponse response = new LoginResponse(token, user.getUsername(),
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername());
return ResponseEntity.ok(response);
} catch (BadCredentialsException e) {
SecurityContextHolder.getContext().setAuthentication(authentication);
String token = tokenProvider.generateToken(authentication);
User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found"));
LoginResponse response = new LoginResponse(token, user.getUsername(),
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
isPasswordChangeRequired(user));
return ResponseEntity.ok(response);
} catch (BadCredentialsException e) {
Map<String, String> error = new HashMap<>();
error.put("message", "Invalid username or password");
return ResponseEntity.status(401).body(error);
}
}
@GetMapping("/me")
public ResponseEntity<?> me(Authentication authentication) {
}
}
@GetMapping("/health")
public ResponseEntity<?> health() {
Map<String, Object> data = new HashMap<>();
data.put("app", "ssh-manager");
data.put("status", "ok");
data.put("timestamp", Instant.now().toEpochMilli());
return ResponseEntity.ok(data);
}
@GetMapping("/me")
public ResponseEntity<?> me(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
Map<String, String> error = new HashMap<>();
error.put("error", "Unauthorized");
return ResponseEntity.status(401).body(error);
}
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
Map<String, Object> data = new HashMap<>();
data.put("username", user.getUsername());
data.put("displayName", user.getDisplayName());
return ResponseEntity.ok(data);
}
}
Map<String, Object> data = new HashMap<>();
data.put("username", user.getUsername());
data.put("displayName", user.getDisplayName());
data.put("passwordChangeRequired", isPasswordChangeRequired(user));
return ResponseEntity.ok(data);
}
@PostMapping("/change-password")
public ResponseEntity<?> changePassword(@RequestBody ChangePasswordRequest request, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
Map<String, String> error = new HashMap<>();
error.put("error", "Unauthorized");
return ResponseEntity.status(401).body(error);
}
String currentPassword = request.getCurrentPassword() == null ? "" : request.getCurrentPassword().trim();
String newPassword = request.getNewPassword() == null ? "" : request.getNewPassword().trim();
if (currentPassword.isEmpty() || newPassword.isEmpty()) {
Map<String, String> error = new HashMap<>();
error.put("message", "Current password and new password are required");
return ResponseEntity.badRequest().body(error);
}
if (newPassword.length() < 8) {
Map<String, String> error = new HashMap<>();
error.put("message", "New password must be at least 8 characters");
return ResponseEntity.badRequest().body(error);
}
User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
if (!passwordEncoder.matches(currentPassword, user.getPasswordHash())) {
Map<String, String> error = new HashMap<>();
error.put("message", "Current password is incorrect");
return ResponseEntity.badRequest().body(error);
}
if (passwordEncoder.matches(newPassword, user.getPasswordHash())) {
Map<String, String> error = new HashMap<>();
error.put("message", "New password must be different from current password");
return ResponseEntity.badRequest().body(error);
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
userRepository.save(user);
Map<String, Object> data = new HashMap<>();
data.put("message", "Password updated");
data.put("passwordChangeRequired", false);
return ResponseEntity.ok(data);
}
private boolean isPasswordChangeRequired(User user) {
return "admin".equals(user.getUsername()) && passwordEncoder.matches("admin123", user.getPasswordHash());
}
}

View File

@@ -1,11 +1,17 @@
package com.sshmanager.controller;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.dto.BackupImportResponseDto;
import com.sshmanager.dto.BackupPackageDto;
import com.sshmanager.dto.BatchCommandRequest;
import com.sshmanager.dto.BatchCommandResponseDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.BackupService;
import com.sshmanager.service.BatchCommandService;
import com.sshmanager.service.ConnectionService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@@ -18,14 +24,20 @@ import java.util.Map;
@RequestMapping("/api/connections")
public class ConnectionController {
private final ConnectionService connectionService;
private final UserRepository userRepository;
public ConnectionController(ConnectionService connectionService,
UserRepository userRepository) {
this.connectionService = connectionService;
this.userRepository = userRepository;
}
private final ConnectionService connectionService;
private final BackupService backupService;
private final BatchCommandService batchCommandService;
private final UserRepository userRepository;
public ConnectionController(ConnectionService connectionService,
BackupService backupService,
BatchCommandService batchCommandService,
UserRepository userRepository) {
this.connectionService = connectionService;
this.backupService = backupService;
this.batchCommandService = batchCommandService;
this.userRepository = userRepository;
}
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalStateException("User not found"));
@@ -59,17 +71,37 @@ public class ConnectionController {
return ResponseEntity.ok(connectionService.update(id, request, userId));
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id,
Authentication authentication) {
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, String>> delete(@PathVariable Long id,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
connectionService.delete(id, userId);
Map<String, String> result = new HashMap<>();
result.put("message", "Deleted");
return ResponseEntity.ok(result);
}
@PostMapping("/test")
result.put("message", "Deleted");
return ResponseEntity.ok(result);
}
@GetMapping("/backup/export")
public ResponseEntity<BackupPackageDto> exportBackup(Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(backupService.exportBackup(userId));
}
@PostMapping("/backup/import")
public ResponseEntity<BackupImportResponseDto> importBackup(@RequestBody BackupPackageDto request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(backupService.importBackup(userId, request));
}
@PostMapping("/batch-command")
public ResponseEntity<BatchCommandResponseDto> executeBatchCommand(@RequestBody BatchCommandRequest request,
Authentication authentication) {
Long userId = getCurrentUserId(authentication);
return ResponseEntity.ok(batchCommandService.execute(userId, request));
}
@PostMapping("/test")
public ResponseEntity<Map<String, Object>> connectivity(@RequestBody Connection connection,
Authentication authentication) {
try {

View File

@@ -0,0 +1,18 @@
package com.sshmanager.dto;
import com.sshmanager.entity.Connection;
import lombok.Data;
@Data
public class BackupConnectionDto {
private Long sourceId;
private String name;
private String host;
private Integer port;
private String username;
private Connection.AuthType authType;
private String password;
private String privateKey;
private String passphrase;
}

View File

@@ -0,0 +1,12 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class BackupImportResponseDto {
private int importedConnections;
private int importedTreeNodes;
}

View File

@@ -0,0 +1,18 @@
package com.sshmanager.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Data
@NoArgsConstructor
public class BackupPackageDto {
private Integer version = 1;
private Instant exportedAt = Instant.now();
private List<BackupConnectionDto> connections = new ArrayList<BackupConnectionDto>();
private SessionTreeLayoutDto sessionTree = new SessionTreeLayoutDto();
}

View File

@@ -0,0 +1,13 @@
package com.sshmanager.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class BatchCommandRequest {
private List<Long> connectionIds = new ArrayList<Long>();
private String command;
}

View File

@@ -0,0 +1,16 @@
package com.sshmanager.dto;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class BatchCommandResponseDto {
private String command;
private int total;
private int successCount;
private int failureCount;
private List<BatchCommandResultDto> results = new ArrayList<BatchCommandResultDto>();
}

View File

@@ -0,0 +1,16 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class BatchCommandResultDto {
private Long connectionId;
private String connectionName;
private boolean success;
private String output;
private String error;
private long durationMs;
}

View File

@@ -0,0 +1,22 @@
package com.sshmanager.dto;
public class ChangePasswordRequest {
private String currentPassword;
private String newPassword;
public String getCurrentPassword() {
return currentPassword;
}
public void setCurrentPassword(String currentPassword) {
this.currentPassword = currentPassword;
}
public String getNewPassword() {
return newPassword;
}
public void setNewPassword(String newPassword) {
this.newPassword = newPassword;
}
}

View File

@@ -7,8 +7,9 @@ import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
private String username;
private String displayName;
}
public class LoginResponse {
private String token;
private String username;
private String displayName;
private boolean passwordChangeRequired;
}

View File

@@ -1,6 +1,5 @@
package com.sshmanager.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@@ -9,8 +8,13 @@ import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SessionTreeLayoutDto {
private List<SessionTreeNodeDto> nodes = new ArrayList<SessionTreeNodeDto>();
private String sortMode = "manual";
public SessionTreeLayoutDto(List<SessionTreeNodeDto> nodes) {
this.nodes = nodes;
this.sortMode = "manual";
}
}

View File

@@ -0,0 +1,148 @@
package com.sshmanager.service;
import com.sshmanager.dto.BackupConnectionDto;
import com.sshmanager.dto.BackupImportResponseDto;
import com.sshmanager.dto.BackupPackageDto;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.dto.SessionTreeNodeDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.repository.ConnectionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class BackupService {
private final ConnectionRepository connectionRepository;
private final ConnectionService connectionService;
private final SessionTreeLayoutService sessionTreeLayoutService;
public BackupService(ConnectionRepository connectionRepository,
ConnectionService connectionService,
SessionTreeLayoutService sessionTreeLayoutService) {
this.connectionRepository = connectionRepository;
this.connectionService = connectionService;
this.sessionTreeLayoutService = sessionTreeLayoutService;
}
@Transactional(readOnly = true)
public BackupPackageDto exportBackup(Long userId) {
BackupPackageDto backup = new BackupPackageDto();
backup.setExportedAt(Instant.now());
List<BackupConnectionDto> exportedConnections = new ArrayList<BackupConnectionDto>();
for (Connection connection : connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId)) {
BackupConnectionDto item = new BackupConnectionDto();
item.setSourceId(connection.getId());
item.setName(connection.getName());
item.setHost(connection.getHost());
item.setPort(connection.getPort());
item.setUsername(connection.getUsername());
item.setAuthType(connection.getAuthType());
item.setPassword(connectionService.getDecryptedPassword(connection));
item.setPrivateKey(connectionService.getDecryptedPrivateKey(connection));
item.setPassphrase(connectionService.getDecryptedPassphrase(connection));
exportedConnections.add(item);
}
backup.setConnections(exportedConnections);
backup.setSessionTree(sessionTreeLayoutService.getLayout(userId));
return backup;
}
@Transactional
public BackupImportResponseDto importBackup(Long userId, BackupPackageDto backupPackage) {
if (backupPackage == null) {
throw new IllegalArgumentException("Backup package is required");
}
List<BackupConnectionDto> connections = backupPackage.getConnections() == null
? new ArrayList<BackupConnectionDto>()
: backupPackage.getConnections();
SessionTreeLayoutDto sessionTree = backupPackage.getSessionTree() == null
? new SessionTreeLayoutDto()
: backupPackage.getSessionTree();
connectionRepository.deleteAll(connectionRepository.findByUserIdOrderByUpdatedAtDesc(userId));
Map<Long, Long> connectionIdMapping = new HashMap<Long, Long>();
for (BackupConnectionDto item : connections) {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName(item.getName());
request.setHost(item.getHost());
request.setPort(item.getPort());
request.setUsername(item.getUsername());
request.setAuthType(item.getAuthType());
request.setPassword(item.getPassword());
request.setPrivateKey(item.getPrivateKey());
request.setPassphrase(item.getPassphrase());
ConnectionDto created = connectionService.create(request, userId);
if (item.getSourceId() != null) {
connectionIdMapping.put(item.getSourceId(), created.getId());
}
}
SessionTreeLayoutDto remappedLayout = remapSessionTree(sessionTree, connectionIdMapping);
sessionTreeLayoutService.saveLayout(userId, remappedLayout);
int nodeCount = remappedLayout.getNodes() == null ? 0 : remappedLayout.getNodes().size();
return new BackupImportResponseDto(connections.size(), nodeCount);
}
private SessionTreeLayoutDto remapSessionTree(SessionTreeLayoutDto source,
Map<Long, Long> connectionIdMapping) {
SessionTreeLayoutDto target = new SessionTreeLayoutDto();
target.setSortMode(source.getSortMode());
List<SessionTreeNodeDto> remappedNodes = new ArrayList<SessionTreeNodeDto>();
if (source.getNodes() != null) {
for (SessionTreeNodeDto node : source.getNodes()) {
if (node == null) {
continue;
}
if ("connection".equals(node.getType())) {
Long mappedConnectionId = connectionIdMapping.get(node.getConnectionId());
if (mappedConnectionId == null) {
continue;
}
remappedNodes.add(new SessionTreeNodeDto(
node.getId(),
node.getType(),
node.getName(),
node.getParentId(),
node.getOrder(),
mappedConnectionId,
node.getExpanded(),
node.getCreatedAt(),
node.getUpdatedAt()
));
continue;
}
remappedNodes.add(new SessionTreeNodeDto(
node.getId(),
node.getType(),
node.getName(),
node.getParentId(),
node.getOrder(),
null,
node.getExpanded(),
node.getCreatedAt(),
node.getUpdatedAt()
));
}
}
target.setNodes(remappedNodes);
return target;
}
}

View File

@@ -0,0 +1,81 @@
package com.sshmanager.service;
import com.sshmanager.dto.BatchCommandRequest;
import com.sshmanager.dto.BatchCommandResponseDto;
import com.sshmanager.dto.BatchCommandResultDto;
import com.sshmanager.entity.Connection;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class BatchCommandService {
private final ConnectionService connectionService;
private final SshService sshService;
public BatchCommandService(ConnectionService connectionService,
SshService sshService) {
this.connectionService = connectionService;
this.sshService = sshService;
}
public BatchCommandResponseDto execute(Long userId, BatchCommandRequest request) {
if (request == null || request.getConnectionIds() == null || request.getConnectionIds().isEmpty()) {
throw new IllegalArgumentException("At least one connection is required");
}
String command = request.getCommand() == null ? "" : request.getCommand().trim();
if (command.isEmpty()) {
throw new IllegalArgumentException("Command is required");
}
List<BatchCommandResultDto> results = new ArrayList<BatchCommandResultDto>();
int successCount = 0;
int failureCount = 0;
for (Long connectionId : request.getConnectionIds()) {
Connection connection = connectionService.getConnectionForSsh(connectionId, userId);
long startedAt = System.currentTimeMillis();
try {
String output = sshService.executeCommand(
connection,
connectionService.getDecryptedPassword(connection),
connectionService.getDecryptedPrivateKey(connection),
connectionService.getDecryptedPassphrase(connection),
command
);
long durationMs = System.currentTimeMillis() - startedAt;
results.add(new BatchCommandResultDto(
connection.getId(),
connection.getName(),
true,
output,
null,
durationMs
));
successCount += 1;
} catch (Exception error) {
long durationMs = System.currentTimeMillis() - startedAt;
results.add(new BatchCommandResultDto(
connection.getId(),
connection.getName(),
false,
null,
error.getMessage(),
durationMs
));
failureCount += 1;
}
}
BatchCommandResponseDto response = new BatchCommandResponseDto();
response.setCommand(command);
response.setTotal(results.size());
response.setSuccessCount(successCount);
response.setFailureCount(failureCount);
response.setResults(results);
return response;
}
}

View File

@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.entity.SessionTreeLayout;
import com.sshmanager.repository.SessionTreeLayoutRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -13,6 +15,8 @@ import java.util.ArrayList;
@Service
public class SessionTreeLayoutService {
private static final Logger log = LoggerFactory.getLogger(SessionTreeLayoutService.class);
private final SessionTreeLayoutRepository sessionTreeLayoutRepository;
private final ObjectMapper objectMapper;
@@ -34,8 +38,10 @@ public class SessionTreeLayoutService {
if (parsed.getNodes() == null) {
parsed.setNodes(new ArrayList<>());
}
parsed.setSortMode(normalizeSortMode(parsed.getSortMode()));
return parsed;
} catch (Exception e) {
log.warn("Failed to parse session tree layout for userId={}, returning empty layout", userId, e);
return createEmptyLayout();
}
}
@@ -46,11 +52,13 @@ public class SessionTreeLayoutService {
if (payload.getNodes() == null) {
payload.setNodes(new ArrayList<>());
}
payload.setSortMode(normalizeSortMode(payload.getSortMode()));
final String layoutJson;
try {
layoutJson = objectMapper.writeValueAsString(payload);
} catch (Exception e) {
log.error("Failed to serialize session tree layout for userId={}", userId, e);
throw new RuntimeException("Failed to serialize session tree layout", e);
}
@@ -66,4 +74,11 @@ public class SessionTreeLayoutService {
private SessionTreeLayoutDto createEmptyLayout() {
return new SessionTreeLayoutDto(new ArrayList<>());
}
private String normalizeSortMode(String sortMode) {
if ("nameAsc".equals(sortMode)) {
return "nameAsc";
}
return "manual";
}
}

View File

@@ -12,7 +12,7 @@ spring:
location: ${DATA_DIR:/app/data}/upload-temp # 使用容器数据目录,避免被解析为 Tomcat 工作目录
file-size-threshold: 0 # 立即写入磁盘,不使用内存缓冲
datasource:
url: jdbc:h2:file:./data/sshmanager;DB_CLOSE_DELAY=-1
url: jdbc:h2:file:${DATA_DIR:/app/data}/sshmanager;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:

View File

@@ -0,0 +1,161 @@
package com.sshmanager.controller;
import com.sshmanager.dto.ChangePasswordRequest;
import com.sshmanager.dto.LoginRequest;
import com.sshmanager.dto.LoginResponse;
import com.sshmanager.entity.User;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.security.JwtTokenProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthControllerTest {
@Mock
private AuthenticationManager authenticationManager;
@Mock
private JwtTokenProvider tokenProvider;
@Mock
private UserRepository userRepository;
private PasswordEncoder passwordEncoder;
private AuthController authController;
@BeforeEach
void setUp() {
passwordEncoder = new BCryptPasswordEncoder();
authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder);
}
@Test
void loginMarksDefaultAdminPasswordAsRequiredToChange() {
LoginRequest request = new LoginRequest();
request.setUsername("admin");
request.setPassword("admin123");
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "admin123");
when(authenticationManager.authenticate(any(Authentication.class))).thenReturn(authentication);
when(tokenProvider.generateToken(authentication)).thenReturn("token");
User user = new User();
user.setUsername("admin");
user.setPasswordHash(passwordEncoder.encode("admin123"));
user.setDisplayName("Administrator");
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
ResponseEntity<?> response = authController.login(request);
assertEquals(200, response.getStatusCodeValue());
LoginResponse body = (LoginResponse) response.getBody();
assertNotNull(body);
assertTrue(body.isPasswordChangeRequired());
}
@Test
void healthReturnsApplicationMarker() {
ResponseEntity<?> response = authController.health();
assertEquals(200, response.getStatusCodeValue());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertNotNull(body);
assertEquals("ssh-manager", body.get("app"));
assertEquals("ok", body.get("status"));
assertTrue(((Number) body.get("timestamp")).longValue() > 0);
}
@Test
void changePasswordUpdatesHashAndTimestamp() {
ChangePasswordRequest request = new ChangePasswordRequest();
request.setCurrentPassword("admin123");
request.setNewPassword("newPassword123");
Authentication authentication = mock(Authentication.class);
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn("admin");
User user = new User();
user.setUsername("admin");
user.setPasswordHash(passwordEncoder.encode("admin123"));
user.setPasswordChangedAt(Instant.now().minusSeconds(3600));
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
ResponseEntity<?> response = authController.changePassword(request, authentication);
assertEquals(200, response.getStatusCodeValue());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertNotNull(body);
assertEquals("Password updated", body.get("message"));
assertEquals(false, body.get("passwordChangeRequired"));
assertTrue(passwordEncoder.matches("newPassword123", user.getPasswordHash()));
verify(userRepository).save(user);
}
@Test
void changePasswordRejectsWrongCurrentPassword() {
ChangePasswordRequest request = new ChangePasswordRequest();
request.setCurrentPassword("wrong");
request.setNewPassword("newPassword123");
Authentication authentication = mock(Authentication.class);
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn("admin");
User user = new User();
user.setUsername("admin");
user.setPasswordHash(passwordEncoder.encode("admin123"));
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
ResponseEntity<?> response = authController.changePassword(request, authentication);
assertEquals(400, response.getStatusCodeValue());
@SuppressWarnings("unchecked")
Map<String, String> body = (Map<String, String>) response.getBody();
assertNotNull(body);
assertEquals("Current password is incorrect", body.get("message"));
}
@Test
void meReturnsPasswordChangeRequiredFlag() {
Authentication authentication = mock(Authentication.class);
when(authentication.isAuthenticated()).thenReturn(true);
when(authentication.getName()).thenReturn("admin");
User user = new User();
user.setUsername("admin");
user.setPasswordHash(passwordEncoder.encode("admin123"));
user.setDisplayName("Administrator");
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
ResponseEntity<?> response = authController.me(authentication);
assertEquals(200, response.getStatusCodeValue());
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) response.getBody();
assertNotNull(body);
assertTrue((Boolean) body.get("passwordChangeRequired"));
assertFalse(((String) body.get("username")).isEmpty());
}
}

View File

@@ -2,7 +2,13 @@ package com.sshmanager.controller;
import com.sshmanager.entity.Connection;
import com.sshmanager.entity.User;
import com.sshmanager.dto.BackupImportResponseDto;
import com.sshmanager.dto.BackupPackageDto;
import com.sshmanager.dto.BatchCommandRequest;
import com.sshmanager.dto.BatchCommandResponseDto;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.BackupService;
import com.sshmanager.service.BatchCommandService;
import com.sshmanager.service.ConnectionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +41,12 @@ class ConnectionControllerTest {
@Mock
private ConnectionService connectionService;
@Mock
private BackupService backupService;
@Mock
private BatchCommandService batchCommandService;
@Mock
private UserRepository userRepository;
@@ -98,4 +110,43 @@ class ConnectionControllerTest {
assertFalse((Boolean) body.get("success"));
assertTrue(((String) body.get("message")).contains("Connection failed"));
}
@Test
void exportBackupUsesCurrentUserId() {
BackupPackageDto expected = new BackupPackageDto();
when(backupService.exportBackup(1L)).thenReturn(expected);
ResponseEntity<BackupPackageDto> response = connectionController.exportBackup(authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(expected, response.getBody());
verify(backupService).exportBackup(1L);
}
@Test
void importBackupUsesCurrentUserId() {
BackupPackageDto request = new BackupPackageDto();
BackupImportResponseDto expected = new BackupImportResponseDto(2, 5);
when(backupService.importBackup(1L, request)).thenReturn(expected);
ResponseEntity<BackupImportResponseDto> response = connectionController.importBackup(request, authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(expected, response.getBody());
verify(backupService).importBackup(1L, request);
}
@Test
void executeBatchCommandUsesCurrentUserId() {
BatchCommandRequest request = new BatchCommandRequest();
BatchCommandResponseDto expected = new BatchCommandResponseDto();
expected.setTotal(1);
when(batchCommandService.execute(1L, request)).thenReturn(expected);
ResponseEntity<BatchCommandResponseDto> response = connectionController.executeBatchCommand(request, authentication);
assertEquals(200, response.getStatusCode().value());
assertEquals(expected, response.getBody());
verify(batchCommandService).execute(1L, request);
}
}

View File

@@ -0,0 +1,106 @@
package com.sshmanager.service;
import com.sshmanager.dto.BackupConnectionDto;
import com.sshmanager.dto.BackupImportResponseDto;
import com.sshmanager.dto.BackupPackageDto;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.dto.SessionTreeLayoutDto;
import com.sshmanager.dto.SessionTreeNodeDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.repository.ConnectionRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BackupServiceTest {
@Mock
private ConnectionRepository connectionRepository;
@Mock
private ConnectionService connectionService;
@Mock
private SessionTreeLayoutService sessionTreeLayoutService;
@InjectMocks
private BackupService backupService;
@Test
void exportBackupIncludesConnectionsAndSessionTree() {
Connection connection = new Connection();
connection.setId(11L);
connection.setName("prod");
connection.setHost("127.0.0.1");
connection.setPort(22);
connection.setUsername("root");
connection.setAuthType(Connection.AuthType.PASSWORD);
SessionTreeLayoutDto layout = new SessionTreeLayoutDto(Arrays.asList(
new SessionTreeNodeDto("node-1", "connection", "prod", null, 0, 11L, null, 1L, 2L)
));
when(connectionRepository.findByUserIdOrderByUpdatedAtDesc(1L)).thenReturn(Collections.singletonList(connection));
when(connectionService.getDecryptedPassword(connection)).thenReturn("secret");
when(sessionTreeLayoutService.getLayout(1L)).thenReturn(layout);
BackupPackageDto result = backupService.exportBackup(1L);
assertNotNull(result);
assertEquals(1, result.getConnections().size());
assertEquals(11L, result.getConnections().get(0).getSourceId().longValue());
assertEquals("secret", result.getConnections().get(0).getPassword());
assertEquals(layout, result.getSessionTree());
}
@Test
void importBackupReplacesConnectionsAndRemapsTreeIds() {
BackupConnectionDto backupConnection = new BackupConnectionDto();
backupConnection.setSourceId(7L);
backupConnection.setName("prod");
backupConnection.setHost("10.0.0.10");
backupConnection.setPort(22);
backupConnection.setUsername("root");
backupConnection.setAuthType(Connection.AuthType.PASSWORD);
backupConnection.setPassword("secret");
BackupPackageDto backupPackage = new BackupPackageDto();
backupPackage.setConnections(Collections.singletonList(backupConnection));
backupPackage.setSessionTree(new SessionTreeLayoutDto(Arrays.asList(
new SessionTreeNodeDto("folder-1", "folder", "生产", null, 0, null, true, 1L, 2L),
new SessionTreeNodeDto("conn-1", "connection", "prod", "folder-1", 1, 7L, null, 1L, 2L)
)));
ConnectionDto created = new ConnectionDto();
created.setId(101L);
when(connectionRepository.findByUserIdOrderByUpdatedAtDesc(1L)).thenReturn(Collections.emptyList());
when(connectionService.create(any(), eq(1L))).thenReturn(created);
BackupImportResponseDto result = backupService.importBackup(1L, backupPackage);
assertEquals(1, result.getImportedConnections());
assertEquals(2, result.getImportedTreeNodes());
verify(connectionRepository).deleteAll(Collections.emptyList());
ArgumentCaptor<SessionTreeLayoutDto> captor = ArgumentCaptor.forClass(SessionTreeLayoutDto.class);
verify(sessionTreeLayoutService).saveLayout(eq(1L), captor.capture());
SessionTreeLayoutDto savedLayout = captor.getValue();
assertEquals(2, savedLayout.getNodes().size());
assertEquals(101L, savedLayout.getNodes().get(1).getConnectionId().longValue());
}
}

View File

@@ -0,0 +1,71 @@
package com.sshmanager.service;
import com.sshmanager.dto.BatchCommandRequest;
import com.sshmanager.dto.BatchCommandResponseDto;
import com.sshmanager.entity.Connection;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BatchCommandServiceTest {
@Mock
private ConnectionService connectionService;
@Mock
private SshService sshService;
@InjectMocks
private BatchCommandService batchCommandService;
@Test
void executeAggregatesSuccessAndFailure() throws Exception {
Connection successConnection = new Connection();
successConnection.setId(1L);
successConnection.setUserId(99L);
successConnection.setName("prod");
Connection failedConnection = new Connection();
failedConnection.setId(2L);
failedConnection.setUserId(99L);
failedConnection.setName("test");
BatchCommandRequest request = new BatchCommandRequest();
request.setConnectionIds(Arrays.asList(1L, 2L));
request.setCommand("uptime");
when(connectionService.getConnectionForSsh(1L, 99L)).thenReturn(successConnection);
when(connectionService.getConnectionForSsh(2L, 99L)).thenReturn(failedConnection);
when(sshService.executeCommand(eq(successConnection), eq(null), eq(null), eq(null), eq("uptime"))).thenReturn("ok");
when(sshService.executeCommand(eq(failedConnection), eq(null), eq(null), eq(null), eq("uptime"))).thenThrow(new RuntimeException("boom"));
BatchCommandResponseDto response = batchCommandService.execute(99L, request);
assertEquals(2, response.getTotal());
assertEquals(1, response.getSuccessCount());
assertEquals(1, response.getFailureCount());
assertEquals("prod", response.getResults().get(0).getConnectionName());
assertEquals("ok", response.getResults().get(0).getOutput());
assertEquals("boom", response.getResults().get(1).getError());
}
@Test
void executeRejectsEmptyCommand() {
BatchCommandRequest request = new BatchCommandRequest();
request.setConnectionIds(Arrays.asList(1L));
request.setCommand(" ");
IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> batchCommandService.execute(1L, request));
assertEquals("Command is required", error.getMessage());
}
}

View File

@@ -41,6 +41,7 @@ class SessionTreeLayoutServiceTest {
SessionTreeLayoutDto result = sessionTreeLayoutService.getLayout(1L);
assertTrue(result.getNodes().isEmpty());
assertEquals("manual", result.getSortMode());
}
@Test
@@ -52,6 +53,7 @@ class SessionTreeLayoutServiceTest {
SessionTreeLayoutDto parsed = new SessionTreeLayoutDto(Arrays.asList(
new SessionTreeNodeDto("n1", "folder", "我的连接", null, 0, null, true, 1L, 1L)
));
parsed.setSortMode(null);
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.of(saved));
when(objectMapper.readValue(saved.getLayoutJson(), SessionTreeLayoutDto.class)).thenReturn(parsed);
@@ -60,12 +62,14 @@ class SessionTreeLayoutServiceTest {
assertEquals(1, result.getNodes().size());
assertEquals("n1", result.getNodes().get(0).getId());
assertEquals("manual", result.getSortMode());
}
@Test
void saveLayoutNormalizesNullNodes() throws Exception {
SessionTreeLayoutDto request = new SessionTreeLayoutDto();
request.setNodes(null);
request.setSortMode("unknown");
when(sessionTreeLayoutRepository.findByUserId(1L)).thenReturn(Optional.empty());
when(objectMapper.writeValueAsString(any(SessionTreeLayoutDto.class))).thenReturn("{\"nodes\":[]}");
@@ -73,6 +77,7 @@ class SessionTreeLayoutServiceTest {
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, request);
assertTrue(result.getNodes().isEmpty());
assertEquals("manual", result.getSortMode());
ArgumentCaptor<SessionTreeLayout> captor = ArgumentCaptor.forClass(SessionTreeLayout.class);
verify(sessionTreeLayoutRepository).save(captor.capture());
assertEquals(1L, captor.getValue().getUserId().longValue());
@@ -87,5 +92,6 @@ class SessionTreeLayoutServiceTest {
SessionTreeLayoutDto result = sessionTreeLayoutService.saveLayout(1L, null);
assertEquals(Collections.emptyList(), result.getNodes());
assertEquals("manual", result.getSortMode());
}
}