diff --git a/.gitignore b/.gitignore index a5d9a78..73e6d9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -HELP.md -/target/ - -### IntelliJ IDEA ### .idea -*.iws +out +target *.iml -*.ipr \ No newline at end of file +log +*.patch + + diff --git a/README.md b/README.md index 22f19d8..cf6f27c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,19 @@ -Открытый курс для всех желающих приобщиться к живой современной разработке на Java -# [Разработка Spring Boot 2.x HATEOAS приложения (BootJava)](http://javaops.ru/view/bootjava?ref=gh) -## [Программа](http://javaops.ru/view/bootjava#program) +# [Spring Boot 4 / Spring 7.0, JDK 25](https://javaops.pro/view/bootjava4?ref=gh) -### Java приложения на самом современном и востребованном стеке: Spring Boot 2.x, Spring Data Rest/HATEOAS, Lombok, JPA, H2, .... -Мы создадим с нуля основу любого современного REST веб-приложения: аутентификация и авторизация на основе ролей, регистрация пользователя в приложении, управление своим профилем и администрирование пользователей. \ No newline at end of file +Migration of [Spring Boot 3.x + HATEOAS (BootJava)](https://javaops.pro/view/bootjava?ref=gh) to a new stack: Spring Boot 4, Spring 7, JDK 25 +Implementation of the functionality of any modern web application: authentication and authorization based on roles, user registration in the application, profile management and user administration. + +------------------------------------------------------------- +- Stack: [JDK 25](http://jdk.java.net/25/), Spring Boot 4.x, Spring 7, SpringDoc OpenApi 3.x, Jackson 3, Lombok, H2, Caffeine Cache +- Run: `mvn spring-boot:run` in root directory. +----------------------------------------------------- +### [REST API documentation](http://localhost:8080/) + +Credentials: +``` +User: user@yandex.ru / password +Admin: admin@gmail.com / admin +Guest: guest@gmail.com / guest +``` diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..eb6db90 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier \ No newline at end of file diff --git a/pom.xml b/pom.xml index ca66a72..36fd861 100644 --- a/pom.xml +++ b/pom.xml @@ -1,22 +1,26 @@ - - 4.0.0 + + 4.1.0 org.springframework.boot spring-boot-starter-parent - 2.4.0 + 4.0.0 - ru.javaops.bootjava - restaurant-voting - 1.0 - restaurant-voting - Spring Boot 2.x HATEOAS application (BootJava) - https://javaops.ru/view/bootjava + ru.javaops + bootjava + 1.1 + BootJava + Spring Boot 4 + https://javaops.ru/view/bootjava4 - 15 + 25 + 3.0.0 + 1.21.2 + UTF-8 + UTF-8 @@ -28,11 +32,46 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + tools.jackson.datatype + jackson-datatype-hibernate7 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + com.h2database h2 - runtime + + + org.jsoup + jsoup + ${jsoup.version} org.projectlombok @@ -40,20 +79,57 @@ true - + org.springframework.boot spring-boot-starter-test test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + org.junit.platform + junit-platform-launcher + test + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + org.springframework.boot spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Dfile.encoding=UTF-8 + + diff --git a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java b/src/main/java/ru/javaops/bootjava/BootJavaApplication.java similarity index 57% rename from src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java rename to src/main/java/ru/javaops/bootjava/BootJavaApplication.java index 3326420..fd874f1 100644 --- a/src/main/java/ru/javaops/bootjava/RestaurantVotingApplication.java +++ b/src/main/java/ru/javaops/bootjava/BootJavaApplication.java @@ -1,14 +1,12 @@ package ru.javaops.bootjava; -import lombok.AllArgsConstructor; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@AllArgsConstructor -public class RestaurantVotingApplication { +public class BootJavaApplication { public static void main(String[] args) { - SpringApplication.run(RestaurantVotingApplication.class, args); + SpringApplication.run(BootJavaApplication.class, args); } } diff --git a/src/main/java/ru/javaops/bootjava/app/AuthUser.java b/src/main/java/ru/javaops/bootjava/app/AuthUser.java new file mode 100644 index 0000000..af7da00 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/AuthUser.java @@ -0,0 +1,30 @@ +package ru.javaops.bootjava.app; + +import lombok.Getter; +import org.jspecify.annotations.NonNull; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; + +public class AuthUser extends org.springframework.security.core.userdetails.User { + + @Getter + private final User user; + + public AuthUser(@NonNull User user) { + super(user.getEmail(), user.getPassword(), user.isEnabled(), true, true, true, user.getRoles()); + this.user = user; + } + + public int id() { + return user.id(); + } + + public boolean hasRole(Role role) { + return user.hasRole(role); + } + + @Override + public String toString() { + return "AuthUser:" + id() + '[' + user.getEmail() + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/app/AuthUtil.java b/src/main/java/ru/javaops/bootjava/app/AuthUtil.java new file mode 100644 index 0000000..08fcbd0 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/AuthUtil.java @@ -0,0 +1,20 @@ +package ru.javaops.bootjava.app; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static java.util.Objects.requireNonNull; + +public class AuthUtil { + public static AuthUser safeGet() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null) { + return null; + } + return (auth.getPrincipal() instanceof AuthUser au) ? au : null; + } + + public static AuthUser get() { + return requireNonNull(safeGet(), "No authorized user found"); + } +} diff --git a/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java new file mode 100644 index 0000000..402a036 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/AppConfig.java @@ -0,0 +1,55 @@ +package ru.javaops.bootjava.app.config; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import lombok.extern.slf4j.Slf4j; +import org.h2.tools.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.json.ProblemDetailJacksonMixin; +import ru.javaops.bootjava.common.util.JsonUtil; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.datatype.hibernate7.Hibernate7Module; + +import java.sql.SQLException; + +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY; +import static com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.NONE; + +@Configuration +@Slf4j +public class AppConfig { + + @Profile("!test") + @Bean(initMethod = "start", destroyMethod = "stop") + Server h2Server() throws SQLException { + log.info("Start H2 TCP server"); + return Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092"); + } + + // https://stackoverflow.com/a/74630129/548473 + @JsonAutoDetect(fieldVisibility = NONE, getterVisibility = ANY) + interface MixIn extends ProblemDetailJacksonMixin { + } + + // https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide#upgrading-jackson + @Bean + ObjectMapper objectMapper() { + ObjectMapper mapper = JsonMapper.builder() + .changeDefaultVisibility(visibilityChecker -> visibilityChecker + .withVisibility(PropertyAccessor.FIELD, ANY) + .withVisibility(PropertyAccessor.GETTER, NONE) + .withVisibility(PropertyAccessor.SETTER, NONE) + .withVisibility(PropertyAccessor.IS_GETTER, NONE) + ) + .addModule(new Hibernate7Module()) + // ErrorHandling: https://stackoverflow.com/questions/7421474/548473 + .addMixIn(ProblemDetail.class, MixIn.class) + .build(); + JsonUtil.setMapper(mapper); + return mapper; + } +} diff --git a/src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java b/src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java new file mode 100644 index 0000000..e06783e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/OpenApiConfig.java @@ -0,0 +1,44 @@ +package ru.javaops.bootjava.app.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +//https://sabljakovich.medium.com/adding-basic-auth-authorization-option-to-openapi-swagger-documentation-java-spring-95abbede27e9 +@SecurityScheme( + name = "basicAuth", + type = SecuritySchemeType.HTTP, + scheme = "basic" +) +@OpenAPIDefinition( + info = @Info( + title = "REST API documentation", + version = "1.0", + description = """ + Приложение по курсу BootJava +

