[+] Repo init, with node /register & Tests.
This commit is contained in:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user