Refectory Session maintaining

- LoginSessionUtils 가 세션을 관리하도록 리펙토링
- @LoginAccount 가 AccountPrincipal 을 반환하도록 수정
- 테스트용 ExceptionHandler 삭제, ArgumentResolver 에서 예외 대신 null 을 반환하도록 수정.
- ArchUnitTests에서 프레젠테이션이 인프라 영역을 사용할 수 있도록 테스트 검증 수정
This commit is contained in:
JiwonDev
2021-09-13 19:51:48 +09:00
committed by Jiwon
parent 363f2b5375
commit d877744afd
17 changed files with 114 additions and 265 deletions

View File

@@ -1,7 +1,6 @@
package com.yam.app.account.application;
import com.yam.app.account.domain.AccountPrincipal;
import com.yam.app.account.domain.AccountService;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.ConfirmRegisterAccountProcessor;
import com.yam.app.account.domain.LoginAccountProcessor;
import com.yam.app.account.domain.RegisterAccountEvent;
@@ -22,19 +21,18 @@ public class AccountFacade {
private final ApplicationEventPublisher publisher;
private final ConfirmRegisterAccountProcessor confirmRegisterProcessor;
private final LoginAccountProcessor loginProcessor;
private final AccountService accountService;
private final AccountReader accountReader;
public AccountFacade(RegisterAccountProcessor registerProcessor,
AccountTranslator translator, ApplicationEventPublisher publisher,
ConfirmRegisterAccountProcessor confirmRegisterProcessor,
LoginAccountProcessor loginProcessor,
AccountService accountService) {
LoginAccountProcessor loginProcessor, AccountReader accountReader) {
this.registerProcessor = registerProcessor;
this.translator = translator;
this.publisher = publisher;
this.confirmRegisterProcessor = confirmRegisterProcessor;
this.loginProcessor = loginProcessor;
this.accountService = accountService;
this.accountReader = accountReader;
}
@Transactional
@@ -59,7 +57,9 @@ public class AccountFacade {
}
@Transactional(readOnly = true)
public AccountResponse getLoginAccount(AccountPrincipal accountPrincipal) {
return translator.toResponse(accountService.findByEmail(accountPrincipal.getEmail()));
public AccountResponse getLoginAccount(String email) {
return translator.toResponse(accountReader.findByEmail(email)
.orElseThrow(IllegalStateException::new));
}
}

View File

@@ -1,9 +0,0 @@
package com.yam.app.account.common;
public final class SessionConst {
public static final String LOGIN_ACCOUNT_PRINCIPAL = "LOGIN_ACCOUNT_PRINCIPAL";
private SessionConst() {
}
}

View File

@@ -1,6 +0,0 @@
package com.yam.app.account.domain;
public interface AccountPrincipal {
String getEmail();
}

View File

@@ -1,16 +0,0 @@
package com.yam.app.account.domain;
public final class AccountService {
private final AccountReader accountReader;
public AccountService(AccountReader accountReader) {
this.accountReader = accountReader;
}
public Account findByEmail(String email) {
return accountReader.findByEmail(email)
.orElseThrow(IllegalArgumentException::new);
}
}

View File

@@ -1,13 +1,12 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.AccountPrincipal;
import java.io.Serializable;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
public final class LoginAccountPrincipal implements AccountPrincipal, Serializable {
public final class AccountPrincipal implements Serializable {
@NotBlank
@Email

View File

@@ -2,13 +2,11 @@ package com.yam.app.account.infrastructure;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.AccountRepository;
import com.yam.app.account.domain.AccountService;
import com.yam.app.account.domain.ConfirmRegisterAccountProcessor;
import com.yam.app.account.domain.LoginAccountProcessor;
import com.yam.app.account.domain.PasswordEncrypter;
import com.yam.app.account.domain.RegisterAccountProcessor;
import com.yam.app.account.domain.TokenVerifier;
import javax.servlet.http.HttpSession;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -67,12 +65,8 @@ public class AppConfiguration {
@Bean
public LoginAccountProcessor loginAccountProcessor(AccountReader accountReader,
PasswordEncrypter passwordEncrypter, HttpSession httpSession) {
return new SessionBasedLoginAccountProcessor(accountReader, passwordEncrypter, httpSession);
PasswordEncrypter passwordEncrypter) {
return new SessionBasedLoginAccountProcessor(accountReader, passwordEncrypter);
}
@Bean
public AccountService accountService(AccountReader accountReader) {
return new AccountService(accountReader);
}
}

View File

@@ -0,0 +1,27 @@
package com.yam.app.account.infrastructure;
import javax.servlet.http.HttpSession;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
public final class LoginSessionUtils {
public static final String LOGIN_ACCOUNT_EMAIL = "LOGIN_ACCOUNT_EMAIL";
private LoginSessionUtils() {
}
private static HttpSession getHttpSession() {
return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest()
.getSession(true);
}
public static AccountPrincipal getAccountPrincipal() {
return (AccountPrincipal) getHttpSession().getAttribute(LOGIN_ACCOUNT_EMAIL);
}
public static void setAccountPrincipal(AccountPrincipal accountPrincipal) {
getHttpSession().setAttribute(LOGIN_ACCOUNT_EMAIL, accountPrincipal);
}
}

View File

@@ -1,28 +1,24 @@
package com.yam.app.account.infrastructure;
import com.yam.app.account.common.SessionConst;
import com.yam.app.account.domain.AccountReader;
import com.yam.app.account.domain.LoginAccountProcessor;
import com.yam.app.account.domain.PasswordEncrypter;
import javax.servlet.http.HttpSession;
public final class SessionBasedLoginAccountProcessor implements LoginAccountProcessor {
private final AccountReader accountReader;
private final PasswordEncrypter passwordEncrypter;
private final HttpSession httpSession;
public SessionBasedLoginAccountProcessor(AccountReader accountReader,
PasswordEncrypter passwordEncrypter, HttpSession httpSession) {
PasswordEncrypter passwordEncrypter) {
this.accountReader = accountReader;
this.passwordEncrypter = passwordEncrypter;
this.httpSession = httpSession;
}
@Override
public void login(String email, String password) {
var account = accountReader.findByEmail(email)
.orElseThrow(IllegalStateException::new);
.orElseThrow(IllegalArgumentException::new);
if (!account.isEmailVerified()) {
throw new IllegalStateException();
@@ -32,7 +28,6 @@ public final class SessionBasedLoginAccountProcessor implements LoginAccountProc
throw new IllegalStateException();
}
httpSession.setAttribute(SessionConst.LOGIN_ACCOUNT_PRINCIPAL,
new LoginAccountPrincipal(email));
LoginSessionUtils.setAccountPrincipal(new AccountPrincipal(email));
}
}

View File

@@ -1,12 +1,12 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import com.yam.app.account.infrastructure.AccountPrincipal;
import javax.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.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -41,13 +41,19 @@ public final class AccountQueryApi {
@GetMapping("/api/accounts/me")
public ResponseEntity<AccountResponse> getAccount(
@LoginAccount AccountResponse response) {
return ResponseEntity.ok(response);
@LoginAccount AccountPrincipal accountPrincipal) {
if (accountPrincipal == null) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}
AccountResponse accountResponse;
try {
accountResponse = accountFacade.getLoginAccount(accountPrincipal.getEmail());
} catch (Exception e) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
return ResponseEntity.ok(accountResponse);
}
// LoginAccountMethodArgumentResolver 테스트를 위해 임시로 만든 @ExceptionHandler
@ExceptionHandler
public ResponseEntity<Void> defaultException(IllegalStateException e) {
return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
}
}

View File

@@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 현재 로그인 되어있는 사용자의 AccountResponse 를 반환합니다.
* 현재 로그인 되어있는 사용자의 세션 AccountPrincipal 객체를 반환합니다.
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)

View File

@@ -1,8 +1,6 @@
package com.yam.app.account.presentation;
import com.yam.app.account.application.AccountFacade;
import com.yam.app.account.common.SessionConst;
import com.yam.app.account.domain.AccountPrincipal;
import com.yam.app.account.infrastructure.LoginSessionUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.springframework.core.MethodParameter;
@@ -15,13 +13,6 @@ import org.springframework.web.method.support.ModelAndViewContainer;
@Component
public final class LoginAccountMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final AccountFacade accountFacade;
public LoginAccountMethodArgumentResolver(
AccountFacade accountFacade) {
this.accountFacade = accountFacade;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(LoginAccount.class);
@@ -34,11 +25,10 @@ public final class LoginAccountMethodArgumentResolver implements HandlerMethodAr
.getSession(false);
if (session == null) {
throw new IllegalStateException();
return null;
}
return accountFacade.getLoginAccount(
(AccountPrincipal) session.getAttribute(SessionConst.LOGIN_ACCOUNT_PRINCIPAL));
return session.getAttribute(LoginSessionUtils.LOGIN_ACCOUNT_EMAIL);
}
}