Тестовые креденшелы:
+ - user@yandex.ru / password
+ - admin@gmail.com / admin
+ - guest@gmail.com / guest

+ """, + contact = @Contact(url = "https://javaops.ru/#contacts", name = "Grigory Kislin", email = "admin@javaops.ru") + ), + security = @SecurityRequirement(name = "basicAuth") +) +public class OpenApiConfig { + + @Bean + public GroupedOpenApi api() { + return GroupedOpenApi.builder() + .group("REST API") + .pathsToMatch("/api/**") + .build(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java new file mode 100644 index 0000000..4fd63c5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/RestExceptionHandler.java @@ -0,0 +1,144 @@ +package ru.javaops.bootjava.app.config; + +import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ValidationException; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.NestedExceptionUtils; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ProblemDetail; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.firewall.RequestRejectedException; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.ErrorResponse; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.ServletRequestBindingException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import ru.javaops.bootjava.common.error.AppException; +import ru.javaops.bootjava.common.error.ErrorType; + +import java.io.FileNotFoundException; +import java.net.URI; +import java.nio.file.AccessDeniedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import static ru.javaops.bootjava.common.error.ErrorType.*; + +@RestControllerAdvice +@AllArgsConstructor +@Slf4j +public class RestExceptionHandler { + public static final String ERR_PFX = "ERR# "; + + @Getter + private final MessageSource messageSource; + + // https://stackoverflow.com/a/52254601/548473 + static final Map, ErrorType> HTTP_STATUS_MAP = new LinkedHashMap<>() { + { +// more specific first + put(NoResourceFoundException.class, NOT_FOUND); + put(AuthenticationException.class, UNAUTHORIZED); + put(FileNotFoundException.class, NOT_FOUND); + put(NoHandlerFoundException.class, NOT_FOUND); + put(UnsupportedOperationException.class, APP_ERROR); + put(EntityNotFoundException.class, DATA_CONFLICT); + put(DataIntegrityViolationException.class, DATA_CONFLICT); + put(IllegalArgumentException.class, BAD_DATA); + put(ValidationException.class, BAD_REQUEST); + put(HttpRequestMethodNotSupportedException.class, BAD_REQUEST); + put(ServletRequestBindingException.class, BAD_REQUEST); + put(RequestRejectedException.class, BAD_REQUEST); + put(AccessDeniedException.class, FORBIDDEN); + } + }; + + @ExceptionHandler(BindException.class) + ProblemDetail bindException(BindException ex, HttpServletRequest request) { + Map invalidParams = getErrorMap(ex.getBindingResult()); + String path = request.getRequestURI(); + log.warn(ERR_PFX + "BindException with invalidParams {} at request {}", invalidParams, path); + return createProblemDetail(ex, path, BAD_REQUEST, "BindException", Map.of("invalid_params", invalidParams)); + } + + private Map getErrorMap(BindingResult result) { + Map invalidParams = new LinkedHashMap<>(); + for (ObjectError error : result.getGlobalErrors()) { + invalidParams.put(error.getObjectName(), getErrorMessage(error)); + } + for (FieldError error : result.getFieldErrors()) { + invalidParams.put(error.getField(), getErrorMessage(error)); + } + return invalidParams; + } + + private String getErrorMessage(ObjectError error) { + return error.getCode() == null ? error.getDefaultMessage() : + messageSource.getMessage(error.getCode(), error.getArguments(), error.getDefaultMessage(), LocaleContextHolder.getLocale()); + } + + @ExceptionHandler(Exception.class) + ProblemDetail exception(Exception ex, HttpServletRequest request) { + return processException(ex, request, Map.of()); + } + + ProblemDetail processException(@NonNull Throwable ex, HttpServletRequest request, Map additionalParams) { + Optional optType = findErrorType(ex); + if (optType.isEmpty()) { + Throwable root = getRootCause(ex); + if (root != ex) { + optType = findErrorType(root); + ex = root; + } + } + String path = request.getRequestURI(); + if (optType.isPresent()) { + log.error(ERR_PFX + "Exception {} at request {}", ex, path); + return createProblemDetail(ex, path, optType.get(), ex.getMessage(), additionalParams); + } else { + Throwable root = getRootCause(ex); + log.error(ERR_PFX + "Exception " + root + " at request " + path, root); + return createProblemDetail(ex, path, APP_ERROR, "Exception " + root.getClass().getSimpleName(), additionalParams); + } + } + + private Optional findErrorType(Throwable ex) { + if (ex instanceof AppException aex) { + return Optional.of(aex.getErrorType()); + } + Class exClass = ex.getClass(); + return HTTP_STATUS_MAP.entrySet().stream() + .filter(entry -> entry.getKey().isAssignableFrom(exClass)) + .findAny().map(Map.Entry::getValue); + } + + // https://datatracker.ietf.org/doc/html/rfc7807 + private ProblemDetail createProblemDetail(Throwable ex, String path, ErrorType type, String defaultDetail, @NonNull Map additionalParams) { + ErrorResponse.Builder builder = ErrorResponse.builder(ex, type.status, defaultDetail); + ProblemDetail pd = builder + .title(type.title).instance(URI.create(path)) + .build().updateAndGetBody(messageSource, LocaleContextHolder.getLocale()); + additionalParams.forEach(pd::setProperty); + return pd; + } + + // https://stackoverflow.com/a/65442410/548473 + @NonNull + private static Throwable getRootCause(@NonNull Throwable t) { + Throwable rootCause = NestedExceptionUtils.getRootCause(t); + return rootCause != null ? rootCause : t; + } +} diff --git a/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java new file mode 100644 index 0000000..4b7aecc --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/SecurityConfig.java @@ -0,0 +1,85 @@ +package ru.javaops.bootjava.app.config; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.CachingUserDetailsService; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserCache; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.userdetails.cache.SpringCacheBasedUserCache; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import ru.javaops.bootjava.app.AuthUser; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; + +import java.util.Optional; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebSecurity +@EnableCaching +@Slf4j +@AllArgsConstructor +public class SecurityConfig { + public static final PasswordEncoder PASSWORD_ENCODER = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UserRepository userRepository; + private final CacheManager cacheManager; + + @Bean + PasswordEncoder passwordEncoder() { + return PASSWORD_ENCODER; + } + + @Bean + UserCache userCache() { + return new SpringCacheBasedUserCache(cacheManager.getCache("users")); + } + + // https://www.phind.com/search/cmihyvg060000356u4nci6bie + @Bean + UserDetailsService userDetailsService() { + CachingUserDetailsService service = new CachingUserDetailsService(email -> { + log.debug("Authenticating '{}'", email); + Optional optionalUser = userRepository.findByEmailIgnoreCase(email); + return new AuthUser(optionalUser.orElseThrow( + () -> new UsernameNotFoundException("User '" + email + "' was not found"))); + }); + service.setUserCache(userCache()); + return service; + } + + @Autowired + public void configure(AuthenticationManagerBuilder builder) { + builder.eraseCredentials(false); + } + + //https://stackoverflow.com/a/76538979/548473 + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.securityMatcher("/api/**").authorizeHttpRequests(authz -> + authz.requestMatchers("/api/admin/**").hasRole(Role.ADMIN.name()) + .requestMatchers(HttpMethod.POST, "/api/profile").anonymous() + .requestMatchers("/", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**").permitAll() + .requestMatchers("/api/**").authenticated()) + .httpBasic(withDefaults()) + .sessionManagement(smc -> smc.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java b/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java new file mode 100644 index 0000000..e7bfc25 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/app/config/WebConfig.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.app.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + public static final String VERSION_HEADER = "API-Version"; + public static final String CURRENT_VERSION = "1.0"; + + @Override + public void configureApiVersioning(ApiVersionConfigurer configurer) { + configurer + .useRequestHeader(VERSION_HEADER) + .setVersionRequired(false) + .setDefaultVersion(CURRENT_VERSION) + .addSupportedVersions(CURRENT_VERSION); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/BaseRepository.java b/src/main/java/ru/javaops/bootjava/common/BaseRepository.java new file mode 100644 index 0000000..edcf4ab --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/BaseRepository.java @@ -0,0 +1,31 @@ +package ru.javaops.bootjava.common; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.NoRepositoryBean; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.common.error.NotFoundException; + +// https://stackoverflow.com/questions/42781264/multiple-base-repositories-in-spring-data-jpa +@NoRepositoryBean +public interface BaseRepository extends JpaRepository { + + // https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query.spel-expressions + @Transactional + @Modifying + @Query("DELETE FROM #{#entityName} e WHERE e.id=:id") + int delete(int id); + + // https://stackoverflow.com/a/60695301/548473 (existed delete code 204, not existed: 404) + @SuppressWarnings("all") // transaction invoked + default void deleteExisted(int id) { + if (delete(id) == 0) { + throw new NotFoundException("Entity with id=" + id + " not found"); + } + } + + default T getExisted(int id) { + return findById(id).orElseThrow(() -> new NotFoundException("Entity with id=" + id + " not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/HasId.java b/src/main/java/ru/javaops/bootjava/common/HasId.java new file mode 100644 index 0000000..2a3ac2b --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/HasId.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.common; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.springframework.util.Assert; + +public interface HasId { + Integer getId(); + + void setId(Integer id); + + @JsonIgnore + default boolean isNew() { + return getId() == null; + } + + // doesn't work for hibernate lazy proxy + default int id() { + Assert.notNull(getId(), "Entity must has id"); + return getId(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java b/src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java new file mode 100644 index 0000000..1dd6819 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/HasIdAndEmail.java @@ -0,0 +1,5 @@ +package ru.javaops.bootjava.common; + +public interface HasIdAndEmail extends HasId { + String getEmail(); +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/error/AppException.java b/src/main/java/ru/javaops/bootjava/common/error/AppException.java new file mode 100644 index 0000000..799fcec --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/AppException.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.common.error; + +import lombok.Getter; +import org.jspecify.annotations.NonNull; + +public class AppException extends RuntimeException { + @Getter + private final ErrorType errorType; + + public AppException(@NonNull String message, ErrorType errorType) { + super(message); + this.errorType = errorType; + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java b/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java new file mode 100644 index 0000000..fa3085a --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/DataConflictException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.DATA_CONFLICT; + +public class DataConflictException extends AppException { + public DataConflictException(String msg) { + super(msg, DATA_CONFLICT); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java new file mode 100644 index 0000000..cbd53d4 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/ErrorType.java @@ -0,0 +1,22 @@ +package ru.javaops.bootjava.common.error; + +import org.springframework.http.HttpStatus; + +public enum ErrorType { + APP_ERROR("Application error", HttpStatus.INTERNAL_SERVER_ERROR), + BAD_DATA("Wrong data", HttpStatus.UNPROCESSABLE_CONTENT), + BAD_REQUEST("Bad request", HttpStatus.UNPROCESSABLE_CONTENT), + DATA_CONFLICT("DataBase conflict", HttpStatus.CONFLICT), + NOT_FOUND("Resource not found", HttpStatus.NOT_FOUND), + AUTH_ERROR("Authorization error", HttpStatus.FORBIDDEN), + UNAUTHORIZED("Request unauthorized", HttpStatus.UNAUTHORIZED), + FORBIDDEN("Request forbidden", HttpStatus.FORBIDDEN); + + ErrorType(String title, HttpStatus status) { + this.title = title; + this.status = status; + } + + public final String title; + public final HttpStatus status; +} diff --git a/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java b/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java new file mode 100644 index 0000000..79aaec9 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/IllegalRequestDataException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.BAD_REQUEST; + +public class IllegalRequestDataException extends AppException { + public IllegalRequestDataException(String msg) { + super(msg, BAD_REQUEST); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java b/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java new file mode 100644 index 0000000..f1dc318 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/error/NotFoundException.java @@ -0,0 +1,9 @@ +package ru.javaops.bootjava.common.error; + +import static ru.javaops.bootjava.common.error.ErrorType.NOT_FOUND; + +public class NotFoundException extends AppException { + public NotFoundException(String msg) { + super(msg, NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java b/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java new file mode 100644 index 0000000..ae8bd2f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/model/BaseEntity.java @@ -0,0 +1,41 @@ +package ru.javaops.bootjava.common.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import ru.javaops.bootjava.common.HasId; + +import static ru.javaops.bootjava.common.util.HibernateProxyHelper.getClassWithoutInitializingProxy; + +@MappedSuperclass +// https://stackoverflow.com/a/6084701/548473 +@Access(AccessType.FIELD) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity implements HasId { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + // https://stackoverflow.com/questions/1638723 + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClassWithoutInitializingProxy(this) != getClassWithoutInitializingProxy(o)) return false; + return getId() != null && getId().equals(((BaseEntity) o).getId()); + } + + @Override + public int hashCode() { + return getClassWithoutInitializingProxy(this).hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + getId(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java b/src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java new file mode 100644 index 0000000..a4835ab --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/model/NamedEntity.java @@ -0,0 +1,35 @@ +package ru.javaops.bootjava.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import ru.javaops.bootjava.common.validation.NoHtml; + + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NamedEntity extends BaseEntity { + + @NotBlank + @Size(min = 2, max = 128) + @Column(name = "name", nullable = false) + @NoHtml + protected String name; + + protected NamedEntity(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + name + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/to/BaseTo.java b/src/main/java/ru/javaops/bootjava/common/to/BaseTo.java new file mode 100644 index 0000000..71e05ff --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/to/BaseTo.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.common.to; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.javaops.bootjava.common.HasId; + +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@Data +public abstract class BaseTo implements HasId { + @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473 + protected Integer id; + + @Override + public String toString() { + return getClass().getSimpleName() + ":" + id; + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/to/NamedTo.java b/src/main/java/ru/javaops/bootjava/common/to/NamedTo.java new file mode 100644 index 0000000..f2308b5 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/to/NamedTo.java @@ -0,0 +1,26 @@ +package ru.javaops.bootjava.common.to; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.EqualsAndHashCode; +import ru.javaops.bootjava.common.validation.NoHtml; + +@Data +@EqualsAndHashCode(callSuper = true) +public class NamedTo extends BaseTo { + @NotBlank + @Size(min = 2, max = 64) + @NoHtml + protected String name; + + public NamedTo(Integer id, String name) { + super(id); + this.name = name; + } + + @Override + public String toString() { + return super.toString() + '[' + name + ']'; + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java b/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java new file mode 100644 index 0000000..b38606f --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/util/HibernateProxyHelper.java @@ -0,0 +1,24 @@ +/* + * Hibernate, Relational Persistence for Idiomatic Java + * + * License: GNU Lesser General Public License (LGPL), version 2.1 or later. + * See the lgpl.txt file in the root directory or . + */ +package ru.javaops.bootjava.common.util; + + +import lombok.experimental.UtilityClass; +import org.hibernate.proxy.HibernateProxy; + +@UtilityClass +public final class HibernateProxyHelper { + + /** + * Get the class of an instance or the underlying class + * of a proxy (without initializing the proxy!) + */ + public static Class getClassWithoutInitializingProxy(Object object) { + return (object instanceof HibernateProxy proxy) ? + proxy.getHibernateLazyInitializer().getPersistentClass() : object.getClass(); + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java new file mode 100644 index 0000000..5605471 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/util/JsonUtil.java @@ -0,0 +1,54 @@ +package ru.javaops.bootjava.common.util; + +import lombok.experimental.UtilityClass; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectReader; + +import java.util.List; +import java.util.Map; + +@UtilityClass +public class JsonUtil { + private static ObjectMapper mapper; + + public static void setMapper(ObjectMapper mapper) { + JsonUtil.mapper = mapper; + } + + public static List readValues(String json, Class clazz) { + ObjectReader reader = mapper.readerFor(clazz); + try { + return reader.readValues(json).readAll(); + } catch (JacksonException e) { + throw new IllegalArgumentException("Invalid read array from JSON:\n'" + json + "'", e); + } + } + + public static T readValue(String json, Class clazz) { + try { + return mapper.readValue(json, clazz); + } catch (JacksonException e) { + throw new IllegalArgumentException("Invalid read from JSON:\n'" + json + "'", e); + } + } + + public static String writeValue(T obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JacksonException e) { + throw new IllegalStateException("Invalid write to JSON:\n'" + obj + "'", e); + } + } + + public static String writeAdditionProps(T obj, String addName, Object addValue) { + return writeAdditionProps(obj, Map.of(addName, addValue)); + } + + public static String writeAdditionProps(T obj, Map addProps) { + Map map = mapper.convertValue(obj, new TypeReference<>() {}); + map.putAll(addProps); + return writeValue(map); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java b/src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java new file mode 100644 index 0000000..f730209 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/validation/NoHtml.java @@ -0,0 +1,23 @@ +package ru.javaops.bootjava.common.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.*; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +@Documented +@Constraint(validatedBy = NoHtmlValidator.class) +@Target({METHOD, FIELD, ANNOTATION_TYPE, TYPE_USE}) +@Retention(RUNTIME) +public @interface NoHtml { + String message() default "HTML tags forbidden"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java b/src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java new file mode 100644 index 0000000..68dd323 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/validation/NoHtmlValidator.java @@ -0,0 +1,13 @@ +package ru.javaops.bootjava.common.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + +public class NoHtmlValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext ctx) { + return value == null || Jsoup.isValid(value, Safelist.none()); + } +} diff --git a/src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java b/src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java new file mode 100644 index 0000000..d55e887 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/common/validation/ValidationUtil.java @@ -0,0 +1,24 @@ +package ru.javaops.bootjava.common.validation; + +import lombok.experimental.UtilityClass; +import ru.javaops.bootjava.common.HasId; +import ru.javaops.bootjava.common.error.IllegalRequestDataException; + +@UtilityClass +public class ValidationUtil { + + public static void checkNew(HasId bean) { + if (!bean.isNew()) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must be new (id=null)"); + } + } + + // Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473) + public static void assureIdConsistent(HasId bean, int id) { + if (bean.isNew()) { + bean.setId(id); + } else if (bean.id() != id) { + throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/UsersUtil.java b/src/main/java/ru/javaops/bootjava/user/UsersUtil.java new file mode 100644 index 0000000..63f3bcf --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/UsersUtil.java @@ -0,0 +1,21 @@ +package ru.javaops.bootjava.user; + +import lombok.experimental.UtilityClass; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.to.UserTo; + +@UtilityClass +public class UsersUtil { + + public static User createNewFromTo(UserTo userTo) { + return new User(null, userTo.getName(), userTo.getEmail().toLowerCase(), userTo.getPassword(), Role.USER); + } + + public static User updateFromTo(User user, UserTo userTo) { + user.setName(userTo.getName()); + user.setEmail(userTo.getEmail().toLowerCase()); + user.setPassword(userTo.getPassword()); + return user; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/model/Role.java b/src/main/java/ru/javaops/bootjava/user/model/Role.java new file mode 100644 index 0000000..53c9c4e --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/model/Role.java @@ -0,0 +1,14 @@ +package ru.javaops.bootjava.user.model; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + // https://stackoverflow.com/a/19542316/548473 + return "ROLE_" + name(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/model/User.java b/src/main/java/ru/javaops/bootjava/user/model/User.java new file mode 100644 index 0000000..aac25c9 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/model/User.java @@ -0,0 +1,87 @@ +package ru.javaops.bootjava.user.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jspecify.annotations.NonNull; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.common.model.NamedEntity; +import ru.javaops.bootjava.common.validation.NoHtml; + +import java.util.*; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends NamedEntity implements HasIdAndEmail { +// No session, no needs Serializable + + @Column(name = "email", nullable = false, unique = true) + @Email + @NotBlank + @Size(max = 64) + @NoHtml // https://stackoverflow.com/questions/17480809 + private String email; + + @Column(name = "password", nullable = false) + @NotBlank + @Size(max = 128) + // https://stackoverflow.com/a/12505165/548473 + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private String password; + + @Column(name = "enabled", nullable = false, columnDefinition = "bool default true") + private boolean enabled = true; + + @Column(name = "registered", nullable = false, columnDefinition = "timestamp default now()", updatable = false) + @NotNull + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + private Date registered = new Date(); + + @Enumerated(EnumType.STRING) + @CollectionTable(name = "user_role", + joinColumns = @JoinColumn(name = "user_id"), + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "role"}, name = "uk_user_role")) + @Column(name = "role") + @ElementCollection(fetch = FetchType.EAGER) + private Set roles = EnumSet.noneOf(Role.class); + + public User(User u) { + this(u.id, u.name, u.email, u.password, u.enabled, u.registered, u.roles); + } + + public User(Integer id, String name, String email, String password, Role... roles) { + this(id, name, email, password, true, new Date(), Arrays.asList(roles)); + } + + public User(Integer id, String name, String email, String password, boolean enabled, Date registered, @NonNull Collection roles) { + super(id, name); + this.email = email; + this.password = password; + this.enabled = enabled; + this.registered = registered; + setRoles(roles); + } + + public void setRoles(Collection roles) { + this.roles = roles.isEmpty() ? EnumSet.noneOf(Role.class) : EnumSet.copyOf(roles); + } + + public boolean hasRole(Role role) { + return roles.contains(role); + } + + @Override + public String toString() { + return "User:" + id + '[' + email + ']'; + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java b/src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java new file mode 100644 index 0000000..def57ea --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/repository/UserRepository.java @@ -0,0 +1,28 @@ +package ru.javaops.bootjava.user.repository; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.common.BaseRepository; +import ru.javaops.bootjava.common.error.NotFoundException; +import ru.javaops.bootjava.user.model.User; + +import java.util.Optional; + +import static ru.javaops.bootjava.app.config.SecurityConfig.PASSWORD_ENCODER; + +@Transactional(readOnly = true) +public interface UserRepository extends BaseRepository { + @Query("SELECT u FROM User u WHERE u.email = LOWER(:email)") + Optional findByEmailIgnoreCase(String email); + + @Transactional + default User prepareAndSave(User user) { + user.setPassword(PASSWORD_ENCODER.encode(user.getPassword())); + user.setEmail(user.getEmail().toLowerCase()); + return save(user); + } + + default User getExistedByEmail(String email) { + return findByEmailIgnoreCase(email).orElseThrow(() -> new NotFoundException("User with email=" + email + " not found")); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/to/UserTo.java b/src/main/java/ru/javaops/bootjava/user/to/UserTo.java new file mode 100644 index 0000000..33922a4 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/to/UserTo.java @@ -0,0 +1,35 @@ +package ru.javaops.bootjava.user.to; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.EqualsAndHashCode; +import lombok.Value; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.common.to.NamedTo; +import ru.javaops.bootjava.common.validation.NoHtml; + +@Value +@EqualsAndHashCode(callSuper = true) +public class UserTo extends NamedTo implements HasIdAndEmail { + @Email + @NotBlank + @Size(max = 64) + @NoHtml // https://stackoverflow.com/questions/17480809 + String email; + + @NotBlank + @Size(min = 5, max = 32) + String password; + + public UserTo(Integer id, String name, String email, String password) { + super(id, name); + this.email = email; + this.password = password; + } + + @Override + public String toString() { + return "UserTo:" + id + '[' + email + ']'; + } +} diff --git a/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java new file mode 100644 index 0000000..efdf979 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/web/AbstractUserController.java @@ -0,0 +1,45 @@ +package ru.javaops.bootjava.user.web; + +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserCache; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.InitBinder; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; + +import static org.slf4j.LoggerFactory.getLogger; + +public abstract class AbstractUserController { + protected final Logger log = getLogger(getClass()); + + @Autowired + protected UserCache userCache; + + @Autowired + protected UserRepository repository; + + @Autowired + private UniqueMailValidator emailValidator; + + @InitBinder + protected void initBinder(WebDataBinder binder) { + binder.addValidators(emailValidator); + } + + public User get(int id) { + log.info("get {}", id); + return repository.getExisted(id); + } + + public void delete(int id, String invalidateEmail) { + log.info("delete {}", id); + repository.deleteExisted(id); + userCache.removeUserFromCache(invalidateEmail); + } + + public void update(User user, String invalidateEmail) { + repository.prepareAndSave(user); + userCache.removeUserFromCache(invalidateEmail); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java new file mode 100644 index 0000000..5bc7f76 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/web/AdminUserController.java @@ -0,0 +1,80 @@ +package ru.javaops.bootjava.user.web; + +import jakarta.validation.Valid; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.app.config.WebConfig; +import ru.javaops.bootjava.user.model.User; + +import java.net.URI; +import java.util.List; + +import static ru.javaops.bootjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; + +@RestController +@RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE, version = WebConfig.CURRENT_VERSION) +public class AdminUserController extends AbstractUserController { + + static final String REST_URL = "/api/admin/users"; + + @Override + @GetMapping("/{id}") + public User get(@PathVariable int id) { + return super.get(id); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable int id) { + User user = repository.getExisted(id); + super.delete(id, user.getEmail()); + } + + @GetMapping + public List getAll() { + log.info("getAll"); + return repository.findAll(Sort.by(Sort.Direction.ASC, "name", "email")); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity createWithLocation(@Valid @RequestBody User user) { + log.info("create {}", user); + checkNew(user); + User created = repository.prepareAndSave(user); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL + "/{id}") + .buildAndExpand(created.getId()).toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + public void update(@Valid @RequestBody User user, @PathVariable int id) { + log.info("update {} with id={}", user, id); + assureIdConsistent(user, id); + User dbUser = repository.getExisted(id); + super.update(user, dbUser.getEmail()); + } + + @GetMapping("/by-email") + public User getByEmail(@RequestParam String email) { + log.info("getByEmail {}", email); + return repository.getExistedByEmail(email); + } + + @PatchMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void enable(@PathVariable int id, @RequestParam boolean enabled) { + log.info(enabled ? "enable {}" : "disable {}", id); + User user = repository.getExisted(id); + user.setEnabled(enabled); + userCache.removeUserFromCache(user.getEmail()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java new file mode 100644 index 0000000..26c98b7 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/web/ProfileController.java @@ -0,0 +1,61 @@ +package ru.javaops.bootjava.user.web; + +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import ru.javaops.bootjava.app.AuthUser; +import ru.javaops.bootjava.app.config.WebConfig; +import ru.javaops.bootjava.user.UsersUtil; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.to.UserTo; + +import java.net.URI; + +import static ru.javaops.bootjava.common.validation.ValidationUtil.assureIdConsistent; +import static ru.javaops.bootjava.common.validation.ValidationUtil.checkNew; + +@RestController +@RequestMapping(value = ProfileController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE, version = WebConfig.CURRENT_VERSION) +@Slf4j +public class ProfileController extends AbstractUserController { + static final String REST_URL = "/api/profile"; + + @GetMapping + public User get(@AuthenticationPrincipal AuthUser authUser) { + log.info("get {}", authUser); + return authUser.getUser(); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@AuthenticationPrincipal AuthUser authUser) { + super.delete(authUser.id(), authUser.getUsername()); + } + + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity register(@Valid @RequestBody UserTo userTo) { + log.info("register {}", userTo); + checkNew(userTo); + User created = repository.prepareAndSave(UsersUtil.createNewFromTo(userTo)); + URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath() + .path(REST_URL).build().toUri(); + return ResponseEntity.created(uriOfNewResource).body(created); + } + + @PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional + public void update(@RequestBody @Valid UserTo userTo, @AuthenticationPrincipal AuthUser authUser) { + log.info("update {} with id={}", userTo, authUser.id()); + assureIdConsistent(userTo, authUser.id()); + User user = authUser.getUser(); + super.update(UsersUtil.updateFromTo(user, userTo), authUser.getUsername()); + } +} \ No newline at end of file diff --git a/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java new file mode 100644 index 0000000..8ab5d23 --- /dev/null +++ b/src/main/java/ru/javaops/bootjava/user/web/UniqueMailValidator.java @@ -0,0 +1,48 @@ +package ru.javaops.bootjava.user.web; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import ru.javaops.bootjava.app.AuthUtil; +import ru.javaops.bootjava.common.HasIdAndEmail; +import ru.javaops.bootjava.user.repository.UserRepository; + +@Component +@AllArgsConstructor +public class UniqueMailValidator implements org.springframework.validation.Validator { + public static final String EXCEPTION_DUPLICATE_EMAIL = "User with this email already exists"; + + private final UserRepository repository; + private final HttpServletRequest request; + + @Override + public boolean supports(@NonNull Class clazz) { + return HasIdAndEmail.class.isAssignableFrom(clazz); + } + + @Override + public void validate(@NonNull Object target, @NonNull Errors errors) { + HasIdAndEmail user = ((HasIdAndEmail) target); + if (StringUtils.hasText(user.getEmail())) { + repository.findByEmailIgnoreCase(user.getEmail()) + .ifPresent(dbUser -> { + if (request.getMethod().equals("PUT")) { // UPDATE + int dbId = dbUser.id(); + + // it is ok, if update ourselves + if (user.getId() != null && dbId == user.id()) return; + + // Workaround for update with user.id=null in request body + // ValidationUtil.assureIdConsistent called after this validation + String requestURI = request.getRequestURI(); + if (requestURI.endsWith("/" + dbId) || (dbId == AuthUtil.get().id() && requestURI.contains("/profile"))) + return; + } + errors.rejectValue("email", "", EXCEPTION_DUPLICATE_EMAIL); + }); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..52d74f0 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,49 @@ +# https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html +spring: + jpa: + show-sql: true + open-in-view: false + # https://stackoverflow.com/a/67678945/548473 + defer-datasource-initialization: true + hibernate: + ddl-auto: create + properties: + # http://docs.jboss.org/hibernate/orm/5.3/userguide/html_single/Hibernate_User_Guide.html#configurations + hibernate: + format_sql: true + default_batch_fetch_size: 20 + # https://stackoverflow.com/questions/21257819/what-is-the-difference-between-hibernate-jdbc-fetch-size-and-hibernate-jdbc-batc + jdbc.batch_size: 20 + datasource: + # ImMemory + url: jdbc:h2:mem:bootjava + # tcp: jdbc:h2:tcp://localhost:9092/mem:bootjava + # Absolute path + # url: jdbc:h2:C:/projects/bootjava/db/bootjava + # tcp: jdbc:h2:tcp://localhost:9092/C:/projects/bootjava/db/bootjava + # Relative path form current dir + # url: jdbc:h2:./db/bootjava + # Relative path from home + # url: jdbc:h2:~/bootjava + # tcp: jdbc:h2:tcp://localhost:9092/~/bootjava + username: sa + password: + + # https://docs.spring.io/spring-boot/docs/current/reference/html/appendix-application-properties.html#common-application-properties-cache + cache: + cache-names: users + caffeine.spec: maximumSize=5000,expireAfterAccess=60s + servlet: + encoding: + charset: UTF-8 + enabled: true + force: true + +# mvc.apiversion.use.header: API-Version + + level: + root: WARN + ru.javaops.bootjava: DEBUG + org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG + +#springdoc.swagger-ui.path: / diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..2fd1045 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,9 @@ +INSERT INTO USERS (name, email, password) +VALUES ('User', 'user@yandex.ru', '{noop}password'), + ('Admin', 'admin@gmail.com', '{noop}admin'), + ('Guest', 'guest@gmail.com', '{noop}guest'); + +INSERT INTO USER_ROLE (role, user_id) +VALUES ('USER', 1), + ('ADMIN', 2), + ('USER', 2); \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java new file mode 100644 index 0000000..c95b2e6 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/AbstractControllerTest.java @@ -0,0 +1,35 @@ +package ru.javaops.bootjava; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.transaction.annotation.Transactional; +import ru.javaops.bootjava.app.config.WebConfig; +import ru.javaops.bootjava.user.model.User; + +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications +@SpringBootTest +@Transactional +@AutoConfigureMockMvc +@ActiveProfiles("test") +//https://docs.spring.io/spring-boot/docs/current/reference/html/spring-boot-features.html#boot-features-testing-spring-boot-applications-testing-with-mock-environment +public abstract class AbstractControllerTest { + + @Autowired + private MockMvc mockMvc; + + protected ResultActions perform(MockHttpServletRequestBuilder builder) throws Exception { + builder.header(WebConfig.VERSION_HEADER, WebConfig.CURRENT_VERSION); + return mockMvc.perform(builder); + } + + protected static RequestPostProcessor userHttpBasic(User user) { + return SecurityMockMvcRequestPostProcessors.httpBasic(user.getEmail(), user.getPassword()); + } +} diff --git a/src/test/java/ru/javaops/bootjava/MatcherFactory.java b/src/test/java/ru/javaops/bootjava/MatcherFactory.java new file mode 100644 index 0000000..6a41537 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/MatcherFactory.java @@ -0,0 +1,83 @@ +package ru.javaops.bootjava; + +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.ResultMatcher; +import ru.javaops.bootjava.common.util.JsonUtil; + +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.function.BiConsumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Factory for creating test matchers. + *

+ * Comparing actual and expected objects via AssertJ + * Support converting json MvcResult to objects for comparation. + */ +public class MatcherFactory { + + public static Matcher usingAssertions(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + return new Matcher<>(clazz, assertion, iterableAssertion); + } + + public static Matcher usingEqualsComparator(Class clazz) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).isEqualTo(e), + (a, e) -> assertThat(a).isEqualTo(e)); + } + + public static Matcher usingIgnoringFieldsComparator(Class clazz, String... fieldsToIgnore) { + return usingAssertions(clazz, + (a, e) -> assertThat(a).usingRecursiveComparison().ignoringFields(fieldsToIgnore).isEqualTo(e), + (a, e) -> assertThat(a).usingRecursiveFieldByFieldElementComparatorIgnoringFields(fieldsToIgnore).isEqualTo(e)); + } + + public static class Matcher { + private final Class clazz; + private final BiConsumer assertion; + private final BiConsumer, Iterable> iterableAssertion; + + private Matcher(Class clazz, BiConsumer assertion, BiConsumer, Iterable> iterableAssertion) { + this.clazz = clazz; + this.assertion = assertion; + this.iterableAssertion = iterableAssertion; + } + + public void assertMatch(T actual, T expected) { + assertion.accept(actual, expected); + } + + @SafeVarargs + public final void assertMatch(Iterable actual, T... expected) { + assertMatch(actual, List.of(expected)); + } + + public void assertMatch(Iterable actual, Iterable expected) { + iterableAssertion.accept(actual, expected); + } + + public ResultMatcher contentJson(T expected) { + return result -> assertMatch(JsonUtil.readValue(getContent(result), clazz), expected); + } + + @SafeVarargs + public final ResultMatcher contentJson(T... expected) { + return contentJson(List.of(expected)); + } + + public ResultMatcher contentJson(Iterable expected) { + return result -> assertMatch(JsonUtil.readValues(getContent(result), clazz), expected); + } + + public T readFromJson(ResultActions action) throws UnsupportedEncodingException { + return JsonUtil.readValue(getContent(action.andReturn()), clazz); + } + + private static String getContent(MvcResult result) throws UnsupportedEncodingException { + return result.getResponse().getContentAsString(); + } + } +} diff --git a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java b/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java deleted file mode 100644 index 52bba6d..0000000 --- a/src/test/java/ru/javaops/bootjava/RestaurantVotingApplicationTests.java +++ /dev/null @@ -1,12 +0,0 @@ -package ru.javaops.bootjava; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class RestaurantVotingApplicationTests { - - @Test - void contextLoads() { - } -} diff --git a/src/test/java/ru/javaops/bootjava/user/UserTestData.java b/src/test/java/ru/javaops/bootjava/user/UserTestData.java new file mode 100644 index 0000000..3bb21fa --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/user/UserTestData.java @@ -0,0 +1,39 @@ +package ru.javaops.bootjava.user; + +import ru.javaops.bootjava.MatcherFactory; +import ru.javaops.bootjava.common.util.JsonUtil; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +public class UserTestData { + public static final MatcherFactory.Matcher USER_MATCHER = MatcherFactory.usingIgnoringFieldsComparator(User.class, "registered", "password"); + + public static final int USER_ID = 1; + public static final int ADMIN_ID = 2; + public static final int GUEST_ID = 3; + public static final int NOT_FOUND = 100; + public static final String USER_MAIL = "user@yandex.ru"; + public static final String ADMIN_MAIL = "admin@gmail.com"; + public static final String GUEST_MAIL = "guest@gmail.com"; + public static final String NEW_MAIL = "new@gmail.com"; + + public static final User user = new User(USER_ID, "User", USER_MAIL, "password", Role.USER); + public static final User admin = new User(ADMIN_ID, "Admin", ADMIN_MAIL, "admin", Role.ADMIN, Role.USER); + public static final User guest = new User(GUEST_ID, "Guest", GUEST_MAIL, "guest"); + + public static User getNew() { + return new User(null, "New", "new@gmail.com", "newPass", false, new Date(), Collections.singleton(Role.USER)); + } + + public static User getUpdated() { + return new User(USER_ID, "UpdatedName", USER_MAIL, "newPass", false, new Date(), List.of(Role.ADMIN)); + } + + public static String jsonWithPassword(User user, String passw) { + return JsonUtil.writeAdditionProps(user, "password", passw); + } +} diff --git a/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java new file mode 100644 index 0000000..231195e --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/user/web/AdminUserControllerTest.java @@ -0,0 +1,230 @@ +package ru.javaops.bootjava.user.web; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.bootjava.AbstractControllerTest; +import ru.javaops.bootjava.user.model.Role; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; +import static ru.javaops.bootjava.user.UserTestData.*; +import static ru.javaops.bootjava.user.web.AdminUserController.REST_URL; +import static ru.javaops.bootjava.user.web.UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL; + +class AdminUserControllerTest extends AbstractControllerTest { + + private static final String REST_URL_SLASH = REST_URL + '/'; + + @Autowired + private UserRepository repository; + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + ADMIN_ID)) + .andExpect(status().isOk()) + .andDo(print()) + // https://jira.spring.io/browse/SPR-14472 + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getNotFound() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getByEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL_SLASH + "by-email?email=" + admin.getEmail())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + USER_ID)) + .andDo(print()) + .andExpect(status().isNoContent()); + assertFalse(repository.findByEmailIgnoreCase(USER_MAIL).isPresent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void deleteNotFound() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL_SLASH + NOT_FOUND)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enableNotFound() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + NOT_FOUND) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void getForbidden() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isForbidden()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void update() throws Exception { + User updated = getUpdated(); + updated.setId(null); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "newPass"))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), getUpdated()); + } + + @Test + void updateEmail() throws Exception { + perform(MockMvcRequestBuilders.get(ProfileController.REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isOk()); + + User updated = getUpdated(); + updated.setEmail(NEW_MAIL); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .with(userHttpBasic(admin)) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "newPass"))) + .andDo(print()) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), updated); + + perform(MockMvcRequestBuilders.get(ProfileController.REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createWithLocation() throws Exception { + User newUser = getNew(); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(newUser, "newPass"))) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(repository.getExisted(newId), newUser); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void getAll() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(admin, guest, user)); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void enable() throws Exception { + perform(MockMvcRequestBuilders.patch(REST_URL_SLASH + USER_ID) + .param("enabled", "false") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isNoContent()); + + assertFalse(repository.getExisted(USER_ID).isEnabled()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createInvalid() throws Exception { + User invalid = new User(null, null, "", "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateInvalid() throws Exception { + User invalid = new User(user); + invalid.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(invalid, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateHtmlUnsafe() throws Exception { + User updated = new User(user); + updated.setName(""); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void updateDuplicate() throws Exception { + User updated = new User(user); + updated.setEmail(ADMIN_MAIL); + perform(MockMvcRequestBuilders.put(REST_URL_SLASH + USER_ID) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(updated, "password"))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } + + @Test + @WithUserDetails(value = ADMIN_MAIL) + void createDuplicate() throws Exception { + User expected = new User(null, "New", USER_MAIL, "newPass", Role.USER, Role.ADMIN); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(jsonWithPassword(expected, "newPass"))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()) + .andExpect(content().string(containsString(EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java new file mode 100644 index 0000000..3794e37 --- /dev/null +++ b/src/test/java/ru/javaops/bootjava/user/web/ProfileControllerTest.java @@ -0,0 +1,130 @@ +package ru.javaops.bootjava.user.web; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithUserDetails; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import ru.javaops.bootjava.AbstractControllerTest; +import ru.javaops.bootjava.common.util.JsonUtil; +import ru.javaops.bootjava.user.UsersUtil; +import ru.javaops.bootjava.user.model.User; +import ru.javaops.bootjava.user.repository.UserRepository; +import ru.javaops.bootjava.user.to.UserTo; + +import static org.hamcrest.Matchers.containsString; +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; +import static ru.javaops.bootjava.user.UserTestData.*; +import static ru.javaops.bootjava.user.web.ProfileController.REST_URL; + +class ProfileControllerTest extends AbstractControllerTest { + + @Autowired + private UserRepository repository; + + @Test + @WithUserDetails(value = USER_MAIL) + void get() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(USER_MATCHER.contentJson(user)); + } + + @Test + void getUnAuth() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void delete() throws Exception { + perform(MockMvcRequestBuilders.delete(REST_URL)) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.findAll(), admin, guest); + } + + @Test + void register() throws Exception { + UserTo newTo = new UserTo(null, "newName", "newemail@ya.ru", "newPassword"); + User newUser = UsersUtil.createNewFromTo(newTo); + ResultActions action = perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isCreated()); + + User created = USER_MATCHER.readFromJson(action); + int newId = created.id(); + newUser.setId(newId); + USER_MATCHER.assertMatch(created, newUser); + USER_MATCHER.assertMatch(repository.getExisted(newId), newUser); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void update() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", USER_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), UsersUtil.updateFromTo(new User(user), updatedTo)); + } + + @Test + void registerInvalid() throws Exception { + UserTo newTo = new UserTo(null, null, null, null); + perform(MockMvcRequestBuilders.post(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(newTo))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + void updateEmail() throws Exception { + perform(MockMvcRequestBuilders.get(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isOk()); + + UserTo updatedTo = new UserTo(null, "newName", NEW_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .with(userHttpBasic(user)) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isNoContent()); + USER_MATCHER.assertMatch(repository.getExisted(USER_ID), UsersUtil.updateFromTo(new User(user), updatedTo)); + + perform(MockMvcRequestBuilders.get(REST_URL) + .with(userHttpBasic(user))) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateInvalid() throws Exception { + UserTo updatedTo = new UserTo(null, null, "password", null); + perform(MockMvcRequestBuilders.put(REST_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()); + } + + @Test + @WithUserDetails(value = USER_MAIL) + void updateDuplicate() throws Exception { + UserTo updatedTo = new UserTo(null, "newName", ADMIN_MAIL, "newPassword"); + perform(MockMvcRequestBuilders.put(REST_URL).contentType(MediaType.APPLICATION_JSON) + .content(JsonUtil.writeValue(updatedTo))) + .andDo(print()) + .andExpect(status().isUnprocessableContent()) + .andExpect(content().string(containsString(UniqueMailValidator.EXCEPTION_DUPLICATE_EMAIL))); + } +} \ No newline at end of file diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..41f9662 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1 @@ +#spring.cache.type: none \ No newline at end of file