diff --git a/build.gradle b/build.gradle index 4228a64..c6ab6a8 100644 --- a/build.gradle +++ b/build.gradle @@ -41,11 +41,17 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' + // Flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-test-autoconfigure' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java new file mode 100644 index 0000000..0681cce --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/controller/ApiKeyController.java @@ -0,0 +1,40 @@ +package com.tokenledgercloud.api.controller; + +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.service.ApiKeyService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/api-keys") +@RequiredArgsConstructor +public class ApiKeyController { + + private final ApiKeyService apiKeyService; + + @PostMapping + public ApiKeyCreateResponse createApiKey(Authentication authentication, @Valid @RequestBody ApiKeyCreateRequest request) { + return apiKeyService.createApiKey(authentication, request); + } + + @GetMapping + public List getMyApiKeys(Authentication authentication) { + return apiKeyService.getMyApiKeys(authentication); + } + + @PatchMapping("/{id}/deactivate") + public void deactivateApiKey(Authentication authentication, @PathVariable Long id) { + apiKeyService.deactivateApiKey(authentication, id); + } + + @DeleteMapping("/{id}") + public void deleteApiKey(Authentication authentication, @PathVariable Long id) { + apiKeyService.deleteApiKey(authentication, id); + } +} diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java new file mode 100644 index 0000000..a739f7a --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKey.java @@ -0,0 +1,51 @@ +package com.tokenledgercloud.api.domain.apikey; + +import com.tokenledgercloud.api.domain.member.Member; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_keys") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "hashed_key", nullable = false, unique = true) + private String hashedKey; + + @Column(name = "display_key", nullable = false) + private String displayKey; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(name = "name") + private String name; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @Column(name = "is_active") + @Builder.Default + private boolean isActive = true; + + @PrePersist + public void prePersist() { + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java new file mode 100644 index 0000000..33c666a --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/domain/apikey/ApiKeyRepository.java @@ -0,0 +1,11 @@ +package com.tokenledgercloud.api.domain.apikey; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; +import java.util.Optional; + +public interface ApiKeyRepository extends JpaRepository { + Optional findByHashedKey(String hashedKey); + Optional findByHashedKeyAndIsActiveTrue(String hashedKey); + List findByMemberId(Long memberId); +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java new file mode 100644 index 0000000..11c5d88 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateRequest.java @@ -0,0 +1,16 @@ +package com.tokenledgercloud.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ApiKeyCreateRequest { + @NotBlank(message = "API Key name is required") + @Size(max = 50, message = "API Key name must be less than 50 characters") + private String name; +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java new file mode 100644 index 0000000..be46f04 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyCreateResponse.java @@ -0,0 +1,21 @@ +package com.tokenledgercloud.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKeyCreateResponse { + private Long id; + private String rawKey; + private String displayKey; + private String name; + private LocalDateTime createdAt; + private boolean isActive; +} diff --git a/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java new file mode 100644 index 0000000..836c330 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/dto/ApiKeyResponse.java @@ -0,0 +1,20 @@ +package com.tokenledgercloud.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApiKeyResponse { + private Long id; + private String displayKey; + private String name; + private LocalDateTime createdAt; + private boolean isActive; +} diff --git a/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java b/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java index 1dc3e0b..1cf77fc 100644 --- a/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java +++ b/src/main/java/com/tokenledgercloud/api/global/config/SecurityConfig.java @@ -14,7 +14,9 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import com.tokenledgercloud.api.service.CustomOAuth2UserService; +import com.tokenledgercloud.api.service.ApiKeyService; import com.tokenledgercloud.api.global.security.JwtAuthenticationFilter; +import com.tokenledgercloud.api.global.security.ApiKeyAuthenticationFilter; import com.tokenledgercloud.api.global.security.JwtTokenProvider; import com.tokenledgercloud.api.global.security.OAuth2SuccessHandler; @@ -28,6 +30,7 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final JwtTokenProvider jwtTokenProvider; private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final ApiKeyService apiKeyService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -51,7 +54,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) .successHandler(oAuth2SuccessHandler) ) - .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new ApiKeyAuthenticationFilter(apiKeyService), JwtAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java b/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..5f63513 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/global/security/ApiKeyAuthenticationFilter.java @@ -0,0 +1,59 @@ +package com.tokenledgercloud.api.global.security; + +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.service.ApiKeyService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@RequiredArgsConstructor +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final ApiKeyService apiKeyService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String apiKey = resolveApiKey(request); + + if (StringUtils.hasText(apiKey)) { + try { + Member member = apiKeyService.verifyApiKey(apiKey); + + UserDetails userDetails = new User( + member.getEmail(), + "", + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + member.getRole().name())) + ); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // Invalid API key - we can either throw error or just let it pass to be caught by security chain + // Here we just don't set the authentication + } + } + + filterChain.doFilter(request, response); + } + + private String resolveApiKey(HttpServletRequest request) { + return request.getHeader("X-API-KEY"); + } +} diff --git a/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java b/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java new file mode 100644 index 0000000..ccb36bb --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/global/util/HashingUtil.java @@ -0,0 +1,19 @@ +package com.tokenledgercloud.api.global.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public class HashingUtil { + + public static String hash(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedhash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(encodedhash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } +} diff --git a/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java new file mode 100644 index 0000000..600dfb0 --- /dev/null +++ b/src/main/java/com/tokenledgercloud/api/service/ApiKeyService.java @@ -0,0 +1,124 @@ +package com.tokenledgercloud.api.service; + +import com.tokenledgercloud.api.domain.apikey.ApiKey; +import com.tokenledgercloud.api.domain.apikey.ApiKeyRepository; +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.domain.member.MemberRepository; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.global.util.HashingUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ApiKeyService { + + private final ApiKeyRepository apiKeyRepository; + private final MemberRepository memberRepository; + + private static final int MAX_API_KEYS_PER_MEMBER = 5; + + @Transactional + public ApiKeyCreateResponse createApiKey(Authentication authentication, ApiKeyCreateRequest request) { + Member member = getMember(authentication); + + long currentKeysCount = apiKeyRepository.findByMemberId(member.getId()).size(); + if (currentKeysCount >= MAX_API_KEYS_PER_MEMBER) { + throw new IllegalStateException("Maximum number of API keys reached (" + MAX_API_KEYS_PER_MEMBER + ")"); + } + + String rawKey = "tk-" + UUID.randomUUID().toString().replace("-", ""); + String hashedKey = HashingUtil.hash(rawKey); + String displayKey = rawKey.substring(0, 7) + "..." + rawKey.substring(rawKey.length() - 4); + + ApiKey apiKey = ApiKey.builder() + .hashedKey(hashedKey) + .displayKey(displayKey) + .member(member) + .name(request.getName()) + .isActive(true) + .build(); + + apiKeyRepository.save(apiKey); + + return toCreateResponse(apiKey, rawKey); + } + + @Transactional + public Member verifyApiKey(String rawKey) { + String hashedKey = HashingUtil.hash(rawKey); + ApiKey apiKey = apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey) + .orElseThrow(() -> new IllegalArgumentException("Invalid or inactive API Key")); + + apiKey.setLastUsedAt(LocalDateTime.now()); + return apiKey.getMember(); + } + + @Transactional(readOnly = true) + public List getMyApiKeys(Authentication authentication) { + Member member = getMember(authentication); + return apiKeyRepository.findByMemberId(member.getId()).stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + @Transactional + public void deactivateApiKey(Authentication authentication, Long apiKeyId) { + ApiKey apiKey = getMyApiKey(authentication, apiKeyId); + apiKey.setActive(false); + } + + @Transactional + public void deleteApiKey(Authentication authentication, Long apiKeyId) { + ApiKey apiKey = getMyApiKey(authentication, apiKeyId); + apiKeyRepository.delete(apiKey); + } + + private ApiKey getMyApiKey(Authentication authentication, Long apiKeyId) { + Member member = getMember(authentication); + ApiKey apiKey = apiKeyRepository.findById(apiKeyId) + .orElseThrow(() -> new IllegalArgumentException("API Key not found")); + + if (!apiKey.getMember().getId().equals(member.getId())) { + throw new IllegalArgumentException("Unauthorized to access this API Key"); + } + return apiKey; + } + + private Member getMember(Authentication authentication) { + String identifier = authentication.getName(); + return memberRepository.findByEmail(identifier) + .or(() -> memberRepository.findByProviderId(identifier)) + .orElseThrow(() -> new IllegalStateException("Member not found")); + } + + private ApiKeyResponse toResponse(ApiKey apiKey) { + return ApiKeyResponse.builder() + .id(apiKey.getId()) + .displayKey(apiKey.getDisplayKey()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .isActive(apiKey.isActive()) + .build(); + } + + private ApiKeyCreateResponse toCreateResponse(ApiKey apiKey, String rawKey) { + return ApiKeyCreateResponse.builder() + .id(apiKey.getId()) + .rawKey(rawKey) + .displayKey(apiKey.getDisplayKey()) + .name(apiKey.getName()) + .createdAt(apiKey.getCreatedAt()) + .isActive(apiKey.isActive()) + .build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8d42aef..bad1a64 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,6 +59,9 @@ spring: hibernate: ddl-auto: update show-sql: true # 실행되는 SQL 쿼리를 터미널에 보여줍니다 + flyway: + enabled: true + baseline-on-migrate: true jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..2596a93 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,34 @@ +CREATE TABLE members ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE, + name VARCHAR(255) NOT NULL, + password VARCHAR(255), + role VARCHAR(50) NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) +); + +CREATE TABLE usage_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + event_id VARCHAR(255) NOT NULL, + idempotency_key VARCHAR(255) NOT NULL UNIQUE, + project_id BIGINT, + application_id BIGINT, + user_id BIGINT, + model_id VARCHAR(255) NOT NULL, + input_tokens BIGINT NOT NULL, + output_tokens BIGINT NOT NULL, + total_tokens BIGINT NOT NULL, + total_cost DECIMAL(18, 8) NOT NULL, + currency_code VARCHAR(10) NOT NULL, + status VARCHAR(30) NOT NULL, + started_at DATETIME NOT NULL, + finished_at DATETIME, + latency_ms BIGINT, + error_code VARCHAR(255), + error_message VARCHAR(1000), + created_at DATETIME NOT NULL +); + +CREATE INDEX idx_usage_project_started ON usage_logs (project_id, started_at); +CREATE INDEX idx_usage_model_started ON usage_logs (model_id, started_at); diff --git a/src/main/resources/db/migration/V2__create_api_keys_table.sql b/src/main/resources/db/migration/V2__create_api_keys_table.sql new file mode 100644 index 0000000..0b63a0e --- /dev/null +++ b/src/main/resources/db/migration/V2__create_api_keys_table.sql @@ -0,0 +1,14 @@ +CREATE TABLE api_keys ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + hashed_key VARCHAR(255) NOT NULL UNIQUE, + display_key VARCHAR(255) NOT NULL, + member_id BIGINT NOT NULL, + name VARCHAR(255), + created_at DATETIME NOT NULL, + last_used_at DATETIME, + is_active BOOLEAN DEFAULT TRUE, + CONSTRAINT fk_api_keys_member FOREIGN KEY (member_id) REFERENCES members(id) +); + +CREATE INDEX idx_api_keys_member ON api_keys (member_id); +CREATE INDEX idx_api_keys_hashed ON api_keys (hashed_key); diff --git a/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java b/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java new file mode 100644 index 0000000..a0d5d52 --- /dev/null +++ b/src/test/java/com/tokenledgercloud/api/controller/ApiKeyControllerTest.java @@ -0,0 +1,93 @@ +package com.tokenledgercloud.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.dto.ApiKeyResponse; +import com.tokenledgercloud.api.service.ApiKeyService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +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.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class ApiKeyControllerTest { + + private MockMvc mockMvc; + + @Mock + private ApiKeyService apiKeyService; + + @InjectMocks + private ApiKeyController apiKeyController; + + private ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(apiKeyController).build(); + } + + @Test + @DisplayName("API 키 생성 API 호출 성공") + void createApiKey_ApiSuccess() throws Exception { + // given + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("New Key"); + + ApiKeyCreateResponse response = ApiKeyCreateResponse.builder() + .id(1L) + .rawKey("tk-123456789") + .displayKey("tk-123...6789") + .name("New Key") + .createdAt(LocalDateTime.now()) + .isActive(true) + .build(); + + given(apiKeyService.createApiKey(any(), any())).willReturn(response); + + // when & then + mockMvc.perform(post("/api/api-keys") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.rawKey").value("tk-123456789")) + .andExpect(jsonPath("$.displayKey").value("tk-123...6789")); + } + + @Test + @DisplayName("내 API 키 목록 조회 API 호출 성공") + void getMyApiKeys_ApiSuccess() throws Exception { + // given + ApiKeyResponse response = ApiKeyResponse.builder() + .id(1L) + .displayKey("tk-abc...def") + .name("My Key") + .isActive(true) + .build(); + + given(apiKeyService.getMyApiKeys(any())).willReturn(List.of(response)); + + // when & then + mockMvc.perform(get("/api/api-keys")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].displayKey").value("tk-abc...def")) + .andExpect(jsonPath("$[0].name").value("My Key")); + } +} diff --git a/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java b/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java new file mode 100644 index 0000000..e6a8ab0 --- /dev/null +++ b/src/test/java/com/tokenledgercloud/api/service/ApiKeyServiceTest.java @@ -0,0 +1,134 @@ +package com.tokenledgercloud.api.service; + +import com.tokenledgercloud.api.domain.apikey.ApiKey; +import com.tokenledgercloud.api.domain.apikey.ApiKeyRepository; +import com.tokenledgercloud.api.domain.member.Member; +import com.tokenledgercloud.api.domain.member.MemberRepository; +import com.tokenledgercloud.api.domain.member.Role; +import com.tokenledgercloud.api.dto.ApiKeyCreateRequest; +import com.tokenledgercloud.api.dto.ApiKeyCreateResponse; +import com.tokenledgercloud.api.global.util.HashingUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ApiKeyServiceTest { + + @InjectMocks + private ApiKeyService apiKeyService; + + @Mock + private ApiKeyRepository apiKeyRepository; + + @Mock + private MemberRepository memberRepository; + + private Member testMember; + + @BeforeEach + void setUp() { + testMember = Member.builder() + .id(1L) + .email("test@example.com") + .name("Tester") + .role(Role.USER) + .provider("google") + .build(); + } + + @Test + @DisplayName("API 키 생성 성공") + void createApiKey_Success() { + // given + Authentication authentication = mock(Authentication.class); + given(authentication.getName()).willReturn("test@example.com"); + given(memberRepository.findByEmail("test@example.com")).willReturn(Optional.of(testMember)); + given(apiKeyRepository.findByMemberId(testMember.getId())).willReturn(new ArrayList<>()); + + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("My Test Key"); + + // when + ApiKeyCreateResponse response = apiKeyService.createApiKey(authentication, request); + + // then + assertThat(response.getRawKey()).startsWith("tk-"); + assertThat(response.getDisplayKey()).contains("..."); + assertThat(response.getName()).isEqualTo("My Test Key"); + verify(apiKeyRepository).save(any(ApiKey.class)); + } + + @Test + @DisplayName("API 키 생성 실패 - 최대 개수(5개) 초과") + void createApiKey_LimitExceeded() { + // given + Authentication authentication = mock(Authentication.class); + given(authentication.getName()).willReturn("test@example.com"); + given(memberRepository.findByEmail("test@example.com")).willReturn(Optional.of(testMember)); + + List existingKeys = new ArrayList<>(); + for (int i = 0; i < 5; i++) existingKeys.add(new ApiKey()); + given(apiKeyRepository.findByMemberId(testMember.getId())).willReturn(existingKeys); + + ApiKeyCreateRequest request = new ApiKeyCreateRequest(); + request.setName("Sixth Key"); + + // when & then + assertThatThrownBy(() -> apiKeyService.createApiKey(authentication, request)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Maximum number of API keys reached"); + } + + @Test + @DisplayName("API 키 검증 성공") + void verifyApiKey_Success() { + // given + String rawKey = "tk-valid-key-example-123456789"; + String hashedKey = HashingUtil.hash(rawKey); + ApiKey apiKey = ApiKey.builder() + .hashedKey(hashedKey) + .member(testMember) + .isActive(true) + .build(); + + given(apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey)).willReturn(Optional.of(apiKey)); + + // when + Member result = apiKeyService.verifyApiKey(rawKey); + + // then + assertThat(result.getEmail()).isEqualTo(testMember.getEmail()); + assertThat(apiKey.getLastUsedAt()).isNotNull(); + } + + @Test + @DisplayName("API 키 검증 실패 - 잘못된 키") + void verifyApiKey_Invalid() { + // given + String rawKey = "tk-wrong-key"; + String hashedKey = HashingUtil.hash(rawKey); + given(apiKeyRepository.findByHashedKeyAndIsActiveTrue(hashedKey)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> apiKeyService.verifyApiKey(rawKey)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid or inactive API Key"); + } +}