View File

@@ -25,6 +25,6 @@ final class ArchUnitTests {
.whereLayer("Presentation").mayOnlyBeAccessedByLayers("Application", "Integration")
.whereLayer("Application").mayOnlyBeAccessedByLayers("Presentation", "Domain")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer()
.whereLayer("Infrastructure").mayOnlyBeAccessedByLayers("Presentation")
.whereLayer("Integration").mayNotBeAccessedByAnyLayer();
}

View File

@@ -0,0 +1,15 @@
package com.yam.app.account.domain;
public class PasswordEncrypterStub implements PasswordEncrypter {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
}

View File

@@ -21,16 +21,4 @@ final class PasswordEncrypterTest {
assertThat(result).isTrue();
}
public static class PasswordEncrypterStub implements PasswordEncrypter {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
}
}

View File

@@ -3,7 +3,6 @@ package com.yam.app.account.domain;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import com.yam.app.account.domain.PasswordEncrypterTest.PasswordEncrypterStub;
import java.util.Arrays;
import java.util.Collection;
import org.junit.jupiter.api.DisplayName;

View File

@@ -3,47 +3,21 @@ package com.yam.app.account.infrastructure;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.yam.app.account.common.SessionConst;
import com.yam.app.account.domain.Account;
import com.yam.app.account.domain.AccountPrincipal;
import com.yam.app.account.domain.FakeAccountRepository;
import com.yam.app.account.domain.PasswordEncrypter;
import com.yam.app.account.domain.PasswordEncrypterStub;
import java.util.Arrays;
import java.util.Collection;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestFactory;
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.mock.web.MockHttpSession;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
final class SessionBasedLoginAccountProcessorTest {
@Autowired
private MockMvc mockMvc;
@TestFactory
@DisplayName("세션 회원 로그인 검증 테스트")
Collection<DynamicTest> login_success() throws Exception {
Collection<DynamicTest> login_success() {
//Arrange
var accountRepository = new FakeAccountRepository();
var accountNotConfirm = Account.of("hello1@naver.com", "hello1",
@@ -54,24 +28,11 @@ final class SessionBasedLoginAccountProcessorTest {
accountRepository.save(accountCompleted);
accountRepository.save(accountNotConfirm);
var session = new FakeHttpSession();
var passwordEncryptor = new FakePasswordEncrypter();
var passwordEncryptor = new PasswordEncrypterStub();
var loginAccountProcessor = new SessionBasedLoginAccountProcessor(accountRepository,
passwordEncryptor, session);
passwordEncryptor);
return Arrays.asList(
dynamicTest("회원 로그인에 성공한다.", () -> {
// Act
var throwable = catchThrowable(
() -> loginAccountProcessor.login(accountCompleted.getEmail(),
accountCompleted.getPassword())
);
// Assert
assertThat(throwable).isNull();
assertThat(session.getAttribute(SessionConst.LOGIN_ACCOUNT_PRINCIPAL)).isInstanceOf(
AccountPrincipal.class);
}),
dynamicTest("이메일이 유효하지 않은 경우 예외를 리턴한다.", () -> {
// Act
var throwable = catchThrowable(
@@ -80,7 +41,7 @@ final class SessionBasedLoginAccountProcessorTest {
);
// Assert
assertThat(throwable).isInstanceOf(IllegalStateException.class);
assertThat(throwable).isInstanceOf(IllegalArgumentException.class);
}),
dynamicTest("이메일은 유효하나 검증을 완료하지 않은 경우 예외를 리턴한다.", () -> {
// Act
@@ -103,132 +64,4 @@ final class SessionBasedLoginAccountProcessorTest {
})
);
}
@Test
@DisplayName("로그인한 회원이 세션에서 자신의 정보를 받아오는 시나리오")
void login_member_get_account_session_request() throws Exception {
//Arrange
var session = new MockHttpSession();
session.setAttribute(SessionConst.LOGIN_ACCOUNT_PRINCIPAL,
new LoginAccountPrincipal("loginCheck@gmail.com"));
//Act
final var actions = mockMvc.perform(get("/api/accounts/me")
.session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON));
//Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
session.clearAttributes();
}
private static class FakePasswordEncrypter implements PasswordEncrypter {
@Override
public String encode(CharSequence rawPassword) {
return rawPassword.toString();
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return rawPassword.toString().equals(encodedPassword);
}
}
private static class FakeHttpSession implements HttpSession {
private final Map<String, Object> sessionData = new ConcurrentHashMap<>();
@Override
public long getCreationTime() {
return 0;
}
@Override
public String getId() {
return null;
}
@Override
public long getLastAccessedTime() {
return 0;
}
@Override
public ServletContext getServletContext() {
return null;
}
@Override
public int getMaxInactiveInterval() {
return 0;
}
@Override
public void setMaxInactiveInterval(int interval) {
}
@Override
public HttpSessionContext getSessionContext() {
return null;
}
@Override
public Object getAttribute(String name) {
return sessionData.getOrDefault(name, null);
}
@Override
public Object getValue(String name) {
return null;
}
@Override
public Enumeration<String> getAttributeNames() {
return null;
}
@Override
public String[] getValueNames() {
return new String[0];
}
@Override
public void setAttribute(String name, Object value) {
sessionData.put(name, value);
}
@Override
public void putValue(String name, Object value) {
}
@Override
public void removeAttribute(String name) {
}
@Override
public void removeValue(String name) {
}
@Override
public void invalidate() {
}
@Override
public boolean isNew() {
return false;
}
}
}

View File

@@ -2,13 +2,17 @@ package com.yam.app.account.presentation;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yam.app.account.application.AccountFacade;
import com.yam.app.account.infrastructure.AccountPrincipal;
import com.yam.app.account.infrastructure.LoginSessionUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@@ -19,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.web.servlet.MockMvc;
@DisplayName("Account Query HTTP API")
@@ -36,6 +41,35 @@ class AccountQueryApiTest {
@DisplayName("Login HTTP API")
class LoginApi {
@Test
@DisplayName("로그인한 회원이 요청하면 세션에서 Account 정보를 성공적으로 반환 받는다.")
void login_member_get_account_session_request() throws Exception {
//Arrange
var session = new MockHttpSession();
session.setAttribute(LoginSessionUtils.LOGIN_ACCOUNT_EMAIL,
new AccountPrincipal("loginCheck@gmail.com"));
when(accountFacade.getLoginAccount("loginCheck@gmail.com"))
.thenReturn(new AccountResponse(1L, "loginCheck@gmail.com",
"loginNick"));
//Act
final var actions = mockMvc.perform(get("/api/accounts/me")
.session(session)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON));
//Assert
actions
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.email").isString())
.andExpect(jsonPath("$.nickname").isString());
session.clearAttributes();
}
@Test
@DisplayName("세션이 없는 상태로 Account 정보를 요청하면 401 에러를 반환한다.")
void no_current_session_account_request() throws Exception {