Add password-bootstrap SSH setup for new connections

This commit is contained in:
liumangmang
2026-04-21 16:32:46 +08:00
parent 05b835eb02
commit 42836aa4c3
9 changed files with 862 additions and 215 deletions
@@ -3,6 +3,7 @@ package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.dto.ConnectionDto;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.InvalidOperationException;
import com.sshmanager.repository.ConnectionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -17,9 +18,13 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
class ConnectionServiceTest {
@@ -30,12 +35,18 @@ class ConnectionServiceTest {
@Mock
private EncryptionService encryptionService;
@Mock
private SshService sshService;
@Mock
private SshBootstrapService sshBootstrapService;
@InjectMocks
private ConnectionService connectionService;
@BeforeEach
void setUp() {
when(connectionRepository.save(any(Connection.class))).thenAnswer(invocation -> invocation.getArgument(0));
lenient().when(connectionRepository.save(any(Connection.class))).thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
@@ -91,11 +102,82 @@ class ConnectionServiceTest {
assertNull(saved.getEncryptedPassword());
}
@Test
void createPasswordBootstrapConnectionSavesGeneratedPrivateKey() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
request.setBootstrapPassword("bootstrap-secret");
when(sshBootstrapService.bootstrapWithPassword(request, 1L))
.thenReturn(new SshBootstrapService.BootstrapResult("generated-private-key"));
when(encryptionService.encrypt("generated-private-key")).thenReturn("enc-generated-private-key");
ConnectionDto result = connectionService.create(request, 1L);
assertNotNull(result);
ArgumentCaptor<Connection> captor = ArgumentCaptor.forClass(Connection.class);
verify(connectionRepository).save(captor.capture());
Connection saved = captor.getValue();
assertEquals(Connection.AuthType.PRIVATE_KEY, saved.getAuthType());
assertNull(saved.getEncryptedPassword());
assertEquals("enc-generated-private-key", saved.getEncryptedPrivateKey());
assertNull(saved.getPassphrase());
verify(sshBootstrapService).bootstrapWithPassword(request, 1L);
verify(encryptionService, never()).encrypt("bootstrap-secret");
}
@Test
void createPasswordBootstrapConnectionRequiresBootstrapPassword() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.create(request, 1L)
);
assertEquals("启用一键免密配置时必须填写初始登录密码", exception.getMessage());
verifyNoInteractions(connectionRepository, sshBootstrapService, encryptionService);
}
@Test
void createPasswordBootstrapConnectionDoesNotSaveWhenBootstrapFails() {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setUsername("root");
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
request.setBootstrapPassword("bootstrap-secret");
when(sshBootstrapService.bootstrapWithPassword(request, 1L))
.thenThrow(new InvalidOperationException("免密初始化失败"));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.create(request, 1L)
);
assertEquals("免密初始化失败", exception.getMessage());
verify(connectionRepository, never()).save(any(Connection.class));
}
@Test
void updateSwitchToPrivateKeyClearsPasswordCredential() {
Connection existing = new Connection();
existing.setId(10L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PASSWORD);
existing.setEncryptedPassword("old-password");
@@ -126,6 +208,10 @@ class ConnectionServiceTest {
Connection existing = new Connection();
existing.setId(20L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PRIVATE_KEY);
existing.setEncryptedPrivateKey("old-key");
existing.setPassphrase("old-passphrase");
@@ -149,4 +235,30 @@ class ConnectionServiceTest {
assertNull(saved.getEncryptedPrivateKey());
assertNull(saved.getPassphrase());
}
@Test
void updateRejectsPasswordBootstrapMode() {
Connection existing = new Connection();
existing.setId(20L);
existing.setUserId(1L);
existing.setName("prod");
existing.setHost("127.0.0.1");
existing.setPort(22);
existing.setUsername("root");
existing.setAuthType(Connection.AuthType.PASSWORD);
existing.setEncryptedPassword("old-password");
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setSetupMode(ConnectionCreateRequest.SetupMode.PASSWORD_BOOTSTRAP);
when(connectionRepository.findById(20L)).thenReturn(Optional.of(existing));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> connectionService.update(20L, request, 1L)
);
assertEquals("编辑连接时不支持一键免密配置", exception.getMessage());
verify(connectionRepository, never()).save(any(Connection.class));
}
}
@@ -0,0 +1,104 @@
package com.sshmanager.service;
import com.sshmanager.dto.ConnectionCreateRequest;
import com.sshmanager.entity.Connection;
import com.sshmanager.exception.InvalidOperationException;
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.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SshBootstrapServiceTest {
@Mock
private SshService sshService;
@InjectMocks
private SshBootstrapService sshBootstrapService;
@Test
void bootstrapWithPasswordGeneratesKeyAndVerifiesPrivateKeyLogin() throws Exception {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setBootstrapPassword("bootstrap-secret");
when(sshService.executeCommandWithResult(any(Connection.class), anyString(), isNull(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("", "", 0));
when(sshService.executeCommandWithResult(any(Connection.class), isNull(), anyString(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("ssh-manager bootstrap ok", "", 0));
SshBootstrapService.BootstrapResult result = sshBootstrapService.bootstrapWithPassword(request, 1L);
assertNotNull(result);
assertTrue(result.getPrivateKey().contains("PRIVATE KEY"));
ArgumentCaptor<Connection> connectionCaptor = ArgumentCaptor.forClass(Connection.class);
ArgumentCaptor<String> passwordCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> privateKeyCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> commandCaptor = ArgumentCaptor.forClass(String.class);
verify(sshService, times(2)).executeCommandWithResult(
connectionCaptor.capture(),
passwordCaptor.capture(),
privateKeyCaptor.capture(),
isNull(),
commandCaptor.capture()
);
List<Connection> connections = connectionCaptor.getAllValues();
List<String> passwords = passwordCaptor.getAllValues();
List<String> privateKeys = privateKeyCaptor.getAllValues();
List<String> commands = commandCaptor.getAllValues();
assertEquals(Connection.AuthType.PASSWORD, connections.get(0).getAuthType());
assertEquals("bootstrap-secret", passwords.get(0));
assertNull(privateKeys.get(0));
assertTrue(commands.get(0).contains("authorized_keys"));
assertEquals(Connection.AuthType.PRIVATE_KEY, connections.get(1).getAuthType());
assertNull(passwords.get(1));
assertNotNull(privateKeys.get(1));
assertTrue(privateKeys.get(1).contains("PRIVATE KEY"));
assertTrue(commands.get(1).contains("ssh-manager bootstrap ok"));
}
@Test
void bootstrapWithPasswordFailsWhenRemoteAuthorizationCommandFails() throws Exception {
ConnectionCreateRequest request = new ConnectionCreateRequest();
request.setName("prod");
request.setHost("127.0.0.1");
request.setPort(22);
request.setUsername("root");
request.setBootstrapPassword("bootstrap-secret");
when(sshService.executeCommandWithResult(any(Connection.class), anyString(), isNull(), isNull(), anyString()))
.thenReturn(new SshService.CommandResult("", "permission denied", 1));
InvalidOperationException exception = assertThrows(
InvalidOperationException.class,
() -> sshBootstrapService.bootstrapWithPassword(request, 1L)
);
assertTrue(exception.getMessage().contains("authorized_keys"));
assertTrue(exception.getMessage().contains("permission denied"));
}
}