From 0633edbd4e7d3cbe384f0701f56e8eaa89058dd4 Mon Sep 17 00:00:00 2001 From: Kaiser-Yang <624626089@qq.com> Date: Tue, 24 Sep 2024 19:24:33 +0800 Subject: [PATCH] Finish the APIs related with ssh-key We finish the APIs related with ssh-key, including the following APIs: * Upload a ssh-key * Delete a ssh-key * Get ssh-keys with pagination * Update a ssh-key We use spring transaction to ensure the atomicity of the operations. See #32. --- README-zh.md | 1 + config_default.json | 1 + database/constraint/all_column_constraint.sql | 6 +- database/table/t_ssh_key.sql | 19 ++ database/table/t_user.sql | 2 +- database/table/t_user_star_repository.sql | 3 +- database/trigger/all_table_trigger.sql | 5 + script/deploy_helper.py | 5 +- .../java/edu/cmipt/gcs/GcsApplication.java | 2 + .../cmipt/gcs/constant/ApiPathConstant.java | 11 ++ .../edu/cmipt/gcs/constant/GitConstant.java | 44 +++++ .../cmipt/gcs/constant/HeaderParameter.java | 1 + .../gcs/constant/ValidationConstant.java | 6 + .../gcs/controller/SshKeyController.java | 180 ++++++++++++++++++ .../java/edu/cmipt/gcs/dao/SshKeyMapper.java | 7 + .../cmipt/gcs/enumeration/ErrorCodeEnum.java | 15 +- .../gcs/exception/GlobalExceptionHandler.java | 2 + .../java/edu/cmipt/gcs/filter/JwtFilter.java | 40 ++++ .../edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java | 48 +++++ .../java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java | 37 ++++ .../java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java | 10 + .../gcs/service/RepositoryServiceImpl.java | 78 ++++---- .../edu/cmipt/gcs/service/SshKeyService.java | 8 + .../cmipt/gcs/service/SshKeyServiceImpl.java | 102 ++++++++++ src/main/resources/message/message.properties | 13 ++ .../cmipt/gcs/SpringBootTestClassOrderer.java | 2 + .../edu/cmipt/gcs/constant/TestConstant.java | 2 + .../gcs/controller/SshKeyControllerTest.java | 124 ++++++++++++ 28 files changed, 732 insertions(+), 42 deletions(-) create mode 100644 database/table/t_ssh_key.sql create mode 100644 src/main/java/edu/cmipt/gcs/constant/GitConstant.java create mode 100644 src/main/java/edu/cmipt/gcs/controller/SshKeyController.java create mode 100644 src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java create mode 100644 src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java create mode 100644 src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java create mode 100644 src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java create mode 100644 src/main/java/edu/cmipt/gcs/service/SshKeyService.java create mode 100644 src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java create mode 100644 src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java diff --git a/README-zh.md b/README-zh.md index 6fe4540..81b70eb 100644 --- a/README-zh.md +++ b/README-zh.md @@ -9,6 +9,7 @@ | `deployLogLevel` | `string` | `"info"` | 部署脚本的日志级别。 | | `skipTest` | `bool` | `true` | 是否跳过测试。 | | `gitUserName` | `string` | `"git"` | 用于保存 `git` 仓库的用户名。 | +| `gitHomeDirectory` | `string` | `"/home/git"` | `git` 用户的家目录。 | | `gitUserPassword` | `string` | `"git"` | 用于保存 `git` 仓库的用户密码。 | | `gitRepositoryDirectory` | `string` | `"/home/git/repository"` | `git` 仓库存放目录。不要使用 `~`。 | | `gitServerDomain` | `string` | `"localhost"` | 服务器域名。 | diff --git a/config_default.json b/config_default.json index 2c13d0e..4d12c24 100644 --- a/config_default.json +++ b/config_default.json @@ -6,6 +6,7 @@ "deployLogLevel": "info", "skipTest": true, "gitUserName": "git", + "gitHomeDirectory": "/home/git", "gitUserPassword": "git", "gitRepositoryDirectory": "/home/git/repository", "gitServerDomain": "localhost", diff --git a/database/constraint/all_column_constraint.sql b/database/constraint/all_column_constraint.sql index db6cd16..5f6789a 100644 --- a/database/constraint/all_column_constraint.sql +++ b/database/constraint/all_column_constraint.sql @@ -8,4 +8,8 @@ ADD CONSTRAINT pk_user_table PRIMARY KEY (id); -- The constraint of t_user_star_repository is added to the table. ALTER TABLE ONLY public.t_user_star_repository -ADD CONSTRAINT pk_user_star_repository PRIMARY KEY (id); \ No newline at end of file +ADD CONSTRAINT pk_user_star_repository PRIMARY KEY (id); + +-- The constraint of the primary key and unique key is added to the table. +ALTER TABLE ONLY public.t_ssh_key +ADD CONSTRAINT pk_ssh_key PRIMARY KEY (id); diff --git a/database/table/t_ssh_key.sql b/database/table/t_ssh_key.sql new file mode 100644 index 0000000..0af685f --- /dev/null +++ b/database/table/t_ssh_key.sql @@ -0,0 +1,19 @@ +CREATE TABLE public.t_ssh_key ( + id bigint NOT NULL, + user_id bigint NOT NULL, + name character varying(255) NOT NULL, + public_key character varying(4096) NOT NULL, + gmt_created timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_updated timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + gmt_deleted timestamp without time zone +); + +COMMENT ON TABLE public.t_ssh_key IS 'Table for storing ssh public key.'; +COMMENT ON COLUMN public.t_ssh_key.id IS 'Primary key of the ssh_key table.'; +COMMENT ON COLUMN public.t_ssh_key.user_id IS 'ID of the user who owns the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.name IS 'Name of the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.public_key IS 'Public key of the ssh key.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_created IS 'Timestamp when the ssh_key record was created.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_updated IS 'Timestamp when the ssh_key record was last updated.'; +COMMENT ON COLUMN public.t_ssh_key.gmt_deleted IS 'Timestamp when the ssh_key record was deleted. +If set to NULL, it indicates that the ssh_key record has not been deleted.'; diff --git a/database/table/t_user.sql b/database/table/t_user.sql index b1bd10b..e71cdc7 100644 --- a/database/table/t_user.sql +++ b/database/table/t_user.sql @@ -17,4 +17,4 @@ COMMENT ON COLUMN public.t_user.user_password IS 'Password of the user, stored a COMMENT ON COLUMN public.t_user.gmt_created IS 'Timestamp when the user record was created.'; COMMENT ON COLUMN public.t_user.gmt_updated IS 'Timestamp when the user record was last updated.'; COMMENT ON COLUMN public.t_user.gmt_deleted IS 'Timestamp when the user record was deleted. -If set to NULL, it indicates that the user information not been deleted.'; +If set to NULL, it indicates that the user information has not been deleted.'; diff --git a/database/table/t_user_star_repository.sql b/database/table/t_user_star_repository.sql index a90a11a..f9033f7 100644 --- a/database/table/t_user_star_repository.sql +++ b/database/table/t_user_star_repository.sql @@ -14,4 +14,5 @@ COMMENT ON COLUMN public.t_user_star_repository.user_id IS 'ID of the user who s COMMENT ON COLUMN public.t_user_star_repository.repository_id IS 'ID of the repository that has been starred.'; COMMENT ON COLUMN public.t_user_star_repository.gmt_created IS 'Timestamp when the relationship was created.'; COMMENT ON COLUMN public.t_user_star_repository.gmt_updated IS 'Timestamp when the relationship was last updated.'; -COMMENT ON COLUMN public.t_user_star_repository.gmt_deleted IS 'Timestamp when the relationship was deleted. If set to NULL, it indicates that this relationship has not been deleted.'; +COMMENT ON COLUMN public.t_user_star_repository.gmt_deleted IS 'Timestamp when the relationship was deleted. +If set to NULL, it indicates that this relationship has not been deleted.'; diff --git a/database/trigger/all_table_trigger.sql b/database/trigger/all_table_trigger.sql index f1820c4..59d204d 100644 --- a/database/trigger/all_table_trigger.sql +++ b/database/trigger/all_table_trigger.sql @@ -14,3 +14,8 @@ FOR EACH ROW EXECUTE FUNCTION public.update_gmt_updated_column(); CREATE TRIGGER update_t_user_repository_gmt_updated BEFORE UPDATE ON public.t_user_repository FOR EACH ROW EXECUTE FUNCTION public.update_gmt_updated_column(); + +-- The trigger of the t_ssh_key table is added. +CREATE TRIGGER update_t_ssh_key_gmt_updated +BEFORE UPDATE ON public.t_ssh_key +FOR EACH ROW EXECUTE FUNCTION public.update_gmt_updated_column(); diff --git a/script/deploy_helper.py b/script/deploy_helper.py index 828755b..391e870 100644 --- a/script/deploy_helper.py +++ b/script/deploy_helper.py @@ -343,6 +343,7 @@ def write_other_config(config): "frontEndUrl": "front-end.url", "gitServerDomain": "git.server.domain", "gitUserName": "git.user.name", + "gitHomeDirectory": "git.home.directory", "gitRepositoryDirectory": "git.repository.directory", "gitRepositorySuffix": "git.repository.suffix", } @@ -376,8 +377,8 @@ def deploy_on_ubuntu(config): command_checker(res, message) create_or_update_user(config.gitUserName, config.gitUserPassword) create_or_update_user(config.serviceUser, config.serviceUserPassword) - # let the service user can use git command as the git user without password - sudoers_entry = f"{config.serviceUser} ALL=(git) NOPASSWD: /usr/bin/git" + # let the service user can use git and tee commands as the git user without password + sudoers_entry = f"{config.serviceUser} ALL=(git) NOPASSWD: /usr/bin/git, /usr/bin/tee" res = subprocess.run(f"echo '{sudoers_entry}' | {sudo_cmd} tee /etc/sudoers.d/{config.serviceUser}", shell=True); command_checker(res.returncode, f"Failed to create /etc/sudoers.d/{config.serviceUser}") res = subprocess.run(f"{sudo_cmd} chmod 440 /etc/sudoers.d/{config.serviceUser}", shell=True) diff --git a/src/main/java/edu/cmipt/gcs/GcsApplication.java b/src/main/java/edu/cmipt/gcs/GcsApplication.java index 7788609..bfeea16 100644 --- a/src/main/java/edu/cmipt/gcs/GcsApplication.java +++ b/src/main/java/edu/cmipt/gcs/GcsApplication.java @@ -3,9 +3,11 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication @MapperScan("edu.cmipt.gcs.dao") +@EnableTransactionManagement public class GcsApplication { public static void main(String[] args) { diff --git a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java index c629e11..28383ac 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ApiPathConstant.java @@ -32,4 +32,15 @@ public class ApiPathConstant { public static final String REPOSITORY_API_PREFIX = ALL_API_PREFIX + "/repository"; public static final String REPOSITORY_CREATE_REPOSITORY_API_PATH = REPOSITORY_API_PREFIX + "/create"; + + public static final String SSH_KEY_API_PREFIX = ALL_API_PREFIX + "/ssh"; + public static final String SSH_KEY_UPLOAD_SSH_KEY_API_PATH = + SSH_KEY_API_PREFIX + "/upload"; + public static final String SSH_KEY_UPDATE_SSH_KEY_API_PATH = + SSH_KEY_API_PREFIX + "/update"; + public static final String SSH_KEY_DELETE_SSH_KEY_API_PATH = + SSH_KEY_API_PREFIX + "/delete"; + public static final String SSH_KEY_PAGE_SSH_KEY_API_PATH = + SSH_KEY_API_PREFIX + "/page"; + } diff --git a/src/main/java/edu/cmipt/gcs/constant/GitConstant.java b/src/main/java/edu/cmipt/gcs/constant/GitConstant.java new file mode 100644 index 0000000..57b84a8 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/constant/GitConstant.java @@ -0,0 +1,44 @@ +package edu.cmipt.gcs.constant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class GitConstant { + public static String GIT_USER_NAME; + + public static String GIT_HOME_DIRECTORY; + + public static String GIT_REPOSITORY_DIRECTORY; + + public static String GIT_REPOSITORY_SUFFIX; + + public static String GIT_SERVER_DOMAIN; + + public static final String SSH_KEY_PREFIX = "no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty "; + + @Value("${git.user.name}") + public void setGIT_USER_NAME(String gitUserName) { + GitConstant.GIT_USER_NAME = gitUserName; + } + + @Value("${git.home.directory}") + public void setGIT_HOME_DIRECTORY(String gitHomeDirectory) { + GitConstant.GIT_HOME_DIRECTORY = gitHomeDirectory; + } + + @Value("${git.repository.directory}") + public void setGIT_REPOSITORY_DIRECTORY(String gitRepositoryDirectory) { + GitConstant.GIT_REPOSITORY_DIRECTORY = gitRepositoryDirectory; + } + + @Value("${git.repository.suffix}") + public void setGIT_REPOSITORY_SUFFIX(String gitRepositorySuffix) { + GitConstant.GIT_REPOSITORY_SUFFIX = gitRepositorySuffix; + } + + @Value("${git.server.domain}") + public void setGIT_SERVER_DOMAIN(String gitServerDomain) { + GitConstant.GIT_SERVER_DOMAIN = gitServerDomain; + } +} diff --git a/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java index 030d655..bca22ca 100644 --- a/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java +++ b/src/main/java/edu/cmipt/gcs/constant/HeaderParameter.java @@ -3,4 +3,5 @@ public class HeaderParameter { public static final String ACCESS_TOKEN = "Access-Token"; public static final String REFRESH_TOKEN = "Refresh-Token"; + public static final String SSH_KEY = "Ssh-Key"; } diff --git a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java index a1332c7..066a2db 100644 --- a/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java +++ b/src/main/java/edu/cmipt/gcs/constant/ValidationConstant.java @@ -15,4 +15,10 @@ public class ValidationConstant { public static final int MAX_REPOSITORY_DESCRIPTION_LENGTH = 255; // the length will be checked by @Size public static final String REPOSITORY_NAME_PATTERN = "^[a-zA-Z0-9_-]*$"; + + public static final int MIN_SSH_KEY_NAME_LENGTH = 1; + public static final int MAX_SSH_KEY_NAME_LENGTH = 255; + + public static final int MIN_SSH_KEY_PUBLICKEY_LENGTH = 1; + public static final int MAX_SSH_KEY_PUBLICKEY_LENGTH = 4096; } diff --git a/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java b/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java new file mode 100644 index 0000000..a03afcf --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/controller/SshKeyController.java @@ -0,0 +1,180 @@ +package edu.cmipt.gcs.controller; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.error.ErrorVO; +import edu.cmipt.gcs.pojo.ssh.SshKeyDTO; +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; +import edu.cmipt.gcs.pojo.ssh.SshKeyVO; +import edu.cmipt.gcs.service.SshKeyService; +import edu.cmipt.gcs.util.JwtUtil; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; + +@RestController +@Tag(name = "SSH", description = "SSH APIs") +public class SshKeyController { + private static final Logger logger = LoggerFactory.getLogger(SshKeyController.class); + + @Autowired private SshKeyService sshKeyService; + + @PostMapping(ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH) + @Operation( + summary = "Upload SSH key", + description = "Upload SSH key with the given information", + tags = {"SSH", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class))}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key uploaded successfully"), + @ApiResponse( + responseCode = "400", + description = "SSH key upload failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class))), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public void uploadSshKey(@Validated(CreateGroup.class) @RequestBody SshKeyDTO sshKeyDTO, + @RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken){ + if (!sshKeyService.save(new SshKeyPO(sshKeyDTO))) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPLOAD_FAILED, sshKeyDTO); + } + } + + @DeleteMapping(ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH) + @Operation( + summary = "Delete SSH key", + description = "Delete SSH key with the given information", + tags = {"SSH", "Delete Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "SSH key ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class))}) + @ApiResponse(responseCode = "200", description = "SSH key deleted successfully") + public void deleteSshKey(@RequestHeader(HeaderParameter.ACCESS_TOKEN) String accessToken, + @RequestParam("id") Long id) { + var res = sshKeyService.getById(id); + if (res == null) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_NOT_FOUND, id); + } + String idInToken = JwtUtil.getId(accessToken); + String idInRes = res.getUserId().toString(); + if (!idInRes.equals(idInToken)) { + logger.info("User[{}] tried to get SSH key of user[{}]", idInToken, idInRes); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + if (!sshKeyService.removeById(id)) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_DELETE_FAILED, id); + } + } + + @PostMapping(ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH) + @Operation( + summary = "Update SSH key", + description = "Update SSH key with the given information", + tags = {"SSH", "Post Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class))}) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "SSH key updated successfully"), + @ApiResponse( + responseCode = "400", + description = "SSH key update failed", + content = @Content(schema = @Schema(implementation = ErrorVO.class)))}) + public ResponseEntity updateSshKey( + @Validated(UpdateGroup.class) @RequestBody SshKeyDTO sshKeyDTO + ) { + if (!sshKeyService.updateById(new SshKeyPO(sshKeyDTO))) { + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPDATE_FAILED, sshKeyDTO); + } + return ResponseEntity.ok().body(new SshKeyVO(sshKeyService.getById(Long.valueOf(sshKeyDTO.id())))); + } + + @GetMapping(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH) + @Operation( + summary = "Page SSH key", + description = "Page SSH key with the given information", + tags = {"SSH", "Get Method"}) + @Parameters({ + @Parameter( + name = HeaderParameter.ACCESS_TOKEN, + description = "Access token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(implementation = String.class)), + @Parameter( + name = "id", + description = "User ID", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Long.class)), + @Parameter( + name = "page", + description = "Page number", + example = "1", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class)), + @Parameter( + name = "size", + description = "Page size", + example = "10", + required = true, + in = ParameterIn.QUERY, + schema = @Schema(implementation = Integer.class))}) + @ApiResponse(responseCode = "200", description = "SSH key paged successfully") + public List pageSshKey(@RequestParam("id") Long userId, + @RequestParam("page") Integer page, @RequestParam("size") Integer size) { + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("user_id", userId); + return sshKeyService.list(new Page<>(page, size), wrapper).stream().map(SshKeyVO::new).collect(Collectors.toList()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java b/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java new file mode 100644 index 0000000..9434b85 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/dao/SshKeyMapper.java @@ -0,0 +1,7 @@ +package edu.cmipt.gcs.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; + +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; + +public interface SshKeyMapper extends BaseMapper {} diff --git a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java index 553f58f..51568bf 100644 --- a/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java +++ b/src/main/java/edu/cmipt/gcs/enumeration/ErrorCodeEnum.java @@ -49,7 +49,20 @@ public enum ErrorCodeEnum { REPOSITORYNAME_PATTERN_MISMATCH("REPOSITORYNAME_PATTERN_MISMATCH"), REPOSITORY_ALREADY_EXISTS("REPOSITORY_ALREADY_EXISTS"), - REPOSITORY_CREATE_FAILED("REPOSITORY_CREATE_FAILED"); + REPOSITORY_CREATE_FAILED("REPOSITORY_CREATE_FAILED"), + + SSHKEYDTO_ID_NULL("SshKeyDTO.id.Null"), + SSHKEYDTO_ID_NOTNULL("SshKeyDTO.id.NotNull"), + SSHKEYDTO_USERID_NOTBLANK("SshKeyDTO.userId.NotBlank"), + SSHKEYDTO_NAME_NOTBLANK("SshKeyDTO.name.NotBlank"), + SSHKEYDTO_NAME_SIZE("SshKeyDTO.name.Size"), + SSHKEYDTO_PUBLICKEY_NOTBLANK("SshKeyDTO.publicKey.NotBlank"), + SSHKEYDTO_PUBLICKEY_SIZE("SshKeyDTO.publicKey.Size"), + + SSH_KEY_UPLOAD_FAILED("SSH_KEY_UPLOAD_FAILED"), + SSH_KEY_UPDATE_FAILED("SSH_KEY_UPDATE_FAILED"), + SSH_KEY_DELETE_FAILED("SSH_KEY_DELETE_FAILED"), + SSH_KEY_NOT_FOUND("SSH_KEY_NOT_FOUND"); // code means the error code in the message.properties private String code; diff --git a/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java index 3e39f18..3618238 100644 --- a/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java +++ b/src/main/java/edu/cmipt/gcs/exception/GlobalExceptionHandler.java @@ -94,6 +94,8 @@ public ResponseEntity handleJsonParseException( @ExceptionHandler(Exception.class) public void handleException(Exception e) { logger.error(e.getMessage()); + // TODO: use logger to log the exception + e.printStackTrace(); } private ResponseEntity handleValidationException( diff --git a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java index a560404..586ac22 100644 --- a/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java +++ b/src/main/java/edu/cmipt/gcs/filter/JwtFilter.java @@ -132,6 +132,7 @@ protected void doFilterInternal( filterChain.doFilter(cachedRequest, response); } + // TODO: move all the pass paths to a set, and use the set to check private void authorize(HttpServletRequest request, String accessToken, String refreshToken) { if (accessToken != null && JwtUtil.getTokenType(accessToken) != TokenTypeEnum.ACCESS_TOKEN) { @@ -150,6 +151,25 @@ private void authorize(HttpServletRequest request, String accessToken, String re && request.getRequestURI() .equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH))) { throw new GenericException(ErrorCodeEnum.TOKEN_NOT_FOUND); + } else if (request.getRequestURI() + .equals(ApiPathConstant.AUTHENTICATION_REFRESH_API_PATH)) { + // pass + } else if (request.getRequestURI() + .equals(ApiPathConstant.USER_PAGE_USER_REPOSITORY_API_PATH)) { + // pass + } else if (request.getRequestURI() + .equals(ApiPathConstant.USER_GET_USER_API_PATH)) { + // pass + } else if (request.getRequestURI() + .equals(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH)) { + String idInToken = JwtUtil.getId(accessToken); + String idInParam = request.getParameter("id"); + if (!idInToken.equals(idInParam)) { + logger.info("User[{}] tried to get SSH key of user[{}]", idInToken, idInParam); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else { + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } break; case "POST": @@ -176,6 +196,22 @@ private void authorize(HttpServletRequest request, String accessToken, String re } else if (request.getRequestURI() .equals(ApiPathConstant.REPOSITORY_CREATE_REPOSITORY_API_PATH)) { // pass + } else if (request.getRequestURI() + .equals(ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH)) { + String idInToken = JwtUtil.getId(accessToken); + String idInBody = getFromRequestBody(request, "userId"); + if (!idInToken.equals(idInBody)) { + logger.info("User[{}] tried to upload SSH key of user[{}]", idInToken, idInBody); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } + } else if (request.getRequestURI() + .equals(ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH)) { + String idInToken = JwtUtil.getId(accessToken); + String idInBody = getFromRequestBody(request, "userId"); + if (!idInToken.equals(idInBody)) { + logger.info("User[{}] tried to update SSH key of user[{}]", idInToken, idInBody); + throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); + } } else { throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } @@ -196,6 +232,10 @@ private void authorize(HttpServletRequest request, String accessToken, String re logger.info("User[{}] tried to delete user[{}]", idInToken, idInParam); throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } + } else if (request.getRequestURI() + .equals(ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH)) { + // this will be checked in controller + // because we must query the database to get the user id of the ssh key } else { throw new GenericException(ErrorCodeEnum.ACCESS_DENIED); } diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java new file mode 100644 index 0000000..bbfc5e9 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyDTO.java @@ -0,0 +1,48 @@ +package edu.cmipt.gcs.pojo.ssh; + +import edu.cmipt.gcs.constant.ValidationConstant; +import edu.cmipt.gcs.validation.group.CreateGroup; +import edu.cmipt.gcs.validation.group.UpdateGroup; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Null; +import jakarta.validation.constraints.Size; + +@Schema(description = "SSH Key Data Transfer Object") +public record SshKeyDTO( + @Schema(description = "SSH Key ID") + @Null(groups = CreateGroup.class, message = "SSHKEYDTO_ID_NULL {SshKeyDTO.id.Null}") + @NotNull( + groups = UpdateGroup.class, + message = "SSHKEYDTO_ID_NOTNULL {SshKeyDTO.id.NotNull}") + String id, + @Schema(description = "User ID") + @NotBlank( + groups = {CreateGroup.class, UpdateGroup.class}, + message = "SSHKEYDTO_USERID_NOTBLANK {SshKeyDTO.userId.NotBlank}") + String userId, + @Schema( + description = "Name", + example = "My SSH Key") + @NotBlank( + groups = {CreateGroup.class, UpdateGroup.class}, + message = "SSHKEYDTO_NAME_NOTBLANK {SshKeyDTO.name.NotBlank}") + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_SSH_KEY_NAME_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_NAME_LENGTH, + message = "SSHKEYDTO_NAME_SIZE {SshKeyDTO.name.Size}") + String name, + @Schema( + description = "Public Key") + @NotBlank( + groups = CreateGroup.class, + message = "SSHKEYDTO_PUBLICKEY_NOTBLANK {SshKeyDTO.publicKey.NotBlank}") + @Size( + groups = {CreateGroup.class, UpdateGroup.class}, + min = ValidationConstant.MIN_SSH_KEY_PUBLICKEY_LENGTH, + max = ValidationConstant.MAX_SSH_KEY_PUBLICKEY_LENGTH, + message = "SSHKEYDTO_PUBLICKEY_SIZE {SshKeyDTO.publicKey.Size}") + String publicKey) { +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java new file mode 100644 index 0000000..625a063 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyPO.java @@ -0,0 +1,37 @@ +package edu.cmipt.gcs.pojo.ssh; + +import java.time.LocalDateTime; + +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +@TableName("t_ssh_key") +public class SshKeyPO { + private Long id; + private Long userId; + private String name; + private String publicKey; + private LocalDateTime gmtCreated; + private LocalDateTime gmtUpdated; + @TableLogic private LocalDateTime gmtDeleted; + + public SshKeyPO(SshKeyDTO sshKeyDTO) { + try { + this.id = Long.valueOf(sshKeyDTO.id()); + } catch (NumberFormatException e) { + this.id = null; + } + try { + this.userId = Long.valueOf(sshKeyDTO.userId()); + } catch (NumberFormatException e) { + this.userId = null; + } + this.name = sshKeyDTO.name(); + this.publicKey = sshKeyDTO.publicKey(); + } +} diff --git a/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java new file mode 100644 index 0000000..101e24e --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/pojo/ssh/SshKeyVO.java @@ -0,0 +1,10 @@ +package edu.cmipt.gcs.pojo.ssh; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "SSH Key Value Object") +public record SshKeyVO(String id, String userId, String name, String publicKey) { + public SshKeyVO(SshKeyPO sshKeyPO) { + this(sshKeyPO.getId().toString(), sshKeyPO.getUserId().toString(), sshKeyPO.getName(), sshKeyPO.getPublicKey()); + } +} diff --git a/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java index 726a846..a14299b 100644 --- a/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java +++ b/src/main/java/edu/cmipt/gcs/service/RepositoryServiceImpl.java @@ -2,16 +2,20 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import edu.cmipt.gcs.constant.GitConstant; import edu.cmipt.gcs.dao.RepositoryMapper; import edu.cmipt.gcs.dao.UserMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; import edu.cmipt.gcs.pojo.repository.RepositoryPO; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.file.Files; import java.nio.file.Paths; @Service @@ -21,56 +25,58 @@ public class RepositoryServiceImpl extends ServiceImpl Usually, the user will not create the same repository at the same time, so we don't + * consider the thread competition + */ + @Transactional @Override public boolean save(RepositoryPO repositoryPO) { + if (!super.save(repositoryPO)) { + logger.error("Failed to save repository to database"); + return false; + } String repositorySavePath = Paths.get( - gitRepositoryDirectory, + GitConstant.GIT_REPOSITORY_DIRECTORY, userMapper.selectById(repositoryPO.getUserId()).getUsername(), - repositoryPO.getRepositoryName() + gitRepositorySuffix) + repositoryPO.getRepositoryName() + GitConstant.GIT_REPOSITORY_SUFFIX) .toString(); - logger.info("Repository save path: {}", repositorySavePath); - try { - ProcessBuilder repositoryInitializer = - new ProcessBuilder( - "sudo", "-u", gitUserName, "git", "init", "--bare", repositorySavePath); - Process process = repositoryInitializer.start(); - if (process.waitFor() != 0) { - logger.error("Failed to initialize repository"); - process.errorReader().lines().forEach(logger::error); - return false; - } - // TODO: add url in the repositoryPO - } catch (Exception e) { - logger.error("Failed to initialize repository: {}", e.getMessage()); - return false; - } - if (!super.save(repositoryPO)) { + // check if the repositorySavePath has been created, if so, remove it + // this may occur, if the last creation failed + if (Files.exists(Paths.get(repositorySavePath))){ + logger.info("Repository save path exists, try to remove it"); try { ProcessBuilder dirRemover = new ProcessBuilder( - "sudo", "-u", gitUserName, "rm", "-rf", repositorySavePath); + "sudo", "-u", GitConstant.GIT_USER_NAME, "rm", "-rf", repositorySavePath); Process process = dirRemover.start(); if (process.waitFor() != 0) { - logger.error("Failed to remove repository directory"); - process.errorReader().lines().forEach(logger::error); + throw new GenericException( + ErrorCodeEnum.REPOSITORY_CREATE_FAILED, + process.errorReader().lines().toList().toString()); } } catch (Exception e) { logger.error("Failed to remove repository directory: {}", e.getMessage()); + throw new GenericException(ErrorCodeEnum.REPOSITORY_CREATE_FAILED, e.getMessage()); } - return false; + } + logger.info("Repository save path: {}", repositorySavePath); + try { + ProcessBuilder repositoryInitializer = + new ProcessBuilder( + "sudo", "-u", GitConstant.GIT_USER_NAME, "git", "init", "--bare", repositorySavePath); + Process process = repositoryInitializer.start(); + if (process.waitFor() != 0) { + throw new GenericException( + ErrorCodeEnum.REPOSITORY_CREATE_FAILED, + process.errorReader().lines().toList().toString()); + } + // TODO: add url in the repositoryPO + } catch (Exception e) { + throw new GenericException(ErrorCodeEnum.REPOSITORY_CREATE_FAILED, e.getMessage()); } return true; } diff --git a/src/main/java/edu/cmipt/gcs/service/SshKeyService.java b/src/main/java/edu/cmipt/gcs/service/SshKeyService.java new file mode 100644 index 0000000..7ce3cb9 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/SshKeyService.java @@ -0,0 +1,8 @@ +package edu.cmipt.gcs.service; + +import com.baomidou.mybatisplus.extension.service.IService; + +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; + + +public interface SshKeyService extends IService {} diff --git a/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java b/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java new file mode 100644 index 0000000..12b7c80 --- /dev/null +++ b/src/main/java/edu/cmipt/gcs/service/SshKeyServiceImpl.java @@ -0,0 +1,102 @@ +package edu.cmipt.gcs.service; + +import java.io.OutputStream; +import java.io.Serializable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +import edu.cmipt.gcs.constant.GitConstant; +import edu.cmipt.gcs.dao.SshKeyMapper; +import edu.cmipt.gcs.enumeration.ErrorCodeEnum; +import edu.cmipt.gcs.exception.GenericException; +import edu.cmipt.gcs.pojo.ssh.SshKeyPO; + +@Service +public class SshKeyServiceImpl extends ServiceImpl implements SshKeyService { + private static final Logger logger = LoggerFactory.getLogger(RepositoryServiceImpl.class); + + @Transactional + @Override + public boolean save(SshKeyPO sshKeyPO) { + if (!super.save(sshKeyPO)) { + logger.error("Failed to save SSH key to database"); + return false; + } + saveSshKeyToAuthorizedKeys(sshKeyPO.getPublicKey()); + return true; + } + + @Transactional + @Override + public boolean removeById(Serializable id) { + SshKeyPO sshKeyPO = super.getById(id); + assert sshKeyPO != null; + if (!super.removeById(id)) { + logger.error("Failed to remove SSH key from database"); + return false; + } + removeSshKeyFromAuthorizedKeys(sshKeyPO.getPublicKey()); + return true; + } + + @Transactional + @Override + public boolean updateById(SshKeyPO sshKeyPO) { + String originSshKey = super.getById(sshKeyPO.getId()).getPublicKey(); + assert originSshKey != null; + if (!super.updateById(sshKeyPO)) { + return false; + } + // no need to update file, we just return true + if (sshKeyPO.getPublicKey() == null || originSshKey.equals(sshKeyPO.getPublicKey())) { return true; } + // remove the origin ssh key and save the new ssh key + removeSshKeyFromAuthorizedKeys(originSshKey); + saveSshKeyToAuthorizedKeys(sshKeyPO.getPublicKey()); + return true; + } + + private void saveSshKeyToAuthorizedKeys(String sshKey) { + try { + ProcessBuilder sshKeySaver = new ProcessBuilder("sudo", "-u", GitConstant.GIT_USER_NAME, + "tee", "-a", GitConstant.GIT_HOME_DIRECTORY + "/.ssh/authorized_keys"); + // for singleton, we can use synchronized(this) to lock the object + synchronized (this) { + Process process = sshKeySaver.start(); + try (OutputStream os = process.getOutputStream()) { + os.write((GitConstant.SSH_KEY_PREFIX + sshKey + '\n').getBytes()); + os.flush(); + } + if (process.waitFor() != 0) { + logger.error("Failed to write SSH key to authorized_keys file"); + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPLOAD_FAILED, process.errorReader().lines().toList().toString()); + } + } + } catch (Exception e) { + // rollback the database operation + throw new GenericException(ErrorCodeEnum.SSH_KEY_UPLOAD_FAILED, e.getMessage()); + } + } + + private void removeSshKeyFromAuthorizedKeys(String sshKey) { + try { + ProcessBuilder sshKeyRemover = new ProcessBuilder("sudo", "-u", GitConstant.GIT_USER_NAME, + "sed", "-i", "/^" + GitConstant.SSH_KEY_PREFIX + sshKey + "$/d", + GitConstant.GIT_HOME_DIRECTORY + "/.ssh/authorized_keys"); + synchronized (this) { + Process process = sshKeyRemover.start(); + if (process.waitFor() != 0) { + logger.error("Failed to remove SSH key from authorized_keys"); + throw new GenericException(ErrorCodeEnum.SSH_KEY_DELETE_FAILED, process.errorReader().lines().toList().toString()); + } + } + } catch (Exception e) { + // rollback the database operation + throw new GenericException(ErrorCodeEnum.SSH_KEY_DELETE_FAILED, e.getMessage()); + } + } +} diff --git a/src/main/resources/message/message.properties b/src/main/resources/message/message.properties index b994d89..fca220f 100644 --- a/src/main/resources/message/message.properties +++ b/src/main/resources/message/message.properties @@ -46,3 +46,16 @@ RepositoryDTO.watcher.Min=Watcher must be greater than or equal to {value} REPOSITORYNAME_PATTERN_MISMATCH=Repository name can only be alphanumeric, hyphen or underline REPOSITORY_ALREADY_EXISTS=Repository already exists: {} REPOSITORY_CREATE_FAILED=Repository create failed: {} + +SshKeyDTO.id.Null=SSH key id must be null when creating a new SSH key +SshKeyDTO.id.NotNull=SSH key id cannot be null +SshKeyDTO.userId.NotBlank=SSH key user id cannot be blank +SshKeyDTO.name.NotBlank=SSH key name cannot be blank +SshKeyDTO.name.Size=SSH key name must be between {min} and {max} characters +SshKeyDTO.publicKey.NotBlank=SSH key public key cannot be blank +SshKeyDTO.publicKey.Size=SSH key public key must be between {min} and {max} characters + +SSH_KEY_UPLOAD_FAILED=SSH key upload failed: {} +SSH_KEY_UPDATE_FAILED=SSH key update failed: {} +SSH_KEY_DELETE_FAILED=SSH key delete failed: {} +SSH_KEY_NOT_FOUND=SSH key not found: {} diff --git a/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java b/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java index 133e35e..e82c54c 100644 --- a/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java +++ b/src/test/java/edu/cmipt/gcs/SpringBootTestClassOrderer.java @@ -2,6 +2,7 @@ import edu.cmipt.gcs.controller.AuthenticationControllerTest; import edu.cmipt.gcs.controller.RepositoryControllerTest; +import edu.cmipt.gcs.controller.SshKeyControllerTest; import edu.cmipt.gcs.controller.UserControllerTest; import org.junit.jupiter.api.ClassDescriptor; @@ -15,6 +16,7 @@ public class SpringBootTestClassOrderer implements ClassOrderer { private static final Class[] classOrder = new Class[] { AuthenticationControllerTest.class, + SshKeyControllerTest.class, RepositoryControllerTest.class, UserControllerTest.class }; diff --git a/src/test/java/edu/cmipt/gcs/constant/TestConstant.java b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java index 40319a1..860456e 100644 --- a/src/test/java/edu/cmipt/gcs/constant/TestConstant.java +++ b/src/test/java/edu/cmipt/gcs/constant/TestConstant.java @@ -16,4 +16,6 @@ public class TestConstant { public static String OTHER_ACCESS_TOKEN; public static String OTHER_REFRESH_TOKEN; public static Integer REPOSITORY_SIZE = 10; + public static Integer SSH_KEY_SIZE = 10; + public static String SSH_KEY_ID; } diff --git a/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java b/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java new file mode 100644 index 0000000..2c15f52 --- /dev/null +++ b/src/test/java/edu/cmipt/gcs/controller/SshKeyControllerTest.java @@ -0,0 +1,124 @@ +package edu.cmipt.gcs.controller; + +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import edu.cmipt.gcs.constant.ApiPathConstant; +import edu.cmipt.gcs.constant.HeaderParameter; +import edu.cmipt.gcs.constant.TestConstant; + +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.json.JsonParserFactory; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.Ordered; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class SshKeyControllerTest { + @Autowired MockMvc mockMvc; + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE) + public void testUploadSshKeyValid() throws Exception { + for (int i = 0; i < TestConstant.SSH_KEY_SIZE; i++) { + String name = "My SSH Key " + i; + String publicKey = "This is my public key " + i; + mockMvc + .perform( + post(ApiPathConstant.SSH_KEY_UPLOAD_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "name": "%s", + "userId": "%s", + "publicKey": "%s" + } + """.formatted(name, TestConstant.ID, publicKey) + )) + .andExpect(status().isOk()); + } + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + public void testPageSshKeyValid() throws Exception { + var response = mockMvc + .perform( + get(ApiPathConstant.SSH_KEY_PAGE_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.ID) + .param("page", "1") + .param("size", TestConstant.SSH_KEY_SIZE.toString())) + .andExpectAll(status().isOk(), + jsonPath("$").isArray(), + jsonPath("$.length()").value(TestConstant.SSH_KEY_SIZE)).andReturn().getResponse(); + Matcher matcher = Pattern.compile("id=(\\d+)").matcher(JsonParserFactory.getJsonParser().parseList(response.getContentAsString()).get(0).toString()); + matcher.find(); + TestConstant.SSH_KEY_ID = matcher.group(1); + } + + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 2) + public void testUpdateSshKeyValid() throws Exception { + mockMvc + .perform( + post(ApiPathConstant.SSH_KEY_UPDATE_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content( + """ + { + "id": "%s", + "name": "My SSH Key Updated", + "userId": "%s", + "publicKey": "This is my public key updated" + } + """.formatted(TestConstant.SSH_KEY_ID, TestConstant.ID))) + .andExpectAll( + status().isOk(), + jsonPath("$.id", is(TestConstant.SSH_KEY_ID)), + jsonPath("$.userId", is(TestConstant.ID)), + jsonPath("$.name", is("My SSH Key Updated")), + jsonPath("$.publicKey", is("This is my public key updated")) + ); + } + + /** + * Test delete ssh-key + * + *

This must excute after {@link #testUpdateSshKeyValid() testUploadSshKeyValid} + * + * @throws Exception + */ + @Test + @Order(Ordered.HIGHEST_PRECEDENCE + 3) + public void testDeleteSshKeyValid() throws Exception { + mockMvc + .perform( + delete(ApiPathConstant.SSH_KEY_DELETE_SSH_KEY_API_PATH) + .header(HeaderParameter.ACCESS_TOKEN, TestConstant.ACCESS_TOKEN) + .param("id", TestConstant.SSH_KEY_ID)) + .andExpect(status().isOk()); + TestConstant.SSH_KEY_ID = null; + TestConstant.SSH_KEY_SIZE--; + // check if the size has been decreased + testPageSshKeyValid(); + } +}