diff --git a/app/build.gradle b/app/build.gradle index c60775d0..ffce1920 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -76,6 +76,12 @@ dependencies { testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + + // Spring AOP + implementation 'org.springframework:spring-aop' + } application { diff --git a/app/src/main/java/com/codesoom/assignment/App.java b/app/src/main/java/com/codesoom/assignment/App.java index 44ca515b..9da15fd5 100644 --- a/app/src/main/java/com/codesoom/assignment/App.java +++ b/app/src/main/java/com/codesoom/assignment/App.java @@ -5,6 +5,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; @SpringBootApplication public class App { @@ -16,4 +18,9 @@ public static void main(String[] args) { public Mapper dozerMapper() { return DozerBeanMapperBuilder.buildDefault(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/app/src/main/java/com/codesoom/assignment/aop/OwnerCheckAspect.java b/app/src/main/java/com/codesoom/assignment/aop/OwnerCheckAspect.java new file mode 100644 index 00000000..17788985 --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/aop/OwnerCheckAspect.java @@ -0,0 +1,50 @@ +package com.codesoom.assignment.aop; + +import com.codesoom.assignment.application.ProductService; +import com.codesoom.assignment.domain.Product; +import com.codesoom.assignment.errors.InvalidTokenException; +import com.codesoom.assignment.errors.ProductNotFoundException; +import com.codesoom.assignment.errors.UserNoPermission; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class OwnerCheckAspect { + private final ProductService productService; + + public OwnerCheckAspect(ProductService productService) { + this.productService = productService; + } + + @Before("@annotation(com.codesoom.assignment.aop.annotation.CheckOwner) && args(id,..)") + public void checkOwner(Long id) throws InvalidTokenException, UserNoPermission, ProductNotFoundException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + authenticationValidate(authentication); + + Long loginUserId = (Long) authentication.getPrincipal(); + + Product product = productService.getProduct(id); + + Long createdUserId = product.getCreateUserId(); + + if (loginUserId != createdUserId) { + throw new UserNoPermission("You do not have permission."); + } + } + + private void authenticationValidate(Authentication authentication) { + if (authentication == null) { + throw new InvalidTokenException("AccessToken is Invalid."); + } + if (authentication.getPrincipal().equals("anonymousUser")) { + throw new AccessDeniedException("You do not have permission."); + } + } +} + diff --git a/app/src/main/java/com/codesoom/assignment/aop/annotation/CheckOwner.java b/app/src/main/java/com/codesoom/assignment/aop/annotation/CheckOwner.java new file mode 100644 index 00000000..55aaaf0e --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/aop/annotation/CheckOwner.java @@ -0,0 +1,11 @@ +package com.codesoom.assignment.aop.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CheckOwner { +} diff --git a/app/src/main/java/com/codesoom/assignment/application/AuthenticationService.java b/app/src/main/java/com/codesoom/assignment/application/AuthenticationService.java index acbe601e..37d53afe 100644 --- a/app/src/main/java/com/codesoom/assignment/application/AuthenticationService.java +++ b/app/src/main/java/com/codesoom/assignment/application/AuthenticationService.java @@ -4,33 +4,30 @@ import com.codesoom.assignment.domain.UserRepository; import com.codesoom.assignment.errors.LoginFailException; import com.codesoom.assignment.utils.JwtUtil; -import io.jsonwebtoken.Claims; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class AuthenticationService { private final UserRepository userRepository; private final JwtUtil jwtUtil; + private PasswordEncoder passwordEncoder; - public AuthenticationService(UserRepository userRepository, - JwtUtil jwtUtil) { + public AuthenticationService(UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder) { this.userRepository = userRepository; this.jwtUtil = jwtUtil; + this.passwordEncoder = passwordEncoder; } public String login(String email, String password) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new LoginFailException(email)); - if (!user.authenticate(password)) { + if (!user.authenticate(password, passwordEncoder)) { throw new LoginFailException(email); } - return jwtUtil.encode(1L); + return jwtUtil.encode(user.getId()); } - public Long parseToken(String accessToken) { - Claims claims = jwtUtil.decode(accessToken); - return claims.get("userId", Long.class); - } } diff --git a/app/src/main/java/com/codesoom/assignment/application/ProductService.java b/app/src/main/java/com/codesoom/assignment/application/ProductService.java index 921337bd..194f3e8e 100644 --- a/app/src/main/java/com/codesoom/assignment/application/ProductService.java +++ b/app/src/main/java/com/codesoom/assignment/application/ProductService.java @@ -32,8 +32,9 @@ public Product getProduct(Long id) { return findProduct(id); } - public Product createProduct(ProductData productData) { + public Product createProduct(ProductData productData, Long createUserId) { Product product = mapper.map(productData, Product.class); + product.setCreateUserId(createUserId); return productRepository.save(product); } diff --git a/app/src/main/java/com/codesoom/assignment/application/UserService.java b/app/src/main/java/com/codesoom/assignment/application/UserService.java index 99eb0260..7de7c1fe 100644 --- a/app/src/main/java/com/codesoom/assignment/application/UserService.java +++ b/app/src/main/java/com/codesoom/assignment/application/UserService.java @@ -7,6 +7,7 @@ import com.codesoom.assignment.errors.UserEmailDuplicationException; import com.codesoom.assignment.errors.UserNotFoundException; import com.github.dozermapper.core.Mapper; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import javax.transaction.Transactional; @@ -17,9 +18,12 @@ public class UserService { private final Mapper mapper; private final UserRepository userRepository; - public UserService(Mapper dozerMapper, UserRepository userRepository) { - this.mapper = dozerMapper; + private final PasswordEncoder passwordEncoder; + + public UserService(Mapper mapper, UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.mapper = mapper; this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; } public User registerUser(UserRegistrationData registrationData) { @@ -29,6 +33,9 @@ public User registerUser(UserRegistrationData registrationData) { } User user = mapper.map(registrationData, User.class); + + user.changePassword(registrationData.getPassword(),passwordEncoder); + return userRepository.save(user); } diff --git a/app/src/main/java/com/codesoom/assignment/config/SecurityJavaConfig.java b/app/src/main/java/com/codesoom/assignment/config/SecurityJavaConfig.java new file mode 100644 index 00000000..6583080a --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/config/SecurityJavaConfig.java @@ -0,0 +1,120 @@ +package com.codesoom.assignment.config; + +import com.codesoom.assignment.filters.AuthenticationErrorFilter; +import com.codesoom.assignment.filters.JwtAuthenticationFilter; +import com.codesoom.assignment.utils.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; + +import javax.servlet.Filter; +import javax.servlet.http.HttpServletRequest; + +@Configuration +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityJavaConfig extends WebSecurityConfigurerAdapter { + private final JwtUtil jwtUtil; + public SecurityJavaConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + + @Override + protected void configure(HttpSecurity http) throws Exception { + + Filter authenticationFilter = new JwtAuthenticationFilter(authenticationManager(), jwtUtil); + Filter authenticationErrorFilter = new AuthenticationErrorFilter(); + + http + .csrf().disable() + .addFilter(authenticationFilter) + .addFilterBefore(authenticationErrorFilter, JwtAuthenticationFilter.class) + .sessionManagement() + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and(); + + configureAuthorizations(http); + + http + .authorizeRequests() + .anyRequest().permitAll() + .and() + .exceptionHandling() + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); + } + + private void configureAuthorizations(HttpSecurity http) throws Exception { + http.authorizeRequests() + .requestMatchers(this::matchesPostProductRequest).authenticated() + .and() + .authorizeRequests() + .requestMatchers(this::matchesPatchProductRequest).authenticated() + .and() + .authorizeRequests() + .requestMatchers(this::matchesDeleteProductRequest).authenticated() + .and() + .authorizeRequests() + .requestMatchers(this::matchesPostUserRequest).authenticated() + .and() + .authorizeRequests() + .requestMatchers(this::matchesDeleteUserRequest).authenticated(); + } + + /** + * 요청이 상품 생성 요청인 경우 true를 아닌경우 false를 반환합니다. + * @param req + * @return + */ + private boolean matchesPostProductRequest(HttpServletRequest req) { + return req.getMethod().equals("POST") && + (req.getRequestURI().matches("^/products$") || + req.getRequestURI().matches("^/products/[0-9]+$")); + } + + /** + * 요청이 상품 수정 요청인 경우 true를 아닌경우 false를 반환합니다. + * @param req + * @return + */ + private boolean matchesPatchProductRequest(HttpServletRequest req) { + return req.getMethod().equals("PATCH") && + req.getRequestURI().matches("^/products/[0-9]+$"); + } + + /** + * 요청이 상품 삭제 요청인 경우 true를 아닌경우 false를 반환합니다. + * @param req + * @return + */ + private boolean matchesDeleteProductRequest(HttpServletRequest req) { + return req.getMethod().equals("DELETE") && + req.getRequestURI().matches("^/products/[0-9]+$"); + } + + /** + * 요청이 유저 생성 요청인 경우 true를 아닌경우 false를 반환합니다. + * @param req + * @return + */ + private boolean matchesPostUserRequest(HttpServletRequest req) { + return req.getMethod().equals("POST") && req.getRequestURI().matches("^/users/[0-9]+$"); + } + + /** + * 요청이 유저 삭제 요청인 경우 true를 아닌경우 false를 반환합니다. + * @param req + * @return + */ + private boolean matchesDeleteUserRequest(HttpServletRequest req) { + return req.getMethod().equals("DELETE") && req.getRequestURI().matches("^/users/[0-9]+$"); + } + + +} diff --git a/app/src/main/java/com/codesoom/assignment/config/WebJavaConfig.java b/app/src/main/java/com/codesoom/assignment/config/WebJavaConfig.java deleted file mode 100644 index d6ffe26f..00000000 --- a/app/src/main/java/com/codesoom/assignment/config/WebJavaConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.codesoom.assignment.config; - -import com.codesoom.assignment.interceptors.AuthenticationInterceptor; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebJavaConfig implements WebMvcConfigurer { - @Autowired - private AuthenticationInterceptor authenticationInterceptor; - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authenticationInterceptor); - } -} diff --git a/app/src/main/java/com/codesoom/assignment/controllers/ControllerErrorAdvice.java b/app/src/main/java/com/codesoom/assignment/controllers/ControllerErrorAdvice.java index 24db2ef2..17bc8b73 100644 --- a/app/src/main/java/com/codesoom/assignment/controllers/ControllerErrorAdvice.java +++ b/app/src/main/java/com/codesoom/assignment/controllers/ControllerErrorAdvice.java @@ -39,12 +39,6 @@ public ErrorResponse handleLoginFailException() { return new ErrorResponse("Log-in failed"); } - @ResponseStatus(HttpStatus.UNAUTHORIZED) - @ExceptionHandler(InvalidTokenException.class) - public ErrorResponse handleInvalidAccessTokenException() { - return new ErrorResponse("Invalid access token"); - } - @ResponseBody @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(ConstraintViolationException.class) @@ -55,6 +49,13 @@ public ErrorResponse handleConstraintValidateError( return new ErrorResponse(messageTemplate); } + @ResponseBody + @ResponseStatus(HttpStatus.FORBIDDEN) + @ExceptionHandler(UserNoPermission.class) + public ErrorResponse handleUserNoPermission(UserNoPermission exception) { + return new ErrorResponse(exception.getMessage()); + } + private String getViolatedMessage(ConstraintViolationException exception) { String messageTemplate = null; Set> violations = exception.getConstraintViolations(); diff --git a/app/src/main/java/com/codesoom/assignment/controllers/ProductController.java b/app/src/main/java/com/codesoom/assignment/controllers/ProductController.java index 42193f4b..7b7ece37 100644 --- a/app/src/main/java/com/codesoom/assignment/controllers/ProductController.java +++ b/app/src/main/java/com/codesoom/assignment/controllers/ProductController.java @@ -4,11 +4,13 @@ package com.codesoom.assignment.controllers; -import com.codesoom.assignment.application.AuthenticationService; +import com.codesoom.assignment.aop.annotation.CheckOwner; import com.codesoom.assignment.application.ProductService; import com.codesoom.assignment.domain.Product; import com.codesoom.assignment.dto.ProductData; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import javax.validation.Valid; @@ -20,12 +22,8 @@ public class ProductController { private final ProductService productService; - private final AuthenticationService authenticationService; - - public ProductController(ProductService productService, - AuthenticationService authenticationService) { + public ProductController(ProductService productService) { this.productService = productService; - this.authenticationService = authenticationService; } @GetMapping @@ -40,16 +38,18 @@ public Product detail(@PathVariable Long id) { @PostMapping @ResponseStatus(HttpStatus.CREATED) + @PreAuthorize("isAuthenticated() and hasAuthority('USER')") public Product create( - @RequestAttribute Long userId, - @RequestBody @Valid ProductData productData + @RequestBody @Valid ProductData productData, + Authentication authentication ) { - return productService.createProduct(productData); + return productService.createProduct(productData, (Long)authentication.getPrincipal()); } @PatchMapping("{id}") + @PreAuthorize("isAuthenticated() and hasAuthority('USER')") + @CheckOwner public Product update( - @RequestAttribute Long userId, @PathVariable Long id, @RequestBody @Valid ProductData productData ) { @@ -57,11 +57,13 @@ public Product update( } @DeleteMapping("{id}") + @PreAuthorize("isAuthenticated() and hasAuthority('USER')") @ResponseStatus(HttpStatus.NO_CONTENT) + @CheckOwner public void destroy( - @RequestAttribute Long userId, @PathVariable Long id ) { productService.deleteProduct(id); } + } diff --git a/app/src/main/java/com/codesoom/assignment/domain/Product.java b/app/src/main/java/com/codesoom/assignment/domain/Product.java index 2a6c959a..e1272273 100644 --- a/app/src/main/java/com/codesoom/assignment/domain/Product.java +++ b/app/src/main/java/com/codesoom/assignment/domain/Product.java @@ -43,10 +43,16 @@ public class Product { private String imageUrl; + private Long createUserId; + public void changeWith(Product source) { this.name = source.name; this.maker = source.maker; this.price = source.price; this.imageUrl = source.imageUrl; } + + public void setCreateUserId(Long createUserId) { + this.createUserId = createUserId; + } } diff --git a/app/src/main/java/com/codesoom/assignment/domain/User.java b/app/src/main/java/com/codesoom/assignment/domain/User.java index c2299bcf..7b7dbb6c 100644 --- a/app/src/main/java/com/codesoom/assignment/domain/User.java +++ b/app/src/main/java/com/codesoom/assignment/domain/User.java @@ -4,6 +4,8 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import javax.persistence.Entity; import javax.persistence.GeneratedValue; @@ -19,25 +21,31 @@ public class User { @GeneratedValue private Long id; - private String email; + @Builder.Default + private String email = ""; - private String name; + @Builder.Default + private String name = ""; - private String password; + @Builder.Default + private String password = ""; @Builder.Default private boolean deleted = false; public void changeWith(User source) { name = source.name; - password = source.password; + } + + public void changePassword(String password, PasswordEncoder passwordEncoder) { + this.password = passwordEncoder.encode(password); } public void destroy() { deleted = true; } - public boolean authenticate(String password) { - return !deleted && password.equals(this.password); + public boolean authenticate(String password, PasswordEncoder passwordEncoder) { + return !deleted && passwordEncoder.matches(password, this.password); } } diff --git a/app/src/main/java/com/codesoom/assignment/dto/SessionRequestData.java b/app/src/main/java/com/codesoom/assignment/dto/SessionRequestData.java index 13d1e192..a5a48387 100644 --- a/app/src/main/java/com/codesoom/assignment/dto/SessionRequestData.java +++ b/app/src/main/java/com/codesoom/assignment/dto/SessionRequestData.java @@ -1,8 +1,10 @@ package com.codesoom.assignment.dto; +import lombok.Builder; import lombok.Getter; @Getter +@Builder public class SessionRequestData { private String email; private String password; diff --git a/app/src/main/java/com/codesoom/assignment/errors/UserNoPermission.java b/app/src/main/java/com/codesoom/assignment/errors/UserNoPermission.java new file mode 100644 index 00000000..58fd05f1 --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/errors/UserNoPermission.java @@ -0,0 +1,7 @@ +package com.codesoom.assignment.errors; + +public class UserNoPermission extends RuntimeException{ + public UserNoPermission(String message) { + super("No user permissions : " + message); + } +} diff --git a/app/src/main/java/com/codesoom/assignment/filters/AuthenticationErrorFilter.java b/app/src/main/java/com/codesoom/assignment/filters/AuthenticationErrorFilter.java new file mode 100644 index 00000000..83fd09b7 --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/filters/AuthenticationErrorFilter.java @@ -0,0 +1,22 @@ +package com.codesoom.assignment.filters; + +import com.codesoom.assignment.errors.InvalidTokenException; +import org.springframework.http.HttpStatus; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpFilter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class AuthenticationErrorFilter extends HttpFilter { + @Override + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + try { + chain.doFilter(request,response); + }catch (InvalidTokenException e){ + response.sendError(HttpStatus.UNAUTHORIZED.value()); + } + } +} diff --git a/app/src/main/java/com/codesoom/assignment/filters/JwtAuthenticationFilter.java b/app/src/main/java/com/codesoom/assignment/filters/JwtAuthenticationFilter.java new file mode 100644 index 00000000..01604a85 --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/filters/JwtAuthenticationFilter.java @@ -0,0 +1,52 @@ +package com.codesoom.assignment.filters; + +import com.codesoom.assignment.errors.InvalidTokenException; +import com.codesoom.assignment.security.UserAuthentication; +import com.codesoom.assignment.utils.JwtUtil; +import io.jsonwebtoken.Claims; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public class JwtAuthenticationFilter extends BasicAuthenticationFilter { + private JwtUtil jwtUtil; + + public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtUtil jwtUtil) { + super(authenticationManager); + this.jwtUtil = jwtUtil; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + + String authorization = request.getHeader("Authorization"); + + if (authorization != null) { + String accessToken = authorization.substring("Bearer ".length()); + Claims claims = jwtUtil.decode(accessToken); + + if(claims == null){ + throw new InvalidTokenException(accessToken); + } + + Long userId = claims.get("userId", Long.class); + Authentication authentication = new UserAuthentication(userId); + + + SecurityContext context = SecurityContextHolder.getContext(); + context.setAuthentication(authentication); + } + + chain.doFilter(request, response); + + } + +} diff --git a/app/src/main/java/com/codesoom/assignment/interceptors/AuthenticationInterceptor.java b/app/src/main/java/com/codesoom/assignment/interceptors/AuthenticationInterceptor.java deleted file mode 100644 index c07f6f98..00000000 --- a/app/src/main/java/com/codesoom/assignment/interceptors/AuthenticationInterceptor.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.codesoom.assignment.interceptors; - -import com.codesoom.assignment.application.AuthenticationService; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -@Component -public class AuthenticationInterceptor implements HandlerInterceptor { - private AuthenticationService authenticationService; - - public AuthenticationInterceptor( - AuthenticationService authenticationService) { - this.authenticationService = authenticationService; - } - - @Override - public boolean preHandle(HttpServletRequest request, - HttpServletResponse response, - Object handler) throws Exception { - return filterWithPathAndMethod(request) || - doAuthentication(request, response); - } - - private boolean filterWithPathAndMethod(HttpServletRequest request) { - String path = request.getRequestURI(); - if (!path.startsWith("/products")) { - return true; - } - - String method = request.getMethod(); - if (method.equals("GET")) { - return true; - } - - if (method.equals("OPTIONS")) { - return true; - } - - return false; - } - - private boolean doAuthentication(HttpServletRequest request, - HttpServletResponse response) - throws IOException { - String authorization = request.getHeader("Authorization"); - - if (authorization == null) { - response.sendError(HttpStatus.UNAUTHORIZED.value()); - return false; - } - - String accessToken = authorization.substring("Bearer ".length()); - Long userId = authenticationService.parseToken(accessToken); - - request.setAttribute("userId", userId); - - return true; - } -} diff --git a/app/src/main/java/com/codesoom/assignment/security/UserAuthentication.java b/app/src/main/java/com/codesoom/assignment/security/UserAuthentication.java new file mode 100644 index 00000000..f4f2666a --- /dev/null +++ b/app/src/main/java/com/codesoom/assignment/security/UserAuthentication.java @@ -0,0 +1,43 @@ +package com.codesoom.assignment.security; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.ArrayList; +import java.util.List; + +public class UserAuthentication extends AbstractAuthenticationToken { + private final Long userId; + + public UserAuthentication(Long userId) { + super(authorities()); + this.userId = userId; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public String toString() { + return String.format("Authentication( %s )", userId); + } + + @Override + public Object getPrincipal() { + return this.userId; + } + + private static List authorities() { + List authorities = new ArrayList<>(); + authorities.add((GrantedAuthority) () -> "USER"); + return authorities; + } + +} diff --git a/app/src/main/java/com/codesoom/assignment/utils/JwtUtil.java b/app/src/main/java/com/codesoom/assignment/utils/JwtUtil.java index 783dda97..a19920a4 100644 --- a/app/src/main/java/com/codesoom/assignment/utils/JwtUtil.java +++ b/app/src/main/java/com/codesoom/assignment/utils/JwtUtil.java @@ -20,7 +20,7 @@ public JwtUtil(@Value("${jwt.secret}") String secret) { public String encode(Long userId) { return Jwts.builder() - .claim("userId", 1L) + .claim("userId", userId) .signWith(key) .compact(); } diff --git a/app/src/test/java/com/codesoom/assignment/application/AuthenticationServiceTest.java b/app/src/test/java/com/codesoom/assignment/application/AuthenticationServiceTest.java index 6044b404..44d87a78 100644 --- a/app/src/test/java/com/codesoom/assignment/application/AuthenticationServiceTest.java +++ b/app/src/test/java/com/codesoom/assignment/application/AuthenticationServiceTest.java @@ -7,6 +7,8 @@ import com.codesoom.assignment.utils.JwtUtil; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; @@ -28,16 +30,18 @@ class AuthenticationServiceTest { private UserRepository userRepository = mock(UserRepository.class); + @BeforeEach void setUp() { JwtUtil jwtUtil = new JwtUtil(SECRET); - + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); authenticationService = new AuthenticationService( - userRepository, jwtUtil); + userRepository, jwtUtil, passwordEncoder); User user = User.builder() - .password("test") + .id(1L) .build(); + user.changePassword("test", passwordEncoder); given(userRepository.findByEmail("tester@example.com")) .willReturn(Optional.of(user)); @@ -71,17 +75,4 @@ void loginWithWrongPassword() { verify(userRepository).findByEmail("tester@example.com"); } - @Test - void parseTokenWithValidToken() { - Long userId = authenticationService.parseToken(VALID_TOKEN); - - assertThat(userId).isEqualTo(1L); - } - - @Test - void parseTokenWithInvalidToken() { - assertThatThrownBy( - () -> authenticationService.parseToken(INVALID_TOKEN) - ).isInstanceOf(InvalidTokenException.class); - } } diff --git a/app/src/test/java/com/codesoom/assignment/application/ProductServiceTest.java b/app/src/test/java/com/codesoom/assignment/application/ProductServiceTest.java index f8325526..7a19384a 100644 --- a/app/src/test/java/com/codesoom/assignment/application/ProductServiceTest.java +++ b/app/src/test/java/com/codesoom/assignment/application/ProductServiceTest.java @@ -99,7 +99,7 @@ void createProduct() { .price(5000) .build(); - Product product = productService.createProduct(productData); + Product product = productService.createProduct(productData ,1L); verify(productRepository).save(any(Product.class)); diff --git a/app/src/test/java/com/codesoom/assignment/application/UserServiceTest.java b/app/src/test/java/com/codesoom/assignment/application/UserServiceTest.java index 4b85229c..e08ac34f 100644 --- a/app/src/test/java/com/codesoom/assignment/application/UserServiceTest.java +++ b/app/src/test/java/com/codesoom/assignment/application/UserServiceTest.java @@ -10,6 +10,8 @@ import com.github.dozermapper.core.Mapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import java.util.Optional; @@ -31,8 +33,9 @@ class UserServiceTest { @BeforeEach void setUp() { Mapper mapper = DozerBeanMapperBuilder.buildDefault(); + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - userService = new UserService(mapper, userRepository); + userService = new UserService(mapper, userRepository, passwordEncoder); given(userRepository.existsByEmail(EXISTED_EMAIL_ADDRESS)) .willReturn(true); diff --git a/app/src/test/java/com/codesoom/assignment/controllers/ProductAopTest.java b/app/src/test/java/com/codesoom/assignment/controllers/ProductAopTest.java new file mode 100644 index 00000000..c39a6ca9 --- /dev/null +++ b/app/src/test/java/com/codesoom/assignment/controllers/ProductAopTest.java @@ -0,0 +1,88 @@ +package com.codesoom.assignment.controllers; + +import com.codesoom.assignment.domain.ProductRepository; +import com.codesoom.assignment.domain.UserRepository; +import com.codesoom.assignment.utils.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.*; +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.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static com.codesoom.assignment.utils.TestHelper.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@AutoConfigureMockMvc +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("ProductController 클래스") +public class ProductAopTest { + @Autowired + MockMvc mockMvc; + + @Autowired + UserRepository userRepository; + + @Autowired + ProductRepository productRepository; + + @Autowired + ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + productRepository.save(TEST_PRODUCT); + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class update_메서드는 { + + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품을_수정할_권한이_없는_경우 { + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_403_error() throws Exception { + mockMvc.perform(patch("/products/1") + .header("Authorization", "Bearer " + OTHER_USER_VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))) + .andExpect(status().isForbidden()) + .andDo(print()); + } + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class delete_메서드는 { + + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품을_삭제할_권한이_없는_경우 { + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_403_error() throws Exception { + mockMvc.perform(delete("/products/1") + .header("Authorization", "Bearer " + OTHER_USER_VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isForbidden()) + .andDo(print()); + } + } + } + +} diff --git a/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerTest.java b/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerTest.java index 9715830a..9220d1c7 100644 --- a/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerTest.java +++ b/app/src/test/java/com/codesoom/assignment/controllers/ProductControllerTest.java @@ -1,13 +1,17 @@ package com.codesoom.assignment.controllers; -import com.codesoom.assignment.application.AuthenticationService; import com.codesoom.assignment.application.ProductService; import com.codesoom.assignment.domain.Product; import com.codesoom.assignment.dto.ProductData; import com.codesoom.assignment.errors.InvalidTokenException; import com.codesoom.assignment.errors.ProductNotFoundException; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import com.codesoom.assignment.utils.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -16,253 +20,356 @@ import java.util.List; +import static com.codesoom.assignment.utils.TestHelper.*; import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(ProductController.class) +@SuppressWarnings({"InnerClassMayBeStatic", "NonAsciiCharacters"}) +@DisplayName("ProductController 클래스") class ProductControllerTest { - private static final String VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9." + - "eyJ1c2VySWQiOjF9.ZZ3CUl0jxeLGvQ1Js5nG2Ty5qGTlqai5ubDMXZOdaDk"; - private static final String INVALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9." + - "eyJ1c2VySWQiOjF9.ZZ3CUl0jxeLGvQ1Js5nG2Ty5qGTlqai5ubDMXZOdaD0"; @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean private ProductService productService; @MockBean - private AuthenticationService authenticationService; - - @BeforeEach - void setUp() { - Product product = Product.builder() - .id(1L) - .name("쥐돌이") - .maker("냥이월드") - .price(5000) - .build(); - given(productService.getProducts()).willReturn(List.of(product)); - - given(productService.getProduct(1L)).willReturn(product); - - given(productService.getProduct(1000L)) - .willThrow(new ProductNotFoundException(1000L)); - - given(productService.createProduct(any(ProductData.class))) - .willReturn(product); - - given(productService.updateProduct(eq(1L), any(ProductData.class))) - .will(invocation -> { - Long id = invocation.getArgument(0); - ProductData productData = invocation.getArgument(1); - return Product.builder() - .id(id) - .name(productData.getName()) - .maker(productData.getMaker()) - .price(productData.getPrice()) - .build(); - }); - - given(productService.updateProduct(eq(1000L), any(ProductData.class))) - .willThrow(new ProductNotFoundException(1000L)); - - given(productService.deleteProduct(1000L)) - .willThrow(new ProductNotFoundException(1000L)); - - given(authenticationService.parseToken(VALID_TOKEN)).willReturn(1L); - - given(authenticationService.parseToken(INVALID_TOKEN)) - .willThrow(new InvalidTokenException(INVALID_TOKEN)); + private JwtUtil jwtUtil; + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class list_메서드는 { + + @BeforeEach + void setUp() { + given(productService.getProducts()).willReturn(List.of(TEST_PRODUCT)); + } + + @Test + @DisplayName("상품목록을 반환한다") + void It_returns_product_list() throws Exception { + mockMvc.perform(get("/products") + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(TEST_PRODUCT_NAME))); + } } - @Test - void list() throws Exception { - mockMvc.perform( - get("/products") - .accept(MediaType.APPLICATION_JSON_UTF8) - ) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("쥐돌이"))); - } + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class detail_메서드는 { + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 요청받은_id에_해당하는_상품이_존재하는_경우 { + @BeforeEach + void setUp() { + given(productService.getProduct(1L)).willReturn(TEST_PRODUCT); + } + + @Test + @DisplayName("해당 id의 상품을 반환한다") + void It_returns_product() throws Exception { + mockMvc.perform(get("/products/1") + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(TEST_PRODUCT_NAME))); + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 요청받은_id에_해당하는_상품이_존재하지_않는_경우 { + @BeforeEach + void setUp() { + given(productService.getProduct(1000L)) + .willThrow(new ProductNotFoundException(1000L)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_404_error() throws Exception { + mockMvc.perform(get("/products/1000") + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isNotFound()) + .andDo(print()); + } + } - @Test - void deatilWithExsitedProduct() throws Exception { - mockMvc.perform( - get("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - ) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("쥐돌이"))); } - @Test - void deatilWithNotExsitedProduct() throws Exception { - mockMvc.perform(get("/products/1000")) - .andExpect(status().isNotFound()); - } - - @Test - void createWithValidAttributes() throws Exception { - mockMvc.perform( - post("/products") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐돌이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isCreated()) - .andExpect(content().string(containsString("쥐돌이"))); - - verify(productService).createProduct(any(ProductData.class)); - } - - @Test - void createWithInvalidAttributes() throws Exception { - mockMvc.perform( - post("/products") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"\",\"maker\":\"\"," + - "\"price\":0}") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isBadRequest()); - } - - @Test - void createWithoutAccessToken() throws Exception { - mockMvc.perform( - post("/products") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐돌이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - ) - .andExpect(status().isUnauthorized()); - } - - @Test - void createWithWrongAccessToken() throws Exception { - mockMvc.perform( - post("/products") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐돌이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + INVALID_TOKEN) - ) - .andExpect(status().isUnauthorized()); - } - - @Test - void updateWithExistedProduct() throws Exception { - mockMvc.perform( - patch("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐순이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("쥐순이"))); - - verify(productService).updateProduct(eq(1L), any(ProductData.class)); - } - - @Test - void updateWithNotExistedProduct() throws Exception { - mockMvc.perform( - patch("/products/1000") - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐순이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isNotFound()); - - verify(productService).updateProduct(eq(1000L), any(ProductData.class)); - } - - @Test - void updateWithInvalidAttributes() throws Exception { - mockMvc.perform( - patch("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"\",\"maker\":\"\"," + - "\"price\":0}") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isBadRequest()); - } - - @Test - void updateWithoutAccessToken() throws Exception { - mockMvc.perform( - patch("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐순이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - ) - .andExpect(status().isUnauthorized()); - } - - @Test - void updateWithInvalidAccessToken() throws Exception { - mockMvc.perform( - patch("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐순이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + INVALID_TOKEN) - ) - .andExpect(status().isUnauthorized()); - } - - @Test - void destroyWithExistedProduct() throws Exception { - mockMvc.perform( - delete("/products/1") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isNoContent()); - - verify(productService).deleteProduct(1L); + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class create_메서드는 { + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효한_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + given(jwtUtil.decode(VALID_TOKEN)).will(invocation -> { + String accessToken = invocation.getArgument(0); + Claims claims = new JwtUtil("12345678901234567890123456789010").decode(accessToken); + return claims; + }); + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품_요청이_정상적인_경우 { + @BeforeEach + void setUp() { + given(productService.createProduct(any(ProductData.class),any(Long.class))) + .willReturn(TEST_PRODUCT); + } + + @Test + @DisplayName("상품을 생성하고, 생성된 상품을 반환한다") + void It_creates_product_and_returns_it() throws Exception { + mockMvc.perform(post("/products") + .header("Authorization", "Bearer " + VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(TEST_PRODUCT_DATA))) + .andExpect(status().isCreated()) + .andExpect(content().string(containsString(TEST_PRODUCT_NAME))); + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품요청이_비정상적인_경우 { + + @ParameterizedTest + @MethodSource("com.codesoom.assignment.utils.TestHelper#provideInvalidProductRequests") + @DisplayName("에러메시지를 반환한다") + void It_returns_400_error() throws Exception { + mockMvc.perform(post("/products") + .header("Authorization", "Bearer " + VALID_TOKEN) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + } + + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효하지_않은_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + Mockito.reset(jwtUtil); + given(jwtUtil.decode(INVALID_TOKEN)).willThrow(new InvalidTokenException(INVALID_TOKEN)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_401_error() throws Exception { + mockMvc.perform(post("/products") + .header("Authorization", "Bearer " + INVALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(TEST_PRODUCT_DATA))) + .andExpect(status().isUnauthorized()) + .andDo(print()); + } + } } - @Test - void destroyWithNotExistedProduct() throws Exception { - mockMvc.perform( - delete("/products/1000") - .header("Authorization", "Bearer " + VALID_TOKEN) - ) - .andExpect(status().isNotFound()); - - verify(productService).deleteProduct(1000L); + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class update_메서드는 { + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효한_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + given(jwtUtil.decode(VALID_TOKEN)).will(invocation -> { + String accessToken = invocation.getArgument(0); + Claims claims = new JwtUtil("12345678901234567890123456789010").decode(accessToken); + return claims; + }); + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품_요청이_정상적인_경우 { + @BeforeEach + void setUp() { + given(productService.updateProduct(eq(1L), any(ProductData.class))) + .will(invocation -> { + Long id = invocation.getArgument(0); + ProductData productData = invocation.getArgument(1); + return Product.builder() + .id(id) + .name(productData.getName()) + .maker(productData.getMaker()) + .price(productData.getPrice()) + .build(); + }); + } + + @Test + @DisplayName("상품을 수정하고, 수정된 상품을 반환한다") + void It_updates_product_and_returns_it() throws Exception { + mockMvc.perform(patch("/products/1") + .header("Authorization", "Bearer " + VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(TEST_UPDATE_PRODUCT_NAME))); + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품요청이_비정상적인_경우 { + + @ParameterizedTest + @MethodSource("com.codesoom.assignment.utils.TestHelper#provideInvalidProductRequests") + @DisplayName("에러메시지를 반환한다") + void It_returns_400_error() throws Exception { + mockMvc.perform(patch("/products/1") + .header("Authorization", "Bearer " + VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 수정할_상품_정보가_없는경우 { + @BeforeEach + void setUp() { + given(productService.updateProduct(eq(1000L), any(ProductData.class))) + .willThrow(new ProductNotFoundException(1000L)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_404_error() throws Exception { + mockMvc.perform(patch("/products/1000") + .header("Authorization", "Bearer " + VALID_TOKEN) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))) + .andExpect(status().isNotFound()) + .andDo(print()); + } + } + + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효하지_않은_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + Mockito.reset(jwtUtil); + given(jwtUtil.decode(INVALID_TOKEN)).willThrow(new InvalidTokenException(INVALID_TOKEN)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_401_error() throws Exception { + mockMvc.perform(patch("/products/1") + .header("Authorization", "Bearer " + INVALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))) + .andExpect(status().isUnauthorized()) + .andDo(print()); + } + } } - @Test - void destroyWithInvalidAccessToken() throws Exception { - mockMvc.perform( - patch("/products/1") - .accept(MediaType.APPLICATION_JSON_UTF8) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"name\":\"쥐순이\",\"maker\":\"냥이월드\"," + - "\"price\":5000}") - .header("Authorization", "Bearer " + INVALID_TOKEN) - ) - .andExpect(status().isUnauthorized()); + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class destroy_메서드는 { + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효한_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + given(jwtUtil.decode(VALID_TOKEN)).will(invocation -> { + String accessToken = invocation.getArgument(0); + Claims claims = new JwtUtil("12345678901234567890123456789010").decode(accessToken); + return claims; + }); + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 상품_삭제요청이_정상적인_경우 { + @Test + @DisplayName("상품을 삭제한다") + void It_delete_product() throws Exception { + mockMvc.perform(delete("/products/1") + .header("Authorization", "Bearer " + VALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8) + .content(objectMapper.writeValueAsString(UPDATE_PRODUCT_DATA))) + .andExpect(status().isNoContent()); + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 삭제할_상품_정보가_없는경우 { + @BeforeEach + void setUp() { + given(productService.deleteProduct(1000L)) + .willThrow(new ProductNotFoundException(1000L)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_404_error() throws Exception { + mockMvc.perform(delete("/products/1000") + .header("Authorization", "Bearer " + VALID_TOKEN) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isNotFound()) + .andDo(print()); + } + } + } + + @Nested + @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) + class 유효하지_않은_토큰을_전달받은_경우 { + @BeforeEach + void setUp() { + Mockito.reset(jwtUtil); + given(jwtUtil.decode(INVALID_TOKEN)).willThrow(new InvalidTokenException(INVALID_TOKEN)); + } + + @Test + @DisplayName("에러메시지를 반환한다") + void It_returns_401_error() throws Exception { + mockMvc.perform(delete("/products/1") + .header("Authorization", "Bearer " + INVALID_TOKEN) + .accept(MediaType.APPLICATION_JSON_UTF8) + .contentType(MediaType.APPLICATION_JSON_UTF8)) + .andExpect(status().isUnauthorized()) + .andDo(print()); + } + } } } diff --git a/app/src/test/java/com/codesoom/assignment/domain/UserTest.java b/app/src/test/java/com/codesoom/assignment/domain/UserTest.java index f9950e61..96682c56 100644 --- a/app/src/test/java/com/codesoom/assignment/domain/UserTest.java +++ b/app/src/test/java/com/codesoom/assignment/domain/UserTest.java @@ -1,21 +1,41 @@ package com.codesoom.assignment.domain; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import static org.assertj.core.api.Assertions.assertThat; class UserTest { + private PasswordEncoder passwordEncoder; + + @BeforeEach + void setUp() { + passwordEncoder = new BCryptPasswordEncoder(); + } + @Test void changeWith() { + User user = User.builder().build(); user.changeWith(User.builder() .name("TEST") - .password("TEST") .build()); assertThat(user.getName()).isEqualTo("TEST"); - assertThat(user.getPassword()).isEqualTo("TEST"); + assertThat(user.getPassword()).isEqualTo(""); + } + + @Test + void changePassword() { + User user = User.builder().build(); + + user.changePassword("TEST", passwordEncoder); + + assertThat(user.getPassword()).isNotEmpty(); + assertThat(user.getPassword()).isNotEqualTo("TEST"); } @Test @@ -31,22 +51,23 @@ void destroy() { @Test void authenticate() { - User user = User.builder() - .password("test") - .build(); + User user = User.builder().build(); + user.changePassword("test", passwordEncoder); - assertThat(user.authenticate("test")).isTrue(); - assertThat(user.authenticate("xxx")).isFalse(); + assertThat(user.authenticate("test", passwordEncoder)).isTrue(); + assertThat(user.authenticate("xxx", passwordEncoder)).isFalse(); } @Test void authenticateWithDeletedUser() { User user = User.builder() - .password("test") .deleted(true) .build(); - assertThat(user.authenticate("test")).isFalse(); - assertThat(user.authenticate("xxx")).isFalse(); + user.changePassword("test", passwordEncoder); + + assertThat(user.authenticate("test", passwordEncoder)).isFalse(); + assertThat(user.authenticate("xxx", passwordEncoder)).isFalse(); } + } diff --git a/app/src/test/java/com/codesoom/assignment/utils/JwtUtilTest.java b/app/src/test/java/com/codesoom/assignment/utils/JwtUtilTest.java index 107cce06..4b74c3c1 100644 --- a/app/src/test/java/com/codesoom/assignment/utils/JwtUtilTest.java +++ b/app/src/test/java/com/codesoom/assignment/utils/JwtUtilTest.java @@ -5,17 +5,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import static com.codesoom.assignment.utils.TestHelper.*; +import static com.codesoom.assignment.utils.TestHelper.OTHER_USER_VALID_TOKEN; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class JwtUtilTest { - private static final String SECRET = "12345678901234567890123456789012"; - - private static final String VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9." + - "eyJ1c2VySWQiOjF9.ZZ3CUl0jxeLGvQ1Js5nG2Ty5qGTlqai5ubDMXZOdaDk"; - private static final String INVALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9." + - "eyJ1c2VySWQiOjF9.ZZ3CUl0jxeLGvQ1Js5nG2Ty5qGTlqai5ubDMXZOdaD0"; - private JwtUtil jwtUtil; @BeforeEach diff --git a/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java b/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java new file mode 100644 index 00000000..00072f11 --- /dev/null +++ b/app/src/test/java/com/codesoom/assignment/utils/TestHelper.java @@ -0,0 +1,140 @@ +package com.codesoom.assignment.utils; + +import com.codesoom.assignment.domain.Product; +import com.codesoom.assignment.domain.User; +import com.codesoom.assignment.dto.ProductData; +import com.codesoom.assignment.dto.SessionRequestData; +import org.junit.jupiter.params.provider.Arguments; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.stream.Stream; + +public class TestHelper { + + public static final String VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.neCsyNLzy3lQ4o2yliotWT06FwSGZagaHpKdAkjnGGw"; + public static final String OTHER_USER_VALID_TOKEN = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjJ9.i-iHszAs6H2JFTdm3vOVuN18tb_w6n2FqEYIRtr6gaU"; + public static final String INVALID_TOKEN = VALID_TOKEN + "INVALID"; + public static final String SECRET = "12345678901234567890123456789010"; + public static final String AUTH_NAME = "AUTH_NAME"; + public static final String AUTH_EMAIL = "auth@foo.com"; + public static final String INVALID_EMAIL = AUTH_EMAIL + "INVALID"; + public static final String AUTH_PASSWORD = "12345678"; + public static final String TEST_PRODUCT_NAME = "쥐돌이"; + public static final String TEST_UPDATE_PRODUCT_NAME = "쥐순이"; + public static final String TEST_PRODUCT_MAKER = "냥이월드"; + public static final int TEST_PRODUCT_PRICE = 5000; + public static final String INVALID_PASSWORD = AUTH_PASSWORD + "INVALID"; + public static final MockHttpServletRequest INVALID_SERVLET_REQUEST = new MockHttpServletRequest(); + private static final String TEST_LONG_PASSWORD = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut dignissim ex vitae congue congue. Nunc fermentum tellus leo. Donec malesuada, dolor non euismod suscipit, quam elit scelerisque ligula, in finibus eros justo eu justo. Duis tempor porta odio, id finibus nibh pellentesque congue. Ut et velit eget nibh tincidunt porta et id risus. Vestibulum suscipit ullamcorper varius. Proin eget arcu quam. Cras id feugiat libero. Integer auctor sem nec tempor pellentesque. Donec tempor molestie ex in viverra. Aliquam nec purus consequat purus ullamcorper tristique eu sodales erat. Nunc vitae accumsan orci. Vestibulum dictum ante non hendrerit convallis. Ut eu interdum nisl.\n" + + "\n" + + "Vestibulum et tellus tortor. Maecenas vulputate urna eu massa mattis, eu vulputate magna pretium. Vestibulum at sapien vitae mi tempus elementum at eget ante. Morbi risus dolor, eleifend eu ante sed, commodo aliquam augue. Pellentesque aliquet, tellus ultrices fermentum bibendum, turpis urna mollis mauris, sagittis posuere dolor mi et enim. Quisque mollis vulputate est vel eleifend. Donec nec sollicitudin massa. Sed mattis posuere metus sed dictum. Pellentesque varius est a arcu vulputate sollicitudin.\n" + + "\n" + + "Cras ac diam vehicula, elementum mauris tempus, accumsan lacus. Sed lectus diam, hendrerit a consequat id, eleifend eget libero. Praesent laoreet tempor magna et imperdiet. Aenean dictum non velit id lacinia. Donec congue ante dui, id rutrum ex accumsan at. Nulla ut massa elementum, posuere nunc sit amet, ornare nisl. Pellentesque in dui ipsum. Vivamus placerat velit sit amet tempus efficitur.\n" + + "\n" + + "Donec auctor lacus sit amet neque luctus, vitae tincidunt tortor lobortis. Fusce aliquam sem ut magna sollicitudin, ac vulputate est placerat. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Nullam scelerisque augue elit, at bibendum libero efficitur ac. Sed fringilla purus pretium tortor condimentum imperdiet. Praesent in nibh lacinia, euismod enim eu, bibendum felis. Aliquam quis placerat ipsum. Integer dictum volutpat."; + + public static final SessionRequestData IS_NOT_EXISTS_USER_DATA = SessionRequestData.builder() + .email(INVALID_EMAIL) + .password(AUTH_PASSWORD).build(); + + public static final SessionRequestData INVALID_PASSWORD_USER_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .password(INVALID_PASSWORD) + .build(); + + public static final SessionRequestData AUTH_USER_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .password(AUTH_PASSWORD) + .build(); + + public static final User AUTH_USER = User.builder() + .name(AUTH_NAME) + .email(AUTH_EMAIL) + .password(AUTH_PASSWORD) + .build(); + + public static final Product TEST_PRODUCT = Product.builder() + .id(1L) + .name(TEST_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .createUserId(1L) + .build(); + + public static final ProductData TEST_PRODUCT_DATA = ProductData.builder() + .name(TEST_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static Stream provideInvalidProductRequests() { + return Stream.of( + Arguments.of(ProductData.builder().name("").maker("").price(0).build()), + Arguments.of(ProductData.builder().name("").maker(TEST_PRODUCT_MAKER).price(TEST_PRODUCT_PRICE).build()), + Arguments.of(ProductData.builder().name(TEST_PRODUCT_NAME).maker("").price(TEST_PRODUCT_PRICE).build()), + Arguments.of(ProductData.builder().name(TEST_PRODUCT_NAME).maker(TEST_PRODUCT_MAKER).price(null).build()) + ); + } + + public static final ProductData UPDATE_PRODUCT_DATA = ProductData.builder() + .name(TEST_UPDATE_PRODUCT_NAME) + .maker(TEST_PRODUCT_MAKER) + .price(TEST_PRODUCT_PRICE) + .build(); + + public static MockHttpServletRequest getInvalidTokenServletRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + INVALID_TOKEN); + return request; + } + + public static MockHttpServletRequest getValidTokenServletRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + return request; + } + + public static SessionRequestData AUTH_USER_LOGIN_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .password(AUTH_PASSWORD) + .build(); + + public static SessionRequestData EMAIL_NULL_LOGIN_USER_DATA = SessionRequestData.builder() + .password(AUTH_PASSWORD) + .build(); + + public static SessionRequestData PASSWORD_NULL_LOGIN_USER_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .build(); + + public static SessionRequestData EMPTY_LOGIN_USER_DATA = SessionRequestData.builder() + .email("") + .password("") + .build(); + + public static SessionRequestData EMAIL_IS_SHORT_LOGIN_USER_DATA = SessionRequestData.builder() + .email("aa") + .password(AUTH_PASSWORD) + .build(); + + public static SessionRequestData PASSWORD_IS_TOO_SHORT_LOGIN_USER_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .password("111") + .build(); + + public static SessionRequestData PASSWORD_IS_TOO_LONG_LOGIN_USER_DATA = SessionRequestData.builder() + .email(AUTH_EMAIL) + .password(TEST_LONG_PASSWORD) + .build(); + + public static Stream provideInvalidUserLoginRequests() { + return Stream.of( + Arguments.of(EMAIL_NULL_LOGIN_USER_DATA), + Arguments.of(PASSWORD_NULL_LOGIN_USER_DATA), + Arguments.of(EMPTY_LOGIN_USER_DATA), + Arguments.of(EMAIL_IS_SHORT_LOGIN_USER_DATA), + Arguments.of(PASSWORD_IS_TOO_SHORT_LOGIN_USER_DATA), + Arguments.of(PASSWORD_IS_TOO_LONG_LOGIN_USER_DATA) + ); + } +} diff --git a/app/src/test/resources/application-test.yml b/app/src/test/resources/application-test.yml new file mode 100644 index 00000000..21d9b9fa --- /dev/null +++ b/app/src/test/resources/application-test.yml @@ -0,0 +1,16 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb + username: sa + password: + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create + h2: + console: + enabled: true + +jwt: + secret: "12345678901234567890123456789010"