[+] Repo init, with node /register & Tests.

This commit is contained in:
VergeDX
2021-01-10 16:56:07 +08:00
parent e11b85fc71
commit eb1e549b09
16 changed files with 534 additions and 0 deletions
@@ -0,0 +1,11 @@
package org.hydev.clock_api;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ClockApiApplication {
public static void main(String[] args) {
SpringApplication.run(ClockApiApplication.class, args);
}
}
@@ -0,0 +1,27 @@
package org.hydev.clock_api.configuration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.annotation.RequestHeaderMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
@Configuration
public class AddHeaderArgsResolver implements WebMvcConfigurer {
private final ConfigurableListableBeanFactory configurableListableBeanFactory;
@Autowired
public AddHeaderArgsResolver(ConfigurableListableBeanFactory configurableListableBeanFactory) {
this.configurableListableBeanFactory = configurableListableBeanFactory;
}
@Override
// https://github.com/spring-projects/spring-framework/issues/23838
// https://stackoverflow.com/questions/49305099/requestheader-not-binding-in-pojo-but-binding-only-in-variable
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new RequestHeaderMethodArgumentResolver(configurableListableBeanFactory));
}
}
@@ -0,0 +1,54 @@
package org.hydev.clock_api.controller;
import org.hydev.clock_api.entity.User;
import org.hydev.clock_api.error.ErrorCode;
import org.hydev.clock_api.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.DigestUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
@Validated
@RestController
public class RegistryController {
private final UserRepository userRepository;
@Autowired
public RegistryController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@PostMapping("/register")
// https://www.baeldung.com/spring-rest-http-headers
// TODO: This method should be synchronized to avoid race condition.
// Also, this method should not be private, or else cannot use userRepository.
public synchronized ResponseEntity<String> register(
// username & password shouldn't be null, and should match thr regex.
// [!] @RequestHeader(required = false) makes no need make another error handler.
// [!] And also, ExceptionHandler of MissingRequestHeaderException cannot deal with all missing fields.
@Pattern(regexp = User.RE_USERNAME, message = ErrorCode.USER_NAME_NOT_MATCH_REGEX)
@NotNull(message = ErrorCode.USER_NAME_IS_NULL) @RequestHeader(required = false) String username,
@Pattern(regexp = User.RE_PASSWORD, message = ErrorCode.USER_PASSWORD_NOT_MATCH_REGEX)
@NotNull(message = ErrorCode.USER_PASSWORD_IS_NULL) @RequestHeader(required = false) String password
) {
// First, spring will check args. If not pass there regex, raise ConstraintViolationException.
// Then, the aspect will check username if already exists.
User user = new User();
user.setUsername(username);
// TODO: Using Spring Security instead.
user.setPasswordMd5(DigestUtils.md5DigestAsHex(password.getBytes()).toLowerCase());
// After save and flush, uuid field will be generated automatically.
userRepository.saveAndFlush(user);
return ResponseEntity.ok(user.getUuid());
}
}
@@ -0,0 +1,32 @@
package org.hydev.clock_api.controller;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.hydev.clock_api.error.ErrorCode;
import org.hydev.clock_api.repository.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import java.util.List;
@Aspect
@Component
public class RegistryControllerAspect {
private final UserRepository userRepository;
public RegistryControllerAspect(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Around(value = "execution(* RegistryController.register(String, String)) " +
"&& args(username, password)", argNames = "pjp, username, password")
// Even if I'm not use the password argument, I still should write it out!
private Object checkUsernameExists(ProceedingJoinPoint pjp, String username, String password) throws Throwable {
if (userRepository.existsByUsername(username))
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE)
.body(List.of(ErrorCode.USER_NAME_ALREADY_EXISTS));
return pjp.proceed();
}
}
@@ -0,0 +1,25 @@
package org.hydev.clock_api.controller;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.List;
import java.util.stream.Collectors;
@RestControllerAdvice
public class RegistryExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
// ConstraintViolationException will be wrapped to TransactionSystemException!
// https://stackoverflow.com/questions/45070642/springboot-doesnt-handle-org-hibernate-exception-constraintviolationexception
private ResponseEntity<List<String>> handleConstraintViolationException(ConstraintViolationException tse) {
List<String> errorMessages = tse.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage).sorted()
.collect(Collectors.toList());
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(errorMessages);
}
}
@@ -0,0 +1,43 @@
package org.hydev.clock_api.entity;
import lombok.Data;
import org.hibernate.annotations.GenericGenerator;
import org.hydev.clock_api.error.ErrorCode;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
@Data
@Entity(name = "users")
public class User {
// https://digitalfortress.tech/tricks/top-15-commonly-used-regex/
// https://www.regexpal.com/?fam=104030
public static final String RE_USERNAME = "^[a-z0-9_-]{3,16}$";
// https://www.regexpal.com/?fam=104029
public static final String RE_PASSWORD = "(?=(.*[0-9]))((?=.*[A-Za-z0-9])(?=.*[A-Z])(?=.*[a-z]))^.{8,}$";
// https://stackoverflow.com/questions/21517102/regex-to-match-md5-hashes
// https://stackoverflow.com/questions/45153520/are-md5-hashes-always-either-capital-or-lowercase
public static final String RE_LOWER_MD5 = "^[a-f0-9]{32}$";
// https://stackoverflow.com/questions/45635827/how-do-i-stop-spring-data-jpa-from-doing-a-select-before-a-save
@Id
// Define a generator, and use it.
// https://thorben-janssen.com/generate-uuids-primary-keys-hibernate/
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@GeneratedValue(generator = "UUID")
private String uuid;
@NotNull(message = ErrorCode.INNER_USERNAME_IS_NULL)
@Pattern(regexp = RE_USERNAME, message = ErrorCode.INNER_USERNAME_NOT_MATCH_REGEX)
private String username;
@NotNull(message = ErrorCode.INNER_PASSWORD_MD5_IS_NULL)
@Pattern(regexp = RE_LOWER_MD5, message = ErrorCode.INNER_PASSWORD_MD5_NOT_MATCH_REGEX)
private String passwordMd5;
}
@@ -0,0 +1,23 @@
package org.hydev.clock_api.error;
public interface ErrorCode {
// Missing field in header.
String USER_NAME_IS_NULL = "A0101";
String USER_PASSWORD_IS_NULL = "A0102";
// Field not match regex.
String USER_NAME_NOT_MATCH_REGEX = "A0111";
String USER_PASSWORD_NOT_MATCH_REGEX = "A0112";
// Field already exists.
String USER_NAME_ALREADY_EXISTS = "A0121";
// Inner system field is null.
String INNER_USERNAME_IS_NULL = "B0101";
String INNER_PASSWORD_MD5_IS_NULL = "B0102";
// Inner system field not match regex.
String INNER_USERNAME_NOT_MATCH_REGEX = "B0111";
String INNER_PASSWORD_MD5_NOT_MATCH_REGEX = "B0112";
}
@@ -0,0 +1,11 @@
package org.hydev.clock_api.repository;
import org.hydev.clock_api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, String> {
// https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods
boolean existsByUsername(String username);
}
@@ -0,0 +1,5 @@
spring.datasource.driver-class-name=org.sqlite.JDBC
spring.datasource.url=jdbc:sqlite:test.db
# For testing, table will be drop after test.
# https://blog.csdn.net/qq_36666651/article/details/80719259
spring.jpa.hibernate.ddl-auto=create-drop
@@ -0,0 +1,6 @@
spring.datasource.driver-class-name=org.sqlite.JDBC
spring.datasource.url=jdbc:sqlite:clock-api.db
# https://blog.csdn.net/qq_36666651/article/details/80719259
spring.jpa.hibernate.ddl-auto=update
# https://blog.csdn.net/qq_35981283/article/details/88994092
spring.jpa.open-in-view=true
@@ -0,0 +1,146 @@
package org.hydev.clock_api
import org.hamcrest.Matchers
import org.hydev.clock_api.entity.User
import org.hydev.clock_api.error.ErrorCode.*
import org.hydev.clock_api.repository.UserRepository
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
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.boot.test.context.SpringBootTest.*
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.util.DigestUtils
import org.springframework.util.LinkedMultiValueMap
import java.util.*
import javax.validation.ConstraintViolationException
// https://stackoverflow.com/questions/59097035/springboottest-vs-webmvctest-datajpatest-service-unit-tests-what-is-the-b
// https://stackoverflow.com/questions/45653753/how-to-tell-spring-boot-to-use-another-db-for-test
// Need RANDOM_PORT to inject TestRestTemplate.
// https://stackoverflow.com/questions/39213531/spring-boot-test-unable-to-inject-testresttemplate-and-mockmvc
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
// https://stackoverflow.com/questions/45653753/how-to-tell-spring-boot-to-use-another-db-for-test
@ActiveProfiles("test")
// https://spring.io/guides/gs/testing-web/
@AutoConfigureMockMvc
class RegisterNodeTest {
companion object {
private const val TEST_NODE = "/register"
private const val H_USERNAME = "username"
private const val V_USERNAME = "vanilla"
private const val H_PASSWORD = "password"
private const val V_PASSWORD = "P1ssW0rd"
private val V_PASSWORD_MD5 = DigestUtils.md5DigestAsHex(V_PASSWORD.toByteArray())
// https://stackoverflow.com/questions/37615731/java-regex-for-uuid
private const val R_UUID = "([a-f0-9]{8}(-[a-f0-9]{4}){4}[a-f0-9]{8})"
}
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var userRepository: UserRepository
@Autowired
private lateinit var restTemplate: TestRestTemplate
// Post with headers, expect 406 and ErrorCodes.
// todo: Using List instead of Array.
private fun pWHsE406AECs(headerMap: Map<String, String>, expectedECList: Array<String>) {
val tempMultiValueMap = LinkedMultiValueMap<String, String>()
headerMap.forEach { tempMultiValueMap[it.key] = listOf(it.value) }
val httpEntity = HttpEntity<String>(tempMultiValueMap)
// Using exchange to custom headers, etc. Args: (node, method, headers, forObject).
// https://stackoverflow.com/questions/16781680/http-get-with-headers-using-resttemplate
val responseEntity = restTemplate.exchange(TEST_NODE, HttpMethod.POST, httpEntity, Array<String>::class.java)
// Expect http status is 406 NOT ACCEPTABLE, and ErrorCode array are same.
assertEquals(HttpStatus.NOT_ACCEPTABLE, responseEntity.statusCode)
assertArrayEquals(expectedECList, responseEntity.body)
}
@Test
// [A0101, A0102, A0101 + A0102] M1 * 2 + M2.
fun testWhenMissingField() {
pWHsE406AECs(mapOf(H_PASSWORD to V_PASSWORD), arrayOf(USER_NAME_IS_NULL))
pWHsE406AECs(mapOf(H_USERNAME to V_USERNAME), arrayOf(USER_PASSWORD_IS_NULL))
pWHsE406AECs(mapOf(), arrayOf(USER_NAME_IS_NULL, USER_PASSWORD_IS_NULL))
}
@Test
// [A0111, A0112, A0111 + A0112] W1 * 2 + W2.
fun testWhenNotMatchRegex() {
pWHsE406AECs(mapOf(H_USERNAME to "", H_PASSWORD to V_PASSWORD), arrayOf(USER_NAME_NOT_MATCH_REGEX))
pWHsE406AECs(mapOf(H_USERNAME to V_USERNAME, H_PASSWORD to ""), arrayOf(USER_PASSWORD_NOT_MATCH_REGEX))
pWHsE406AECs(
mapOf(H_USERNAME to "", H_PASSWORD to ""),
arrayOf(USER_NAME_NOT_MATCH_REGEX, USER_PASSWORD_NOT_MATCH_REGEX)
)
}
@Test
// [A0121] Insert user, check it if already exists.
fun testWhenUserAlreadyExists() {
mockMvc.perform(post(TEST_NODE).header(H_USERNAME, V_USERNAME).header(H_PASSWORD, V_PASSWORD))
.andExpect(status().isOk)
.andExpect(content().string(Matchers.matchesPattern(R_UUID)))
.andDo { result: MvcResult ->
// Notice: inserted user should be delete.
val tempUuid = result.response.contentAsString
mockMvc.perform(post(TEST_NODE).header(H_USERNAME, V_USERNAME).header(H_PASSWORD, V_PASSWORD))
.andExpect(status().isNotAcceptable)
.andExpect(content().json(String.format("[\"%s\"]", USER_NAME_ALREADY_EXISTS)))
userRepository.deleteById(tempUuid)
}
}
// -- Begin inner system test.
// Get ConstraintViolationException contains error messages set.
private fun ConstraintViolationException.getCveMsgSet(): Set<String> {
return this.constraintViolations.map { it.message }.toSet()
}
// Insert custom User, and assert CVE with ErrorCodes.
private fun iUACVEWECs(user: User, cveSet: Set<String>) {
// Expect throw error, and message set = given cve set.
val cve = assertThrows(ConstraintViolationException::class.java) { userRepository.saveAndFlush(user) }
assertEquals(cveSet, cve.getCveMsgSet())
}
@Test
// [B0101, B0102, B0101 + B0102] M1 * 2 + M2.
fun innerTestWhenFieldIsNull() {
iUACVEWECs(User().apply { passwordMd5 = V_PASSWORD_MD5 }, setOf(INNER_USERNAME_IS_NULL))
iUACVEWECs(User().apply { username = V_USERNAME }, setOf(INNER_PASSWORD_MD5_IS_NULL))
iUACVEWECs(User(), setOf(INNER_USERNAME_IS_NULL, INNER_PASSWORD_MD5_IS_NULL))
}
@Test
// [B0111, B0112, B0111 + B0112] W1 * 2 + W2.
fun innerTestWhenFieldNotMatchRegex() {
iUACVEWECs(User().apply { username = ""; passwordMd5 = V_PASSWORD_MD5 }, setOf(INNER_USERNAME_NOT_MATCH_REGEX))
iUACVEWECs(User().apply { username = V_USERNAME; passwordMd5 = "" }, setOf(INNER_PASSWORD_MD5_NOT_MATCH_REGEX))
iUACVEWECs(
User().apply { username = ""; passwordMd5 = "" },
setOf(INNER_USERNAME_NOT_MATCH_REGEX, INNER_PASSWORD_MD5_NOT_MATCH_REGEX)
)
}
}
+16
View File
@@ -0,0 +1,16 @@
package org.hydev.clock_api
import com.fasterxml.jackson.databind.ObjectMapper
import java.util.*
fun main() {
println(listOf("A0101", "A0102"))
println()
val testJsonArray = "[\"A1\", \"A2\"]"
val objectMapper = ObjectMapper()
val stringArray = objectMapper.readValue(testJsonArray, Array<String>::class.java)
println(Arrays.toString(stringArray))
println()
}