Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/main/java/com/afternote/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,14 @@ public LoginResponse login(LoginRequest request) {

public LoginResponse issueTokens(User user) {
activityTouchService.touch(user.getId());
return createSessionTokens(user);
}

/**
* JWT·refresh만 발급한다. 신규 가입 INSERT가 커밋되기 전에
* {@link ActivityTouchService#touch}의 REQUIRES_NEW UPDATE가 같은 행을 기다리지 않게 한다.
*/
private LoginResponse createSessionTokens(User user) {
String accessToken = jwtTokenProvider.generateAccessToken(user.getId());
String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId());
tokenService.saveToken(refreshToken, user.getId());
Expand Down Expand Up @@ -275,7 +282,8 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) {
User user = userRepository.findByEmail(socialUserInfo.getEmail())
.orElse(null);
boolean isNewUser = false;

LoginResponse tokens;

if (user == null) {
withdrawalCooldownService.assertNotInCooldown(socialUserInfo.getEmail());
user = User.builder()
Expand All @@ -286,9 +294,11 @@ public SocialLoginResponse socialLogin(SocialLoginRequest request) {
.build();
user = saveNewUser(user);
isNewUser = true;
tokens = createSessionTokens(user);
} else {
tokens = issueTokens(user);
}

LoginResponse tokens = issueTokens(user);
return SocialLoginResponse.builder()
.accessToken(tokens.getAccessToken())
.refreshToken(tokens.getRefreshToken())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package com.afternote.domain.auth.integration;

import com.afternote.domain.auth.dto.SocialLoginRequest;
import com.afternote.domain.auth.dto.SocialLoginResponse;
import com.afternote.domain.auth.dto.SocialUserInfo;
import com.afternote.domain.auth.service.AuthService;
import com.afternote.domain.auth.service.EmailService;
import com.afternote.domain.auth.service.TokenService;
import com.afternote.domain.auth.service.social.SocialLoginFactory;
import com.afternote.domain.auth.service.social.SocialLoginService;
import com.afternote.domain.user.model.AuthProvider;
import com.afternote.domain.user.model.User;
import com.afternote.domain.user.model.UserStatus;
import com.afternote.domain.user.repository.UserRepository;
import com.afternote.domain.user.service.ActivityTouchService;
import com.afternote.domain.user.service.WithdrawalCooldownService;
import com.afternote.global.jwt.JwtTokenProvider;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.*;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.MySQLContainer;

import javax.sql.DataSource;
import java.time.LocalDateTime;
import java.util.Properties;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;

/**
* 신규 소셜 가입 INSERT와 {@code REQUIRES_NEW} 활동 UPDATE가 같은 행에서
* lock wait 나지 않는지 실제 MySQL 트랜잭션으로 확인한다.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@DisplayName("소셜 로그인 신규 가입 MySQL 잠금")
class SocialLoginSignupLockMySqlTest {

private MySQLContainer<?> mysql;
private AnnotationConfigApplicationContext context;
private AuthService authService;
private UserRepository userRepository;
private JdbcTemplate jdbcTemplate;
private SocialLoginFactory socialLoginFactory;
private JwtTokenProvider jwtTokenProvider;

@BeforeAll
void connectToMysql() {
DataSource dataSource;
String externalUrl = System.getenv("AFTERNOTE_MYSQL_TEST_URL");
if (externalUrl != null && !externalUrl.isBlank()) {
String username = System.getenv().getOrDefault("AFTERNOTE_MYSQL_TEST_USERNAME", "root");
String password = System.getenv().getOrDefault("AFTERNOTE_MYSQL_TEST_PASSWORD", "");
dataSource = hikari(externalUrl, username, password);
} else {
assumeTrue(dockerAvailable(),
"Docker 또는 AFTERNOTE_MYSQL_TEST_URL이 있어야 MySQL 회귀 테스트를 실행할 수 있습니다.");
mysql = new MySQLContainer<>("mysql:8.0");
mysql.start();
dataSource = hikari(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword());
}

context = new AnnotationConfigApplicationContext();
context.registerBean(DataSource.class, () -> dataSource);
context.register(JpaTestConfiguration.class);
context.refresh();

authService = context.getBean(AuthService.class);
userRepository = context.getBean(UserRepository.class);
jdbcTemplate = new JdbcTemplate(dataSource);
socialLoginFactory = context.getBean(SocialLoginFactory.class);
jwtTokenProvider = context.getBean(JwtTokenProvider.class);
}

@AfterAll
void stopMysql() {
if (context != null) {
context.close();
}
if (mysql != null) {
mysql.stop();
}
}

@BeforeEach
void stubTokens() {
given(jwtTokenProvider.generateAccessToken(anyLong())).willReturn("access");
given(jwtTokenProvider.generateRefreshToken(anyLong())).willReturn("refresh");
given(jwtTokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L);
}

@Test
@DisplayName("신규 카카오 가입은 lock wait 없이 커밋되고 lastActiveAt은 INSERT 값이다")
void newKakaoSignup_CommitsWithoutLockWait() {
String email = email();
stubKakao(email);
long waitsBefore = rowLockWaits();
LocalDateTime started = LocalDateTime.now().minusSeconds(1);

SocialLoginResponse response = authService.socialLogin(new SocialLoginRequest("KAKAO", "kakao-token"));

assertThat(response.isNewUser()).isTrue();
assertThat(response.getAccessToken()).isEqualTo("access");
assertThat(response.getRefreshToken()).isEqualTo("refresh");
assertThat(rowLockWaits()).isEqualTo(waitsBefore);

User saved = userRepository.findByEmail(email).orElseThrow();
assertThat(saved.getLastActiveAt()).isNotNull();
assertThat(saved.getLastActiveAt()).isAfterOrEqualTo(started);
assertThat(jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users WHERE email=?", Long.class, email
)).isEqualTo(1L);
}

@Test
@DisplayName("기존 카카오 재로그인은 last_active_at을 갱신한다")
void existingKakaoLogin_TouchesActivity() {
String email = email();
User existing = userRepository.saveAndFlush(User.builder()
.email(email)
.name("기존")
.status(UserStatus.ACTIVE)
.provider(AuthProvider.KAKAO)
.build());
LocalDateTime stale = LocalDateTime.now().minusDays(100);
jdbcTemplate.update("UPDATE users SET last_active_at=? WHERE id=?", stale, existing.getId());
stubKakao(email);

SocialLoginResponse response = authService.socialLogin(new SocialLoginRequest("KAKAO", "kakao-token"));

assertThat(response.isNewUser()).isFalse();
LocalDateTime active = jdbcTemplate.queryForObject(
"SELECT last_active_at FROM users WHERE id=?", LocalDateTime.class, existing.getId()
);
assertThat(active).isAfter(stale);
}

private void stubKakao(String email) {
SocialLoginService kakao = mock(SocialLoginService.class);
given(socialLoginFactory.getService("KAKAO")).willReturn(kakao);
given(kakao.getUserInfo(any())).willReturn(SocialUserInfo.builder()
.provider(AuthProvider.KAKAO)
.providerId("kakao-1")
.email(email)
.name("kakao")
.build());
}

private long rowLockWaits() {
Long waits = jdbcTemplate.queryForObject(
"SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits'",
(rs, rowNum) -> Long.parseLong(rs.getString("Value"))
);
return waits == null ? 0L : waits;
}

private static String email() {
return UUID.randomUUID() + "@example.test";
}

private static DataSource hikari(String jdbcUrl, String username, String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(jdbcUrl);
config.setUsername(username);
config.setPassword(password);
config.setConnectionInitSql("SET SESSION innodb_lock_wait_timeout=2");
return new HikariDataSource(config);
}

private static boolean dockerAvailable() {
try {
return DockerClientFactory.instance().isDockerAvailable();
} catch (RuntimeException ignored) {
return false;
}
}

@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(proxyTargetClass = true)
@EnableJpaAuditing
@EnableJpaRepositories(basePackageClasses = UserRepository.class)
static class JpaTestConfiguration {

@Bean
LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory =
new LocalContainerEntityManagerFactoryBean();
factory.setDataSource(dataSource);
factory.setPackagesToScan("com.afternote.domain");
HibernateJpaVendorAdapter vendor = new HibernateJpaVendorAdapter();
vendor.setGenerateDdl(true);
factory.setJpaVendorAdapter(vendor);
Properties properties = new Properties();
properties.setProperty("hibernate.hbm2ddl.auto", "create-drop");
factory.setJpaProperties(properties);
return factory;
}

@Bean
PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
return new JpaTransactionManager(entityManagerFactory);
}

@Bean
ActivityTouchService activityTouchService(UserRepository userRepository) {
return new ActivityTouchService(userRepository);
}

@Bean
SocialLoginFactory socialLoginFactory() {
return mock(SocialLoginFactory.class);
}

@Bean
TokenService tokenService() {
return mock(TokenService.class);
}

@Bean
JwtTokenProvider jwtTokenProvider() {
return mock(JwtTokenProvider.class);
}

@Bean
EmailService emailService() {
return mock(EmailService.class);
}

@Bean
WithdrawalCooldownService withdrawalCooldownService() {
return mock(WithdrawalCooldownService.class);
}

@Bean
PasswordEncoder passwordEncoder() {
return mock(PasswordEncoder.class);
}

@Bean
AuthService authService(
UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtTokenProvider jwtTokenProvider,
TokenService tokenService,
EmailService emailService,
WithdrawalCooldownService withdrawalCooldownService,
SocialLoginFactory socialLoginFactory,
ActivityTouchService activityTouchService
) {
return new AuthService(
userRepository,
passwordEncoder,
jwtTokenProvider,
tokenService,
emailService,
withdrawalCooldownService,
socialLoginFactory,
activityTouchService
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.util.ReflectionTestUtils;

import java.time.LocalDateTime;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -372,7 +372,7 @@ void emailVerify_Success() {
}

@Test
@DisplayName("소셜 로그인 성공 - 신규 유저 생성, 활동 시각 갱신")
@DisplayName("소셜 로그인 성공 - 신규 유저 생성, INSERT 커밋 전 touch 생략")
void socialLogin_NewUser_Success() {
SocialLoginRequest request = org.mockito.Mockito.mock(SocialLoginRequest.class);
given(request.getProvider()).willReturn("KAKAO");
Expand Down Expand Up @@ -401,8 +401,13 @@ void socialLogin_NewUser_Success() {

assertThat(response.isNewUser()).isTrue();
assertThat(response.getAccessToken()).isEqualTo("social-access");
assertThat(response.getRefreshToken()).isEqualTo("social-refresh");
assertThat(response.getExpiresIn()).isEqualTo(3600L);
verify(activityTouchService).touch(77L);

ArgumentCaptor<User> saved = ArgumentCaptor.forClass(User.class);
verify(userRepository).saveAndFlush(saved.capture());
assertThat(saved.getValue().getLastActiveAt()).isNotNull();
verify(activityTouchService, never()).touch(any());
verify(tokenService).saveToken("social-refresh", 77L);
}

Expand Down Expand Up @@ -442,7 +447,8 @@ void socialLogin_ExistingUser_Success() {
assertThat(response.getAccessToken()).isEqualTo("existing-access");
assertThat(response.getRefreshToken()).isEqualTo("existing-refresh");
assertThat(response.getExpiresIn()).isEqualTo(3600L);
verify(userRepository, org.mockito.Mockito.never()).save(any(User.class));
verify(userRepository, never()).save(any(User.class));
verify(userRepository, never()).saveAndFlush(any(User.class));
verify(activityTouchService).touch(88L);
verify(tokenService).saveToken("existing-refresh", 88L);
}
Expand Down
Loading