diff --git a/backend/src/main/java/io/metersphere/api/controller/ApiTestEnvironmentController.java b/backend/src/main/java/io/metersphere/api/controller/ApiTestEnvironmentController.java index f354f38a9f..6f4a0c011f 100644 --- a/backend/src/main/java/io/metersphere/api/controller/ApiTestEnvironmentController.java +++ b/backend/src/main/java/io/metersphere/api/controller/ApiTestEnvironmentController.java @@ -2,7 +2,10 @@ package io.metersphere.api.controller; import com.github.pagehelper.Page; import com.github.pagehelper.PageHelper; +import io.metersphere.api.dto.ApiTestEnvironmentDTO; +import io.metersphere.api.dto.ssl.KeyStoreEntry; import io.metersphere.api.service.ApiTestEnvironmentService; +import io.metersphere.api.service.CommandService; import io.metersphere.base.domain.ApiTestEnvironmentWithBLOBs; import io.metersphere.commons.constants.RoleConstants; import io.metersphere.commons.utils.PageUtils; @@ -12,6 +15,7 @@ import io.metersphere.service.CheckPermissionService; import org.apache.shiro.authz.annotation.Logical; import org.apache.shiro.authz.annotation.RequiresRoles; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import java.util.List; @@ -25,6 +29,8 @@ public class ApiTestEnvironmentController { ApiTestEnvironmentService apiTestEnvironmentService; @Resource private CheckPermissionService checkPermissionService; + @Resource + private CommandService commandService; @GetMapping("/list/{projectId}") public List list(@PathVariable String projectId) { @@ -34,6 +40,7 @@ public class ApiTestEnvironmentController { /** * 查询指定项目和指定名称的环境 + * * @param goPage * @param pageSize * @param environmentRequest @@ -54,16 +61,23 @@ public class ApiTestEnvironmentController { return apiTestEnvironmentService.get(id); } + + @PostMapping(value = "/get/entry") + @RequiresRoles(value = {RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER,}, logical = Logical.OR) + public List getEntry(@RequestPart("request") String password, @RequestPart(value = "file") MultipartFile sslFiles) { + return commandService.get(password, sslFiles); + } + @PostMapping("/add") @RequiresRoles(value = {RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER,}, logical = Logical.OR) - public String add(@RequestBody ApiTestEnvironmentWithBLOBs apiTestEnvironmentWithBLOBs) { - return apiTestEnvironmentService.add(apiTestEnvironmentWithBLOBs); + public String create(@RequestPart("request") ApiTestEnvironmentDTO apiTestEnvironmentWithBLOBs, @RequestPart(value = "files") List sslFiles) { + return apiTestEnvironmentService.add(apiTestEnvironmentWithBLOBs, sslFiles); } @PostMapping(value = "/update") @RequiresRoles(value = {RoleConstants.TEST_MANAGER, RoleConstants.TEST_USER,}, logical = Logical.OR) - public void update(@RequestBody ApiTestEnvironmentWithBLOBs apiTestEnvironment) { - apiTestEnvironmentService.update(apiTestEnvironment); + public void update(@RequestPart("request") ApiTestEnvironmentDTO apiTestEnvironment, @RequestPart(value = "files") List sslFiles) { + apiTestEnvironmentService.update(apiTestEnvironment, sslFiles); } @GetMapping("/delete/{id}") diff --git a/backend/src/main/java/io/metersphere/api/dto/ApiTestEnvironmentDTO.java b/backend/src/main/java/io/metersphere/api/dto/ApiTestEnvironmentDTO.java new file mode 100644 index 0000000000..d0afe5f521 --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/ApiTestEnvironmentDTO.java @@ -0,0 +1,11 @@ +package io.metersphere.api.dto; + +import io.metersphere.base.domain.ApiTestEnvironmentWithBLOBs; +import lombok.Data; + +import java.util.List; + +@Data +public class ApiTestEnvironmentDTO extends ApiTestEnvironmentWithBLOBs { + private List uploadIds; +} diff --git a/backend/src/main/java/io/metersphere/api/dto/definition/request/ParameterConfig.java b/backend/src/main/java/io/metersphere/api/dto/definition/request/ParameterConfig.java index acf0e3313d..08a9733c08 100644 --- a/backend/src/main/java/io/metersphere/api/dto/definition/request/ParameterConfig.java +++ b/backend/src/main/java/io/metersphere/api/dto/definition/request/ParameterConfig.java @@ -2,10 +2,12 @@ package io.metersphere.api.dto.definition.request; import io.metersphere.api.dto.definition.request.variable.ScenarioVariable; import io.metersphere.api.dto.scenario.environment.EnvironmentConfig; +import io.metersphere.api.dto.ssl.MsKeyStore; import io.metersphere.commons.utils.ScriptEngineUtils; import lombok.Data; import org.apache.jmeter.config.Arguments; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -15,6 +17,10 @@ public class ParameterConfig { * 环境配置 */ private Map config; + /** + * 缓存同一批请求的认证信息 + */ + private Map keyStoreMap = new HashMap<>(); /** * 公共场景参数 */ diff --git a/backend/src/main/java/io/metersphere/api/dto/definition/request/sampler/MsHTTPSamplerProxy.java b/backend/src/main/java/io/metersphere/api/dto/definition/request/sampler/MsHTTPSamplerProxy.java index a6e5a2c4e4..369b192bb5 100644 --- a/backend/src/main/java/io/metersphere/api/dto/definition/request/sampler/MsHTTPSamplerProxy.java +++ b/backend/src/main/java/io/metersphere/api/dto/definition/request/sampler/MsHTTPSamplerProxy.java @@ -14,8 +14,11 @@ import io.metersphere.api.dto.scenario.Body; import io.metersphere.api.dto.scenario.HttpConfig; import io.metersphere.api.dto.scenario.HttpConfigCondition; import io.metersphere.api.dto.scenario.KeyValue; +import io.metersphere.api.dto.ssl.KeyStoreFile; +import io.metersphere.api.dto.ssl.MsKeyStore; import io.metersphere.api.service.ApiDefinitionService; import io.metersphere.api.service.ApiTestCaseService; +import io.metersphere.api.service.CommandService; import io.metersphere.base.domain.ApiDefinition; import io.metersphere.base.domain.ApiDefinitionWithBLOBs; import io.metersphere.base.domain.ApiTestCaseWithBLOBs; @@ -26,6 +29,7 @@ import io.metersphere.commons.constants.MsTestElementConstants; import io.metersphere.commons.constants.RunModeConstants; import io.metersphere.commons.exception.MSException; import io.metersphere.commons.utils.CommonBeanFactory; +import io.metersphere.commons.utils.FileUtils; import io.metersphere.commons.utils.LogUtil; import io.metersphere.commons.utils.ScriptEngineUtils; import io.metersphere.track.service.TestPlanApiCaseService; @@ -34,6 +38,7 @@ import lombok.EqualsAndHashCode; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.jmeter.config.Arguments; +import org.apache.jmeter.config.KeystoreConfig; import org.apache.jmeter.protocol.http.control.Header; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy; @@ -110,6 +115,9 @@ public class MsHTTPSamplerProxy extends MsTestElement { @JSONField(ordinal = 37) private Boolean isRefEnvironment; + @JSONField(ordinal = 38) + private String alias; + private void setRefElement() { try { ApiDefinitionService apiDefinitionService = CommonBeanFactory.getBean(ApiDefinitionService.class); @@ -346,14 +354,63 @@ public class MsHTTPSamplerProxy extends MsTestElement { MsDNSCacheManager.addEnvironmentVariables(httpSamplerTree, this.getName(), config.getConfig().get(this.getProjectId())); MsDNSCacheManager.addEnvironmentDNS(httpSamplerTree, this.getName(), config.getConfig().get(this.getProjectId())); } + + if (this.authManager != null) { + this.authManager.setAuth(tree, this.authManager, sampler); + } + + // 加载SSL认证 + if (config != null && config.isEffective(this.getProjectId()) && config.getConfig().get(this.getProjectId()).getSslConfig() != null) { + if (CollectionUtils.isNotEmpty(config.getConfig().get(this.getProjectId()).getSslConfig().getFiles())) { + MsKeyStore msKeyStore = config.getKeyStoreMap().get(this.getProjectId()); + CommandService commandService = CommonBeanFactory.getBean(CommandService.class); + if (msKeyStore == null) { + msKeyStore = new MsKeyStore(); + if (config.getConfig().get(this.getProjectId()).getSslConfig().getFiles().size() == 1) { + // 加载认证文件 + KeyStoreFile file = config.getConfig().get(this.getProjectId()).getSslConfig().getFiles().get(0); + msKeyStore.setPath(FileUtils.BODY_FILE_DIR + "/ssl/" + file.getId() + "_" + file.getName()); + msKeyStore.setPassword(file.getPassword()); + } else { + // 合并多个认证文件 + msKeyStore.setPath(FileUtils.BODY_FILE_DIR + "/ssl/tmp." + this.getId() + ".jks"); + msKeyStore.setPassword("ms123..."); + commandService.mergeKeyStore(msKeyStore.getPath(), config.getConfig().get(this.getProjectId()).getSslConfig()); + } + } + if (StringUtils.isEmpty(this.alias)) { + this.alias = config.getConfig().get(this.getProjectId()).getSslConfig().getDefaultAlias(); + } + + if (StringUtils.isNotEmpty(this.alias)) { + String aliasVar = UUID.randomUUID().toString(); + this.addArguments(httpSamplerTree, aliasVar, this.alias.trim()); + // 校验 keystore + commandService.checkKeyStore(msKeyStore.getPassword(), msKeyStore.getPath()); + + KeystoreConfig keystoreConfig = new KeystoreConfig(); + keystoreConfig.setEnabled(true); + keystoreConfig.setName(StringUtils.isNotEmpty(this.getName()) ? this.getName() + "-KeyStore" : "KeyStore"); + keystoreConfig.setProperty(TestElement.TEST_CLASS, KeystoreConfig.class.getName()); + keystoreConfig.setProperty(TestElement.GUI_CLASS, SaveService.aliasToClass("TestBeanGUI")); + keystoreConfig.setProperty("clientCertAliasVarName", aliasVar); + keystoreConfig.setProperty("endIndex", -1); + keystoreConfig.setProperty("preload", true); + keystoreConfig.setProperty("startIndex", 0); + keystoreConfig.setProperty("MS-KEYSTORE-FILE-PATH", msKeyStore.getPath()); + keystoreConfig.setProperty("MS-KEYSTORE-FILE-PASSWORD", msKeyStore.getPassword()); + httpSamplerTree.add(keystoreConfig); + + config.getKeyStoreMap().put(this.getProjectId(), new MsKeyStore(msKeyStore.getPath(), msKeyStore.getPassword())); + } + } + } if (CollectionUtils.isNotEmpty(hashTree)) { for (MsTestElement el : hashTree) { el.toHashTree(httpSamplerTree, el.getHashTree(), config); } } - if (this.authManager != null) { - this.authManager.setAuth(tree, this.authManager, sampler); - } + } // 兼容旧数据 @@ -586,6 +643,16 @@ public class MsHTTPSamplerProxy extends MsTestElement { return null; } + private void addArguments(HashTree tree, String key, String value) { + Arguments arguments = new Arguments(); + arguments.setEnabled(true); + arguments.setName(StringUtils.isNotEmpty(this.getName()) ? this.getName() + "-KeyStoreAlias" : "KeyStoreAlias"); + arguments.setProperty(TestElement.TEST_CLASS, Arguments.class.getName()); + arguments.setProperty(TestElement.GUI_CLASS, SaveService.aliasToClass("ArgumentsPanel")); + arguments.addArgument(key, value, "="); + tree.add(arguments); + } + private boolean isRest() { return this.getRest().stream().filter(KeyValue::isEnable).filter(KeyValue::isValid).toArray().length > 0; } diff --git a/backend/src/main/java/io/metersphere/api/dto/scenario/environment/EnvironmentConfig.java b/backend/src/main/java/io/metersphere/api/dto/scenario/environment/EnvironmentConfig.java index a9f2eb5389..1dd36d2e82 100644 --- a/backend/src/main/java/io/metersphere/api/dto/scenario/environment/EnvironmentConfig.java +++ b/backend/src/main/java/io/metersphere/api/dto/scenario/environment/EnvironmentConfig.java @@ -3,6 +3,7 @@ package io.metersphere.api.dto.scenario.environment; import io.metersphere.api.dto.scenario.DatabaseConfig; import io.metersphere.api.dto.scenario.HttpConfig; import io.metersphere.api.dto.scenario.TCPConfig; +import io.metersphere.api.dto.ssl.KeyStoreConfig; import lombok.Data; import java.util.ArrayList; @@ -14,11 +15,13 @@ public class EnvironmentConfig { private HttpConfig httpConfig; private List databaseConfigs; private TCPConfig tcpConfig; + private KeyStoreConfig sslConfig; public EnvironmentConfig() { this.commonConfig = new CommonConfig(); this.httpConfig = new HttpConfig(); this.databaseConfigs = new ArrayList<>(); this.tcpConfig = new TCPConfig(); + this.sslConfig = new KeyStoreConfig(); } } diff --git a/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreConfig.java b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreConfig.java new file mode 100644 index 0000000000..b042e0a13b --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreConfig.java @@ -0,0 +1,28 @@ +package io.metersphere.api.dto.ssl; + +import lombok.Data; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.stream.Collectors; + +@Data +public class KeyStoreConfig { + private List entrys; + private List files; + + public String getDefaultAlias() { + if (CollectionUtils.isNotEmpty(entrys)) { + List entryList = this.entrys.stream().filter(KeyStoreEntry::isDefault).collect(Collectors.toList()); + if (CollectionUtils.isNotEmpty(entryList)) { + if (StringUtils.isNotEmpty(entryList.get(0).getNewAsName())) { + return entryList.get(0).getNewAsName(); + } else { + return entryList.get(0).getOriginalAsName(); + } + } + } + return null; + } +} diff --git a/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreEntry.java b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreEntry.java new file mode 100644 index 0000000000..b062833f7f --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreEntry.java @@ -0,0 +1,15 @@ +package io.metersphere.api.dto.ssl; + +import lombok.Data; + +@Data +public class KeyStoreEntry { + private String id; + private String originalAsName; + private String newAsName; + private String type; + private String password; + private String sourceName; + private String sourceId; + private boolean isDefault; +} diff --git a/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreFile.java b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreFile.java new file mode 100644 index 0000000000..bc53791bd2 --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/ssl/KeyStoreFile.java @@ -0,0 +1,15 @@ +package io.metersphere.api.dto.ssl; + +import io.metersphere.api.dto.scenario.request.BodyFile; +import lombok.Data; + +@Data +public class KeyStoreFile { + private String id; + private String name; + private String type; + private String updateTime; + private String password; + private BodyFile file; + +} diff --git a/backend/src/main/java/io/metersphere/api/dto/ssl/MsKeyStore.java b/backend/src/main/java/io/metersphere/api/dto/ssl/MsKeyStore.java new file mode 100644 index 0000000000..3be0a14352 --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/dto/ssl/MsKeyStore.java @@ -0,0 +1,18 @@ +package io.metersphere.api.dto.ssl; + +import lombok.Data; + +@Data +public class MsKeyStore { + private String id; + private String password; + private String path; + + public MsKeyStore() { + } + + public MsKeyStore(String path, String password) { + this.password = password; + this.path = path; + } +} diff --git a/backend/src/main/java/io/metersphere/api/service/ApiTestEnvironmentService.java b/backend/src/main/java/io/metersphere/api/service/ApiTestEnvironmentService.java index 652928d30e..70e327c585 100644 --- a/backend/src/main/java/io/metersphere/api/service/ApiTestEnvironmentService.java +++ b/backend/src/main/java/io/metersphere/api/service/ApiTestEnvironmentService.java @@ -2,11 +2,13 @@ package io.metersphere.api.service; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; +import io.metersphere.api.dto.ApiTestEnvironmentDTO; import io.metersphere.api.dto.mockconfig.MockConfigStaticData; import io.metersphere.base.domain.ApiTestEnvironmentExample; import io.metersphere.base.domain.ApiTestEnvironmentWithBLOBs; import io.metersphere.base.mapper.ApiTestEnvironmentMapper; import io.metersphere.commons.exception.MSException; +import io.metersphere.commons.utils.FileUtils; import io.metersphere.commons.utils.CommonBeanFactory; import io.metersphere.controller.request.EnvironmentRequest; import io.metersphere.dto.BaseSystemConfigDTO; @@ -16,6 +18,7 @@ import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import java.util.*; @@ -72,6 +75,19 @@ public class ApiTestEnvironmentService { return apiTestEnvironmentWithBLOBs.getId(); } + public String add(ApiTestEnvironmentDTO request, List sslFiles) { + request.setId(UUID.randomUUID().toString()); + checkEnvironmentExist(request); + FileUtils.createFiles(request.getUploadIds(), sslFiles, FileUtils.BODY_FILE_DIR + "/ssl"); + apiTestEnvironmentMapper.insert(request); + return request.getId(); + } + + public void update(ApiTestEnvironmentDTO apiTestEnvironment,List sslFiles) { + checkEnvironmentExist(apiTestEnvironment); + FileUtils.createFiles(apiTestEnvironment.getUploadIds(), sslFiles, FileUtils.BODY_FILE_DIR + "/ssl"); + apiTestEnvironmentMapper.updateByPrimaryKeyWithBLOBs(apiTestEnvironment); + } private void checkEnvironmentExist(ApiTestEnvironmentWithBLOBs environment) { if (environment.getName() != null) { ApiTestEnvironmentExample example = new ApiTestEnvironmentExample(); diff --git a/backend/src/main/java/io/metersphere/api/service/CommandService.java b/backend/src/main/java/io/metersphere/api/service/CommandService.java new file mode 100644 index 0000000000..1cf8a8b0ef --- /dev/null +++ b/backend/src/main/java/io/metersphere/api/service/CommandService.java @@ -0,0 +1,213 @@ +package io.metersphere.api.service; + +import com.alibaba.fastjson.JSON; +import io.metersphere.api.dto.ssl.KeyStoreConfig; +import io.metersphere.api.dto.ssl.KeyStoreEntry; +import io.metersphere.commons.exception.MSException; +import io.metersphere.commons.utils.FileUtils; +import io.metersphere.commons.utils.LogUtil; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.jorphan.exec.SystemCommand; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.util.*; + +@Service +public class CommandService { + + public List get(String password, MultipartFile file) { + try { + String path = FileUtils.createFile(file); + // 执行验证指令 + if (StringUtils.isNotEmpty(password)) { + password = JSON.parseObject(password, String.class); + } + String keytoolArgs[] = {"keytool", "-rfc", "-list", "-keystore", path, "-storepass", password}; + Process p = new ProcessBuilder(keytoolArgs).start(); + List dtoList = new LinkedList<>(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line = null; + KeyStoreEntry dto = null; + while ((line = br.readLine()) != null) { + if (line.contains("keystore password was incorrect")) { + MSException.throwException("认证密码错误,请重新输入密码"); + } + if (line.startsWith("别名")) { + if (dto != null) { + dtoList.add(dto); + } + dto = new KeyStoreEntry(); + dto.setOriginalAsName(line.split(":")[1]); + } + if (line.startsWith("条目类型")) { + dto.setType(line.split(":")[1]); + } + } + if (dto != null) { + dtoList.add(dto); + } + } + FileUtils.deleteFile(path); + return dtoList; + } catch (Exception e) { + LogUtil.error(e.getMessage()); + MSException.throwException(e.getMessage()); + } + return null; + } + + public void createKeyStore(String alias, String path) { + try { + File f = new File(path); + if (f.exists()) { + f.delete(); + } + List arguments = new ArrayList(); + arguments.add("keytool"); + arguments.add("-genkeypair"); + arguments.add("-alias"); + arguments.add(alias); + arguments.add("-dname"); + arguments.add("CN=localhost,OU=cn,O=cn,L=cn,ST=cn,C=cn"); + arguments.add("-keyalg"); + arguments.add("RSA"); + arguments.add("-keystore"); + arguments.add(f.getName()); + arguments.add("-storepass"); + arguments.add("ms123..."); + arguments.add("-keypass"); + arguments.add("ms123..."); + arguments.add("-validity"); + arguments.add(Integer.toString(1024)); + SystemCommand nativeCommand = new SystemCommand(f.getParentFile(), (Map) null); + nativeCommand.run(arguments); + } catch (Exception e) { + MSException.throwException(e.getMessage()); + } + + } + + public void mergeKeyStore(String newKeyStore, KeyStoreConfig sslConfig) { + try { + // 创建零时keyStore + this.createKeyStore("ms-run", newKeyStore); + // 修改别名 + Map> entryMap = new HashMap<>(); + if (sslConfig != null && CollectionUtils.isNotEmpty(sslConfig.getEntrys())) { + sslConfig.getEntrys().forEach(item -> { + if (entryMap.containsKey(item.getSourceId())) { + entryMap.get(item.getSourceId()).add(item); + } else { + List list = new ArrayList<>(); + list.add(item); + entryMap.put(item.getSourceId(), list); + } + }); + } + if (sslConfig != null && CollectionUtils.isNotEmpty(sslConfig.getFiles())) { + sslConfig.getFiles().forEach(item -> { + List entries = entryMap.get(item.getId()); + if (CollectionUtils.isNotEmpty(entries)) { + entries.forEach(entry -> { + File srcFile = new File(FileUtils.BODY_FILE_DIR + "/ssl/" + item.getId() + "_" + item.getName()); + try { + // 开始合并 + File destFile = new File(newKeyStore); + List arguments = new ArrayList(); + arguments.add("keytool"); + arguments.add("-genkeypair"); + arguments.add("-importkeystore"); + arguments.add("-srckeystore"); + arguments.add(srcFile.getName()); + arguments.add("-srcstorepass"); + arguments.add(item.getPassword()); + arguments.add("-srcalias"); + arguments.add(entry.getOriginalAsName().trim()); + arguments.add("-srckeypass"); + arguments.add(entry.getPassword()); + + arguments.add("-destkeystore"); + arguments.add(destFile.getName()); + arguments.add("-deststorepass"); + arguments.add("ms123..."); + arguments.add("-destalias"); + arguments.add(StringUtils.isNotEmpty(entry.getNewAsName()) ? entry.getNewAsName().trim() : entry.getOriginalAsName().trim()); + arguments.add("-destkeypass"); + arguments.add("ms123..."); + + SystemCommand nativeCommand = new SystemCommand(destFile.getParentFile(), (Map) null); + int exitVal = nativeCommand.run(arguments); + if (exitVal > 0) { + MSException.throwException("合并条目:【" + entry.getOriginalAsName() + " 】失败"); + } + } catch (Exception e) { + LogUtil.error(e.getMessage()); + } + }); + } + }); + } + } catch (Exception e) { + LogUtil.error(e.getMessage()); + MSException.throwException(e.getMessage()); + } + } + + public void keypasswd(File file, String storepass, String alias, String keypass) { + // 统一密码 + try { + List arguments = new ArrayList(); + arguments.add("keytool"); + arguments.add("-genkeypair"); + arguments.add("-keypasswd"); + arguments.add("-keystore"); + arguments.add(file.getName()); + arguments.add("-storepass"); + arguments.add(storepass); + arguments.add("-alias"); + arguments.add(alias.trim()); + + arguments.add("-keypass"); + arguments.add(keypass); + arguments.add("-new"); + arguments.add("ms123..."); + SystemCommand nativeCommand = new SystemCommand(file.getParentFile(), (Map) null); + int exitVal = nativeCommand.run(arguments); + if (exitVal > 0) { + MSException.throwException("别名:【" + alias + " 】密码修改失败"); + } + } catch (Exception e) { + MSException.throwException(e.getMessage()); + } + + } + + public boolean checkKeyStore(String password, String path) { + try { + String keytoolArgs[] = {"keytool", "-rfc", "-list", "-keystore", path, "-storepass", password}; + Process p = new ProcessBuilder(keytoolArgs).start(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + String line = null; + KeyStoreEntry dto = null; + while ((line = br.readLine()) != null) { + if (line.contains("keystore password was incorrect")) { + MSException.throwException("认证密码错误,请重新输入密码"); + } + if (line.contains("Exception")) { + MSException.throwException("认证文件加载失败,请检查认证文件"); + } + } + } + return true; + } catch (Exception e) { + LogUtil.error(e.getMessage()); + MSException.throwException(e.getMessage()); + return false; + } + } +} diff --git a/backend/src/main/java/io/metersphere/commons/utils/FileUtils.java b/backend/src/main/java/io/metersphere/commons/utils/FileUtils.java index b4b54d7550..357f4ec469 100644 --- a/backend/src/main/java/io/metersphere/commons/utils/FileUtils.java +++ b/backend/src/main/java/io/metersphere/commons/utils/FileUtils.java @@ -3,27 +3,37 @@ package io.metersphere.commons.utils; import io.metersphere.commons.exception.MSException; import io.metersphere.i18n.Translator; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.lang3.StringUtils; import org.aspectj.util.FileUtil; import org.springframework.web.multipart.MultipartFile; import java.io.*; import java.util.List; +import java.util.UUID; public class FileUtils { public static final String BODY_FILE_DIR = "/opt/metersphere/data/body"; - public static void createBodyFiles(List bodyUploadIds, List bodyFiles) { + private static void create(List bodyUploadIds, List bodyFiles, String path) { + String filePath = BODY_FILE_DIR; + if (StringUtils.isNotEmpty(path)) { + filePath = path; + } if (CollectionUtils.isNotEmpty(bodyUploadIds) && CollectionUtils.isNotEmpty(bodyFiles)) { - File testDir = new File(BODY_FILE_DIR); + File testDir = new File(filePath); if (!testDir.exists()) { testDir.mkdirs(); } for (int i = 0; i < bodyUploadIds.size(); i++) { MultipartFile item = bodyFiles.get(i); - File file = new File(BODY_FILE_DIR + "/" + bodyUploadIds.get(i) + "_" + item.getOriginalFilename()); + File file = new File(filePath + "/" + bodyUploadIds.get(i) + "_" + item.getOriginalFilename()); try (InputStream in = item.getInputStream(); OutputStream out = new FileOutputStream(file)) { file.createNewFile(); - FileUtil.copyStream(in, out); + final int MAX = 4096; + byte[] buf = new byte[MAX]; + for (int bytesRead = in.read(buf, 0, MAX); bytesRead != -1; bytesRead = in.read(buf, 0, MAX)) { + out.write(buf, 0, bytesRead); + } } catch (IOException e) { LogUtil.error(e); MSException.throwException(Translator.get("upload_fail")); @@ -32,6 +42,33 @@ public class FileUtils { } } + public static void createBodyFiles(List bodyUploadIds, List bodyFiles) { + FileUtils.create(bodyUploadIds, bodyFiles, null); + } + + public static void createFiles(List bodyUploadIds, List bodyFiles, String path) { + FileUtils.create(bodyUploadIds, bodyFiles, path); + } + + public static String createFile(MultipartFile bodyFile) { + File file = new File("/opt/metersphere/data/body/tmp" + UUID.randomUUID().toString() + "_" + bodyFile.getOriginalFilename()); + try (InputStream in = bodyFile.getInputStream(); OutputStream out = new FileOutputStream(file)) { + file.createNewFile(); + FileUtil.copyStream(in, out); + } catch (IOException e) { + LogUtil.error(e); + MSException.throwException(Translator.get("upload_fail")); + } + return file.getPath(); + } + + public static void delFile(String path) { + File file = new File(path); + if (file.exists()) { + file.delete(); + } + } + public static String uploadFile(MultipartFile uploadFile, String path, String name) { if (uploadFile == null) { return null; @@ -53,7 +90,7 @@ public class FileUtils { } public static String uploadFile(MultipartFile uploadFile, String path) { - return uploadFile(uploadFile, path, uploadFile.getOriginalFilename()); + return uploadFile(uploadFile, path, uploadFile.getOriginalFilename()); } public static void deleteFile(String path) { diff --git a/backend/src/main/java/org/apache/jmeter/config/KeystoreConfig.java b/backend/src/main/java/org/apache/jmeter/config/KeystoreConfig.java new file mode 100644 index 0000000000..16e4fcfd89 --- /dev/null +++ b/backend/src/main/java/org/apache/jmeter/config/KeystoreConfig.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.config; + +import org.apache.commons.lang3.StringUtils; +import org.apache.jmeter.gui.TestElementMetadata; +import org.apache.jmeter.testbeans.TestBean; +import org.apache.jmeter.testelement.TestStateListener; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jmeter.util.SSLManager; +import org.apache.jorphan.util.JMeterStopTestException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Configure Keystore + */ +@TestElementMetadata(labelResource = "displayName") +public class KeystoreConfig extends ConfigTestElement implements TestBean, TestStateListener { + + private static final long serialVersionUID = 1L; + private static final Logger log = LoggerFactory.getLogger(KeystoreConfig.class); + + private static final String KEY_STORE_START_INDEX = "https.keyStoreStartIndex"; // $NON-NLS-1$ + private static final String KEY_STORE_END_INDEX = "https.keyStoreEndIndex"; // $NON-NLS-1$ + + private String startIndex; + private String endIndex; + private String preload; + private String clientCertAliasVarName; + + public KeystoreConfig() { + super(); + } + + @Override + public void testEnded() { + testEnded(null); + } + + @Override + public void testEnded(String host) { + log.info("Destroying Keystore"); + SSLManager.getInstance().destroyKeystore(); + } + + @Override + public void testStarted() { + testStarted(null); + } + + @Override + public void testStarted(String host) { + String reuseSSLContext = JMeterUtils.getProperty("https.use.cached.ssl.context"); + if (StringUtils.isEmpty(reuseSSLContext) || "true".equals(reuseSSLContext)) { + log.warn("https.use.cached.ssl.context property must be set to false to ensure Multiple Certificates are used"); + } + int startIndexAsInt = JMeterUtils.getPropDefault(KEY_STORE_START_INDEX, 0); + int endIndexAsInt = JMeterUtils.getPropDefault(KEY_STORE_END_INDEX, -1); + + if (!StringUtils.isEmpty(this.startIndex)) { + try { + startIndexAsInt = Integer.parseInt(this.startIndex); + } catch (NumberFormatException e) { + log.warn("Failed parsing startIndex: {}, will default to: {}, error message: {}", this.startIndex, + startIndexAsInt, e, e); + } + } + + if (!StringUtils.isEmpty(this.endIndex)) { + try { + endIndexAsInt = Integer.parseInt(this.endIndex); + } catch (NumberFormatException e) { + log.warn("Failed parsing endIndex: {}, will default to: {}, error message: {}", this.endIndex, + endIndexAsInt, e, e); + } + } + if (endIndexAsInt != -1 && startIndexAsInt > endIndexAsInt) { + throw new JMeterStopTestException("Keystore Config error : Alias start index must be lower than Alias end index"); + } + log.info( + "Configuring Keystore with (preload: '{}', startIndex: {}, endIndex: {}, clientCertAliasVarName: '{}')", + preload, startIndexAsInt, endIndexAsInt, clientCertAliasVarName); + // 加载认证文件 + String path = this.getPropertyAsString("MS-KEYSTORE-FILE-PATH"); + String password = this.getPropertyAsString("MS-KEYSTORE-FILE-PASSWORD"); + InputStream in = null; + try { + in = new FileInputStream(new File(path)); + } catch (IOException e) { + log.error(e.getMessage()); + } + SSLManager.getInstance().configureKeystore(Boolean.parseBoolean(preload), + startIndexAsInt, + endIndexAsInt, + clientCertAliasVarName, in, password); + } + + /** + * @return the endIndex + */ + public String getEndIndex() { + return endIndex; + } + + /** + * @param endIndex the endIndex to set + */ + public void setEndIndex(String endIndex) { + this.endIndex = endIndex; + } + + /** + * @return the startIndex + */ + public String getStartIndex() { + return startIndex; + } + + /** + * @param startIndex the startIndex to set + */ + public void setStartIndex(String startIndex) { + this.startIndex = startIndex; + } + + /** + * @return the preload + */ + public String getPreload() { + return preload; + } + + /** + * @param preload the preload to set + */ + public void setPreload(String preload) { + this.preload = preload; + } + + /** + * @return the clientCertAliasVarName + */ + public String getClientCertAliasVarName() { + return clientCertAliasVarName; + } + + /** + * @param clientCertAliasVarName the clientCertAliasVarName to set + */ + public void setClientCertAliasVarName(String clientCertAliasVarName) { + this.clientCertAliasVarName = clientCertAliasVarName; + } +} diff --git a/backend/src/main/java/org/apache/jmeter/util/SSLManager.java b/backend/src/main/java/org/apache/jmeter/util/SSLManager.java new file mode 100644 index 0000000000..d13c5c7e12 --- /dev/null +++ b/backend/src/main/java/org/apache/jmeter/util/SSLManager.java @@ -0,0 +1,378 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.jmeter.util; + +import org.apache.commons.lang3.Validate; +import org.apache.jmeter.gui.GuiPackage; +import org.apache.jmeter.util.keystore.JmeterKeyStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.security.*; +import java.security.cert.CertificateException; +import java.util.Locale; + +/** + * The SSLManager handles the KeyStore information for JMeter. Basically, it + * handles all the logic for loading and initializing all the JSSE parameters + * and selecting the alias to authenticate against if it is available. + * SSLManager will try to automatically select the client certificate for you, + * but if it can't make a decision, it will pop open a dialog asking you for + * more information. + *

+ * TODO? - N.B. does not currently allow the selection of a client certificate. + * + */ +public abstract class SSLManager { + private static final Logger log = LoggerFactory.getLogger(SSLManager.class); + + private static final String SSL_TRUST_STORE = "javax.net.ssl.trustStore";// $NON-NLS-1$ + + private static final String KEY_STORE_PASSWORD = "javax.net.ssl.keyStorePassword"; // $NON-NLS-1$ NOSONAR no hard coded password + + public static final String JAVAX_NET_SSL_KEY_STORE = "javax.net.ssl.keyStore"; // $NON-NLS-1$ + + private static final String JAVAX_NET_SSL_KEY_STORE_TYPE = "javax.net.ssl.keyStoreType"; // $NON-NLS-1$ + + private static final String PKCS12 = "pkcs12"; // $NON-NLS-1$ + + /** Singleton instance of the manager */ + private static SSLManager manager; + + private static final boolean IS_SSL_SUPPORTED = true; + + /** Cache the KeyStore instance */ + private JmeterKeyStore keyStore; + + /** Cache the TrustStore instance - null if no truststore name was provided */ + private KeyStore trustStore = null; + // Have we yet tried to load the truststore? + private volatile boolean truststoreLoaded=false; + + /** Have the password available */ + protected volatile String defaultpw = System.getProperty(KEY_STORE_PASSWORD); + + private int keystoreAliasStartIndex; + + private int keystoreAliasEndIndex; + + private String clientCertAliasVarName; + + /** + * Resets the SSLManager so that we can create a new one with a new keystore + */ + public static synchronized void reset() { + SSLManager.manager = null; + } + + public abstract void setContext(HttpURLConnection conn); + + /** + * Default implementation of setting the Provider + * + * @param provider + * the provider to use + */ + protected void setProvider(Provider provider) { + if (null != provider) { + Security.addProvider(provider); + } + } + + protected synchronized JmeterKeyStore getKeyStore() { + if (null == this.keyStore) { + String fileName = System.getProperty(JAVAX_NET_SSL_KEY_STORE,""); // empty if not provided + String fileType = System.getProperty(JAVAX_NET_SSL_KEY_STORE_TYPE, // use the system property to determine the type + fileName.toLowerCase(Locale.ENGLISH).endsWith(".p12") ? PKCS12 : "JKS"); // otherwise use the name + log.info("JmeterKeyStore Location: {} type {}", fileName, fileType); + try { + this.keyStore = JmeterKeyStore.getInstance(fileType, keystoreAliasStartIndex, keystoreAliasEndIndex, clientCertAliasVarName); + log.info("KeyStore created OK"); + } catch (Exception e) { + this.keyStore = null; + throw new IllegalArgumentException("Could not create keystore: "+e.getMessage(), e); + } + + try { + + // The string 'NONE' is used for the keystore location when using PKCS11 + // https://docs.oracle.com/javase/8/docs/technotes/guides/security/p11guide.html#JSSE + if ("NONE".equalsIgnoreCase(fileName)) { + retryLoadKeys(null, false); + log.info("Total of {} aliases loaded OK from PKCS11", keyStore.getAliasCount()); + } else { + File initStore = new File(fileName); + if (fileName.length() > 0 && initStore.exists()) { + retryLoadKeys(initStore, true); + if (log.isInfoEnabled()) { + log.info("Total of {} aliases loaded OK from keystore {}", + keyStore.getAliasCount(), fileName); + } + } else { + log.warn("Keystore file not found, loading empty keystore"); + this.defaultpw = ""; // Ensure not null + this.keyStore.load(null, ""); + } + } + } catch (Exception e) { + log.error("Problem loading keystore: {}", e.getMessage(), e); + } + + if (log.isDebugEnabled()) { + log.debug("JmeterKeyStore type: {}", this.keyStore.getClass()); + } + } + + return this.keyStore; + } + + /** + * Opens and initializes the KeyStore. If the password for the KeyStore is + * not set, this method will prompt you to enter it. Unfortunately, there is + * no PasswordEntryField available from JOptionPane. + * + * @return the configured {@link JmeterKeyStore} + */ + protected synchronized JmeterKeyStore getKeyStore(InputStream is,String password) { + if (null == this.keyStore) { + String fileName = System.getProperty(JAVAX_NET_SSL_KEY_STORE,""); // empty if not provided + String fileType = System.getProperty(JAVAX_NET_SSL_KEY_STORE_TYPE, // use the system property to determine the type + fileName.toLowerCase(Locale.ENGLISH).endsWith(".p12") ? PKCS12 : "JKS"); // otherwise use the name + log.info("JmeterKeyStore Location: {} type {}", fileName, fileType); + try { + this.keyStore = JmeterKeyStore.getInstance(fileType, keystoreAliasStartIndex, keystoreAliasEndIndex, clientCertAliasVarName); + log.info("KeyStore created OK"); + } catch (Exception e) { + this.keyStore = null; + throw new IllegalArgumentException("Could not create keystore: "+e.getMessage(), e); + } + + try { + + // The string 'NONE' is used for the keystore location when using PKCS11 + // https://docs.oracle.com/javase/8/docs/technotes/guides/security/p11guide.html#JSSE + if ("NONE".equalsIgnoreCase(fileName)) { + retryLoadKeys(null, false); + log.info("Total of {} aliases loaded OK from PKCS11", keyStore.getAliasCount()); + } else { + File initStore = new File(fileName); + if (fileName.length() > 0 && initStore.exists()) { + retryLoadKeys(initStore, true); + if (log.isInfoEnabled()) { + log.info("Total of {} aliases loaded OK from keystore {}", + keyStore.getAliasCount(), fileName); + } + } else { + log.warn("Keystore file not found, loading empty keystore"); + this.defaultpw = ""; // Ensure not null + this.keyStore.load(is, password); + } + } + } catch (Exception e) { + log.error("Problem loading keystore: {}", e.getMessage(), e); + } + + if (log.isDebugEnabled()) { + log.debug("JmeterKeyStore type: {}", this.keyStore.getClass()); + } + } + + return this.keyStore; + } + + private void retryLoadKeys(File initStore, boolean allowEmptyPassword) throws NoSuchAlgorithmException, + CertificateException, IOException, KeyStoreException, UnrecoverableKeyException { + for (int i = 0; i < 3; i++) { + String password = getPassword(); + if (!allowEmptyPassword) { + Validate.notNull(password, "Password for keystore must not be null"); + } + try { + if (initStore == null) { + this.keyStore.load(null, password); + } else { + try (InputStream fis = new FileInputStream(initStore)) { + this.keyStore.load(fis, password); + } + } + return; + } catch (IOException e) { + log.debug("Could not load keystore. Wrong password for keystore?", e); + } + this.defaultpw = null; + } + } + + /* + * The password can be defined as a property; this dialogue is provided to allow it + * to be entered at run-time. + */ + private String getPassword() { + String password = this.defaultpw; + if (null == password) { + final GuiPackage guiInstance = GuiPackage.getInstance(); +// if (guiInstance != null) { +// JPanel panel = new JPanel(new MigLayout("fillx, wrap 2", "[][fill, grow]")); +// JLabel passwordLabel = new JLabel("Password: "); +// JPasswordField pwf = new JPasswordField(64); +// pwf.setEchoChar('*'); +// passwordLabel.setLabelFor(pwf); +// panel.add(passwordLabel); +// panel.add(pwf); +// int choice = JOptionPane.showConfirmDialog(guiInstance.getMainFrame(), panel, +// JMeterUtils.getResString("ssl_pass_prompt"), JOptionPane.OK_CANCEL_OPTION, +// JOptionPane.PLAIN_MESSAGE); +// if (choice == JOptionPane.OK_OPTION) { +// char[] pwchars = pwf.getPassword(); +// this.defaultpw = new String(pwchars); +// Arrays.fill(pwchars, '*'); +// } +// System.setProperty(KEY_STORE_PASSWORD, this.defaultpw); +// password = this.defaultpw; +// } + } else { + log.warn("No password provided, and no GUI present so cannot prompt"); + } + return password; + } + + /** + * Opens and initializes the TrustStore. + * + * There are 3 possibilities: + *

    + *
  • no truststore name provided, in which case the default Java truststore + * should be used
  • + *
  • truststore name is provided, and loads OK
  • + *
  • truststore name is provided, but is not found or does not load OK, in + * which case an empty + * truststore is created
  • + *
+ * If the KeyStore object cannot be created, then this is currently treated the + * same as if no truststore name was provided. + * + * @return + * {@code null} when Java truststore should be used. + * Otherwise the truststore, which may be empty if the file could not be + * loaded. + * + */ + protected KeyStore getTrustStore() { + if (!truststoreLoaded) { + + truststoreLoaded=true;// we've tried ... + + String fileName = System.getProperty(SSL_TRUST_STORE); + if (fileName == null) { + return null; + } + log.info("TrustStore Location: {}", fileName); + + try { + this.trustStore = KeyStore.getInstance("JKS"); + log.info("TrustStore created OK, Type: JKS"); + } catch (Exception e) { + this.trustStore = null; + throw new RuntimeException("Problem creating truststore: "+e.getMessage(), e); + } + + try { + File initStore = new File(fileName); + + if (initStore.exists()) { + try (InputStream fis = new FileInputStream(initStore)) { + this.trustStore.load(fis, null); + log.info("Truststore loaded OK from file"); + } + } else { + log.warn("Truststore file not found, loading empty truststore"); + this.trustStore.load(null, null); + } + } catch (Exception e) { + throw new RuntimeException("Can't load TrustStore: " + e.getMessage(), e); + } + } + + return this.trustStore; + } + + /** + * Protected Constructor to remove the possibility of directly instantiating + * this object. Create the SSLContext, and wrap all the X509KeyManagers with + * our X509KeyManager so that we can choose our alias. + */ + protected SSLManager() { + } + + /** + * Static accessor for the SSLManager object. The SSLManager is a singleton. + * + * @return the singleton {@link SSLManager} + */ + public static synchronized SSLManager getInstance() { + if (null == SSLManager.manager) { + SSLManager.manager = new JsseSSLManager(null); + } + + return SSLManager.manager; + } + + /** + * Test whether SSL is supported or not. + * + * @return flag whether SSL is supported + */ + public static boolean isSSLSupported() { + return SSLManager.IS_SSL_SUPPORTED; + } + + /** + * Configure Keystore + * + * @param preload + * flag whether the keystore should be opened within this method, + * or the opening should be delayed + * @param startIndex + * first index to consider for a key + * @param endIndex + * last index to consider for a key + * @param clientCertAliasVarName + * name of the default key, if empty the first key will be used + * as default key + */ + public synchronized void configureKeystore(boolean preload, int startIndex, int endIndex, String clientCertAliasVarName,InputStream is,String password) { + this.keystoreAliasStartIndex = startIndex; + this.keystoreAliasEndIndex = endIndex; + this.clientCertAliasVarName = clientCertAliasVarName; + if(preload) { + keyStore = getKeyStore(is,password); + } + } + + /** + * Destroy Keystore + */ + public synchronized void destroyKeystore() { + keyStore=null; + } +} diff --git a/frontend/src/business/components/api/definition/components/request/http/ApiAdvancedConfig.vue b/frontend/src/business/components/api/definition/components/request/http/ApiAdvancedConfig.vue index fb66ae6c70..0dfc92205e 100644 --- a/frontend/src/business/components/api/definition/components/request/http/ApiAdvancedConfig.vue +++ b/frontend/src/business/components/api/definition/components/request/http/ApiAdvancedConfig.vue @@ -14,6 +14,14 @@ + + + 认证别名: + + + + + diff --git a/frontend/src/business/components/api/definition/model/ApiTestModel.js b/frontend/src/business/components/api/definition/model/ApiTestModel.js index c551d5f707..d5f163df7e 100644 --- a/frontend/src/business/components/api/definition/model/ApiTestModel.js +++ b/frontend/src/business/components/api/definition/model/ApiTestModel.js @@ -755,6 +755,7 @@ export class KeyValue extends BaseConfig { this.files = undefined; this.enable = undefined; this.uuid = undefined; + this.time = undefined; this.contentType = undefined; this.set(options); } diff --git a/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue b/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue index cad369b291..353de14958 100644 --- a/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue +++ b/frontend/src/business/components/api/test/components/environment/EnvironmentEdit.vue @@ -23,6 +23,9 @@ + + +