diff --git a/.github/ci_setup.sql b/.github/ci_setup.sql new file mode 100644 index 0000000..14be7ca --- /dev/null +++ b/.github/ci_setup.sql @@ -0,0 +1,18 @@ +CREATE USER 'ci_user'@'localhost' IDENTIFIED BY 'ci_user_pass'; +DROP DATABASE IF EXISTS ci; CREATE DATABASE ci; + +GRANT LOCK TABLES ON `ci`.* TO `ci_user`@`localhost`; +GRANT SELECT, INSERT, UPDATE, CREATE, REFERENCES, ALTER ON `ci`.`flyway_schema_history` TO `ci_user`@`localhost`; + +create table ci.invite(is_disabled INT, used INT); +GRANT SELECT, INSERT, UPDATE (`is_disabled`, `used`), CREATE, REFERENCES, ALTER ON `ci`.`invite` TO `ci_user`@`localhost`; +drop table ci.invite; + +GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`target_app` TO `ci_user`@`localhost`; + +GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord` TO `ci_user`@`localhost`; +GRANT SELECT, INSERT, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord_joined_user` TO `ci_user`@`localhost`; +GRANT SELECT, INSERT, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`invite_discord_state` TO `ci_user`@`localhost`; + +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`user` TO `ci_user`@`localhost`; +GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, REFERENCES, ALTER ON `ci`.`user_authority` TO `ci_user`@`localhost`; diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..75c4572 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,54 @@ +name: Test + +on: + pull_request: + push: + branches: [ develop ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + if: github.actor != 'dependabot[bot]' + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up database + uses: shogo82148/actions-setup-mysql@v1 + with: + mysql-version: "8.0" + + - name: Prepare database + run: | + mysql -u root < ${{ github.workspace }}/.github/ci_setup.sql + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '21' + + - name: Cache Maven packages + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Create config file + run: | + cat << 'EOF' > src/main/resources/application-test.yml + ${{ secrets.APPLICATION_TEST_YML }} + EOF + + - name: Test + run: mvn integration-test diff --git a/pom.xml b/pom.xml index dd03fa3..88626df 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,10 @@ 0.0.1-SNAPSHOT TechUni Invite System TechUni Invitation System. + + IT Student Org, Tech.Uni + https://techuni.org + https://invite.techuni.org diff --git a/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java b/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java index 954c231..151aa9f 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/controller/LoginController.java @@ -41,8 +41,8 @@ public ResponseEntity login(@Validated @NotNull @RequestBo } try { - final var authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(loginRequest.getUser(), loginRequest.getPassword())); + final var authentication = + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getUser(), loginRequest.getPassword())); final var token = tokenProvider.generateToken(authentication); return ResponseEntity.ok().body(new LoginSuccessResponse(token)); diff --git a/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java b/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java index 663d787..83be07d 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/db/mapper/InviteWithDiscordStateMapper.java @@ -11,5 +11,5 @@ public interface InviteWithDiscordStateMapper { Invite getInviteByState(@Param("state") String state); void cleanState(@Param("nowTime") LocalDateTime nowTime, @Param("stateExpireTime") LocalDateTime stateExpireTime); - + } diff --git a/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java b/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java index 419f12d..b516f25 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/domain/invite/InviteDto.java @@ -93,8 +93,8 @@ public Invite intoDB() { public > M intoModel(Class modelClass) { final var _modelClass = targetApplication.getModelClass(); if (!modelClass.equals(_modelClass)) { - throw ErrorCode.UNEXPECTED_ERROR.exception( - "Model class is not matched. (Expected: %s, Selected: %s)".formatted(_modelClass.getName(), modelClass.getName())); + throw ErrorCode.UNEXPECTED_ERROR + .exception("Model class is not matched. (Expected: %s, Selected: %s)".formatted(_modelClass.getName(), modelClass.getName())); } return modelClass.cast(intoModel()); diff --git a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java index ab68b59..b30e55d 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorCode.java @@ -11,8 +11,7 @@ public enum ErrorCode { /** * OTHER */ - UNEXPECTED_ERROR(ErrorSource.OTHER, 1, HttpStatus.INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_UNEXPECTED_ERROR, - ErrorMessage.UNEXPECTED_ERROR), // + UNEXPECTED_ERROR(ErrorSource.OTHER, 1, HttpStatus.INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_UNEXPECTED_ERROR, ErrorMessage.UNEXPECTED_ERROR), // /** * LOGIN @@ -51,8 +50,7 @@ public enum ErrorCode { ErrorMessage.DISCORD_LOGIN_DENIED), // DISCORD_AUTHENTICATED_VALIDATION_ERROR(ErrorSource.INVITATION, 108, HttpStatus.UNAUTHORIZED, null, ErrorMessage.DISCORD_LOGIN_FAILED), // DISCORD_CREATE_JOIN_DM_ERROR(ErrorSource.INVITATION, 109, HttpStatus.INTERNAL_SERVER_ERROR, null, ErrorMessage.DISCORD_UNEXPECTED_ERROR), // - DISCORD_CREATE_INVITE_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 110, HttpStatus.BAD_REQUEST, - ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR), // + DISCORD_CREATE_INVITE_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 110, HttpStatus.BAD_REQUEST, ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR), // DISCORD_GUILD_ACCESS_ERROR(ErrorSource.INVITATION, 111, HttpStatus.NOT_FOUND, ErrorMessage.INTERNAL_DISCORD_GUILD_ACCESS_ERROR, ErrorMessage.INVITATION_INVALID), // DISCORD_LACK_GUILD_PERMISSION(ErrorSource.INVITATION, 112, HttpStatus.NOT_FOUND, ErrorMessage.INTERNAL_LACK_GUILD_PERMISSION, @@ -123,8 +121,8 @@ public String getInternalMessage() { } public String getInternalMessage(String... args) { - final var output = Optional.ofNullable(internalMessage).map(ErrorMessage::getMessage).orElse(status.getReasonPhrase()) - .formatted((Object[]) args); + final var output = + Optional.ofNullable(internalMessage).map(ErrorMessage::getMessage).orElse(status.getReasonPhrase()).formatted((Object[]) args); return "\"%s\" - ErrorCode.%s".formatted(output, this.name()); } diff --git a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java index c1c50d9..ef5ff1d 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/error/ErrorMessage.java @@ -29,10 +29,8 @@ public enum ErrorMessage { INTERNAL_INVITATION_INVALID("Request Invalid Invitation Code. (Code: %s)"), // /* DISCORD INVITATION */ - INTERNAL_DISCORD_ALREADY_JOINED( - "The Discord Invitation target already joined to the guild. (DbId: %s, InvitationCode: %s, Guild: %s, User: %s)"), // - DISCORD_ALREADY_JOINED( - "Your account have already joined to the discord server. If you think this is a mistake, please contact to the inviter."), // + INTERNAL_DISCORD_ALREADY_JOINED("The Discord Invitation target already joined to the guild. (DbId: %s, InvitationCode: %s, Guild: %s, User: %s)"), // + DISCORD_ALREADY_JOINED("Your account have already joined to the discord server. If you think this is a mistake, please contact to the inviter."), // INTERNAL_DISCORD_LOGIN_FAILED("Discord login failed."), // DISCORD_LOGIN_FAILED("Login to Your account is failed. If you think this is a mistake, please try again."), // INTERNAL_DISCORD_LOGIN_UNEXPECTED_ERROR("Unexpected error occurred in Discord login sequence."), // diff --git a/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java b/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java index 14c78c7..1cde21d 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/external/discord/template/MessageType.java @@ -8,8 +8,7 @@ @AllArgsConstructor public enum MessageType { - JOIN_SERVER_DM("join-server", JoinServerDMVariable.class), - ; + JOIN_SERVER_DM("join-server", JoinServerDMVariable.class),; @Getter private final String templateName; diff --git a/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java b/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java index 6154083..c1fbf20 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/security/MyPasswordEncoder.java @@ -1,10 +1,12 @@ package org.techuni.TechUniInviteSystem.security; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +@Profile("!test") @Component public class MyPasswordEncoder { diff --git a/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java b/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java index 744ce90..e331bf5 100644 --- a/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java +++ b/src/main/java/org/techuni/TechUniInviteSystem/service/DiscordDMService.java @@ -80,8 +80,8 @@ private void sendDM(CreateDMData createDMData) { if (attachment.forceLoad) { // isForceLoadAttachment throw new RuntimeException("Failed to load attachment: %s".formatted(attachment.name), e); } else { - final var message = "Failed to load attachment. But the DM will sent(because it is not forced attachment). : %s".formatted( - attachment.name); + final var message = "Failed to load attachment. But the DM will sent(because it is not forced attachment). : %s" + .formatted(attachment.name); log.warn(message, e); return null; } diff --git a/src/main/resources/db/mapper_test/TestUserMapper.xml b/src/main/resources/db/mapper_test/TestUserMapper.xml new file mode 100644 index 0000000..2b0b621 --- /dev/null +++ b/src/main/resources/db/mapper_test/TestUserMapper.xml @@ -0,0 +1,27 @@ + + + + + + + INSERT INTO user (uuid, isEnable, name, passHash) VALUES (#{insertUser.uuid}, #{insertUser.isEnable}, #{insertUser.name}, #{insertUser.passHash}) + + + + INSERT INTO user_authority (id, authority) VALUES + + (#{id}, #{authority}) + + + + + DELETE FROM user; + + + + DELETE FROM user_authority; + + + \ No newline at end of file diff --git a/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java b/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java index d1cd756..1120d94 100644 --- a/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java +++ b/src/test/java/org/techuni/TechUniInviteSystem/TechUniInviteSystemApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("test") class TechUniInviteSystemApplicationTests { @Test diff --git a/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java b/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java new file mode 100644 index 0000000..56432df --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/controller/LoginControllerIT.java @@ -0,0 +1,246 @@ +package org.techuni.TechUniInviteSystem.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.techuni.TechUniInviteSystem.controller.request.LoginRequest; +import org.techuni.TechUniInviteSystem.controller.response.LoginSuccessResponse; +import org.techuni.TechUniInviteSystem.db.TestUser; +import org.techuni.TechUniInviteSystem.domain.user.UserModel; +import org.techuni.TechUniInviteSystem.error.MyHttpException; +import org.techuni.TechUniInviteSystem.security.JwtTokenProvider; +import org.techuni.TechUniInviteSystem.type.AbstractIntegrationTest; +import org.techuni.TechUniInviteSystem.util.StringUtil; + +public class LoginControllerIT extends AbstractIntegrationTest { + + @Autowired + private JwtTokenProvider tokenProvider; + + @Value("${jwt.issuer}") + private String expectedTokenIssuer; + + @Value("${jwt.expiration}") + private long jwtExpirationInMs; + + @Override + @AfterEach + protected void afterEach() { + super.afterEach(); + clearUsers(); + } + + @ParameterizedTest + @MethodSource("validRequestProvider") + void 正_ログインをすることができる(final String userName, final String password) throws Exception { + registerUser(TestUser.builder(userName) // + .rawPassword(password) // + .build()); + final var request = post("/login", null, new LoginRequest(userName, password)); + + // execute + final var result = mockMvc.perform(request); + final var response = result // + .andExpect(status().isOk()) // + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) // + .andReturn() // + .getResponse().getContentAsString(); + + // verify + final var parsedResponse = parseResponse(response, LoginSuccessResponse.class); + + final var _decodedToken = tokenProvider.decode(parsedResponse.getToken()); + assertThat(_decodedToken.isPresent()).isTrue(); + + final var decodedToken = _decodedToken.get(); + assertThat(decodedToken.getIssuer()).isEqualTo(expectedTokenIssuer); + assertThat(decodedToken.getSubject()).isEqualTo(userName); + assertThat(decodedToken.getIssuedAt()).isNotNull(); + if (jwtExpirationInMs > 0) { + assertThat(decodedToken.getExpiresAt()).isNotNull(); + final var expectedExpireAt = new Date(decodedToken.getIssuedAt().getTime() + jwtExpirationInMs); + assertThat(decodedToken.getExpiresAt()).isEqualTo(expectedExpireAt); + } else { + assertThat(decodedToken.getExpiresAt()).isNull(); + } + } + + private static Stream validRequestProvider() { + final var userName = "validUserName"; + final var passwords = new ArrayList<>(List.of("validUser@Password012")); + + // 英字 + for (char large = 'A'; large <= 'Z'; large++) { + final var sample = "0123456789-a" + large; + passwords.add(sample); + + } + for (char small = 'a'; small <= 'z'; small++) { + final var sample = "0123456789-A" + small; + passwords.add(sample); + } + + // 数字 + for (var i = 0; i <= 9; i++) { + final var sample = "TESTAlphabet-" + i; + passwords.add(sample); + } + + // 記号 + for (var c : UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS) { + final var sample = "TESTAlphabet0" + c; + passwords.add(sample); + } + + final var allCondition = passwords.stream() // + .map(StringUtil::shuffleString) // + .map(password -> Arguments.of(userName, password)); + + // 境界 + final var boundary = Stream.of(Arguments.of("Aa@1-aaaaa", "Aa@1-aaaaa"), // min + Arguments.of("Aa@1-aaaaa" + "a".repeat(150 - 10), "Aa@1-aaaaa" + "a".repeat(64 - 10)) // max + ); + + return Stream.concat(allCondition, boundary); + } + + @ParameterizedTest + @MethodSource("invalidRequestProvider") + void 異_要件を満たさない情報でログインできない(final String userName, final String password) throws Exception { + final var request = post("/login", null, new LoginRequest(userName, password)); + + // execute + final var result = mockMvc.perform(request); + final var exception = result // + .andExpect(status().isUnauthorized()) // + .andReturn() // + .getResolvedException(); + + // verify + assertThat(exception) // + .isNotNull() // + .isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED)); + } + + private static Stream invalidRequestProvider() { + final var validUsername = "validUserName"; + final var validPassword = "validUser@Password012"; + + final var basic = Stream.of( // + // null系 + Arguments.of(null, null), Arguments.of(null, validPassword), Arguments.of(validUsername, null), + // 空文字系 + Arguments.of("", ""), Arguments.of("", validPassword), Arguments.of(validUsername, ""), + // 空白文字系 + Arguments.of(" ", " "), Arguments.of(validUsername, " "), Arguments.of(" ", validPassword), Arguments.of(" ", " "), + Arguments.of(validUsername, " "), Arguments.of(" ", validPassword), + // ユーザー名系 + Arguments.of("a", validPassword), // 10文字未満 + Arguments.of("a".repeat(200), validPassword), // 150文字超過 + // パスワード系 + Arguments.of(validUsername, "aA1-"), // 10文字未満 + Arguments.of(validUsername, "aA1-" + "a".repeat(200)) // 64文字超過 + ); + + // ?のみ系 + final var onlyCase = Stream.of("abc", // 英小文字のみ + "ABC", // 英大文字のみ + "123", // 数字のみ + "^-@", // 記号のみ + "aAb", // 英小文字と英大文字 + "a1b", // 英小文字と数字 + "a^b", // 英小文字と記号 + "A1B", // 英大文字と数字 + "A^B", // 英大文字と記号 + "1^1", // 数字と記号 + "aA1", // 英小文字と英大文字と数字 + "aA^", // 英小文字と英大文字と記号 + "a1^", // 英小文字と数字と記号 + "A1^" // 英大文字と数字と記号 + ).map(c -> c.repeat(5)) // + .flatMap(c -> Stream.of(Arguments.of(validUsername, c), Arguments.of(c, validPassword))); + + return Stream.concat(basic, onlyCase) // + .map(a -> Arguments.of(StringUtil.shuffleString((String) a.get()[0]), StringUtil.shuffleString((String) a.get()[1]))); + } + + @Test + void 異_正しくないパスワードでログインできない() throws Exception { + registerUser(TestUser.builder("validUserName") // + .rawPassword("validUser@Password012") // + .build()); + final var request = post("/login", null, new LoginRequest("validUserName", "incorrectUser@Password123")); + + // execute + final var result = mockMvc.perform(request); + final var exception = result // + .andExpect(status().isUnauthorized()) // + .andReturn() // + .getResolvedException(); + + // verify + assertThat(exception) // + .isNotNull() // + .isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED)); + } + + @Test + void 異_存在しないユーザーでログインできない() throws Exception { + clearUsers(); + final var request = post("/login", null, new LoginRequest("notExistUserName", "notFoundUser@Password123")); + + // execute + final var result = mockMvc.perform(request); + final var exception = result // + .andExpect(status().isUnauthorized()) // + .andReturn() // + .getResolvedException(); + + // verify + assertThat(exception) // + .isNotNull() // + .isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED)); + } + + @Test + void 異_無効なユーザーでログインできない() throws Exception { + final String username = "disableUserName", password = "disableUser@Password012"; + registerUser(TestUser.builder(username) // + .rawPassword(password) // + .isEnable(false) // + .build()); + final var request = post("/login", null, new LoginRequest(username, password)); + + // execute + final var result = mockMvc.perform(request); + final var exception = result // + .andExpect(status().isUnauthorized()) // + .andReturn() // + .getResolvedException(); + + // verify + assertThat(exception) // + .isNotNull() // + .isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getStatusCode().equals(HttpStatus.UNAUTHORIZED)); + } + + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java new file mode 100644 index 0000000..516f07d --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUser.java @@ -0,0 +1,58 @@ +package org.techuni.TechUniInviteSystem.db; + +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.techuni.TechUniInviteSystem.domain.user.UserModel; +import org.techuni.TechUniInviteSystem.security.UserAuthority; +import org.techuni.TechUniInviteSystem.util.StringUtil; + +@Data +@Builder(builderMethodName = "") +@AllArgsConstructor +@NoArgsConstructor(force = true) +public class TestUser { + + private Long id; + + @Builder.Default + private String uuid = UUID.randomUUID().toString(); + + @Builder.Default + private boolean isEnable = true; + + private String name; + + @Builder.Default + private String rawPassword = generateRandomPass(); + + private String passHash; + + @Builder.Default + private Set authorities = Set.of(); + + public void setPassHash(PasswordEncoder encoder) { + this.passHash = encoder.encode(rawPassword); + } + + public static TestUserBuilder builder(String name) { + return new TestUserBuilder().name(name); + } + + private final static Random random = new Random(); + + private static String generateRandomPass() { + final int min = 32, max = 64; + StringBuilder sb = new StringBuilder(RandomStringUtils.secure().nextAlphanumeric(min, max + 1)); + // 記号 + sb.setCharAt(random.nextInt(sb.length()), + UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS.get(random.nextInt(UserModel.ALLOWED_PASSWORD_SPECIAL_CHARS.size()))); + return StringUtil.shuffleString(sb.toString()); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java new file mode 100644 index 0000000..e45a460 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/db/TestUserRepository.java @@ -0,0 +1,17 @@ +package org.techuni.TechUniInviteSystem.db; + +import java.util.List; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface TestUserRepository { + + long insertUser(@Param("insertUser") TestUser insertUser); + + void insertUserAuthority(@Param("id") long id, @Param("authorities") List authorities); + + void resetUser(); + + void resetUserAuthority(); +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java new file mode 100644 index 0000000..34b513d --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/InviteSample.java @@ -0,0 +1,38 @@ +package org.techuni.TechUniInviteSystem.sample; + +import java.time.ZonedDateTime; +import java.util.UUID; +import lombok.Builder; +import lombok.Data; +import org.techuni.TechUniInviteSystem.domain.invite.InviteDto; +import org.techuni.TechUniInviteSystem.domain.invite.TargetApplication; +import org.techuni.TechUniInviteSystem.domain.invite.models.additional.AbstractInviteAdditionalData; + +@Data +@Builder +public class InviteSample { + + @Builder.Default + int dbId = -1; + @Builder.Default + UUID invitationCode = UUID.randomUUID(); + @Builder.Default + String searchId = null; + @Builder.Default + boolean isDisabled = false; + @Builder.Default + int used = 0; + @Builder.Default + int maxUsed = 1; + @Builder.Default + TargetApplication targetApplication = TargetApplication.DISCORD; + @Builder.Default + ZonedDateTime expiresAt = null; + @Builder.Default + AbstractInviteAdditionalData additionalData = null; + + public InviteDto intoDto() { + return new InviteDto(dbId, invitationCode, searchId, isDisabled, used, maxUsed, targetApplication, expiresAt, additionalData); + } + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java new file mode 100644 index 0000000..97b73c4 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/MemberDataSample.java @@ -0,0 +1,29 @@ +package org.techuni.TechUniInviteSystem.sample.discord; + +import discord4j.discordjson.json.MemberData; +import discord4j.discordjson.json.UserData; +import discord4j.rest.util.PermissionSet; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class MemberDataSample { + + @Builder.Default + UserData user = UserDataSample.builder().build().intoData(); + + @Builder.Default + String permissions = PermissionSet.all().toString(); + + @Builder.Default + boolean deaf = false; + + @Builder.Default + boolean mute = false; + + public MemberData intoData() { + return MemberData.builder().user(user).permissions(permissions).deaf(deaf).mute(mute).build(); + } + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java new file mode 100644 index 0000000..2cb657c --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/sample/discord/UserDataSample.java @@ -0,0 +1,29 @@ +package org.techuni.TechUniInviteSystem.sample.discord; + +import discord4j.discordjson.Id; +import discord4j.discordjson.json.UserData; +import java.util.Optional; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserDataSample { + + @Builder.Default + Id id = Id.of(1234567890123456789L); + + @Builder.Default + Optional globalName = Optional.of("TechUniSampleUser"); + + @Builder.Default + String username = "TechUniSampleUser"; + + @Builder.Default + String discriminator = "1234"; + + public UserData intoData() { + return UserData.builder().id(id).globalName(globalName).username(username).discriminator(discriminator).build(); + } + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java b/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java new file mode 100644 index 0000000..1483709 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/security/TestPasswordEncoder.java @@ -0,0 +1,20 @@ +package org.techuni.TechUniInviteSystem.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +/** + * Test用 PasswordEncoder rounds数を下げてテスト時間を短くする + */ +@Profile("test") +@Component +public class TestPasswordEncoder { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(BCryptPasswordEncoder.BCryptVersion.$2B, 4); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java b/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java new file mode 100644 index 0000000..ce82141 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/service/DiscordInviteServiceTest.java @@ -0,0 +1,148 @@ +package org.techuni.TechUniInviteSystem.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import discord4j.rest.util.PermissionSet; +import java.util.Optional; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordJoinSuccessResponse; +import org.techuni.TechUniInviteSystem.db.repository.DiscordInviteRepository; +import org.techuni.TechUniInviteSystem.domain.invite.models.DiscordInviteModel; +import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordInviteAdditionalData; +import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordUsingInviteAddtionalData; +import org.techuni.TechUniInviteSystem.external.discord.DiscordAPIFactory; +import org.techuni.TechUniInviteSystem.external.discord.template.variables.JoinServerDMVariable; +import org.techuni.TechUniInviteSystem.sample.InviteSample; +import org.techuni.TechUniInviteSystem.sample.discord.MemberDataSample; +import org.techuni.TechUniInviteSystem.service.invite.DiscordInviteService; +import org.techuni.TechUniInviteSystem.type.AbstractUnitTest; + +public class DiscordInviteServiceTest extends AbstractUnitTest { + + @Mock + DiscordInviteRepository discordInviteRepository; + + @Mock + DiscordAPIFactory discordAPIFactory; + + @Mock + DiscordAPIService apiService; + + @Mock + DiscordDMService discordDMService; + + @InjectMocks + DiscordInviteService discordInviteService; + + String clientId; + String authenticatedEndpoint; + + @BeforeAll + public void beforeAll() { + clientId = RandomStringUtils.insecure().nextNumeric(19); // DiscordBotのClientIDは19桁 + authenticatedEndpoint = "http://localhost:8080/authenticated"; + + // mock + try { + setField("clientId", clientId); + setField("authenticatedEndpoint", authenticatedEndpoint); + setField("zoneId", zoneId); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void 正_正しい内容で招待を作成できる() { + /* input setup */ + final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user"); + final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto(); + + final var guildId = Long.parseLong(additionalData.getGuildID()); + final var inviteDBid = registeredInviteDto.getDbId(); + + /* mock */ + when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true); + doNothing().when(discordInviteRepository).createInvite(inviteDBid, guildId, additionalData.getNickname()); + + /* execute */ + assertThat(discordInviteService.createInvite(registeredInviteDto)) // + .isEqualTo(registeredInviteDto); + } + + @Test + void 正_招待を受諾できる() throws NoSuchFieldException, IllegalAccessException { + /* input setup */ + final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user"); + final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto(); + + final var guildId = Long.parseLong(additionalData.getGuildID()); + final var inviteDBid = registeredInviteDto.getDbId(); + + final int stateLength = (int) getFieldValue("STATE_LENGTH"); + + /* mock */ + when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true); + doNothing().when(discordInviteRepository).addInviteState(eq(inviteDBid), any()); + + /* execute */ + assertThat(discordInviteService.acceptInvite(registeredInviteDto)) // + .isNotNull() // + .matches(r -> r.getClientID().equals(clientId)) // + .matches(r -> r.getRedirectURI().equals(authenticatedEndpoint)) // + .matches(r -> StringUtils.isAlphanumeric(r.getState()) && r.getState().length() == stateLength); + } + + @Test + void 正_招待できる() { + /* input setup */ + final var useInviteAdditionalData = new DiscordUsingInviteAddtionalData("sampleAPIAuthenticatedCode"); + + final var additionalData = new DiscordInviteAdditionalData("1234567890123456789", "sample user"); + final var registeredInviteDto = InviteSample.builder().additionalData(additionalData).dbId(1).build().intoDto(); + final var model = registeredInviteDto.intoModel(DiscordInviteModel.class); + + final var createdMember = MemberDataSample.builder().permissions(PermissionSet.none().toString()).build().intoData(); + + final var guildId = Long.parseLong(additionalData.getGuildID()); + final var inviteDBid = registeredInviteDto.getDbId(); + final var joinedUserId = createdMember.user().id().asLong(); + + /* mock */ + when(apiService.checkBotHasPermission(eq(guildId), any())).thenReturn(true); + when(discordAPIFactory.createAPI(any())).thenReturn(null); + when(apiService.joinGuild(any(), eq(model))).thenReturn(createdMember); + doNothing().when(discordInviteRepository).addJoinedUser(eq(inviteDBid), eq(joinedUserId)); + doNothing().when(discordDMService).scheduleDM(eq(joinedUserId), eq(new JoinServerDMVariable( // + joinedUserId, // + Optional.ofNullable(additionalData.getNickname()).orElse(createdMember.user().username()) // + ))); + + /* execute */ + final var expected = new DiscordJoinSuccessResponse(guildId); + assertThat(discordInviteService.useInvite(registeredInviteDto, useInviteAdditionalData)) // + .isEqualTo(expected); + } + + private Object getFieldValue(String fieldName) throws NoSuchFieldException, IllegalAccessException { + final var field = DiscordInviteService.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(discordInviteService); + } + + private void setField(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException { + final var field = DiscordInviteService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(discordInviteService, value); + } + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java b/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java new file mode 100644 index 0000000..05b87de --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/service/InviteServiceTest.java @@ -0,0 +1,159 @@ +package org.techuni.TechUniInviteSystem.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import java.time.ZonedDateTime; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordAuthRequestResponse; +import org.techuni.TechUniInviteSystem.controller.response.invite.DiscordJoinSuccessResponse; +import org.techuni.TechUniInviteSystem.db.repository.InviteRepository; +import org.techuni.TechUniInviteSystem.domain.invite.InviteDto; +import org.techuni.TechUniInviteSystem.domain.invite.models.additional.DiscordUsingInviteAddtionalData; +import org.techuni.TechUniInviteSystem.error.ErrorCode; +import org.techuni.TechUniInviteSystem.error.MyHttpException; +import org.techuni.TechUniInviteSystem.sample.InviteSample; +import org.techuni.TechUniInviteSystem.service.invite.DiscordInviteService; +import org.techuni.TechUniInviteSystem.type.AbstractUnitTest; + +public class InviteServiceTest extends AbstractUnitTest { + + @Mock + InviteRepository inviteRepository; + + @Mock + DiscordInviteService discordInviteService; + + @InjectMocks + InviteService inviteService; + + @BeforeAll + public void beforeAll() { + // mock + try { + setField("zoneId", zoneId); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + void 正_正しい内容で招待を作成できる() { + /* input setup */ + final var inviteDto = InviteSample.builder().build().intoDto(); + final var resultDto = InviteSample.builder().dbId(1).build().intoDto(); + + /* mock */ + when(inviteRepository.createInvite(inviteDto)).thenReturn(resultDto); + when(discordInviteService.createInvite(resultDto)).thenReturn(resultDto); + + /* execute */ + inviteService.createInvite(inviteDto); + } + + @Test + void 正_有効な招待を受諾できる() { + /* input setup */ + final var inviteDto = InviteSample.builder().dbId(1).build().intoDto(); + final var response = new DiscordAuthRequestResponse("1234567890123456789", "http://localhost:8080", "state"); + + /* mock */ + when(discordInviteService.acceptInvite(inviteDto)).thenReturn(response); + + /* execute */ + assertThat(inviteService.acceptInvite(inviteDto)) // + .isEqualTo(response.intoView()); + } + + @Test + void 正_招待できる() { + /* input setup */ + final var resultDto = InviteSample.builder().dbId(1).build().intoDto(); + final var usingAdditionalData = new DiscordUsingInviteAddtionalData("sampleAPIAuthenticatedCode"); + final var response = new DiscordJoinSuccessResponse(1234567890123456789L); + + /* mock */ + doNothing().when(inviteRepository).useInvite(resultDto.getDbId()); + when(discordInviteService.useInvite(resultDto, usingAdditionalData)).thenReturn(response); + + /* execute */ + assertThat(inviteService.useInvite(resultDto, usingAdditionalData)) // + .isEqualTo(response); + } + + @Test + void 異_すでに登録されている招待を作成できない() { + /* input setup */ + final var inviteDto = InviteSample.builder().dbId(1).build().intoDto(); + + // 期待される例外が発生することを検証 + assertThatThrownBy(() -> { + inviteService.createInvite(inviteDto); + }).isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_CREATE_REGISTERED_INVITE)); + } + + @ParameterizedTest + @MethodSource("invalidCreateInviteAcceptProvider") + void 異_無効な招待を作成できない(final InviteDto inviteDto) { + + // 期待される例外が発生することを検証 + assertThatThrownBy(() -> { + inviteService.createInvite(inviteDto); + }).isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_CREATE_INVALID_INVITE)); + } + + private Stream invalidCreateInviteAcceptProvider() { + final var expiredDate = ZonedDateTime.now(zoneId).minusDays(1); + + return Stream.of( // + InviteSample.builder().isDisabled(true).build().intoDto(), // disable + InviteSample.builder().used(0).maxUsed(0).build().intoDto(), // used + InviteSample.builder().expiresAt(expiredDate).build().intoDto(), // expired + InviteSample.builder().isDisabled(true).used(0).maxUsed(0).build().intoDto(), // disable & used + InviteSample.builder().used(0).maxUsed(0).expiresAt(expiredDate).build().intoDto(), // used & expired + InviteSample.builder().isDisabled(true).expiresAt(expiredDate).build().intoDto(), // disable & expired + InviteSample.builder().isDisabled(true) // + .used(0).maxUsed(0).expiresAt(expiredDate).build().intoDto() // disable & used & expired + ); + } + + @ParameterizedTest + @MethodSource("invalidInviteAcceptProvider") + void 異_無効な招待を受諾できない(final InviteDto inviteDto) { + assertThatThrownBy(() -> { + inviteService.acceptInvite(inviteDto); + }).isInstanceOf(MyHttpException.class) // + .matches(e -> ((MyHttpException) e).getErrorCode().equals(ErrorCode.INVITATION_INVALID)); + } + + private Stream invalidInviteAcceptProvider() { + final var expiredDate = ZonedDateTime.now(zoneId).minusDays(1); + + return Stream.of( // + InviteSample.builder().dbId(1).isDisabled(true).build().intoDto(), // disable + InviteSample.builder().dbId(2).used(2).maxUsed(2).build().intoDto(), // used + InviteSample.builder().dbId(3).expiresAt(expiredDate).build().intoDto(), // expired + InviteSample.builder().dbId(4).isDisabled(true).used(2).maxUsed(2).build().intoDto(), // disable & used + InviteSample.builder().dbId(5).used(2).maxUsed(2).expiresAt(expiredDate).build().intoDto(), // used & expired + InviteSample.builder().dbId(6).isDisabled(true).expiresAt(expiredDate).build().intoDto(), // disable & expired + InviteSample.builder().dbId(7).isDisabled(true) // + .used(2).maxUsed(2).expiresAt(expiredDate).build().intoDto() // disable & used & expired + ); + } + + private void setField(String fieldName, Object value) throws NoSuchFieldException, IllegalAccessException { + final var field = InviteService.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(inviteService, value); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java b/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java new file mode 100644 index 0000000..6277df6 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/service/UserServiceIT.java @@ -0,0 +1,49 @@ +package org.techuni.TechUniInviteSystem.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.techuni.TechUniInviteSystem.db.TestUser; +import org.techuni.TechUniInviteSystem.security.UserAuthority; +import org.techuni.TechUniInviteSystem.type.AbstractIntegrationTest; + +public class UserServiceIT extends AbstractIntegrationTest { + + @Autowired + private UserService userService; + + @Test + void 正_ユーザーを取得できる() throws Exception { + final var expected = registerUser(TestUser.builder("sample-user-123") // + .authorities(Set.of(UserAuthority.INVITE_WRITE)) // + .build()); + + // execute + final var result = userService.getUserByName(expected.getName(), false); + assertThat(result.isPresent()).isTrue(); + + final var user = result.get().intoModel(); + assertThat(user).isEqualTo(expected); + } + + @Test + void 正_無効なユーザーをフィルタできる() throws Exception { + final var expected = registerUser(TestUser.builder("sample-user-disable") // + .isEnable(false) // + .build()); + // execute + final var result = userService.getUserByName(expected.getName(), true); + assertThat(result.isEmpty()).isTrue(); + } + + @Test + void 異_存在しないユーザー名で取得できない() throws Exception { + clearUsers(); + + // execute + assertThat(userService.getUserByName("notfound-user-name", true).isEmpty()).isTrue(); + assertThat(userService.getUserByName("notfound-user-name", false).isEmpty()).isTrue(); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java new file mode 100644 index 0000000..e381d9b --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractIntegrationTest.java @@ -0,0 +1,156 @@ +package org.techuni.TechUniInviteSystem.type; + +import java.util.Optional; +import lombok.NoArgsConstructor; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.DefaultTransactionDefinition; +import org.techuni.TechUniInviteSystem.db.TestUser; +import org.techuni.TechUniInviteSystem.db.TestUserRepository; +import org.techuni.TechUniInviteSystem.db.entity.User; +import org.techuni.TechUniInviteSystem.db.repository.UserRepository; +import org.techuni.TechUniInviteSystem.domain.user.UserDto; +import org.techuni.TechUniInviteSystem.domain.user.UserModel; +import org.techuni.TechUniInviteSystem.security.TestPasswordEncoder; +import org.techuni.TechUniInviteSystem.security.UserAuthority; +import org.techuni.TechUniInviteSystem.util.AuthTokenUtil; +import org.techuni.TechUniInviteSystem.util.GsonUtil; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +@TestMethodOrder(OrderAnnotation.class) +@IntegrationTest +@NoArgsConstructor +public abstract class AbstractIntegrationTest { + + @Autowired + private PlatformTransactionManager transactionManager; + @Autowired + private GsonUtil gsonUtil; + @Autowired + private AuthTokenUtil authTokenUtil; + @Autowired + private UserRepository userRepository; + @Autowired + private TestPasswordEncoder passwordEncoder; + @Autowired + private TestUserRepository testUserRepository; + + @Autowired + protected MockMvc mockMvc; + + private TransactionStatus allTransactionsStatus; + private TransactionStatus eachTransactionStatus; + + + @BeforeAll + protected void beforeAll() { + Assertions.setMaxStackTraceElementsDisplayed(100); + allTransactionsStatus = transactionManager.getTransaction(new DefaultTransactionDefinition()); + } + + @BeforeEach + protected void beforeEach() { + final var def = new DefaultTransactionDefinition(); + def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_NESTED); + eachTransactionStatus = transactionManager.getTransaction(def); + } + + @AfterEach + protected void afterEach() { + transactionManager.rollback(eachTransactionStatus); + } + + @AfterAll + protected void afterAll() { + transactionManager.rollback(allTransactionsStatus); + } + + protected UserModel registerUser(TestUser testUser) { + testUser.setPassHash(passwordEncoder.passwordEncoder()); + + // execute + final var generatedId = testUserRepository.insertUser(testUser); + if (!testUser.getAuthorities().isEmpty()) { + testUserRepository.insertUserAuthority(testUser.getId(), testUser.getAuthorities().stream().map(UserAuthority::getAuthority).toList()); + } + + return new UserDto(new User(testUser.getId(), testUser.getUuid(), testUser.isEnable(), testUser.getName(), testUser.getPassHash(), + testUser.getAuthorities())).intoModel(); + } + + protected void clearUsers() { + testUserRepository.resetUserAuthority(); + testUserRepository.resetUser(); + } + + protected MockHttpServletRequestBuilder get(final String url, final UserModel user, final Object content) { + final var headers = new HttpHeaders(); + Optional.ofNullable(user).ifPresent(u -> // + headers.setBearerAuth(authTokenUtil.generateToken(u))); + + return MockMvcRequestBuilders.get(url) // + .contentType("application/json") // + .content(gsonUtil.getGson().toJson(content)) // + .headers(headers).servletPath(url); + } + + protected MockHttpServletRequestBuilder post(final String url, final UserModel user, final Object content) { + final var headers = new HttpHeaders(); + Optional.ofNullable(user).ifPresent(u -> // + headers.setBearerAuth(authTokenUtil.generateToken(u))); + + return MockMvcRequestBuilders.post(url) // + .contentType("application/json") // + .content(gsonUtil.getGson().toJson(content)) // + .headers(headers).servletPath(url); + } + + protected MockHttpServletRequestBuilder put(final String url, final UserModel user, final Object content) { + final var headers = new HttpHeaders(); + Optional.ofNullable(user).ifPresent(u -> // + headers.setBearerAuth(authTokenUtil.generateToken(u))); + + return MockMvcRequestBuilders.put(url) // + .contentType("application/json") // + .content(gsonUtil.getGson().toJson(content)) // + .headers(headers).servletPath(url); + } + + protected MockHttpServletRequestBuilder delete(final String url, final UserModel user, final Object content) { + final var headers = new HttpHeaders(); + Optional.ofNullable(user).ifPresent(u -> // + headers.setBearerAuth(authTokenUtil.generateToken(u))); + + return MockMvcRequestBuilders.delete(url) // + .contentType("application/json") // + .content(gsonUtil.getGson().toJson(content)) // + .headers(headers).servletPath(url); + } + + protected T parseResponse(String responseJson, Class clazz) { + return gsonUtil.getGson().fromJson(responseJson, clazz); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java new file mode 100644 index 0000000..6d6457e --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/type/AbstractUnitTest.java @@ -0,0 +1,42 @@ +package org.techuni.TechUniInviteSystem.type; + +import java.time.ZoneId; +import lombok.NoArgsConstructor; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.TestMethodOrder; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ActiveProfiles("test") +@ExtendWith(SpringExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +@TestMethodOrder(OrderAnnotation.class) +@UnitTest +@NoArgsConstructor +public abstract class AbstractUnitTest { + + protected ZoneId zoneId = ZoneId.of("Asia/Tokyo"); + + @BeforeAll + protected void beforeAll() { + Assertions.setMaxStackTraceElementsDisplayed(100); + } + + @BeforeEach + protected void beforeEach() {} + + @AfterEach + protected void afterEach() {} + + @AfterAll + protected void afterAll() {} + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java new file mode 100644 index 0000000..134be79 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/type/IntegrationTest.java @@ -0,0 +1,16 @@ +package org.techuni.TechUniInviteSystem.type; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Tag("integrationTest") +@Inherited +public @interface IntegrationTest { + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java b/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java new file mode 100644 index 0000000..36e386a --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/type/UnitTest.java @@ -0,0 +1,16 @@ +package org.techuni.TechUniInviteSystem.type; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Tag; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Tag("unitTest") +@Inherited +public @interface UnitTest { + +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java new file mode 100644 index 0000000..f791d35 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/util/AuthTokenUtil.java @@ -0,0 +1,21 @@ +package org.techuni.TechUniInviteSystem.util; + +import lombok.AllArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.stereotype.Component; +import org.techuni.TechUniInviteSystem.domain.user.UserModel; +import org.techuni.TechUniInviteSystem.security.JwtTokenProvider; + +@Component +@AllArgsConstructor +public class AuthTokenUtil { + + private final JwtTokenProvider tokenProvider; + + public String generateToken(UserModel model) { + final var userDetails = model.intoSpringUser(); + final var authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + return tokenProvider.generateToken(authentication); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java new file mode 100644 index 0000000..3873f03 --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/util/GsonUtil.java @@ -0,0 +1,33 @@ +package org.techuni.TechUniInviteSystem.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonSerializer; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import lombok.Getter; +import org.springframework.stereotype.Component; + +@Component +@Getter +public class GsonUtil { + + private final static DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private final Gson gson; + + public GsonUtil() { + gson = (new GsonBuilder()) // + .registerTypeAdapter(OffsetDateTime.class, offsetDateTimeJsonDeserializer()) // + .registerTypeAdapter(OffsetDateTime.class, offsetDateTimeJsonSerializer()) // + .create(); + } + + private static JsonDeserializer offsetDateTimeJsonDeserializer() { + return (json, typeOfT, context) -> OffsetDateTime.parse(json.getAsString(), dateTimeFormatter); + } + + private static JsonSerializer offsetDateTimeJsonSerializer() { + return (src, typeOfSrc, context) -> context.serialize(src.format(dateTimeFormatter)); + } +} diff --git a/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java b/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java new file mode 100644 index 0000000..ee469bb --- /dev/null +++ b/src/test/java/org/techuni/TechUniInviteSystem/util/StringUtil.java @@ -0,0 +1,19 @@ +package org.techuni.TechUniInviteSystem.util; + +import static java.util.Objects.isNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class StringUtil { + + public static String shuffleString(String string) { + if (isNull(string)) { + return null; + } + List letters = new ArrayList<>(List.of(string.split(""))); + Collections.shuffle(letters); + return String.join("", letters); + } +}