From ece3adf718576a25a7c4d6f2f6214f3aebd5a9cf Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 22 May 2026 21:24:04 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EB=A0=88=EA=B1=B0=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=9D=90=EB=A6=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/jwt/JwtAuthenticationFilter.java | 2 +- .../global/security/jwt/JwtTokenProvider.java | 14 -- .../user/config/OauthConfigProperties.java | 2 - .../user/dto/request/BasicAccountRequest.java | 27 ---- .../user/dto/request/GuestCodeRequest.java | 9 -- .../dto/response/BasicAccountResponse.java | 7 - .../user/repository/OauthRepository.java | 10 -- .../bottlenote/user/service/OauthService.java | 77 --------- .../config/TestConfigProperties.java | 1 - .../follow/follow-search-follower-list.adoc | 7 +- .../follow/follow-search-following-list.adoc | 7 +- .../asciidoc/api/user/user-guest-login.adoc | 52 ------ .../src/docs/asciidoc/product-api.adoc | 3 - .../user/controller/OauthController.java | 39 ----- .../user/fake/FakeOauthRepository.java | 5 - .../follow/RestDocsFollowControllerTest.java | 4 +- .../docs/user/RestOauthControllerTest.java | 152 ------------------ ...\353\240\210\352\261\260\354\213\234.http" | 40 ----- ...\355\201\260\353\260\234\352\270\211.http" | 15 -- 19 files changed, 9 insertions(+), 464 deletions(-) delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java delete mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java delete mode 100644 bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc delete mode 100644 "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" delete mode 100644 "http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java index 9e7ca76f8..d300cafce 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtAuthenticationFilter.java @@ -122,7 +122,7 @@ private Optional resolveToken(HttpServletRequest request) { @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - List excludePath = List.of("login", "guest-login", "regions", "alcohols/categories"); + List excludePath = List.of("login", "regions", "alcohols/categories"); return excludePath.stream().anyMatch(path::contains); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java index 4ec3f0ab4..c0736b622 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/JwtTokenProvider.java @@ -68,20 +68,6 @@ public String createAccessToken(String userEmail, UserType role, Long userId) { .compact(); } - public String createGuestToken(Long userId, int expireTime) { - Claims claims = Jwts.claims(); - claims.put("userId", userId); - claims.put(KEY_ROLES, UserType.ROLE_GUEST.name()); - Date now = new Date(); - return Jwts.builder() - .setClaims(claims) - .setSubject("guest") - .setIssuedAt(now) - .setExpiration(new Date(now.getTime() + expireTime)) - .signWith(secretKey, SignatureAlgorithm.HS512) - .compact(); - } - /** * 필수적인 파라미터를 받아 리프레시 토큰을 생성하는 메소드 * diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java b/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java index 52c4247fd..738de45d2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/config/OauthConfigProperties.java @@ -23,11 +23,9 @@ public class OauthConfigProperties { private String refreshTokenHeaderPrefix; private int cookieExpireTime; - private String guestCode; public void printConfigs() { log.info("refreshTokenHeaderPrefix: {}", refreshTokenHeaderPrefix); log.info("cookieExpireTime: {}", cookieExpireTime); - log.info("guestCode: {}", guestCode); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java deleted file mode 100644 index 06f3291c0..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/BasicAccountRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package app.bottlenote.user.dto.request; - -import app.bottlenote.user.constant.GenderType; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -/** 해당 클래스는 사용자의 기본 계정 정보를 요청하는 DTO 클래스입니다. */ -@Builder -@Getter -@AllArgsConstructor -@NoArgsConstructor -public class BasicAccountRequest { - @NotBlank(message = "EMAIL_IS_REQUIRED") - private String email; - - @NotBlank(message = "PASSWORD_IS_REQUIRED") - private String password; - - @Min(value = 0, message = "AGE_NEED_OVER_19") - private Integer age; - - private GenderType gender; -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java deleted file mode 100644 index 85e56650b..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/GuestCodeRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package app.bottlenote.user.dto.request; - -import jakarta.validation.constraints.NotBlank; - -public record GuestCodeRequest(@NotBlank(message = "REQUIRED_GUEST_CODE") String code) { - public static GuestCodeRequest of(String code) { - return new GuestCodeRequest(code); - } -} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java deleted file mode 100644 index 82f03429a..000000000 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/BasicAccountResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package app.bottlenote.user.dto.response; - -import lombok.Builder; - -@Builder -public record BasicAccountResponse( - String message, String email, String nickname, String accessToken, String refreshToken) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java index 7043f2629..04ea1b08d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/OauthRepository.java @@ -31,16 +31,6 @@ Optional findByEmailAndSocialType( @Query("select u from users u order by u.id limit 1") Optional getFirstUser(); - @Query( - """ - SELECT u - FROM users u - WHERE u.role = 'ROLE_GUEST' - ORDER BY u.id - LIMIT 1 - """) - Optional loadGuestUser(); - @Query("select count (u)+1 from users u") String getNextNicknameSequence(); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java index 5d2fdc994..a586897db 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/OauthService.java @@ -6,12 +6,10 @@ import app.bottlenote.global.security.jwt.JwtAuthenticationManager; import app.bottlenote.global.security.jwt.JwtTokenProvider; import app.bottlenote.global.security.jwt.TokenValidator; -import app.bottlenote.user.constant.GenderType; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.constant.UserType; import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.request.OauthRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.exception.UserException; import app.bottlenote.user.exception.UserExceptionCode; @@ -156,23 +154,6 @@ public void restoreUser(String email, String password) { user.restore(); } - @Transactional - public String guestLogin() { - final int expireTime = 1000 * 60 * 60 * 24; - OauthRequest oauthRequest = - new OauthRequest( - "guest" + UUID.randomUUID() + "@bottlenote.com", - "socialUniqueId" + UUID.randomUUID(), - SocialType.APPLE, - GenderType.MALE, - 30); - User guest = - oauthRepository - .loadGuestUser() - .orElseGet(() -> oauthSignUp(oauthRequest, UserType.ROLE_GUEST)); - return tokenProvider.createGuestToken(guest.getId(), expireTime); - } - private User oauthSignUp(OauthRequest oauthRequest, UserType userType) { String socialUniqueId = oauthRequest.socialUniqueId(); @@ -228,64 +209,6 @@ public TokenItem refresh(String refreshToken) { return reissuedToken; } - @Transactional - public BasicAccountResponse basicSignup( - String email, String password, Integer age, GenderType gender) { - oauthRepository - .findByEmail(email) - .ifPresent( - user -> { - throw new UserException(UserExceptionCode.USER_ALREADY_EXISTS); - }); - - String encodePassword = passwordEncoder.encode(password); - User user = - oauthRepository.save( - User.builder() - .email(email) - .password(encodePassword) - .role(UserType.ROLE_USER) - .socialType(List.of(SocialType.BASIC)) - .nickName(generateNickname()) - .age(age) - .gender(gender) - .build()); - - TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId()); - user.updateRefreshToken(token.refreshToken()); - - log.info( - "기본 회원가입 완료: email={}, userId={}, nickname={}", - user.getEmail(), - user.getId(), - user.getNickName()); - - return BasicAccountResponse.builder() - .message(user.getNickName() + "님 환영합니다!") - .email(user.getEmail()) - .nickname(user.getNickName()) - .accessToken(token.accessToken()) - .refreshToken(token.refreshToken()) - .build(); - } - - @Transactional - public TokenItem basicLogin(String email, String password) { - User user = - oauthRepository - .findByEmail(email) - .orElseThrow(() -> new UserException(UserExceptionCode.USER_NOT_FOUND)); - if (!passwordEncoder.matches(password, user.getPassword())) { - throw new UserException(UserExceptionCode.INVALID_PASSWORD); - } - - checkActiveUser(user); - - TokenItem token = tokenProvider.generateToken(user.getEmail(), user.getRole(), user.getId()); - user.updateRefreshToken(token.refreshToken()); - return token; - } - protected String generateNickname() { List a = Arrays.asList("부드러운", "향기로운", "숙성된", "풍부한", "깊은", "황금빛", "오크향의", "스모키한", "달콤한", "강렬한"); diff --git a/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java index ff8a0d30b..db5dcd894 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/config/TestConfigProperties.java @@ -10,7 +10,6 @@ public class TestConfigProperties { @Bean public OauthConfigProperties oauthConfigProperties() { return OauthConfigProperties.builder() - .guestCode("guest-code-for-test") .cookieExpireTime(100000) .refreshTokenHeaderPrefix("refresh-token") .build(); diff --git a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc index 9eb14552d..b8d69933c 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-follower-list.adoc @@ -24,12 +24,11 @@ GET /api/v1/follow/{userId}/following-list [discrete] ==== 요청 파라미터 ==== -include::{snippets}/follow/search/query-parameters.adoc[] +include::{snippets}/follow/follower-list/query-parameters.adoc[] [discrete] ==== 응답 파라미터 ==== -include::{snippets}/follow/search/response-fields.adoc[] -include::{snippets}/follow/search/response-body.adoc[] - +include::{snippets}/follow/follower-list/response-fields.adoc[] +include::{snippets}/follow/follower-list/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc index 666064a7a..81e933320 100644 --- a/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/api/follow/follow-search-following-list.adoc @@ -24,12 +24,11 @@ GET /api/v1/follow/{userId}/follower-list [discrete] ==== 요청 파라미터 ==== -include::{snippets}/follow/search/query-parameters.adoc[] +include::{snippets}/follow/following-list/query-parameters.adoc[] [discrete] ==== 응답 파라미터 ==== -include::{snippets}/follow/search/response-fields.adoc[] -include::{snippets}/follow/search/response-body.adoc[] - +include::{snippets}/follow/following-list/response-fields.adoc[] +include::{snippets}/follow/following-list/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc b/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc deleted file mode 100644 index a832707fb..000000000 --- a/bottlenote-product-api/src/docs/asciidoc/api/user/user-guest-login.adoc +++ /dev/null @@ -1,52 +0,0 @@ -=== 베이직 회원가입 === - -[.line-through]#게스트 로그인 API# - -베이직 회원가입 API - -[source] ----- -POST /api/v1/oauth/basic/signup ----- - -include::{snippets}/user/basic-signup/curl-request.adoc[] - -[discrete] -==== 요청 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-signup/request-fields.adoc[] -include::{snippets}/user/basic-signup/request-body.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-signup/response-fields.adoc[] -include::{snippets}/user/basic-signup/response-body.adoc[] - -=== 베이직 로그인 === - -베이직 회원가입 API - -[source] ----- -POST /api/v1/oauth/basic/login ----- - -include::{snippets}/user/basic-login/curl-request.adoc[] - - -[discrete] -==== 요청 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-login/request-fields.adoc[] -include::{snippets}/user/basic-login/request-body.adoc[] - -[discrete] -==== 응답 파라미터 ==== - -[discrete] -include::{snippets}/user/basic-login/response-fields.adoc[] -include::{snippets}/user/basic-login/response-body.adoc[] diff --git a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc index db4722f19..39b452345 100644 --- a/bottlenote-product-api/src/docs/asciidoc/product-api.adoc +++ b/bottlenote-product-api/src/docs/asciidoc/product-api.adoc @@ -43,9 +43,6 @@ include::api/auth/auth.adoc[] ''' include::api/user/user-login.adoc[] -''' -include::api/user/user-guest-login.adoc[] - ''' include::api/user/user-reissue.adoc[] diff --git a/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java b/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java index c7642f32b..66e314c78 100644 --- a/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java +++ b/bottlenote-product-api/src/main/java/app/bottlenote/user/controller/OauthController.java @@ -2,22 +2,16 @@ import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.user.config.OauthConfigProperties; -import app.bottlenote.user.dto.request.BasicAccountRequest; import app.bottlenote.user.dto.request.BasicLoginRequest; -import app.bottlenote.user.dto.request.GuestCodeRequest; import app.bottlenote.user.dto.request.OauthRequest; import app.bottlenote.user.dto.request.TokenVerifyRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.OauthResponse; import app.bottlenote.user.dto.response.TokenItem; -import app.bottlenote.user.exception.UserException; -import app.bottlenote.user.exception.UserExceptionCode; import app.bottlenote.user.service.OauthService; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; -import java.util.Base64; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -36,24 +30,6 @@ public class OauthController { private final OauthService oauthService; private final OauthConfigProperties configProperties; - @PostMapping("/basic/signup") - public ResponseEntity executeBasicSignup( - @RequestBody @Valid BasicAccountRequest request, HttpServletResponse response) { - BasicAccountResponse token = - oauthService.basicSignup( - request.getEmail(), request.getPassword(), request.getAge(), request.getGender()); - setRefreshTokenInCookie(response, token.refreshToken()); - return GlobalResponse.ok(token); - } - - @PostMapping("/basic/login") - public ResponseEntity executeBasicLogin( - @RequestBody @Valid BasicLoginRequest request, HttpServletResponse response) { - TokenItem token = oauthService.basicLogin(request.getEmail(), request.getPassword()); - setRefreshTokenInCookie(response, token.refreshToken()); - return GlobalResponse.ok(OauthResponse.of(token.accessToken())); - } - @PostMapping("/login") public ResponseEntity executeOauthLogin( @RequestBody @Valid OauthRequest oauthReq, HttpServletResponse response) { @@ -62,21 +38,6 @@ public ResponseEntity executeOauthLogin( return GlobalResponse.ok(OauthResponse.of(token.accessToken())); } - @PostMapping("/guest-login") - public ResponseEntity executeGuestLogin(@RequestBody @Valid GuestCodeRequest guestCode) { - final String key = - Base64.getEncoder().encodeToString(configProperties.getGuestCode().getBytes()); - final String code = guestCode.code(); - - if (!code.equals(key)) { - throw new UserException(UserExceptionCode.NOT_MATCH_GUEST_CODE); - } - - final String token = oauthService.guestLogin(); - final OauthResponse oauthResponse = OauthResponse.of(token); - return GlobalResponse.ok(oauthResponse); - } - @PostMapping("/reissue") public ResponseEntity reissueOauthToken( HttpServletRequest request, HttpServletResponse response) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java index dc29df717..f2353c43d 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/fake/FakeOauthRepository.java @@ -33,11 +33,6 @@ public Optional getFirstUser() { return Optional.empty(); } - @Override - public Optional loadGuestUser() { - return Optional.empty(); - } - @Override public String getNextNicknameSequence() { return "User" + (nicknameSequence++); diff --git a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java index 07aaf116c..9e4d6f493 100644 --- a/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/follow/RestDocsFollowControllerTest.java @@ -137,7 +137,7 @@ void docs_2() throws Exception { .andExpect(status().isOk()) .andDo( document( - "follow/search", + "follow/following-list", queryParameters( parameterWithName("type").optional().description("팔로잉 or 팔로워 조회 여부 쿼리파라미터"), parameterWithName("cursor").optional().description("조회 할 시작 기준 위치"), @@ -190,7 +190,7 @@ void docs_3() throws Exception { .andExpect(status().isOk()) .andDo( document( - "follow/search", + "follow/follower-list", queryParameters( parameterWithName("type").optional().description("팔로잉 or 팔로워 조회 여부 쿼리파라미터"), parameterWithName("cursor").optional().description("조회 할 시작 기준 위치"), diff --git a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java index d80bf93b3..13e7116d7 100644 --- a/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/user/RestOauthControllerTest.java @@ -1,6 +1,5 @@ package app.docs.user; -import static java.util.Base64.getEncoder; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -20,12 +19,9 @@ import app.bottlenote.user.constant.GenderType; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.controller.OauthController; -import app.bottlenote.user.dto.request.BasicAccountRequest; import app.bottlenote.user.dto.request.BasicLoginRequest; -import app.bottlenote.user.dto.request.GuestCodeRequest; import app.bottlenote.user.dto.request.OauthRequest; import app.bottlenote.user.dto.request.TokenVerifyRequest; -import app.bottlenote.user.dto.response.BasicAccountResponse; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.service.OauthService; import app.docs.AbstractRestDocs; @@ -44,7 +40,6 @@ class RestOauthControllerTest extends AbstractRestDocs { public RestOauthControllerTest() { this.config = OauthConfigProperties.builder() - .guestCode("TEST_GUEST_CODE") .cookieExpireTime(123456) .refreshTokenHeaderPrefix("refresh-token") .build(); @@ -176,48 +171,6 @@ void reissue_test() throws Exception { responseHeaders(headerWithName("Set-Cookie").description("리프레쉬 토큰")))); } - @Test - @DisplayName("게스트 토큰을 발급 받을수 있다.") - void guest_login() throws Exception { - // given - final String guestCode = config.getGuestCode(); - final var request = GuestCodeRequest.of(getEncoder().encodeToString(guestCode.getBytes())); - final String token = "response-token"; - - // when - when(oauthService.guestLogin()).thenReturn(token); - - // then - mockMvc - .perform( - post("/api/v1/oauth/guest-login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/guest-login", - requestFields(fieldWithPath("code").description("게스트 코드")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data.accessToken").description("액세스 토큰"), - fieldWithPath("data.isFirstLogin") - .type(JsonFieldType.BOOLEAN) - .description("최초 로그인 여부 (true: 최초 로그인, false: 기존 사용자)") - .optional(), - fieldWithPath("data.nickname") - .type(JsonFieldType.STRING) - .description("사용자 닉네임 (최초 로그인 시 자동 생성된 닉네임)") - .optional(), - fieldWithPath("meta.serverEncoding").description("서버 인코딩 정도"), - fieldWithPath("meta.serverVersion").description("서버 버전"), - fieldWithPath("meta.serverPathVersion").description("서버 경로 버전"), - fieldWithPath("meta.serverResponseTime").description("서버 응답 시간")))); - } - @Test @DisplayName("토큰 유효성을 검사할 수 있다.") void token_validation() throws Exception { @@ -251,111 +204,6 @@ void token_validation() throws Exception { fieldWithPath("meta.serverResponseTime").ignored()))); } - @Test - @DisplayName("베이식 회원가입을 할 수 있다.") - void basic_signup() throws Exception { - // given - final String nickName = "부드러운몰트"; - final BasicAccountRequest request = - BasicAccountRequest.builder() - .email("test@email.com") - .password("test-password") - .age(27) - .gender(null) - .build(); - - final BasicAccountResponse response = - BasicAccountResponse.builder() - .message(nickName + "님 환영합니다!") - .email(request.getEmail()) - .nickname(nickName) - .accessToken("access-token") - .refreshToken("refresh-token") - .build(); - - // when - when(oauthService.basicSignup( - request.getEmail(), request.getPassword(), request.getAge(), request.getGender())) - .thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/oauth/basic/signup") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/basic-signup", - requestFields( - fieldWithPath("email").description("이메일"), - fieldWithPath("password").description("비밀번호"), - fieldWithPath("age").description("나이"), - fieldWithPath("gender").description("성별")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data").description("결과"), - fieldWithPath("data.message").description("결과 메시지"), - fieldWithPath("data.email").description("이메일"), - fieldWithPath("data.nickname").description("닉네임"), - fieldWithPath("data.accessToken").description("accessToken"), - fieldWithPath("data.refreshToken").description("refreshToken"), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - } - - @Test - @DisplayName("베이식 로그인을 할 수 있다.") - void basic_login() throws Exception { - // given - final String nickName = "부드러운몰트"; - final BasicLoginRequest request = - BasicLoginRequest.builder().email("test@email.com").password("test-password").build(); - - final TokenItem response = TokenItem.of("access-token", "refresh-token"); - // when - when(oauthService.basicLogin(request.getEmail(), request.getPassword())).thenReturn(response); - - // then - mockMvc - .perform( - post("/api/v1/oauth/basic/login") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo( - document( - "user/basic-login", - requestFields( - fieldWithPath("email").description("이메일"), - fieldWithPath("password").description("비밀번호")), - responseFields( - fieldWithPath("success").ignored(), - fieldWithPath("code").ignored(), - fieldWithPath("errors").ignored(), - fieldWithPath("data").description("결과"), - fieldWithPath("data.accessToken").description("accessToken"), - fieldWithPath("data.isFirstLogin") - .type(JsonFieldType.BOOLEAN) - .description("최초 로그인 여부 (true: 최초 로그인, false: 기존 사용자)") - .optional(), - fieldWithPath("data.nickname") - .type(JsonFieldType.STRING) - .description("사용자 닉네임 (최초 로그인 시 자동 생성된 닉네임)") - .optional(), - fieldWithPath("meta.serverEncoding").ignored(), - fieldWithPath("meta.serverVersion").ignored(), - fieldWithPath("meta.serverPathVersion").ignored(), - fieldWithPath("meta.serverResponseTime").ignored()))); - } - @Test @DisplayName("회원 탈퇴를 복구 할 수 있다.") void restore() throws Exception { diff --git "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" deleted file mode 100644 index c107061a1..000000000 --- "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/OAuth\353\240\210\352\261\260\354\213\234.http" +++ /dev/null @@ -1,40 +0,0 @@ -### 기본 회원가입 (이메일/비밀번호) -POST {{host}}/api/v1/oauth/basic/signup -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!", - "age": 25, - "gender": "MALE" -} - -### 기본 로그인 (이메일/비밀번호) -POST {{host}}/api/v1/oauth/basic/login -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!" -} - -### 토큰 재발급 -POST {{host}}/api/v1/oauth/reissue -refresh-token: {{refreshToken}} - -### 토큰 검증 -PUT {{host}}/api/v1/oauth/token/verify -Content-Type: application/json - -{ - "token": "{{accessToken}}" -} - -### 계정 복구 -POST {{host}}/api/v1/oauth/restore -Content-Type: application/json - -{ - "email": "user@example.com", - "password": "password123!" -} diff --git "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" "b/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" deleted file mode 100644 index cd95e18ea..000000000 --- "a/http/product/01_\355\232\214\354\233\220\352\264\200\353\246\254/\352\260\200\354\236\205_\353\241\234\352\267\270\354\235\270/\352\262\214\354\212\244\355\212\270\355\206\240\355\201\260\353\260\234\352\270\211.http" +++ /dev/null @@ -1,15 +0,0 @@ -### 게스트 토큰 발급 실패 (토큰이 다른 경우) -POST {{host}}/api/v1/oauth/guest-login -Content-Type: application/json - -{ - "code": "Qk9UVExFTk9URS1HVUVTVC1DT0RFLTIwMjQ=" -} - -### 게스트 토큰 발급 -POST {{host}}/api/v1/oauth/guest-login -Content-Type: application/json - -{ - "code": "aW9zLWd1ZXN0LWNvZGUtNzI5MzE0" -} From 1f4c94245b1354bdb310a4029542439f14720db0 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 22 May 2026 22:27:20 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20AWS=20SDK=20v2=EB=A1=9C=20S3=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bottlenote-admin-api/build.gradle.kts | 3 +- .../file/AdminImageUploadIntegrationTest.kt | 50 +- bottlenote-mono/build.gradle | 4 +- .../file/service/ImageUploadService.java | 33 +- .../bottlenote/global/config/AwsS3Config.java | 31 +- .../handler/GlobalExceptionHandler.java | 12 +- .../service/converter/AdminRoleConverter.java | 3 +- .../service/converter/JsonArrayConverter.java | 3 +- .../common/file/ImageUploadUnitTest.java | 102 +- .../operation/utils/TestContainersConfig.java | 80 +- bottlenote-product-api/build.gradle | 3 +- .../file/upload/ImageUploadServiceTest.java | 30 +- .../upload/MinioContainerLoadingTest.java | 21 +- .../upload/fixture/AbstractFakeAmazonS3.java | 1267 ----------------- .../file/upload/fixture/FakeAmazonS3.java | 49 - gradle/libs.versions.toml | 7 +- plan/aws-sdk-v2-migration.md | 144 ++ 17 files changed, 420 insertions(+), 1422 deletions(-) delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java delete mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java create mode 100644 plan/aws-sdk-v2-migration.md diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index 714624624..9635b0aa5 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -38,7 +38,8 @@ dependencies { testImplementation(libs.bundles.testcontainers.complete) // Test - AWS S3 (for MinIO integration test) - testImplementation(libs.aws.s3) + testImplementation(platform(libs.aws.sdk.bom)) + testImplementation(libs.aws.sdk.s3) } sourceSets { diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt index 8a3752bb8..529b25606 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -2,7 +2,6 @@ package app.integration.file import app.IntegrationTestSupport import app.bottlenote.operation.utils.TestContainersConfig -import com.amazonaws.services.s3.AmazonS3 import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName @@ -10,15 +9,19 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.GetObjectRequest +import software.amazon.awssdk.services.s3.model.HeadObjectRequest import java.io.OutputStreamWriter import java.net.HttpURLConnection import java.net.URI +import java.nio.charset.StandardCharsets @Tag("admin_integration") @DisplayName("[integration] Admin Image Upload API 통합 테스트") class AdminImageUploadIntegrationTest : IntegrationTestSupport() { @Autowired - private lateinit var amazonS3: AmazonS3 + private lateinit var s3Client: S3Client private lateinit var accessToken: String @@ -182,12 +185,26 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { // then: S3에서 파일 존재 확인 val bucketName = TestContainersConfig.getTestBucket() - val exists = amazonS3.doesObjectExist(bucketName, s3Key) - assertThat(exists).isEqualTo(true) + s3Client.headObject( + HeadObjectRequest + .builder() + .bucket(bucketName) + .key(s3Key) + .build() + ) // then: 업로드된 내용 확인 - val s3Object = amazonS3.getObject(bucketName, s3Key) - val content = s3Object.objectContent.bufferedReader().use { it.readText() } + val content = + s3Client + .getObject( + GetObjectRequest + .builder() + .bucket(bucketName) + .key(s3Key) + .build() + ) + .bufferedReader(StandardCharsets.UTF_8) + .use { it.readText() } assertThat(content).isEqualTo(testContent) log.info("업로드된 파일 내용 확인 완료: {}", content) @@ -229,13 +246,24 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { val bucketName = TestContainersConfig.getTestBucket() uploadResults.forEach { (s3Key, expectedContent, responseCode) -> assertThat(responseCode).isEqualTo(200) - assertThat(amazonS3.doesObjectExist(bucketName, s3Key)).isEqualTo(true) + s3Client.headObject( + HeadObjectRequest + .builder() + .bucket(bucketName) + .key(s3Key) + .build() + ) val actualContent = - amazonS3 - .getObject(bucketName, s3Key) - .objectContent - .bufferedReader() + s3Client + .getObject( + GetObjectRequest + .builder() + .bucket(bucketName) + .key(s3Key) + .build() + ) + .bufferedReader(StandardCharsets.UTF_8) .use { it.readText() } assertThat(actualContent).isEqualTo(expectedContent) } diff --git a/bottlenote-mono/build.gradle b/bottlenote-mono/build.gradle index 4564a4363..0f1ba0365 100644 --- a/bottlenote-mono/build.gradle +++ b/bottlenote-mono/build.gradle @@ -24,6 +24,7 @@ dependencies { // ===== Text Processing ===== implementation libs.ahocorasick + implementation libs.commons.codec // ===== Security ===== implementation libs.spring.boot.starter.security @@ -32,7 +33,8 @@ dependencies { implementation libs.google.guava // ===== External Services ===== - implementation libs.aws.s3 + implementation platform(libs.aws.sdk.bom) + implementation libs.aws.sdk.s3 implementation libs.spring.cloud.starter.openfeign // ===== Observability ===== diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index b31989d1a..0884bd5d8 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -7,14 +7,14 @@ import app.bottlenote.common.file.dto.response.ImageUploadItem; import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.global.security.SecurityContextUtil; -import com.amazonaws.HttpMethod; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import java.time.Duration; import java.util.ArrayList; -import java.util.Calendar; import java.util.List; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @Slf4j @ThirdPartyService @@ -22,17 +22,17 @@ public class ImageUploadService implements PreSignUrlProvider { private static final Integer EXPIRY_TIME = 5; private final ResourceCommandService resourceCommandService; - private final AmazonS3 amazonS3; + private final S3Presigner s3Presigner; private final String imageBucketName; private final String cloudFrontUrl; public ImageUploadService( ResourceCommandService resourceCommandService, - AmazonS3 amazonS3, + S3Presigner s3Presigner, @Value("${amazon.aws.bucket}") String imageBucketName, @Value("${amazon.aws.cloudFrontUrl}") String cloudFrontUrl) { this.resourceCommandService = resourceCommandService; - this.amazonS3 = amazonS3; + this.s3Presigner = s3Presigner; this.imageBucketName = imageBucketName; this.cloudFrontUrl = cloudFrontUrl; } @@ -100,13 +100,18 @@ public String generateViewUrl(String cloudFrontUrl, String imageKey) { @Override public String generatePreSignUrl(String imageKey, String contentType) { - Calendar uploadExpiryTime = getUploadExpiryTime(EXPIRY_TIME); - GeneratePresignedUrlRequest request = - new GeneratePresignedUrlRequest(imageBucketName, imageKey) - .withMethod(HttpMethod.PUT) - .withExpiration(uploadExpiryTime.getTime()) - .withContentType(contentType); - return amazonS3.generatePresignedUrl(request).toString(); + PutObjectRequest putObjectRequest = + PutObjectRequest.builder() + .bucket(imageBucketName) + .key(imageKey) + .contentType(contentType) + .build(); + PutObjectPresignRequest presignRequest = + PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(EXPIRY_TIME)) + .putObjectRequest(putObjectRequest) + .build(); + return s3Presigner.presignPutObject(presignRequest).url().toString(); } private void saveImageUploadLogs(String rootPath, List items) { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java b/bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java index 9cd2cfc3b..5ca781800 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java @@ -1,12 +1,13 @@ package app.bottlenote.global.config; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Configuration public class AwsS3Config { @@ -21,13 +22,23 @@ public class AwsS3Config { /** s3 의 client 를 생성한다. */ @Bean - public AmazonS3 getAmazonS3Client() { - final BasicAWSCredentials basicAWSCredentials = - new BasicAWSCredentials(accessKeyId, accessKeySecret); + public S3Client getAmazonS3Client() { + return S3Client.builder() + .credentialsProvider(credentialsProvider()) + .region(Region.of(s3RegionName)) + .build(); + } - return AmazonS3ClientBuilder.standard() - .withCredentials(new AWSStaticCredentialsProvider(basicAWSCredentials)) - .withRegion(s3RegionName) + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .credentialsProvider(credentialsProvider()) + .region(Region.of(s3RegionName)) .build(); } + + private StaticCredentialsProvider credentialsProvider() { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKeyId, accessKeySecret)); + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java index 3642562b6..74d4e32e1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java @@ -7,9 +7,6 @@ import app.bottlenote.global.exception.custom.AbstractCustomException; import app.bottlenote.global.exception.custom.code.ValidExceptionCode; import app.bottlenote.global.security.jwt.JwtExceptionType; -import com.amazonaws.AmazonClientException; -import com.amazonaws.AmazonServiceException; -import com.amazonaws.SdkClientException; import com.fasterxml.jackson.databind.JsonMappingException; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; @@ -28,6 +25,9 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkException; @Slf4j(topic = "GlobalExceptionHandler") @RestControllerAdvice @@ -209,11 +209,11 @@ private JwtExceptionType getJwtExceptionType(Exception e) { * @param exception the exception * @return the response entity */ - @ExceptionHandler(AmazonClientException.class) - public ResponseEntity handleAmazonClientException(AmazonClientException exception) { + @ExceptionHandler(SdkException.class) + public ResponseEntity handleSdkException(SdkException exception) { String errorMessage; - if (exception instanceof AmazonServiceException ase) { + if (exception instanceof AwsServiceException ase) { errorMessage = "AWS 서비스 오류가 발생했습니다: " + ase.getMessage(); } else if (exception instanceof SdkClientException sce) { errorMessage = "AWS SDK 오류가 발생했습니다: " + sce.getMessage(); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java index b0adf4acd..a12a332a5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java @@ -4,7 +4,6 @@ import app.bottlenote.user.constant.AdminRole; import app.bottlenote.user.exception.UserException; -import com.amazonaws.util.CollectionUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -22,7 +21,7 @@ public class AdminRoleConverter implements AttributeConverter, S @Override public String convertToDatabaseColumn(List roles) { - if (CollectionUtils.isNullOrEmpty(roles)) { + if (roles == null || roles.isEmpty()) { return "[]"; } try { diff --git a/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java index c65f26e35..d3b75fd98 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java @@ -4,7 +4,6 @@ import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.exception.UserException; -import com.amazonaws.util.CollectionUtils; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,7 +23,7 @@ public class JsonArrayConverter implements AttributeConverter, @Override public String convertToDatabaseColumn(List list) { - if (CollectionUtils.isNullOrEmpty(list)) { + if (list == null || list.isEmpty()) { return "[]"; } try { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index b264b05a8..95af468c0 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -11,19 +11,17 @@ import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.common.file.service.ImageUploadService; import app.bottlenote.common.file.service.ResourceCommandService; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; import java.io.OutputStream; import java.net.HttpURLConnection; +import java.net.URI; import java.net.URL; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -39,6 +37,17 @@ import org.testcontainers.containers.MinIOContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Tag("unit") @Testcontainers @@ -57,25 +66,45 @@ class ImageUploadUnitTest { .withUserName(MINIO_ACCESS_KEY) .withPassword(MINIO_SECRET_KEY); - private static AmazonS3 amazonS3; + private static S3Client s3Client; + private static S3Presigner s3Presigner; private ImageUploadService imageUploadService; private ResourceCommandService resourceCommandService; private InMemoryResourceLogRepository resourceLogRepository; @BeforeAll static void setUpContainer() { - amazonS3 = - AmazonS3ClientBuilder.standard() - .withEndpointConfiguration( - new AwsClientBuilder.EndpointConfiguration(minioContainer.getS3URL(), "us-east-1")) - .withCredentials( - new AWSStaticCredentialsProvider( - new BasicAWSCredentials(MINIO_ACCESS_KEY, MINIO_SECRET_KEY))) - .withPathStyleAccessEnabled(true) + StaticCredentialsProvider credentialsProvider = + StaticCredentialsProvider.create( + AwsBasicCredentials.create(MINIO_ACCESS_KEY, MINIO_SECRET_KEY)); + S3Configuration s3Configuration = + S3Configuration.builder().pathStyleAccessEnabled(true).build(); + URI endpoint = URI.create(minioContainer.getS3URL()); + + s3Client = + S3Client.builder() + .endpointOverride(endpoint) + .region(Region.US_EAST_1) + .credentialsProvider(credentialsProvider) + .serviceConfiguration(s3Configuration) .build(); + s3Presigner = + S3Presigner.builder() + .endpointOverride(endpoint) + .region(Region.US_EAST_1) + .credentialsProvider(credentialsProvider) + .serviceConfiguration(s3Configuration) + .build(); + s3Client.createBucket(CreateBucketRequest.builder().bucket(TEST_BUCKET).build()); + } - if (!amazonS3.doesBucketExistV2(TEST_BUCKET)) { - amazonS3.createBucket(TEST_BUCKET); + @AfterAll + static void closeClients() { + if (s3Client != null) { + s3Client.close(); + } + if (s3Presigner != null) { + s3Presigner.close(); } } @@ -84,7 +113,7 @@ void setUp() { resourceLogRepository = new InMemoryResourceLogRepository(); resourceCommandService = new ResourceCommandService(resourceLogRepository); imageUploadService = - new ImageUploadService(resourceCommandService, amazonS3, TEST_BUCKET, CLOUD_FRONT_URL); + new ImageUploadService(resourceCommandService, s3Presigner, TEST_BUCKET, CLOUD_FRONT_URL); } @AfterEach @@ -167,11 +196,44 @@ void test_3() throws Exception { connection.disconnect(); // when - boolean exists = amazonS3.doesObjectExist(TEST_BUCKET, imageKey); + s3Client.headObject(HeadObjectRequest.builder().bucket(TEST_BUCKET).key(imageKey).build()); + + // then + log.info("업로드된 객체 키 = {}", imageKey); + } + + @Test + @DisplayName("업로드된 파일 내용을 MinIO에서 조회할 수 있다") + void test_4() throws Exception { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L, null); + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); + String viewUrl = response.imageUploadInfo().get(0).viewUrl(); + String imageKey = viewUrl.substring(CLOUD_FRONT_URL.length() + 1); + byte[] testData = "test image content".getBytes(); + + URL url = new URL(uploadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("PUT"); + connection.setRequestProperty("Content-Type", "image/jpeg"); + try (OutputStream os = connection.getOutputStream()) { + os.write(testData); + } + connection.getResponseCode(); + connection.disconnect(); + + // when + String content; + try (ResponseInputStream responseInputStream = + s3Client.getObject( + GetObjectRequest.builder().bucket(TEST_BUCKET).key(imageKey).build())) { + content = new String(responseInputStream.readAllBytes(), StandardCharsets.UTF_8); + } // then - assertTrue(exists); - log.info("업로드된 객체 키 = {}, 존재 여부 = {}", imageKey, exists); + assertEquals("test image content", content); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 47612571a..d95a4d814 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -1,15 +1,14 @@ package app.bottlenote.operation.utils; +import app.bottlenote.common.file.service.ImageUploadService; +import app.bottlenote.common.file.service.ResourceCommandService; import app.bottlenote.common.profanity.ProfanityClient; import app.bottlenote.common.profanity.dto.response.ProfanityResponse; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.redis.testcontainers.RedisContainer; +import java.net.URI; import java.util.Collections; import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; @@ -17,6 +16,15 @@ import org.testcontainers.containers.MinIOContainer; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; /** * TestContainers 설정을 관리하는 Spring Bean 기반 Configuration @@ -71,27 +79,61 @@ MinIOContainer minioContainer() { return container; } - /** MinIO에 연결하는 AmazonS3 클라이언트를 등록합니다. */ - @Bean + /** MinIO에 연결하는 S3Client를 등록합니다. */ + @Bean("testS3Client") @Primary - AmazonS3 amazonS3(MinIOContainer minioContainer) { - AmazonS3 s3Client = - AmazonS3ClientBuilder.standard() - .withEndpointConfiguration( - new AwsClientBuilder.EndpointConfiguration(minioContainer.getS3URL(), "us-east-1")) - .withCredentials( - new AWSStaticCredentialsProvider( - new BasicAWSCredentials(MINIO_ACCESS_KEY, MINIO_SECRET_KEY))) - .withPathStyleAccessEnabled(true) + S3Client s3Client(MinIOContainer minioContainer) { + S3Client s3Client = + S3Client.builder() + .endpointOverride(URI.create(minioContainer.getS3URL())) + .region(Region.US_EAST_1) + .credentialsProvider(credentialsProvider()) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) .build(); - if (!s3Client.doesBucketExistV2(TEST_BUCKET)) { - s3Client.createBucket(TEST_BUCKET); - } + createBucketIfAbsent(s3Client); return s3Client; } + /** MinIO에 연결하는 S3Presigner를 등록합니다. */ + @Bean("testS3Presigner") + @Primary + S3Presigner s3Presigner(MinIOContainer minioContainer) { + return S3Presigner.builder() + .endpointOverride(URI.create(minioContainer.getS3URL())) + .region(Region.US_EAST_1) + .credentialsProvider(credentialsProvider()) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + } + + private StaticCredentialsProvider credentialsProvider() { + return StaticCredentialsProvider.create( + AwsBasicCredentials.create(MINIO_ACCESS_KEY, MINIO_SECRET_KEY)); + } + + private void createBucketIfAbsent(S3Client s3Client) { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(TEST_BUCKET).build()); + } catch (S3Exception exception) { + if (exception.statusCode() == 404) { + s3Client.createBucket(CreateBucketRequest.builder().bucket(TEST_BUCKET).build()); + return; + } + throw exception; + } + } + + @Bean("testImageUploadService") + @Primary + ImageUploadService imageUploadService( + ResourceCommandService resourceCommandService, + S3Presigner s3Presigner, + @Value("${amazon.aws.cloudFrontUrl}") String cloudFrontUrl) { + return new ImageUploadService(resourceCommandService, s3Presigner, TEST_BUCKET, cloudFrontUrl); + } + public static String getTestBucket() { return TEST_BUCKET; } diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index 82732a76d..b41a2b237 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -62,7 +62,8 @@ dependencies { testImplementation libs.restdocs.api.spec.mockmvc // Test - AWS - testImplementation libs.aws.s3 + testImplementation platform(libs.aws.sdk.bom) + testImplementation libs.aws.sdk.s3 // Test - Feign testImplementation libs.spring.cloud.starter.openfeign diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java index 002b49968..8b57ac55a 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java @@ -13,12 +13,12 @@ import app.bottlenote.common.file.exception.FileExceptionCode; import app.bottlenote.common.file.service.ImageUploadService; import app.bottlenote.common.file.service.ResourceCommandService; -import app.bottlenote.common.file.upload.fixture.FakeAmazonS3; import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; import java.time.LocalDate; import java.util.Calendar; import java.util.Map; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -26,6 +26,10 @@ import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; @Tag("unit") @DisplayName("[unit] [service] ImageUploadService") @@ -34,21 +38,26 @@ class ImageUploadServiceTest { private static final Logger log = LoggerFactory.getLogger(ImageUploadServiceTest.class); private static final String BUCKET_NAME = "test-bucket"; private static final String CLOUD_FRONT_URL = "https://cdn.example.com"; - private static final String AWS_URL = "https://" + BUCKET_NAME + ".s3.amazonaws.com/"; private static final String UPLOAD_DATE = LocalDate.of(2024, 5, 1).format(ofPattern("yyyyMMdd")); private static final String FAKE_UUID = "ddd8d2d8-7b0c-47e9-91d0-d21251f891e8"; private ImageUploadService imageUploadService; + private S3Presigner s3Presigner; private InMemoryResourceLogRepository resourceLogRepository; @BeforeEach void setUp() { + s3Presigner = + S3Presigner.builder() + .region(Region.AP_NORTHEAST_2) + .credentialsProvider( + StaticCredentialsProvider.create(AwsBasicCredentials.create("access", "secret"))) + .build(); resourceLogRepository = new InMemoryResourceLogRepository(); ResourceCommandService resourceCommandService = new ResourceCommandService(resourceLogRepository); imageUploadService = - new ImageUploadService( - resourceCommandService, new FakeAmazonS3(), BUCKET_NAME, CLOUD_FRONT_URL) { + new ImageUploadService(resourceCommandService, s3Presigner, BUCKET_NAME, CLOUD_FRONT_URL) { @Override public String getImageKey(String rootPath, Long index, String contentType) { if (rootPath.startsWith(PATH_DELIMITER)) { @@ -67,6 +76,13 @@ public String getImageKey(String rootPath, Long index, String contentType) { }; } + @AfterEach + void closePresigner() { + if (s3Presigner != null) { + s3Presigner.close(); + } + } + @Nested @DisplayName("PreSigned URL 생성 테스트") class PreSignedUrlTest { @@ -83,7 +99,9 @@ void test_1() { // then log.info("PreSignUrl: {}", preSignUrl); assertNotNull(preSignUrl); - assertEquals(AWS_URL + imageKey, preSignUrl); + assertTrue(preSignUrl.startsWith("https://" + BUCKET_NAME + ".s3.")); + assertTrue(preSignUrl.contains(imageKey)); + assertTrue(preSignUrl.contains("X-Amz-Algorithm=")); } @Test @@ -130,7 +148,7 @@ void test_3() { assertEquals(BUCKET_NAME, response.bucketName()); ImageUploadItem item = response.imageUploadInfo().get(0); - assertTrue(item.uploadUrl().startsWith(AWS_URL)); + assertTrue(item.uploadUrl().startsWith("https://" + BUCKET_NAME + ".s3.")); assertTrue(item.viewUrl().startsWith(CLOUD_FRONT_URL)); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java index bf38c482a..84de68c5e 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java @@ -5,12 +5,13 @@ import app.bottlenote.IntegrationTestSupport; import app.bottlenote.operation.utils.TestContainersConfig; -import com.amazonaws.services.s3.AmazonS3; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.testcontainers.containers.MinIOContainer; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; @Tag("integration") @DisplayName("[integration] MinIO 컨테이너 로딩 테스트") @@ -18,7 +19,7 @@ class MinioContainerLoadingTest extends IntegrationTestSupport { @Autowired private MinIOContainer minioContainer; - @Autowired private AmazonS3 amazonS3; + @Autowired private S3Client s3Client; @Test @DisplayName("MinIO 컨테이너가 정상적으로 시작될 때 running 상태가 된다") @@ -33,24 +34,22 @@ void test_1() { } @Test - @DisplayName("AmazonS3 클라이언트가 MinIO에 연결될 때 테스트 버킷이 존재한다") + @DisplayName("S3Client가 MinIO에 연결될 때 테스트 버킷이 존재한다") void test_2() { // given String testBucket = TestContainersConfig.getTestBucket(); - // when - boolean bucketExists = amazonS3.doesBucketExistV2(testBucket); + // when & then + s3Client.headBucket(HeadBucketRequest.builder().bucket(testBucket).build()); - // then - assertTrue(bucketExists); - log.info("테스트 버킷 존재 여부 = {}: {}", testBucket, bucketExists); + log.info("테스트 버킷 존재 = {}", testBucket); } @Test - @DisplayName("AmazonS3 클라이언트가 정상적으로 주입될 때 null이 아니다") + @DisplayName("S3Client가 정상적으로 주입될 때 null이 아니다") void test_3() { // given & when & then - assertNotNull(amazonS3); - log.info("AmazonS3 클라이언트 = {}", amazonS3); + assertNotNull(s3Client); + log.info("S3Client = {}", s3Client); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java deleted file mode 100644 index 87b7f21fb..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java +++ /dev/null @@ -1,1267 +0,0 @@ -package app.bottlenote.common.file.upload.fixture; - -import com.amazonaws.AmazonServiceException; -import com.amazonaws.AmazonWebServiceRequest; -import com.amazonaws.HttpMethod; -import com.amazonaws.SdkClientException; -import com.amazonaws.regions.Region; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.S3ClientOptions; -import com.amazonaws.services.s3.S3ResponseMetadata; -import com.amazonaws.services.s3.model.AbortMultipartUploadRequest; -import com.amazonaws.services.s3.model.AccessControlList; -import com.amazonaws.services.s3.model.Bucket; -import com.amazonaws.services.s3.model.BucketAccelerateConfiguration; -import com.amazonaws.services.s3.model.BucketCrossOriginConfiguration; -import com.amazonaws.services.s3.model.BucketLifecycleConfiguration; -import com.amazonaws.services.s3.model.BucketLoggingConfiguration; -import com.amazonaws.services.s3.model.BucketNotificationConfiguration; -import com.amazonaws.services.s3.model.BucketPolicy; -import com.amazonaws.services.s3.model.BucketReplicationConfiguration; -import com.amazonaws.services.s3.model.BucketTaggingConfiguration; -import com.amazonaws.services.s3.model.BucketVersioningConfiguration; -import com.amazonaws.services.s3.model.BucketWebsiteConfiguration; -import com.amazonaws.services.s3.model.CannedAccessControlList; -import com.amazonaws.services.s3.model.CompleteMultipartUploadRequest; -import com.amazonaws.services.s3.model.CompleteMultipartUploadResult; -import com.amazonaws.services.s3.model.CopyObjectRequest; -import com.amazonaws.services.s3.model.CopyObjectResult; -import com.amazonaws.services.s3.model.CopyPartRequest; -import com.amazonaws.services.s3.model.CopyPartResult; -import com.amazonaws.services.s3.model.CreateBucketRequest; -import com.amazonaws.services.s3.model.DeleteBucketAnalyticsConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketAnalyticsConfigurationResult; -import com.amazonaws.services.s3.model.DeleteBucketCrossOriginConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketEncryptionRequest; -import com.amazonaws.services.s3.model.DeleteBucketEncryptionResult; -import com.amazonaws.services.s3.model.DeleteBucketIntelligentTieringConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketIntelligentTieringConfigurationResult; -import com.amazonaws.services.s3.model.DeleteBucketInventoryConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketInventoryConfigurationResult; -import com.amazonaws.services.s3.model.DeleteBucketLifecycleConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketMetricsConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketMetricsConfigurationResult; -import com.amazonaws.services.s3.model.DeleteBucketOwnershipControlsRequest; -import com.amazonaws.services.s3.model.DeleteBucketOwnershipControlsResult; -import com.amazonaws.services.s3.model.DeleteBucketPolicyRequest; -import com.amazonaws.services.s3.model.DeleteBucketReplicationConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketRequest; -import com.amazonaws.services.s3.model.DeleteBucketTaggingConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteBucketWebsiteConfigurationRequest; -import com.amazonaws.services.s3.model.DeleteObjectRequest; -import com.amazonaws.services.s3.model.DeleteObjectTaggingRequest; -import com.amazonaws.services.s3.model.DeleteObjectTaggingResult; -import com.amazonaws.services.s3.model.DeleteObjectsRequest; -import com.amazonaws.services.s3.model.DeleteObjectsResult; -import com.amazonaws.services.s3.model.DeletePublicAccessBlockRequest; -import com.amazonaws.services.s3.model.DeletePublicAccessBlockResult; -import com.amazonaws.services.s3.model.DeleteVersionRequest; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; -import com.amazonaws.services.s3.model.GetBucketAccelerateConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketAclRequest; -import com.amazonaws.services.s3.model.GetBucketAnalyticsConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketAnalyticsConfigurationResult; -import com.amazonaws.services.s3.model.GetBucketCrossOriginConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketEncryptionRequest; -import com.amazonaws.services.s3.model.GetBucketEncryptionResult; -import com.amazonaws.services.s3.model.GetBucketIntelligentTieringConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketIntelligentTieringConfigurationResult; -import com.amazonaws.services.s3.model.GetBucketInventoryConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketInventoryConfigurationResult; -import com.amazonaws.services.s3.model.GetBucketLifecycleConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketLocationRequest; -import com.amazonaws.services.s3.model.GetBucketLoggingConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketMetricsConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketMetricsConfigurationResult; -import com.amazonaws.services.s3.model.GetBucketNotificationConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketOwnershipControlsRequest; -import com.amazonaws.services.s3.model.GetBucketOwnershipControlsResult; -import com.amazonaws.services.s3.model.GetBucketPolicyRequest; -import com.amazonaws.services.s3.model.GetBucketPolicyStatusRequest; -import com.amazonaws.services.s3.model.GetBucketPolicyStatusResult; -import com.amazonaws.services.s3.model.GetBucketReplicationConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketTaggingConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketVersioningConfigurationRequest; -import com.amazonaws.services.s3.model.GetBucketWebsiteConfigurationRequest; -import com.amazonaws.services.s3.model.GetObjectAclRequest; -import com.amazonaws.services.s3.model.GetObjectLegalHoldRequest; -import com.amazonaws.services.s3.model.GetObjectLegalHoldResult; -import com.amazonaws.services.s3.model.GetObjectLockConfigurationRequest; -import com.amazonaws.services.s3.model.GetObjectLockConfigurationResult; -import com.amazonaws.services.s3.model.GetObjectMetadataRequest; -import com.amazonaws.services.s3.model.GetObjectRequest; -import com.amazonaws.services.s3.model.GetObjectRetentionRequest; -import com.amazonaws.services.s3.model.GetObjectRetentionResult; -import com.amazonaws.services.s3.model.GetObjectTaggingRequest; -import com.amazonaws.services.s3.model.GetObjectTaggingResult; -import com.amazonaws.services.s3.model.GetPublicAccessBlockRequest; -import com.amazonaws.services.s3.model.GetPublicAccessBlockResult; -import com.amazonaws.services.s3.model.GetS3AccountOwnerRequest; -import com.amazonaws.services.s3.model.HeadBucketRequest; -import com.amazonaws.services.s3.model.HeadBucketResult; -import com.amazonaws.services.s3.model.InitiateMultipartUploadRequest; -import com.amazonaws.services.s3.model.InitiateMultipartUploadResult; -import com.amazonaws.services.s3.model.ListBucketAnalyticsConfigurationsRequest; -import com.amazonaws.services.s3.model.ListBucketAnalyticsConfigurationsResult; -import com.amazonaws.services.s3.model.ListBucketIntelligentTieringConfigurationsRequest; -import com.amazonaws.services.s3.model.ListBucketIntelligentTieringConfigurationsResult; -import com.amazonaws.services.s3.model.ListBucketInventoryConfigurationsRequest; -import com.amazonaws.services.s3.model.ListBucketInventoryConfigurationsResult; -import com.amazonaws.services.s3.model.ListBucketMetricsConfigurationsRequest; -import com.amazonaws.services.s3.model.ListBucketMetricsConfigurationsResult; -import com.amazonaws.services.s3.model.ListBucketsRequest; -import com.amazonaws.services.s3.model.ListMultipartUploadsRequest; -import com.amazonaws.services.s3.model.ListNextBatchOfObjectsRequest; -import com.amazonaws.services.s3.model.ListNextBatchOfVersionsRequest; -import com.amazonaws.services.s3.model.ListObjectsRequest; -import com.amazonaws.services.s3.model.ListObjectsV2Request; -import com.amazonaws.services.s3.model.ListObjectsV2Result; -import com.amazonaws.services.s3.model.ListPartsRequest; -import com.amazonaws.services.s3.model.ListVersionsRequest; -import com.amazonaws.services.s3.model.MultipartUploadListing; -import com.amazonaws.services.s3.model.ObjectListing; -import com.amazonaws.services.s3.model.ObjectMetadata; -import com.amazonaws.services.s3.model.Owner; -import com.amazonaws.services.s3.model.PartListing; -import com.amazonaws.services.s3.model.PresignedUrlDownloadRequest; -import com.amazonaws.services.s3.model.PresignedUrlDownloadResult; -import com.amazonaws.services.s3.model.PresignedUrlUploadRequest; -import com.amazonaws.services.s3.model.PresignedUrlUploadResult; -import com.amazonaws.services.s3.model.PutObjectRequest; -import com.amazonaws.services.s3.model.PutObjectResult; -import com.amazonaws.services.s3.model.RestoreObjectRequest; -import com.amazonaws.services.s3.model.RestoreObjectResult; -import com.amazonaws.services.s3.model.S3Object; -import com.amazonaws.services.s3.model.SelectObjectContentRequest; -import com.amazonaws.services.s3.model.SelectObjectContentResult; -import com.amazonaws.services.s3.model.SetBucketAccelerateConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketAclRequest; -import com.amazonaws.services.s3.model.SetBucketAnalyticsConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketAnalyticsConfigurationResult; -import com.amazonaws.services.s3.model.SetBucketCrossOriginConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketEncryptionRequest; -import com.amazonaws.services.s3.model.SetBucketEncryptionResult; -import com.amazonaws.services.s3.model.SetBucketIntelligentTieringConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketIntelligentTieringConfigurationResult; -import com.amazonaws.services.s3.model.SetBucketInventoryConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketInventoryConfigurationResult; -import com.amazonaws.services.s3.model.SetBucketLifecycleConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketLoggingConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketMetricsConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketMetricsConfigurationResult; -import com.amazonaws.services.s3.model.SetBucketNotificationConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketOwnershipControlsRequest; -import com.amazonaws.services.s3.model.SetBucketOwnershipControlsResult; -import com.amazonaws.services.s3.model.SetBucketPolicyRequest; -import com.amazonaws.services.s3.model.SetBucketReplicationConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketTaggingConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketVersioningConfigurationRequest; -import com.amazonaws.services.s3.model.SetBucketWebsiteConfigurationRequest; -import com.amazonaws.services.s3.model.SetObjectAclRequest; -import com.amazonaws.services.s3.model.SetObjectLegalHoldRequest; -import com.amazonaws.services.s3.model.SetObjectLegalHoldResult; -import com.amazonaws.services.s3.model.SetObjectLockConfigurationRequest; -import com.amazonaws.services.s3.model.SetObjectLockConfigurationResult; -import com.amazonaws.services.s3.model.SetObjectRetentionRequest; -import com.amazonaws.services.s3.model.SetObjectRetentionResult; -import com.amazonaws.services.s3.model.SetObjectTaggingRequest; -import com.amazonaws.services.s3.model.SetObjectTaggingResult; -import com.amazonaws.services.s3.model.SetPublicAccessBlockRequest; -import com.amazonaws.services.s3.model.SetPublicAccessBlockResult; -import com.amazonaws.services.s3.model.SetRequestPaymentConfigurationRequest; -import com.amazonaws.services.s3.model.StorageClass; -import com.amazonaws.services.s3.model.UploadPartRequest; -import com.amazonaws.services.s3.model.UploadPartResult; -import com.amazonaws.services.s3.model.VersionListing; -import com.amazonaws.services.s3.model.WriteGetObjectResponseRequest; -import com.amazonaws.services.s3.model.WriteGetObjectResponseResult; -import com.amazonaws.services.s3.model.analytics.AnalyticsConfiguration; -import com.amazonaws.services.s3.model.intelligenttiering.IntelligentTieringConfiguration; -import com.amazonaws.services.s3.model.inventory.InventoryConfiguration; -import com.amazonaws.services.s3.model.metrics.MetricsConfiguration; -import com.amazonaws.services.s3.model.ownership.OwnershipControls; -import com.amazonaws.services.s3.waiters.AmazonS3Waiters; -import java.io.File; -import java.io.InputStream; -import java.net.URL; -import java.util.Date; -import java.util.List; - -public abstract class AbstractFakeAmazonS3 implements AmazonS3 { - - @Override - public void setEndpoint(String endpoint) {} - - @Override - public void setS3ClientOptions(S3ClientOptions clientOptions) {} - - @Override - public void changeObjectStorageClass(String bucketName, String key, StorageClass newStorageClass) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setObjectRedirectLocation(String bucketName, String key, String newRedirectLocation) - throws SdkClientException, AmazonServiceException {} - - @Override - public ObjectListing listObjects(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectListing listObjects(String bucketName, String prefix) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectListing listObjects(ListObjectsRequest listObjectsRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ListObjectsV2Result listObjectsV2(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ListObjectsV2Result listObjectsV2(String bucketName, String prefix) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ListObjectsV2Result listObjectsV2(ListObjectsV2Request listObjectsV2Request) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectListing listNextBatchOfObjects(ObjectListing previousObjectListing) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectListing listNextBatchOfObjects( - ListNextBatchOfObjectsRequest listNextBatchOfObjectsRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public VersionListing listVersions(String bucketName, String prefix) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public VersionListing listNextBatchOfVersions(VersionListing previousVersionListing) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public VersionListing listNextBatchOfVersions( - ListNextBatchOfVersionsRequest listNextBatchOfVersionsRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public VersionListing listVersions( - String bucketName, - String prefix, - String keyMarker, - String versionIdMarker, - String delimiter, - Integer maxResults) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public VersionListing listVersions(ListVersionsRequest listVersionsRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public Owner getS3AccountOwner() throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public Owner getS3AccountOwner(GetS3AccountOwnerRequest getS3AccountOwnerRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public boolean doesBucketExist(String bucketName) - throws SdkClientException, AmazonServiceException { - return false; - } - - @Override - public boolean doesBucketExistV2(String bucketName) - throws SdkClientException, AmazonServiceException { - return false; - } - - @Override - public HeadBucketResult headBucket(HeadBucketRequest headBucketRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public List listBuckets() throws SdkClientException, AmazonServiceException { - return List.of(); - } - - @Override - public List listBuckets(ListBucketsRequest listBucketsRequest) - throws SdkClientException, AmazonServiceException { - return List.of(); - } - - @Override - public String getBucketLocation(String bucketName) - throws SdkClientException, AmazonServiceException { - return ""; - } - - @Override - public String getBucketLocation(GetBucketLocationRequest getBucketLocationRequest) - throws SdkClientException, AmazonServiceException { - return ""; - } - - @Override - public Bucket createBucket(CreateBucketRequest createBucketRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public Bucket createBucket(String bucketName) throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public Bucket createBucket(String bucketName, com.amazonaws.services.s3.model.Region region) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public Bucket createBucket(String bucketName, String region) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public AccessControlList getObjectAcl(String bucketName, String key) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public AccessControlList getObjectAcl(String bucketName, String key, String versionId) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public AccessControlList getObjectAcl(GetObjectAclRequest getObjectAclRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setObjectAcl(String bucketName, String key, AccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setObjectAcl(String bucketName, String key, CannedAccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setObjectAcl(String bucketName, String key, String versionId, AccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setObjectAcl( - String bucketName, String key, String versionId, CannedAccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setObjectAcl(SetObjectAclRequest setObjectAclRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public AccessControlList getBucketAcl(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketAcl(SetBucketAclRequest setBucketAclRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public AccessControlList getBucketAcl(GetBucketAclRequest getBucketAclRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketAcl(String bucketName, AccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setBucketAcl(String bucketName, CannedAccessControlList acl) - throws SdkClientException, AmazonServiceException {} - - @Override - public ObjectMetadata getObjectMetadata(String bucketName, String key) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectMetadata getObjectMetadata(GetObjectMetadataRequest getObjectMetadataRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public S3Object getObject(String bucketName, String key) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public S3Object getObject(GetObjectRequest getObjectRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public ObjectMetadata getObject(GetObjectRequest getObjectRequest, File destinationFile) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public String getObjectAsString(String bucketName, String key) - throws AmazonServiceException, SdkClientException { - return ""; - } - - @Override - public GetObjectTaggingResult getObjectTagging(GetObjectTaggingRequest getObjectTaggingRequest) { - return null; - } - - @Override - public SetObjectTaggingResult setObjectTagging(SetObjectTaggingRequest setObjectTaggingRequest) { - return null; - } - - @Override - public DeleteObjectTaggingResult deleteObjectTagging( - DeleteObjectTaggingRequest deleteObjectTaggingRequest) { - return null; - } - - @Override - public void deleteBucket(DeleteBucketRequest deleteBucketRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteBucket(String bucketName) throws SdkClientException, AmazonServiceException {} - - @Override - public PutObjectResult putObject(PutObjectRequest putObjectRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public PutObjectResult putObject(String bucketName, String key, File file) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public PutObjectResult putObject( - String bucketName, String key, InputStream input, ObjectMetadata metadata) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public PutObjectResult putObject(String bucketName, String key, String content) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public CopyObjectResult copyObject( - String sourceBucketName, - String sourceKey, - String destinationBucketName, - String destinationKey) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public CopyObjectResult copyObject(CopyObjectRequest copyObjectRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public CopyPartResult copyPart(CopyPartRequest copyPartRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void deleteObject(String bucketName, String key) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteObject(DeleteObjectRequest deleteObjectRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public DeleteObjectsResult deleteObjects(DeleteObjectsRequest deleteObjectsRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void deleteVersion(String bucketName, String key, String versionId) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteVersion(DeleteVersionRequest deleteVersionRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public BucketLoggingConfiguration getBucketLoggingConfiguration(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public BucketLoggingConfiguration getBucketLoggingConfiguration( - GetBucketLoggingConfigurationRequest getBucketLoggingConfigurationRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketLoggingConfiguration( - SetBucketLoggingConfigurationRequest setBucketLoggingConfigurationRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public BucketVersioningConfiguration getBucketVersioningConfiguration(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public BucketVersioningConfiguration getBucketVersioningConfiguration( - GetBucketVersioningConfigurationRequest getBucketVersioningConfigurationRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketVersioningConfiguration( - SetBucketVersioningConfigurationRequest setBucketVersioningConfigurationRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public BucketLifecycleConfiguration getBucketLifecycleConfiguration(String bucketName) { - return null; - } - - @Override - public BucketLifecycleConfiguration getBucketLifecycleConfiguration( - GetBucketLifecycleConfigurationRequest getBucketLifecycleConfigurationRequest) { - return null; - } - - @Override - public void setBucketLifecycleConfiguration( - String bucketName, BucketLifecycleConfiguration bucketLifecycleConfiguration) {} - - @Override - public void setBucketLifecycleConfiguration( - SetBucketLifecycleConfigurationRequest setBucketLifecycleConfigurationRequest) {} - - @Override - public void deleteBucketLifecycleConfiguration(String bucketName) {} - - @Override - public void deleteBucketLifecycleConfiguration( - DeleteBucketLifecycleConfigurationRequest deleteBucketLifecycleConfigurationRequest) {} - - @Override - public BucketCrossOriginConfiguration getBucketCrossOriginConfiguration(String bucketName) { - return null; - } - - @Override - public BucketCrossOriginConfiguration getBucketCrossOriginConfiguration( - GetBucketCrossOriginConfigurationRequest getBucketCrossOriginConfigurationRequest) { - return null; - } - - @Override - public void setBucketCrossOriginConfiguration( - String bucketName, BucketCrossOriginConfiguration bucketCrossOriginConfiguration) {} - - @Override - public void setBucketCrossOriginConfiguration( - SetBucketCrossOriginConfigurationRequest setBucketCrossOriginConfigurationRequest) {} - - @Override - public void deleteBucketCrossOriginConfiguration(String bucketName) {} - - @Override - public void deleteBucketCrossOriginConfiguration( - DeleteBucketCrossOriginConfigurationRequest deleteBucketCrossOriginConfigurationRequest) {} - - @Override - public BucketTaggingConfiguration getBucketTaggingConfiguration(String bucketName) { - return null; - } - - @Override - public BucketTaggingConfiguration getBucketTaggingConfiguration( - GetBucketTaggingConfigurationRequest getBucketTaggingConfigurationRequest) { - return null; - } - - @Override - public void setBucketTaggingConfiguration( - String bucketName, BucketTaggingConfiguration bucketTaggingConfiguration) {} - - @Override - public void setBucketTaggingConfiguration( - SetBucketTaggingConfigurationRequest setBucketTaggingConfigurationRequest) {} - - @Override - public void deleteBucketTaggingConfiguration(String bucketName) {} - - @Override - public void deleteBucketTaggingConfiguration( - DeleteBucketTaggingConfigurationRequest deleteBucketTaggingConfigurationRequest) {} - - @Override - public BucketNotificationConfiguration getBucketNotificationConfiguration(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public BucketNotificationConfiguration getBucketNotificationConfiguration( - GetBucketNotificationConfigurationRequest getBucketNotificationConfigurationRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketNotificationConfiguration( - SetBucketNotificationConfigurationRequest setBucketNotificationConfigurationRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setBucketNotificationConfiguration( - String bucketName, BucketNotificationConfiguration bucketNotificationConfiguration) - throws SdkClientException, AmazonServiceException {} - - @Override - public BucketWebsiteConfiguration getBucketWebsiteConfiguration(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public BucketWebsiteConfiguration getBucketWebsiteConfiguration( - GetBucketWebsiteConfigurationRequest getBucketWebsiteConfigurationRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketWebsiteConfiguration( - String bucketName, BucketWebsiteConfiguration configuration) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setBucketWebsiteConfiguration( - SetBucketWebsiteConfigurationRequest setBucketWebsiteConfigurationRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteBucketWebsiteConfiguration(String bucketName) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteBucketWebsiteConfiguration( - DeleteBucketWebsiteConfigurationRequest deleteBucketWebsiteConfigurationRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public BucketPolicy getBucketPolicy(String bucketName) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public BucketPolicy getBucketPolicy(GetBucketPolicyRequest getBucketPolicyRequest) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void setBucketPolicy(String bucketName, String policyText) - throws SdkClientException, AmazonServiceException {} - - @Override - public void setBucketPolicy(SetBucketPolicyRequest setBucketPolicyRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteBucketPolicy(String bucketName) - throws SdkClientException, AmazonServiceException {} - - @Override - public void deleteBucketPolicy(DeleteBucketPolicyRequest deleteBucketPolicyRequest) - throws SdkClientException, AmazonServiceException {} - - @Override - public URL generatePresignedUrl(String bucketName, String key, Date expiration) - throws SdkClientException { - return null; - } - - @Override - public URL generatePresignedUrl(String bucketName, String key, Date expiration, HttpMethod method) - throws SdkClientException { - return null; - } - - @Override - public URL generatePresignedUrl(GeneratePresignedUrlRequest generatePresignedUrlRequest) - throws SdkClientException { - return null; - } - - @Override - public InitiateMultipartUploadResult initiateMultipartUpload( - InitiateMultipartUploadRequest request) throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public UploadPartResult uploadPart(UploadPartRequest request) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public PartListing listParts(ListPartsRequest request) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public void abortMultipartUpload(AbortMultipartUploadRequest request) - throws SdkClientException, AmazonServiceException {} - - @Override - public CompleteMultipartUploadResult completeMultipartUpload( - CompleteMultipartUploadRequest request) throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public MultipartUploadListing listMultipartUploads(ListMultipartUploadsRequest request) - throws SdkClientException, AmazonServiceException { - return null; - } - - @Override - public S3ResponseMetadata getCachedResponseMetadata(AmazonWebServiceRequest request) { - return null; - } - - @Override - public void restoreObject(RestoreObjectRequest request) throws AmazonServiceException {} - - @Override - public RestoreObjectResult restoreObjectV2(RestoreObjectRequest request) - throws AmazonServiceException { - return null; - } - - @Override - public void restoreObject(String bucketName, String key, int expirationInDays) - throws AmazonServiceException {} - - @Override - public void enableRequesterPays(String bucketName) - throws AmazonServiceException, SdkClientException {} - - @Override - public void disableRequesterPays(String bucketName) - throws AmazonServiceException, SdkClientException {} - - @Override - public boolean isRequesterPaysEnabled(String bucketName) - throws AmazonServiceException, SdkClientException { - return false; - } - - @Override - public void setRequestPaymentConfiguration( - SetRequestPaymentConfigurationRequest setRequestPaymentConfigurationRequest) {} - - @Override - public void setBucketReplicationConfiguration( - String bucketName, BucketReplicationConfiguration configuration) - throws AmazonServiceException, SdkClientException {} - - @Override - public void setBucketReplicationConfiguration( - SetBucketReplicationConfigurationRequest setBucketReplicationConfigurationRequest) - throws AmazonServiceException, SdkClientException {} - - @Override - public BucketReplicationConfiguration getBucketReplicationConfiguration(String bucketName) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public BucketReplicationConfiguration getBucketReplicationConfiguration( - GetBucketReplicationConfigurationRequest getBucketReplicationConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public void deleteBucketReplicationConfiguration(String bucketName) - throws AmazonServiceException, SdkClientException {} - - @Override - public void deleteBucketReplicationConfiguration( - DeleteBucketReplicationConfigurationRequest request) - throws AmazonServiceException, SdkClientException {} - - @Override - public boolean doesObjectExist(String bucketName, String objectName) - throws AmazonServiceException, SdkClientException { - return false; - } - - @Override - public BucketAccelerateConfiguration getBucketAccelerateConfiguration(String bucketName) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public BucketAccelerateConfiguration getBucketAccelerateConfiguration( - GetBucketAccelerateConfigurationRequest getBucketAccelerateConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public void setBucketAccelerateConfiguration( - String bucketName, BucketAccelerateConfiguration accelerateConfiguration) - throws AmazonServiceException, SdkClientException {} - - @Override - public void setBucketAccelerateConfiguration( - SetBucketAccelerateConfigurationRequest setBucketAccelerateConfigurationRequest) - throws AmazonServiceException, SdkClientException {} - - @Override - public DeleteBucketMetricsConfigurationResult deleteBucketMetricsConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketMetricsConfigurationResult deleteBucketMetricsConfiguration( - DeleteBucketMetricsConfigurationRequest deleteBucketMetricsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketMetricsConfigurationResult getBucketMetricsConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketMetricsConfigurationResult getBucketMetricsConfiguration( - GetBucketMetricsConfigurationRequest getBucketMetricsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketMetricsConfigurationResult setBucketMetricsConfiguration( - String bucketName, MetricsConfiguration metricsConfiguration) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketMetricsConfigurationResult setBucketMetricsConfiguration( - SetBucketMetricsConfigurationRequest setBucketMetricsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public ListBucketMetricsConfigurationsResult listBucketMetricsConfigurations( - ListBucketMetricsConfigurationsRequest listBucketMetricsConfigurationsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketOwnershipControlsResult deleteBucketOwnershipControls( - DeleteBucketOwnershipControlsRequest deleteBucketOwnershipControlsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketOwnershipControlsResult getBucketOwnershipControls( - GetBucketOwnershipControlsRequest getBucketOwnershipControlsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketOwnershipControlsResult setBucketOwnershipControls( - String bucketName, OwnershipControls ownershipControls) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketOwnershipControlsResult setBucketOwnershipControls( - SetBucketOwnershipControlsRequest setBucketOwnershipControlsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketAnalyticsConfigurationResult deleteBucketAnalyticsConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketAnalyticsConfigurationResult deleteBucketAnalyticsConfiguration( - DeleteBucketAnalyticsConfigurationRequest deleteBucketAnalyticsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketAnalyticsConfigurationResult getBucketAnalyticsConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketAnalyticsConfigurationResult getBucketAnalyticsConfiguration( - GetBucketAnalyticsConfigurationRequest getBucketAnalyticsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketAnalyticsConfigurationResult setBucketAnalyticsConfiguration( - String bucketName, AnalyticsConfiguration analyticsConfiguration) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketAnalyticsConfigurationResult setBucketAnalyticsConfiguration( - SetBucketAnalyticsConfigurationRequest setBucketAnalyticsConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public ListBucketAnalyticsConfigurationsResult listBucketAnalyticsConfigurations( - ListBucketAnalyticsConfigurationsRequest listBucketAnalyticsConfigurationsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketIntelligentTieringConfigurationResult - deleteBucketIntelligentTieringConfiguration(String bucketName, String id) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketIntelligentTieringConfigurationResult - deleteBucketIntelligentTieringConfiguration( - DeleteBucketIntelligentTieringConfigurationRequest - deleteBucketIntelligentTieringConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketIntelligentTieringConfigurationResult getBucketIntelligentTieringConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketIntelligentTieringConfigurationResult getBucketIntelligentTieringConfiguration( - GetBucketIntelligentTieringConfigurationRequest - getBucketIntelligentTieringConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketIntelligentTieringConfigurationResult setBucketIntelligentTieringConfiguration( - String bucketName, IntelligentTieringConfiguration intelligentTieringConfiguration) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketIntelligentTieringConfigurationResult setBucketIntelligentTieringConfiguration( - SetBucketIntelligentTieringConfigurationRequest - setBucketIntelligentTieringConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public ListBucketIntelligentTieringConfigurationsResult - listBucketIntelligentTieringConfigurations( - ListBucketIntelligentTieringConfigurationsRequest - listBucketIntelligentTieringConfigurationsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketInventoryConfigurationResult deleteBucketInventoryConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketInventoryConfigurationResult deleteBucketInventoryConfiguration( - DeleteBucketInventoryConfigurationRequest deleteBucketInventoryConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketInventoryConfigurationResult getBucketInventoryConfiguration( - String bucketName, String id) throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketInventoryConfigurationResult getBucketInventoryConfiguration( - GetBucketInventoryConfigurationRequest getBucketInventoryConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketInventoryConfigurationResult setBucketInventoryConfiguration( - String bucketName, InventoryConfiguration inventoryConfiguration) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketInventoryConfigurationResult setBucketInventoryConfiguration( - SetBucketInventoryConfigurationRequest setBucketInventoryConfigurationRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public ListBucketInventoryConfigurationsResult listBucketInventoryConfigurations( - ListBucketInventoryConfigurationsRequest listBucketInventoryConfigurationsRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketEncryptionResult deleteBucketEncryption(String bucketName) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public DeleteBucketEncryptionResult deleteBucketEncryption(DeleteBucketEncryptionRequest request) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketEncryptionResult getBucketEncryption(String bucketName) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public GetBucketEncryptionResult getBucketEncryption(GetBucketEncryptionRequest request) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetBucketEncryptionResult setBucketEncryption( - SetBucketEncryptionRequest setBucketEncryptionRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetPublicAccessBlockResult setPublicAccessBlock(SetPublicAccessBlockRequest request) { - return null; - } - - @Override - public GetPublicAccessBlockResult getPublicAccessBlock(GetPublicAccessBlockRequest request) { - return null; - } - - @Override - public DeletePublicAccessBlockResult deletePublicAccessBlock( - DeletePublicAccessBlockRequest request) { - return null; - } - - @Override - public GetBucketPolicyStatusResult getBucketPolicyStatus(GetBucketPolicyStatusRequest request) { - return null; - } - - @Override - public SelectObjectContentResult selectObjectContent(SelectObjectContentRequest selectRequest) - throws AmazonServiceException, SdkClientException { - return null; - } - - @Override - public SetObjectLegalHoldResult setObjectLegalHold( - SetObjectLegalHoldRequest setObjectLegalHoldRequest) { - return null; - } - - @Override - public GetObjectLegalHoldResult getObjectLegalHold( - GetObjectLegalHoldRequest getObjectLegalHoldRequest) { - return null; - } - - @Override - public SetObjectLockConfigurationResult setObjectLockConfiguration( - SetObjectLockConfigurationRequest setObjectLockConfigurationRequest) { - return null; - } - - @Override - public GetObjectLockConfigurationResult getObjectLockConfiguration( - GetObjectLockConfigurationRequest getObjectLockConfigurationRequest) { - return null; - } - - @Override - public SetObjectRetentionResult setObjectRetention( - SetObjectRetentionRequest setObjectRetentionRequest) { - return null; - } - - @Override - public GetObjectRetentionResult getObjectRetention( - GetObjectRetentionRequest getObjectRetentionRequest) { - return null; - } - - @Override - public WriteGetObjectResponseResult writeGetObjectResponse( - WriteGetObjectResponseRequest writeGetObjectResponseRequest) { - return null; - } - - @Override - public PresignedUrlDownloadResult download( - PresignedUrlDownloadRequest presignedUrlDownloadRequest) { - return null; - } - - @Override - public void download( - PresignedUrlDownloadRequest presignedUrlDownloadRequest, File destinationFile) {} - - @Override - public PresignedUrlUploadResult upload(PresignedUrlUploadRequest presignedUrlUploadRequest) { - return null; - } - - @Override - public void shutdown() {} - - @Override - public com.amazonaws.services.s3.model.Region getRegion() { - return null; - } - - @Override - public void setRegion(Region region) throws IllegalArgumentException {} - - @Override - public String getRegionName() { - return ""; - } - - @Override - public URL getUrl(String bucketName, String key) { - return null; - } - - @Override - public AmazonS3Waiters waiters() { - return null; - } -} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java deleted file mode 100644 index 02563b961..000000000 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java +++ /dev/null @@ -1,49 +0,0 @@ -package app.bottlenote.common.file.upload.fixture; - -import com.amazonaws.HttpMethod; -import com.amazonaws.SdkClientException; -import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Date; - -public class FakeAmazonS3 extends AbstractFakeAmazonS3 { - @Override - public URL generatePresignedUrl(String bucketName, String key, Date expiration) - throws SdkClientException { - URL url; - try { - url = new URL("http://localhost:8080"); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - return url; - } - - @Override - public URL generatePresignedUrl( - String bucketName, String key, Date expiration, HttpMethod method) { - URL url; - try { - url = new URL("https", bucketName + ".s3.amazonaws.com", "/" + key); - System.out.println("Fake url 생성 : " + url); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - return url; - } - - @Override - public URL generatePresignedUrl(GeneratePresignedUrlRequest generatePresignedUrlRequest) - throws SdkClientException { - URL url; - try { - String bucketName = generatePresignedUrlRequest.getBucketName(); - String key = generatePresignedUrlRequest.getKey(); - url = new URL("https", bucketName + ".s3.amazonaws.com", "/" + key); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - return url; - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a626b0687..9e27e91c1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ google-guava = "32.1.2-jre" # Utilities commons-lang3 = "3.17.0" +commons-codec = "1.22.0" jetbrains-annotations = "26.0.2" # Caching @@ -32,7 +33,7 @@ caffeine = "3.1.8" ahocorasick = "0.6.3" # AWS -aws-java-sdk-s3 = "1.12.725" +aws-sdk = "2.44.3" # Testing testng = "7.7.0" @@ -114,6 +115,7 @@ google-guava = { module = "com.google.guava:guava", version.ref = "google-guava" # Utilities commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" } +commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" } # Caching caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } @@ -122,7 +124,8 @@ caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "c ahocorasick = { module = "org.ahocorasick:ahocorasick", version.ref = "ahocorasick" } # AWS -aws-s3 = { module = "com.amazonaws:aws-java-sdk-s3", version.ref = "aws-java-sdk-s3" } +aws-sdk-bom = { module = "software.amazon.awssdk:bom", version.ref = "aws-sdk" } +aws-sdk-s3 = { module = "software.amazon.awssdk:s3" } # Monitoring & Observability - OpenTelemetry opentelemetry-bom = { module = "io.opentelemetry:opentelemetry-bom", version.ref = "opentelemetry" } diff --git a/plan/aws-sdk-v2-migration.md b/plan/aws-sdk-v2-migration.md new file mode 100644 index 000000000..65db5ebad --- /dev/null +++ b/plan/aws-sdk-v2-migration.md @@ -0,0 +1,144 @@ +# Plan: AWS SDK Java v2 전환 + +## Overview + +현재 프로젝트는 S3 Presigned URL 생성과 MinIO 기반 테스트에서 AWS SDK Java v1 +`com.amazonaws:aws-java-sdk-s3`를 사용한다. 이 작업의 목표는 S3 연동을 AWS SDK +Java v2로 전환하여 v1 의존성을 제거하고, product/admin 파일 업로드 API의 외부 +동작을 유지하는 것이다. + +전환 대상은 구현 승인 이후 `bottlenote-mono`의 S3 설정과 `ImageUploadService`, +product/admin 테스트 인프라, Gradle version catalog 및 각 모듈 테스트 의존성이다. +이번 `/define` 단계에서는 구현하지 않고 영향 범위와 성공 기준만 확정한다. + +### Assumptions + +- S3 Presigned URL API의 HTTP 응답 구조는 유지한다. +- 업로드 방식은 기존과 동일하게 HTTP `PUT` Presigned URL을 사용한다. +- `amazon.aws.*` 설정 키는 배포 환경 영향 최소화를 위해 기본적으로 유지한다. +- CloudFront view URL 생성 방식은 AWS SDK 전환 범위가 아니므로 변경하지 않는다. +- MinIO/Testcontainers 테스트는 유지하고, v2 `S3Client` 및 `S3Presigner` 기반으로 바꾼다. +- `git.environment-variables` 서브모듈 포인터 변경은 사용자/환경 변경으로 보고 건드리지 않는다. +- 작업 3번 `batch component scan 축소`와 무관한 batch component scan 변경은 하지 않는다. +- AWS SDK v2 의존성은 AWS 공식 문서의 권장 방식대로 BOM + `software.amazon.awssdk:s3` 구성을 우선 검토한다. + +### Success Criteria + +- `gradle/libs.versions.toml`에서 v1 `com.amazonaws:aws-java-sdk-s3` 의존성이 제거되고 v2 S3 의존성으로 대체된다. +- main/test 소스에서 `com.amazonaws.*` S3 관련 import가 제거된다. +- `AwsS3Config`가 v2 `S3Client`와 Presigned URL 생성에 필요한 v2 `S3Presigner`를 제공한다. +- `ImageUploadService.generatePreSignUrl()`이 v2 `S3Presigner.presignPutObject()`로 동일한 만료 시간과 content type 의미를 유지한다. +- MinIO 기반 테스트가 v2 클라이언트로 버킷 생성, 객체 존재 확인, 객체 조회를 수행한다. +- product/admin Presigned URL API의 응답 필드 `bucketName`, `expiryTime`, `uploadSize`, `imageUploadInfo[].viewUrl`, `imageUploadInfo[].uploadUrl`이 유지된다. +- `/verify full` 또는 승인된 동일 수준 검증에서 compile, rule, unit, integration, admin integration, asciidoctor가 통과한다. + +### Impact Scope + +- Build: + - `gradle/libs.versions.toml` + - `bottlenote-mono/build.gradle` + - `bottlenote-product-api/build.gradle` + - `bottlenote-admin-api/build.gradle.kts` +- Main code: + - `bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java` + - `bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java` + - `bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java` + - `bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java` + - `bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java` +- Tests and fixtures: + - `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` + - `bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java` + - `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java` + - `bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt` +- Persistence: + - DB schema 변경 없음. +- API/docs: + - Presigned URL API 계약 변경 없음. + - REST Docs 산출물은 동작 유지 검증 대상이지만 문서 구조 변경은 목표가 아니다. +- External behavior: + - 실제 S3 업로드용 Presigned URL 생성 방식은 유지한다. + - MinIO 테스트에서는 v2 S3-compatible endpoint 설정과 path-style 접근을 검증한다. + +### Approval Gate + +이 정의는 구현 전 승인용이다. 승인 후 다음 단계에서 `/plan`으로 작업을 분해하고, +그 뒤 별도 구현 단계에서만 코드를 수정한다. + +## Tasks + +### Task 1: v2 의존성 기반 도입 +- Acceptance: AWS SDK v2 BOM/S3 의존성이 version catalog와 `bottlenote-mono`에 추가된다. +- Acceptance: `AwsS3Config`가 기존 `amazon.aws.*` 설정 키로 v2 `S3Client`와 `S3Presigner` 빈을 생성한다. +- Acceptance: v1 유틸/예외 타입에 의존한 main code가 v2 전환을 막지 않도록 정리된다. +- Verification: `./gradlew compileJava` +- Files: `gradle/libs.versions.toml`, `bottlenote-mono/build.gradle`, `bottlenote-mono/src/main/java/app/bottlenote/global/config/AwsS3Config.java`, `bottlenote-mono/src/main/java/app/bottlenote/global/exception/handler/GlobalExceptionHandler.java`, `bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/AdminRoleConverter.java`, `bottlenote-mono/src/main/java/app/bottlenote/global/service/converter/JsonArrayConverter.java` +- Size: M +- Status: [x] done + +### Task 2: Presigned URL 서비스 전환 +- Acceptance: `ImageUploadService.generatePreSignUrl()`이 v2 `S3Presigner.presignPutObject()`를 사용한다. +- Acceptance: 만료 시간 5분, HTTP `PUT`, content type 지정 의미가 유지된다. +- Acceptance: product service 단위 테스트가 v2 전용 fake/stub으로 동일 URL 생성 계약을 검증한다. +- Verification: `./gradlew :bottlenote-product-api:test --tests '*ImageUploadServiceTest'` +- Files: `bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java`, `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java`, `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/FakeAmazonS3.java`, `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/fixture/AbstractFakeAmazonS3.java` +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-2 +- [ ] `./gradlew compileJava compileTestJava` +- [ ] `./gradlew unit_test` +- [ ] `rg -n "com\\.amazonaws" bottlenote-mono/src/main/java bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload` + +### Task 3: MinIO 공통 테스트 인프라 전환 +- Acceptance: `TestContainersConfig`가 MinIO용 v2 `S3Client`/`S3Presigner`를 제공한다. +- Acceptance: MinIO endpoint override와 path-style 설정이 v2 방식으로 적용된다. +- Acceptance: mono/product MinIO 테스트가 v2 클라이언트로 버킷 존재와 업로드 가능 여부를 검증한다. +- Verification: `./gradlew :bottlenote-mono:test --tests '*ImageUploadUnitTest'` and `./gradlew :bottlenote-product-api:integration_test --tests '*MinioContainerLoadingTest'` +- Files: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java`, `bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java`, `bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/MinioContainerLoadingTest.java` +- Size: S +- Status: [x] done + +### Task 4: Admin 업로드 통합 테스트 전환 +- Acceptance: admin 통합 테스트가 v2 `S3Client`로 객체 존재와 객체 본문을 검증한다. +- Acceptance: admin Presigned URL API 응답 구조와 인증 동작이 유지된다. +- Acceptance: admin 테스트 의존성이 v2 S3 의존성 기준으로 정리된다. +- Verification: `./gradlew admin_integration_test --tests '*AdminImageUploadIntegrationTest'` +- Files: `bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt`, `bottlenote-admin-api/build.gradle.kts` +- Size: S +- Status: [x] done + +### Task 5: v1 의존성 제거 검증 +- Acceptance: `com.amazonaws:aws-java-sdk-s3` 및 `libs.aws.s3`의 v1 좌표가 제거된다. +- Acceptance: main/test 소스에 S3 관련 `com.amazonaws.*` import가 남지 않는다. +- Acceptance: product/admin/mono 빌드 의존성이 v2 S3 의존성만 사용한다. +- Verification: `rg -n "com\\.amazonaws|aws-java-sdk-s3|libs\\.aws\\.s3" gradle/libs.versions.toml bottlenote-*` +- Files: `gradle/libs.versions.toml`, `bottlenote-mono/build.gradle`, `bottlenote-product-api/build.gradle`, `bottlenote-admin-api/build.gradle.kts` +- Size: S +- Status: [x] done + +### Checkpoint: after Tasks 3-5 +- [ ] `./gradlew compileJava compileTestJava` +- [ ] `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` +- [ ] `./gradlew integration_test` +- [ ] `./gradlew admin_integration_test` + +### Task 6: 전체 검증 +- Acceptance: `/verify full` 기준 compile, rule, unit, build, integration, admin integration, asciidoctor가 통과한다. +- Acceptance: Presigned URL 관련 product/admin REST Docs 생성이 실패하지 않는다. +- Acceptance: `git.environment-variables`와 batch scan 작업 산출물을 변경하지 않는다. +- Verification: `./gradlew compileJava compileTestJava`, `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin`, `./gradlew check_rule_test`, `./gradlew unit_test`, `./gradlew build -x test -x asciidoctor --build-cache --parallel`, `./gradlew integration_test`, `./gradlew admin_integration_test`, `./gradlew asciidoctor` +- Files: no new implementation files expected; verification only +- Size: S +- Status: [ ] pending master full verify + +## Progress Log + +- 2026-05-22: Task 1 완료. AWS SDK v2 BOM/S3 의존성으로 전환하고, v1 전이 의존이던 `commons-codec`을 명시 의존성으로 추가했다. `AwsS3Config`는 v2 `S3Client`와 `S3Presigner`를 제공한다. +- 2026-05-22: Task 2 완료. `ImageUploadService`를 v2 `S3Presigner.presignPutObject()` 기반으로 전환하고, product 단위 테스트에서 v1 `AmazonS3` fake를 제거했다. +- 2026-05-22: Task 3 완료. `TestContainersConfig`, mono MinIO 단위 테스트, product MinIO 통합 테스트를 v2 `S3Client`/`S3Presigner` 기준으로 전환했다. +- 2026-05-22: Task 4 완료. admin 업로드 통합 테스트를 v2 `S3Client` 검증으로 전환했다. 테스트 컨텍스트에서 MinIO presigner가 선택되도록 테스트 전용 primary bean 이름을 분리했다. +- 2026-05-22: Task 5 완료. `com.amazonaws`, `aws-java-sdk-s3`, `libs.aws.s3` 잔존 검색 결과 없음. +- 2026-05-22: 구현 단계 검증 완료. `compileJava compileTestJava`, product `ImageUploadServiceTest`, mono `ImageUploadUnitTest`, product `MinioContainerLoadingTest`, `admin_integration_test`, `unit_test` 통과. `/verify full`은 마스터 지시 전까지 대기한다. From 49aafd0c8847131aebbe928733677e84c1a654f1 Mon Sep 17 00:00:00 2001 From: hgkim Date: Fri, 22 May 2026 23:10:00 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/listener/ResourceEventListener.java | 2 +- .../payload/ImageResourceActivatedEvent.java | 17 +- .../file/service/ResourceCommandService.java | 16 ++ .../review/service/ReviewService.java | 10 +- .../common/file/ImageUploadUnitTest.java | 33 +++ .../ImageUploadIntegrationTest.java | 254 ++++++++++++++++++ .../upload/ResourceCommandServiceTest.java | 19 ++ plan/aws-sdk-v2-image-upload-tests.md | 22 ++ 8 files changed, 365 insertions(+), 8 deletions(-) create mode 100644 plan/aws-sdk-v2-image-upload-tests.md diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java index 14d830444..f4f0571e2 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/listener/ResourceEventListener.java @@ -33,7 +33,7 @@ public void handleImageResourceActivated(ImageResourceActivatedEvent event) { for (String resourceKey : event.resourceKeys()) { resourceCommandService.activateImageResource( - resourceKey, event.referenceId(), event.referenceType()); + resourceKey, event.referenceId(), event.referenceType(), event.userId()); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java index 7fe5fc19a..edb0f73a1 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/event/payload/ImageResourceActivatedEvent.java @@ -4,7 +4,7 @@ import java.util.Objects; public record ImageResourceActivatedEvent( - List resourceKeys, Long referenceId, String referenceType) { + List resourceKeys, Long referenceId, String referenceType, Long userId) { public ImageResourceActivatedEvent { Objects.requireNonNull(resourceKeys, "resourceKeys must not be null"); @@ -14,11 +14,22 @@ public record ImageResourceActivatedEvent( public static ImageResourceActivatedEvent of( List resourceKeys, Long referenceId, String referenceType) { - return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType); + return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType, null); } public static ImageResourceActivatedEvent of( String resourceKey, Long referenceId, String referenceType) { - return new ImageResourceActivatedEvent(List.of(resourceKey), referenceId, referenceType); + return new ImageResourceActivatedEvent(List.of(resourceKey), referenceId, referenceType, null); + } + + public static ImageResourceActivatedEvent of( + List resourceKeys, Long referenceId, String referenceType, Long userId) { + return new ImageResourceActivatedEvent(resourceKeys, referenceId, referenceType, userId); + } + + public static ImageResourceActivatedEvent of( + String resourceKey, Long referenceId, String referenceType, Long userId) { + return new ImageResourceActivatedEvent( + List.of(resourceKey), referenceId, referenceType, userId); } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java index 096333072..a146ebdd5 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -52,6 +52,13 @@ public CompletableFuture saveImageResourceCreated( @Transactional(propagation = Propagation.REQUIRES_NEW) public CompletableFuture> activateImageResource( String resourceKey, Long referenceId, String referenceType) { + return activateImageResource(resourceKey, referenceId, referenceType, null); + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CompletableFuture> activateImageResource( + String resourceKey, Long referenceId, String referenceType, Long expectedUserId) { Optional resourceLogOpt = resourceLogRepository.findByResourceKey(resourceKey); if (resourceLogOpt.isEmpty()) { @@ -61,6 +68,15 @@ public CompletableFuture> activateImageResource( ResourceLog resourceLog = resourceLogOpt.get(); + if (expectedUserId != null && !expectedUserId.equals(resourceLog.getUserId())) { + log.warn( + "리소스 소유자가 일치하지 않아 활성화 스킵 - resourceKey: {}, expectedUserId: {}, actualUserId: {}", + resourceKey, + expectedUserId, + resourceLog.getUserId()); + return CompletableFuture.completedFuture(Optional.empty()); + } + if (resourceLog.isActivated()) { log.info("이미 활성화된 리소스 스킵 - resourceKey: {}", resourceKey); return CompletableFuture.completedFuture(Optional.empty()); diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java index 829ade2c2..dcca2884f 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java @@ -129,7 +129,8 @@ public ReviewCreateResponse createReview( saveReview.getContent()); reviewEventPublisher.publishReviewHistoryEvent(event); - publishImageActivatedEvent(reviewCreateRequest.imageUrlList(), saveReview.getId()); + publishImageActivatedEvent( + reviewCreateRequest.imageUrlList(), saveReview.getId(), currentUserId); log.info( "리뷰 생성 - reviewId: {}, userId: {}, alcoholId: {}, rating: {}, status: {}, traceId: {}", @@ -183,7 +184,7 @@ public ReviewResultResponse modifyReview( publishImageInvalidatedEvent(oldImageUrls, newImageUrls, reviewId); // 새 이미지에 대해 ACTIVATED 이벤트 발행 - publishImageActivatedEvent(reviewImageInfoRequests, reviewId); + publishImageActivatedEvent(reviewImageInfoRequests, reviewId, currentUserId); return ReviewResultResponse.response(MODIFY_SUCCESS, reviewId); } @@ -233,7 +234,8 @@ public ReviewResultResponse changeStatus( : ReviewResultResponse.response(PRIVATE_SUCCESS, review.getId()); } - private void publishImageActivatedEvent(List imageList, Long reviewId) { + private void publishImageActivatedEvent( + List imageList, Long reviewId, Long userId) { List images = Objects.requireNonNullElse(imageList, Collections.emptyList()); if (images.isEmpty() || reviewId == null) { @@ -247,7 +249,7 @@ private void publishImageActivatedEvent(List imageList, .toList(); if (!resourceKeys.isEmpty()) { eventPublisher.publishEvent( - ImageResourceActivatedEvent.of(resourceKeys, reviewId, REFERENCE_TYPE_REVIEW)); + ImageResourceActivatedEvent.of(resourceKeys, reviewId, REFERENCE_TYPE_REVIEW, userId)); } } diff --git a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java index 95af468c0..77b1581f4 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/common/file/ImageUploadUnitTest.java @@ -1,6 +1,7 @@ package app.bottlenote.common.file; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -122,6 +123,22 @@ void tearDown() { resourceLogRepository.clear(); } + private int upload(String uploadUrl, byte[] data, String contentType) throws Exception { + URL url = new URL(uploadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("PUT"); + connection.setRequestProperty("Content-Type", contentType); + connection.setRequestProperty("Content-Length", String.valueOf(data.length)); + + try (OutputStream os = connection.getOutputStream()) { + os.write(data); + } + int responseCode = connection.getResponseCode(); + connection.disconnect(); + return responseCode; + } + @Nested @DisplayName("PreSigned URL 생성 테스트") class PreSignedUrlTest { @@ -173,6 +190,22 @@ void test_2() throws Exception { log.info("업로드 응답 코드 = {}", responseCode); } + @Test + @DisplayName("presigned URL 발급 시 서명된 contentType과 다른 contentType으로 PUT 하는 경우") + void test_2_content_type_mismatch() throws Exception { + // given + ImageUploadRequest request = new ImageUploadRequest("review", 1L, "image/png"); + ImageUploadResponse response = imageUploadService.getPreSignUrl(request); + String uploadUrl = response.imageUploadInfo().get(0).uploadUrl(); + byte[] testData = "test image content".getBytes(StandardCharsets.UTF_8); + + // when + int responseCode = upload(uploadUrl, testData, "image/jpeg"); + + // then + assertNotEquals(200, responseCode); + } + @Test @DisplayName("업로드된 파일이 MinIO에 존재한다") void test_3() throws Exception { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index 43f8dc36f..2ab5c07ec 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -22,8 +22,13 @@ import app.bottlenote.review.dto.request.ReviewImageInfoRequest; import app.bottlenote.review.dto.request.ReviewModifyRequest; import app.bottlenote.review.dto.response.ReviewCreateResponse; +import app.bottlenote.review.dto.response.ReviewDetailResponse; import app.bottlenote.review.dto.response.ReviewResultResponse; +import java.io.OutputStream; import java.math.BigDecimal; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.concurrent.TimeUnit; import org.awaitility.Awaitility; @@ -46,6 +51,60 @@ private LocationInfoRequest createTestLocationInfo() { "테스트 장소", "12345", "서울시 강남구", "상세 주소", "BAR", "https://map.test.com", "37.123", "127.456"); } + private ImageUploadResponse createPresignedUrls(String token, int uploadSize) throws Exception { + MvcTestResult presignResult = + mockMvcTester + .get() + .uri("/api/v1/s3/presign-url") + .param("rootPath", "review") + .param("uploadSize", String.valueOf(uploadSize)) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + return extractData(presignResult, ImageUploadResponse.class); + } + + private int upload(String uploadUrl, byte[] data, String contentType) throws Exception { + URL url = new URL(uploadUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("PUT"); + connection.setRequestProperty("Content-Type", contentType); + connection.setRequestProperty("Content-Length", String.valueOf(data.length)); + + try (OutputStream os = connection.getOutputStream()) { + os.write(data); + } + int responseCode = connection.getResponseCode(); + connection.disconnect(); + return responseCode; + } + + private ReviewCreateRequest createReviewRequest( + Long alcoholId, List imageRequests) { + return new ReviewCreateRequest( + alcoholId, + ReviewDisplayStatus.PUBLIC, + "테스트 리뷰 내용입니다.", + SizeType.GLASS, + BigDecimal.valueOf(30000), + createTestLocationInfo(), + imageRequests, + List.of("테이스팅태그"), + 4.5); + } + + private List fakeImageRequests(int size) { + return java.util.stream.LongStream.rangeClosed(1, size) + .mapToObj( + order -> + new ReviewImageInfoRequest( + order, "https://fake-cloudfront.net/review/20260522/" + order + "-fake.jpg")) + .toList(); + } + @Nested @DisplayName("PreSigned URL 생성 테스트") class PreSignedUrlTest { @@ -196,6 +255,201 @@ void test_1() throws Exception { @DisplayName("이미지 리소스 활성화 테스트") class ResourceActivationTest { + @Test + @DisplayName( + "PreSigned URL 발급 -> 실제 PUT 업로드 -> 리뷰 등록 -> 리뷰 상세 조회에서 이미지 노출 -> ResourceLog ACTIVATED") + void presign_upload_review_detail_activates_resource_log() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ImageUploadResponse uploadResponse = createPresignedUrls(token, 1); + ImageUploadItem uploadInfo = uploadResponse.imageUploadInfo().get(0); + byte[] imageBytes = "uploaded review image".getBytes(StandardCharsets.UTF_8); + + int uploadStatus = upload(uploadInfo.uploadUrl(), imageBytes, "image/jpeg"); + assertEquals(200, uploadStatus); + + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); + }); + + ReviewCreateRequest reviewRequest = + createReviewRequest( + alcohol.getId(), List.of(new ReviewImageInfoRequest(1L, uploadInfo.viewUrl()))); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + MvcTestResult detailResult = + mockMvcTester + .get() + .uri("/api/v1/reviews/detail/{reviewId}", reviewResponse.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then + ReviewDetailResponse detailResponse = extractData(detailResult, ReviewDetailResponse.class); + assertEquals(uploadInfo.viewUrl(), detailResponse.reviewInfo().reviewImageUrl()); + assertEquals(1L, detailResponse.reviewInfo().totalImageCount()); + + Awaitility.await() + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(userId); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.ACTIVATED, logs.get(0).getEventType()); + assertEquals(reviewResponse.getId(), logs.get(0).getReferenceId()); + }); + } + + @Test + @DisplayName("presign 없이 임의로 만든 viewUrl로 리뷰 등록하는 경우") + void arbitrary_view_url_does_not_activate_resource_log() throws Exception { + // given + String token = getToken(); + Long userId = getTokenUserId(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest reviewRequest = + createReviewRequest(alcohol.getId(), fakeImageRequests(1)); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + // then + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + assertNotNull(reviewResponse.getId()); + Thread.sleep(1000); + assertEquals(0, resourceLogRepository.findByUserId(userId).size()); + } + + @Test + @DisplayName("다른 사용자가 발급받은 viewUrl을 내 리뷰에 등록하는 경우") + void other_user_view_url_does_not_activate_original_resource_log() throws Exception { + // given + String ownerToken = getToken(); + Long ownerId = getTokenUserId(); + ImageUploadResponse uploadResponse = createPresignedUrls(ownerToken, 1); + String ownerViewUrl = uploadResponse.imageUploadInfo().get(0).viewUrl(); + + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted( + () -> { + List logs = resourceLogRepository.findByUserId(ownerId); + assertEquals(1, logs.size()); + assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); + }); + + String otherToken = authSupport.getRandomAccessToken(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest reviewRequest = + createReviewRequest( + alcohol.getId(), List.of(new ReviewImageInfoRequest(1L, ownerViewUrl))); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + otherToken) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + // then + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + assertNotNull(reviewResponse.getId()); + Thread.sleep(1000); + List ownerLogs = resourceLogRepository.findByUserId(ownerId); + assertEquals(1, ownerLogs.size()); + assertEquals(ResourceEventType.CREATED, ownerLogs.get(0).getEventType()); + } + + @Test + @DisplayName("리뷰 이미지 5장은 등록 가능하다") + void review_can_attach_five_images() throws Exception { + // given + String token = getToken(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest reviewRequest = + createReviewRequest(alcohol.getId(), fakeImageRequests(5)); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + // then + ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); + MvcTestResult detailResult = + mockMvcTester + .get() + .uri("/api/v1/reviews/detail/{reviewId}", reviewResponse.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + ReviewDetailResponse detailResponse = extractData(detailResult, ReviewDetailResponse.class); + assertEquals(5L, detailResponse.reviewInfo().totalImageCount()); + assertEquals( + fakeImageRequests(5).get(0).viewUrl(), detailResponse.reviewInfo().reviewImageUrl()); + } + + @Test + @DisplayName("리뷰 이미지 6장은 등록 실패한다") + void review_rejects_six_images() throws Exception { + // given + String token = getToken(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest reviewRequest = + createReviewRequest(alcohol.getId(), fakeImageRequests(6)); + + // when + MvcTestResult reviewResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewRequest)) + .with(csrf()) + .exchange(); + + // then + reviewResult.assertThat().hasStatus4xxClientError(); + } + @Test @DisplayName("리뷰 생성 시 이미지가 포함되면 ResourceLog 상태가 ACTIVATED로 변경된다") void test_review_with_images_creates_activated_log() throws Exception { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java index 8bfc28f20..13864aedf 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -159,6 +159,25 @@ void test_3() { log.info("중복 활성화 시도 결과 = {}", result); } + + @Test + @DisplayName("다른 사용자의 이미지 리소스는 활성화하지 않는다") + void test_4() { + // given + String resourceKey = "review/20251231/1-uuid.jpg"; + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + + // when + Optional result = + resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW", 2L).join(); + + // then + assertTrue(result.isEmpty()); + Optional resourceLog = + resourceCommandService.findByResourceKey(resourceKey); + assertTrue(resourceLog.isPresent()); + assertEquals(ResourceEventType.CREATED, resourceLog.get().eventType()); + } } @Nested diff --git a/plan/aws-sdk-v2-image-upload-tests.md b/plan/aws-sdk-v2-image-upload-tests.md new file mode 100644 index 000000000..0ebff036b --- /dev/null +++ b/plan/aws-sdk-v2-image-upload-tests.md @@ -0,0 +1,22 @@ +# Plan: AWS SDK v2 이미지 업로드 테스트 보강 + +## Overview +AWS SDK v2 전환 브랜치에서 이미지 업로드 검증을 레이어별로 분리해 보강한다. S3/MinIO 직접 동작은 unit 성격의 Testcontainers 테스트에서 확인하고, 리뷰 API와 ResourceLog 연결은 product-api integration 테스트에서 확인한다. + +### Assumptions +- 새 브랜치를 만들지 않고 현재 `codex/aws-sdk-v2-migration` 브랜치에서 작업한다. +- 실제 S3 대신 기존 테스트 관습대로 MinIO Testcontainers를 사용한다. +- Mock 프레임워크는 추가하지 않고 기존 Fake/InMemory/Testcontainers 기반으로 검증한다. +- 리뷰 이미지 정책은 현재 코드의 ResourceLog 기반 상태 전환과 리뷰 이미지 최대 5장 제한을 기준으로 검증한다. + +### Success Criteria +- PreSigned URL 발급 후 실제 PUT 업로드와 객체 조회가 AWS SDK v2 클라이언트로 검증된다. +- 리뷰 등록 통합 흐름에서 업로드 이미지가 상세 조회에 노출되고 ResourceLog가 `ACTIVATED`로 전환된다. +- contentType 불일치 업로드, presign 없이 만든 URL, 다른 사용자 URL, 리뷰 이미지 5장/6장 경계 조건이 테스트로 고정된다. +- unit 테스트와 integration 테스트가 각 레이어의 책임에 맞게 분리된다. +- `/verify full` 수준의 전체 검증을 통과한 뒤 커밋 및 푸시한다. + +### Impact Scope +- `bottlenote-mono`: `ImageUploadUnitTest`에 MinIO/S3 SDK v2 레벨 테스트 추가. +- `bottlenote-product-api`: `ImageUploadIntegrationTest`에 리뷰 이미지 연결 및 ResourceLog 통합 테스트 추가. +- 스키마, 운영 설정, API 응답 계약 변경은 포함하지 않는다. From 5471cb5bdbb3a7cc066534d38131aa049290c835 Mon Sep 17 00:00:00 2001 From: hgkim Date: Sat, 23 May 2026 00:03:44 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/exception/FileExceptionCode.java | 6 +- .../file/service/ImageUploadService.java | 12 +- .../file/service/ResourceCommandService.java | 6 +- .../file/service/ResourceVerifierService.java | 67 +++++++ .../review/service/ReviewService.java | 15 ++ ...mageResourceActivatedEventPublishTest.java | 80 ++++---- .../file/event/ResourceEventListenerTest.java | 2 +- .../ImageUploadIntegrationTest.java | 177 +++++++++++++----- .../file/upload/ImageUploadServiceTest.java | 41 ++++ .../upload/ResourceCommandServiceTest.java | 44 ++--- .../upload/ResourceVerifierServiceTest.java | 128 +++++++++++++ .../config/JpaAuditingIntegrationTest.java | 16 +- .../integration/ReviewIntegrationTest.java | 33 +++- plan/aws-sdk-v2-resource-verify-followup.md | 53 ++++++ 14 files changed, 555 insertions(+), 125 deletions(-) create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceVerifierService.java create mode 100644 bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceVerifierServiceTest.java create mode 100644 plan/aws-sdk-v2-resource-verify-followup.md diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java index 51e69a2e4..cf5067af9 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/exception/FileExceptionCode.java @@ -5,7 +5,11 @@ public enum FileExceptionCode implements ExceptionCode { EXPIRY_TIME_RANGE_INVALID(HttpStatus.BAD_REQUEST, "만료 기간의 범위가 적절하지 않습니다.( 최소 1분 ,최대 10분) "), - UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다."); + UNSUPPORTED_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 Content-Type입니다."), + INVALID_RESOURCE_URL(HttpStatus.BAD_REQUEST, "유효하지 않은 리소스 URL입니다."), + RESOURCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "등록되지 않은 리소스입니다."), + RESOURCE_OWNER_MISMATCH(HttpStatus.BAD_REQUEST, "리소스 소유자가 일치하지 않습니다."), + RESOURCE_ALREADY_USED(HttpStatus.BAD_REQUEST, "사용할 수 없는 리소스 상태입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java index 0884bd5d8..bdcc55a8c 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ImageUploadService.java @@ -34,7 +34,7 @@ public ImageUploadService( this.resourceCommandService = resourceCommandService; this.s3Presigner = s3Presigner; this.imageBucketName = imageBucketName; - this.cloudFrontUrl = cloudFrontUrl; + this.cloudFrontUrl = normalizeCloudFrontUrl(cloudFrontUrl); } /** @@ -95,7 +95,7 @@ private ImageUploadResponse buildResponse(List keys) { @Override public String generateViewUrl(String cloudFrontUrl, String imageKey) { - return cloudFrontUrl + PATH_DELIMITER + imageKey; + return normalizeCloudFrontUrl(cloudFrontUrl) + PATH_DELIMITER + imageKey; } @Override @@ -139,4 +139,12 @@ private String extractImageKey(String viewUrl) { int lastSlashOfCloudFront = cloudFrontUrl.length() + 1; return viewUrl.substring(lastSlashOfCloudFront); } + + private String normalizeCloudFrontUrl(String url) { + String normalized = url; + while (normalized.endsWith(PATH_DELIMITER)) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java index a146ebdd5..dabcc33fd 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceCommandService.java @@ -26,10 +26,8 @@ public class ResourceCommandService { private final ResourceLogRepository resourceLogRepository; - @Async @Transactional(propagation = Propagation.REQUIRES_NEW) - public CompletableFuture saveImageResourceCreated( - ResourceLogRequest request) { + public ResourceLogResponse saveImageResourceCreated(ResourceLogRequest request) { ResourceLog entity = ResourceLog.builder() .userId(request.userId()) @@ -45,7 +43,7 @@ public CompletableFuture saveImageResourceCreated( "이미지 리소스 생성 로그 저장 - resourceKey: {}, userId: {}", saved.getResourceKey(), saved.getUserId()); - return CompletableFuture.completedFuture(toResponse(saved)); + return toResponse(saved); } @Async diff --git a/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceVerifierService.java b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceVerifierService.java new file mode 100644 index 000000000..2b5435ec3 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/common/file/service/ResourceVerifierService.java @@ -0,0 +1,67 @@ +package app.bottlenote.common.file.service; + +import static app.bottlenote.common.file.constant.ResourceEventType.ACTIVATED; +import static app.bottlenote.common.file.constant.ResourceEventType.CREATED; +import static app.bottlenote.common.file.exception.FileExceptionCode.INVALID_RESOURCE_URL; +import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_ALREADY_USED; +import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_NOT_FOUND; +import static app.bottlenote.common.file.exception.FileExceptionCode.RESOURCE_OWNER_MISMATCH; + +import app.bottlenote.common.file.domain.ResourceLog; +import app.bottlenote.common.file.domain.ResourceLogRepository; +import app.bottlenote.common.file.exception.FileException; +import app.bottlenote.common.image.ImageUtil; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ResourceVerifierService { + + private final ResourceLogRepository resourceLogRepository; + + @Transactional(readOnly = true) + public List verifyOwnedImageResources( + List viewUrls, Long userId, Long referenceId, String referenceType) { + return Objects.requireNonNullElse(viewUrls, Collections.emptyList()).stream() + .map(viewUrl -> verifyOwnedImageResource(viewUrl, userId, referenceId, referenceType)) + .toList(); + } + + private String verifyOwnedImageResource( + String viewUrl, Long userId, Long referenceId, String referenceType) { + String resourceKey = ImageUtil.extractResourceKey(viewUrl); + if (resourceKey == null || resourceKey.isBlank()) { + throw new FileException(INVALID_RESOURCE_URL); + } + + ResourceLog resourceLog = + resourceLogRepository + .findByResourceKey(resourceKey) + .orElseThrow(() -> new FileException(RESOURCE_NOT_FOUND)); + + if (!Objects.equals(userId, resourceLog.getUserId())) { + throw new FileException(RESOURCE_OWNER_MISMATCH); + } + if (!Objects.equals(viewUrl, resourceLog.getViewUrl())) { + throw new FileException(INVALID_RESOURCE_URL); + } + if (!isUsable(resourceLog, referenceId, referenceType)) { + throw new FileException(RESOURCE_ALREADY_USED); + } + return resourceKey; + } + + private boolean isUsable(ResourceLog resourceLog, Long referenceId, String referenceType) { + if (resourceLog.getEventType() == CREATED) { + return true; + } + return resourceLog.getEventType() == ACTIVATED + && Objects.equals(referenceId, resourceLog.getReferenceId()) + && Objects.equals(referenceType, resourceLog.getReferenceType()); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java index dcca2884f..218930e42 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/review/service/ReviewService.java @@ -11,6 +11,7 @@ import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; import app.bottlenote.common.file.event.payload.ImageResourceInvalidatedEvent; +import app.bottlenote.common.file.service.ResourceVerifierService; import app.bottlenote.common.image.ImageUtil; import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.history.event.publisher.HistoryEventPublisher; @@ -57,6 +58,7 @@ public class ReviewService { private final HistoryEventPublisher reviewEventPublisher; private final TracingService tracingService; private final ApplicationEventPublisher eventPublisher; + private final ResourceVerifierService resourceVerifierService; /** Read */ @Transactional(readOnly = true) @@ -92,6 +94,7 @@ public ReviewCreateResponse createReview( ReviewCreateRequest reviewCreateRequest, Long currentUserId) { alcoholFacade.isValidAlcoholId(reviewCreateRequest.alcoholId()); userDomainSupport.isValidUserId(currentUserId); + verifyReviewImages(reviewCreateRequest.imageUrlList(), currentUserId, null); RatingPoint point = RatingPoint.of(reviewCreateRequest.rating()); Review review = @@ -167,6 +170,7 @@ public ReviewResultResponse modifyReview( ReviewModifyRequestWrapperItem reviewModifyRequestWrapperItem = ReviewModifyRequestWrapperItem.create(request); List reviewImageInfoRequests = request.imageUrlList(); + verifyReviewImages(reviewImageInfoRequests, currentUserId, reviewId); review.update(reviewModifyRequestWrapperItem); review.imageInitialization(reviewImageInfoRequests); @@ -253,6 +257,17 @@ private void publishImageActivatedEvent( } } + private void verifyReviewImages( + List imageList, Long userId, Long reviewId) { + List viewUrls = + Objects.requireNonNullElse(imageList, Collections.emptyList()) + .stream() + .map(ReviewImageInfoRequest::viewUrl) + .toList(); + resourceVerifierService.verifyOwnedImageResources( + viewUrls, userId, reviewId, REFERENCE_TYPE_REVIEW); + } + private void publishImageInvalidatedEvent( List oldImageUrls, List newImageUrls, Long reviewId) { if (oldImageUrls == null || oldImageUrls.isEmpty() || reviewId == null) { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java index 155a1b249..40e4b3065 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ImageResourceActivatedEventPublishTest.java @@ -11,6 +11,7 @@ import app.bottlenote.common.file.event.listener.ResourceEventListener; import app.bottlenote.common.file.event.payload.ImageResourceActivatedEvent; import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.service.ResourceVerifierService; import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; import app.bottlenote.common.profanity.FakeProfanityClient; import app.bottlenote.history.fixture.FakeHistoryEventPublisher; @@ -54,11 +55,15 @@ class ReviewServiceTest { private ReviewService reviewService; private InMemoryReviewRepository reviewRepository; + private InMemoryResourceLogRepository resourceLogRepository; + private ResourceCommandService resourceCommandService; private FakeApplicationEventPublisher eventPublisher; @BeforeEach void setUp() { reviewRepository = new InMemoryReviewRepository(); + resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); eventPublisher = new FakeApplicationEventPublisher(); reviewService = @@ -68,7 +73,8 @@ void setUp() { reviewRepository, new FakeHistoryEventPublisher(), new LocalTracingService(), - eventPublisher); + eventPublisher, + new ResourceVerifierService(resourceLogRepository)); } @Test @@ -79,6 +85,7 @@ void test_create_review_with_images_publishes_event() { List.of( new ReviewImageInfoRequest(1L, "https://cdn.bottlenote.com/review/img1.jpg"), new ReviewImageInfoRequest(2L, "https://cdn.bottlenote.com/review/img2.jpg")); + images.forEach(image -> saveCreated(1L, image.viewUrl(), "review")); ReviewCreateRequest request = createReviewRequest(images); // when @@ -121,6 +128,7 @@ void test_modify_review_with_images_publishes_event() { List newImages = List.of(new ReviewImageInfoRequest(1L, "https://cdn.bottlenote.com/review/new-img.jpg")); + newImages.forEach(image -> saveCreated(1L, image.viewUrl(), "review")); ReviewModifyRequest modifyRequest = new ReviewModifyRequest( "수정된 내용", null, null, newImages, null, null, LocationInfoRequest.empty(), null); @@ -143,6 +151,12 @@ private ReviewCreateRequest createReviewRequest(List ima return new ReviewCreateRequest( 1L, null, "테스트 리뷰 내용", null, null, LocationInfoRequest.empty(), images, List.of(), 4.5); } + + private void saveCreated(Long userId, String viewUrl, String rootPath) { + String resourceKey = viewUrl.substring("https://cdn.bottlenote.com/".length()); + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest(userId, resourceKey, viewUrl, rootPath, "test-bucket")); + } } @Nested @@ -353,15 +367,13 @@ void test_event_listener_creates_activated_log() { Long referenceId = 100L; String referenceType = "REVIEW"; - resourceCommandService - .saveImageResourceCreated( - new ResourceLogRequest( - 1L, - resourceKey, - "https://cdn.bottlenote.com/" + resourceKey, - "review", - "test-bucket")) - .join(); + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey, + "https://cdn.bottlenote.com/" + resourceKey, + "review", + "test-bucket")); ImageResourceActivatedEvent event = ImageResourceActivatedEvent.of(resourceKey, referenceId, referenceType); @@ -389,24 +401,20 @@ void test_multiple_resources_create_multiple_activated_logs() { Long referenceId = 200L; String referenceType = "HELP"; - resourceCommandService - .saveImageResourceCreated( - new ResourceLogRequest( - 1L, - resourceKey1, - "https://cdn.bottlenote.com/" + resourceKey1, - "help", - "test-bucket")) - .join(); - resourceCommandService - .saveImageResourceCreated( - new ResourceLogRequest( - 1L, - resourceKey2, - "https://cdn.bottlenote.com/" + resourceKey2, - "help", - "test-bucket")) - .join(); + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey1, + "https://cdn.bottlenote.com/" + resourceKey1, + "help", + "test-bucket")); + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest( + 1L, + resourceKey2, + "https://cdn.bottlenote.com/" + resourceKey2, + "help", + "test-bucket")); ImageResourceActivatedEvent event = ImageResourceActivatedEvent.of( @@ -442,15 +450,13 @@ void test_resource_log_sequence_created_to_activated() { Long referenceId = 300L; String referenceType = "BUSINESS"; - resourceCommandService - .saveImageResourceCreated( - new ResourceLogRequest( - userId, - resourceKey, - "https://cdn.bottlenote.com/" + resourceKey, - "business", - "test-bucket")) - .join(); + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest( + userId, + resourceKey, + "https://cdn.bottlenote.com/" + resourceKey, + "business", + "test-bucket")); ImageResourceActivatedEvent event = ImageResourceActivatedEvent.of(resourceKey, referenceId, referenceType); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java index 0002312df..c1d8c3cae 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/event/ResourceEventListenerTest.java @@ -40,7 +40,7 @@ private void createResourceLog(String resourceKey, Long userId) { .rootPath("review") .bucketName("test-bucket") .build(); - resourceCommandService.saveImageResourceCreated(request).join(); + resourceCommandService.saveImageResourceCreated(request); } @Nested diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java index 2ab5c07ec..bf17537da 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/integration/ImageUploadIntegrationTest.java @@ -17,6 +17,7 @@ import app.bottlenote.common.file.dto.response.ImageUploadResponse; import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.constant.SizeType; +import app.bottlenote.review.domain.ReviewRepository; import app.bottlenote.review.dto.request.LocationInfoRequest; import app.bottlenote.review.dto.request.ReviewCreateRequest; import app.bottlenote.review.dto.request.ReviewImageInfoRequest; @@ -45,6 +46,7 @@ class ImageUploadIntegrationTest extends IntegrationTestSupport { @Autowired private ResourceLogRepository resourceLogRepository; @Autowired private AlcoholTestFactory alcoholTestFactory; + @Autowired private ReviewRepository reviewRepository; private LocationInfoRequest createTestLocationInfo() { return new LocationInfoRequest( @@ -105,6 +107,12 @@ private List fakeImageRequests(int size) { .toList(); } + private List imageRequests(ImageUploadResponse response) { + return response.imageUploadInfo().stream() + .map(item -> new ReviewImageInfoRequest(item.order(), item.viewUrl())) + .toList(); + } + @Nested @DisplayName("PreSigned URL 생성 테스트") class PreSignedUrlTest { @@ -227,17 +235,10 @@ void test_1() throws Exception { .with(csrf()) .exchange(); - // then - 비동기 로그 저장 대기 - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted( - () -> { - List logs = resourceLogRepository.findByUserId(userId); - assertFalse(logs.isEmpty()); - assertEquals(uploadSize.intValue(), logs.size()); - }); - + // then List logs = resourceLogRepository.findByUserId(userId); + assertFalse(logs.isEmpty()); + assertEquals(uploadSize.intValue(), logs.size()); logs.forEach( resourceLog -> { assertEquals(ResourceEventType.CREATED, resourceLog.getEventType()); @@ -270,15 +271,6 @@ void presign_upload_review_detail_activates_resource_log() throws Exception { int uploadStatus = upload(uploadInfo.uploadUrl(), imageBytes, "image/jpeg"); assertEquals(200, uploadStatus); - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted( - () -> { - List logs = resourceLogRepository.findByUserId(userId); - assertEquals(1, logs.size()); - assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); - }); - ReviewCreateRequest reviewRequest = createReviewRequest( alcohol.getId(), List.of(new ReviewImageInfoRequest(1L, uploadInfo.viewUrl()))); @@ -320,14 +312,15 @@ void presign_upload_review_detail_activates_resource_log() throws Exception { } @Test - @DisplayName("presign 없이 임의로 만든 viewUrl로 리뷰 등록하는 경우") - void arbitrary_view_url_does_not_activate_resource_log() throws Exception { + @DisplayName("presign 없이 임의로 만든 viewUrl로 리뷰 등록하면 저장 전에 실패한다") + void arbitrary_view_url_rejects_review_before_save() throws Exception { // given String token = getToken(); Long userId = getTokenUserId(); Alcohol alcohol = alcoholTestFactory.persistAlcohol(); ReviewCreateRequest reviewRequest = createReviewRequest(alcohol.getId(), fakeImageRequests(1)); + int beforeCount = reviewRepository.findByUserId(userId).size(); // when MvcTestResult reviewResult = @@ -341,29 +334,22 @@ void arbitrary_view_url_does_not_activate_resource_log() throws Exception { .exchange(); // then - ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); - assertNotNull(reviewResponse.getId()); - Thread.sleep(1000); + reviewResult.assertThat().hasStatus4xxClientError(); + assertEquals(beforeCount, reviewRepository.findByUserId(userId).size()); assertEquals(0, resourceLogRepository.findByUserId(userId).size()); } @Test - @DisplayName("다른 사용자가 발급받은 viewUrl을 내 리뷰에 등록하는 경우") - void other_user_view_url_does_not_activate_original_resource_log() throws Exception { + @DisplayName("다른 사용자가 발급받은 viewUrl을 내 리뷰에 등록하면 저장 전에 실패한다") + void other_user_view_url_rejects_review_before_save() throws Exception { // given String ownerToken = getToken(); Long ownerId = getTokenUserId(); ImageUploadResponse uploadResponse = createPresignedUrls(ownerToken, 1); String ownerViewUrl = uploadResponse.imageUploadInfo().get(0).viewUrl(); - - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted( - () -> { - List logs = resourceLogRepository.findByUserId(ownerId); - assertEquals(1, logs.size()); - assertEquals(ResourceEventType.CREATED, logs.get(0).getEventType()); - }); + List createdLogs = resourceLogRepository.findByUserId(ownerId); + assertEquals(1, createdLogs.size()); + assertEquals(ResourceEventType.CREATED, createdLogs.get(0).getEventType()); String otherToken = authSupport.getRandomAccessToken(); Alcohol alcohol = alcoholTestFactory.persistAlcohol(); @@ -383,22 +369,120 @@ void other_user_view_url_does_not_activate_original_resource_log() throws Except .exchange(); // then - ReviewCreateResponse reviewResponse = extractData(reviewResult, ReviewCreateResponse.class); - assertNotNull(reviewResponse.getId()); - Thread.sleep(1000); + reviewResult.assertThat().hasStatus4xxClientError(); List ownerLogs = resourceLogRepository.findByUserId(ownerId); assertEquals(1, ownerLogs.size()); assertEquals(ResourceEventType.CREATED, ownerLogs.get(0).getEventType()); } + @Test + @DisplayName("presign 없이 임의로 만든 viewUrl로 리뷰 수정하면 저장 전에 실패한다") + void arbitrary_view_url_rejects_review_modify_before_save() throws Exception { + // given + String token = getToken(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest createRequest = createReviewRequest(alcohol.getId(), List.of()); + ReviewCreateResponse createResponse = + extractData( + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(), + ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정 요청은 실패해야 한다", + ReviewDisplayStatus.PUBLIC, + null, + fakeImageRequests(1), + null, + null, + createTestLocationInfo(), + null); + + // when + MvcTestResult modifyResult = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(modifyRequest)) + .with(csrf()) + .exchange(); + + // then + modifyResult.assertThat().hasStatus4xxClientError(); + assertEquals( + createRequest.content(), reviewRepository.findById(reviewId).orElseThrow().getContent()); + } + + @Test + @DisplayName("다른 사용자가 발급받은 viewUrl로 리뷰 수정하면 저장 전에 실패한다") + void other_user_view_url_rejects_review_modify_before_save() throws Exception { + // given + String token = getToken(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ReviewCreateRequest createRequest = createReviewRequest(alcohol.getId(), List.of()); + ReviewCreateResponse createResponse = + extractData( + mockMvcTester + .post() + .uri("/api/v1/reviews") + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(createRequest)) + .with(csrf()) + .exchange(), + ReviewCreateResponse.class); + Long reviewId = createResponse.getId(); + + String otherToken = authSupport.getRandomAccessToken(); + ImageUploadResponse otherUploadResponse = createPresignedUrls(otherToken, 1); + String otherViewUrl = otherUploadResponse.imageUploadInfo().get(0).viewUrl(); + ReviewModifyRequest modifyRequest = + new ReviewModifyRequest( + "수정 요청은 실패해야 한다", + ReviewDisplayStatus.PUBLIC, + null, + List.of(new ReviewImageInfoRequest(1L, otherViewUrl)), + null, + null, + createTestLocationInfo(), + null); + + // when + MvcTestResult modifyResult = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .header("Authorization", "Bearer " + token) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(modifyRequest)) + .with(csrf()) + .exchange(); + + // then + modifyResult.assertThat().hasStatus4xxClientError(); + assertEquals( + createRequest.content(), reviewRepository.findById(reviewId).orElseThrow().getContent()); + } + @Test @DisplayName("리뷰 이미지 5장은 등록 가능하다") void review_can_attach_five_images() throws Exception { // given String token = getToken(); Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ImageUploadResponse uploadResponse = createPresignedUrls(token, 5); ReviewCreateRequest reviewRequest = - createReviewRequest(alcohol.getId(), fakeImageRequests(5)); + createReviewRequest(alcohol.getId(), imageRequests(uploadResponse)); // when MvcTestResult reviewResult = @@ -423,7 +507,8 @@ void review_can_attach_five_images() throws Exception { ReviewDetailResponse detailResponse = extractData(detailResult, ReviewDetailResponse.class); assertEquals(5L, detailResponse.reviewInfo().totalImageCount()); assertEquals( - fakeImageRequests(5).get(0).viewUrl(), detailResponse.reviewInfo().reviewImageUrl()); + uploadResponse.imageUploadInfo().get(0).viewUrl(), + detailResponse.reviewInfo().reviewImageUrl()); } @Test @@ -432,8 +517,9 @@ void review_rejects_six_images() throws Exception { // given String token = getToken(); Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + ImageUploadResponse uploadResponse = createPresignedUrls(token, 6); ReviewCreateRequest reviewRequest = - createReviewRequest(alcohol.getId(), fakeImageRequests(6)); + createReviewRequest(alcohol.getId(), imageRequests(uploadResponse)); // when MvcTestResult reviewResult = @@ -473,15 +559,6 @@ void test_review_with_images_creates_activated_log() throws Exception { ImageUploadResponse uploadResponse = extractData(presignResult, ImageUploadResponse.class); List uploadInfos = uploadResponse.imageUploadInfo(); - // CREATED 로그 저장 대기 - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted( - () -> { - List logs = resourceLogRepository.findByUserId(userId); - assertEquals(2, logs.size()); - }); - // 리뷰 생성 요청 (이미지 URL 포함) List imageRequests = List.of( diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java index 8b57ac55a..073ce9ecd 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ImageUploadServiceTest.java @@ -171,6 +171,47 @@ void test_1() { assertNotNull(viewUrl); assertEquals(CLOUD_FRONT_URL + "/" + imageKey, viewUrl); } + + @Test + @DisplayName("CloudFront URL 끝에 slash가 있어도 조회용 URL에 중복 slash가 생기지 않는다") + void generateViewUrl_whenCloudFrontUrlHasTrailingSlash_normalizesUrl() { + // given + String imageKey = imageUploadService.getImageKey("review", 1L, "image/jpeg"); + + // when + String viewUrl = imageUploadService.generateViewUrl(CLOUD_FRONT_URL + "/", imageKey); + + // then + assertEquals(CLOUD_FRONT_URL + "/" + imageKey, viewUrl); + } + } + + @Nested + @DisplayName("ResourceLog 저장 테스트") + class ResourceLogTest { + + @Test + @DisplayName("어드민 PreSign URL 생성은 응답 전 CREATED 로그를 저장한다") + void getPreSignUrlForAdmin_savesCreatedLogsSynchronously() { + // given + Long adminId = 1L; + ImageUploadRequest request = new ImageUploadRequest("review", 2L, null); + + // when + ImageUploadResponse response = imageUploadService.getPreSignUrlForAdmin(adminId, request); + + // then + assertEquals(2, response.imageUploadInfo().size()); + assertEquals(2, resourceLogRepository.findByUserId(adminId).size()); + response + .imageUploadInfo() + .forEach( + item -> + assertTrue( + resourceLogRepository + .findByResourceKey(item.viewUrl().substring(CLOUD_FRONT_URL.length() + 1)) + .isPresent())); + } } @Nested diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java index 13864aedf..1644b494c 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceCommandServiceTest.java @@ -58,9 +58,7 @@ void test_1() { ResourceLogRequest request = createRequest(1L, "review/20251231/1-uuid.jpg"); // when - CompletableFuture future = - resourceCommandService.saveImageResourceCreated(request); - ResourceLogResponse response = future.join(); + ResourceLogResponse response = resourceCommandService.saveImageResourceCreated(request); // then assertNotNull(response); @@ -81,10 +79,8 @@ void test_2() { ResourceLogRequest request2 = createRequest(1L, "review/20251231/2-uuid2.jpg"); // when - ResourceLogResponse response1 = - resourceCommandService.saveImageResourceCreated(request1).join(); - ResourceLogResponse response2 = - resourceCommandService.saveImageResourceCreated(request2).join(); + ResourceLogResponse response1 = resourceCommandService.saveImageResourceCreated(request1); + ResourceLogResponse response2 = resourceCommandService.saveImageResourceCreated(request2); // then assertEquals(1L, response1.id()); @@ -105,7 +101,7 @@ class ActivateImageResourceTest { void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); // when CompletableFuture> future = @@ -126,7 +122,7 @@ void test_1() { void test_2() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); // when resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); @@ -146,7 +142,7 @@ void test_3() { // given String resourceKey = "review/20251231/1-uuid.jpg"; Long referenceId = 100L; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); resourceCommandService.activateImageResource(resourceKey, referenceId, "REVIEW").join(); // when @@ -165,7 +161,7 @@ void test_3() { void test_4() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); // when Optional result = @@ -189,7 +185,7 @@ class InvalidateImageResourceTest { void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); // when CompletableFuture> future = @@ -209,7 +205,7 @@ void test_1() { void test_2() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); resourceCommandService.invalidateImageResource(resourceKey).join(); // when @@ -233,7 +229,7 @@ class DeleteImageResourceTest { void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); resourceCommandService.invalidateImageResource(resourceKey).join(); // when @@ -254,7 +250,7 @@ void test_1() { void test_2() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); // when Optional result = @@ -271,7 +267,7 @@ void test_2() { void test_3() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); // when @@ -294,7 +290,7 @@ class FindResourceLogTest { void test_1() { // given String resourceKey = "review/20251231/1-uuid.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey)); resourceCommandService.activateImageResource(resourceKey, 100L, "REVIEW").join(); // when @@ -331,12 +327,10 @@ class FindByEventTypeTest { @DisplayName("CREATED 이벤트와 날짜 기준으로 로그 목록을 조회할 때 조건에 맞는 목록을 반환한다") void test_1() { // given - resourceCommandService - .saveImageResourceCreated(createRequest(1L, "review/20251231/1-uuid1.jpg")) - .join(); - resourceCommandService - .saveImageResourceCreated(createRequest(2L, "review/20251231/2-uuid2.jpg")) - .join(); + resourceCommandService.saveImageResourceCreated( + createRequest(1L, "review/20251231/1-uuid1.jpg")); + resourceCommandService.saveImageResourceCreated( + createRequest(2L, "review/20251231/2-uuid2.jpg")); // createAt 설정 (과거 날짜로) resourceLogRepository @@ -367,8 +361,8 @@ void test_2() { // given String resourceKey1 = "review/20251231/1-uuid1.jpg"; String resourceKey2 = "review/20251231/2-uuid2.jpg"; - resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey1)).join(); - resourceCommandService.saveImageResourceCreated(createRequest(2L, resourceKey2)).join(); + resourceCommandService.saveImageResourceCreated(createRequest(1L, resourceKey1)); + resourceCommandService.saveImageResourceCreated(createRequest(2L, resourceKey2)); resourceLogRepository .findById(1L) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceVerifierServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceVerifierServiceTest.java new file mode 100644 index 000000000..cb6048fae --- /dev/null +++ b/bottlenote-product-api/src/test/java/app/bottlenote/common/file/upload/ResourceVerifierServiceTest.java @@ -0,0 +1,128 @@ +package app.bottlenote.common.file.upload; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import app.bottlenote.common.file.dto.request.ResourceLogRequest; +import app.bottlenote.common.file.exception.FileException; +import app.bottlenote.common.file.service.ResourceCommandService; +import app.bottlenote.common.file.service.ResourceVerifierService; +import app.bottlenote.common.file.upload.fixture.InMemoryResourceLogRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("[unit] [service] ResourceVerifierService") +class ResourceVerifierServiceTest { + + private ResourceCommandService resourceCommandService; + private ResourceVerifierService resourceVerifierService; + + @BeforeEach + void setUp() { + InMemoryResourceLogRepository resourceLogRepository = new InMemoryResourceLogRepository(); + resourceCommandService = new ResourceCommandService(resourceLogRepository); + resourceVerifierService = new ResourceVerifierService(resourceLogRepository); + } + + @Test + @DisplayName("현재 사용자가 발급받은 CREATED 리소스면 검증에 성공한다") + void verifyOwnedImageResources_whenCreatedByUser_returnsResourceKeys() { + // given + String viewUrl = "https://cdn.bottlenote.com/review/20260522/1-image.jpg"; + saveCreated(1L, "review/20260522/1-image.jpg", viewUrl); + + // when + List result = + resourceVerifierService.verifyOwnedImageResources(List.of(viewUrl), 1L, null, "REVIEW"); + + // then + assertThat(result).containsExactly("review/20260522/1-image.jpg"); + } + + @Test + @DisplayName("이미 같은 참조에 연결된 ACTIVATED 리소스면 검증에 성공한다") + void verifyOwnedImageResources_whenActivatedForSameReference_returnsResourceKeys() { + // given + String resourceKey = "review/20260522/1-image.jpg"; + String viewUrl = "https://cdn.bottlenote.com/" + resourceKey; + saveCreated(1L, resourceKey, viewUrl); + resourceCommandService.activateImageResource(resourceKey, 10L, "REVIEW", 1L).join(); + + // when + List result = + resourceVerifierService.verifyOwnedImageResources(List.of(viewUrl), 1L, 10L, "REVIEW"); + + // then + assertThat(result).containsExactly(resourceKey); + } + + @Test + @DisplayName("등록되지 않은 viewUrl이면 검증에 실패한다") + void verifyOwnedImageResources_whenResourceLogMissing_throwsException() { + assertThatThrownBy( + () -> + resourceVerifierService.verifyOwnedImageResources( + List.of("https://cdn.bottlenote.com/review/missing.jpg"), 1L, null, "REVIEW")) + .isInstanceOf(FileException.class); + } + + @Test + @DisplayName("다른 사용자가 발급받은 viewUrl이면 검증에 실패한다") + void verifyOwnedImageResources_whenOwnerMismatch_throwsException() { + // given + String viewUrl = "https://cdn.bottlenote.com/review/20260522/1-image.jpg"; + saveCreated(1L, "review/20260522/1-image.jpg", viewUrl); + + // when & then + assertThatThrownBy( + () -> + resourceVerifierService.verifyOwnedImageResources( + List.of(viewUrl), 2L, null, "REVIEW")) + .isInstanceOf(FileException.class); + } + + @Test + @DisplayName("저장된 viewUrl과 호스트가 다르면 검증에 실패한다") + void verifyOwnedImageResources_whenViewUrlMismatch_throwsException() { + // given + String resourceKey = "review/20260522/1-image.jpg"; + saveCreated(1L, resourceKey, "https://cdn.bottlenote.com/" + resourceKey); + + // when & then + assertThatThrownBy( + () -> + resourceVerifierService.verifyOwnedImageResources( + List.of("https://evil.example.com/" + resourceKey), 1L, null, "REVIEW")) + .isInstanceOf(FileException.class); + } + + @Test + @DisplayName("이미 다른 참조에 연결된 리소스면 검증에 실패한다") + void verifyOwnedImageResources_whenAlreadyUsedByOtherReference_throwsException() { + // given + String resourceKey = "review/20260522/1-image.jpg"; + String viewUrl = "https://cdn.bottlenote.com/" + resourceKey; + saveCreated(1L, resourceKey, viewUrl); + resourceCommandService.activateImageResource(resourceKey, 10L, "REVIEW", 1L).join(); + + // when & then + assertThatThrownBy( + () -> + resourceVerifierService.verifyOwnedImageResources( + List.of(viewUrl), 1L, 11L, "REVIEW")) + .isInstanceOf(FileException.class); + } + + private void saveCreated(Long userId, String resourceKey, String viewUrl) { + resourceCommandService.saveImageResourceCreated( + new ResourceLogRequest(userId, resourceKey, viewUrl, "review", "test-bucket")); + assertThat(resourceCommandService.findByResourceKey(resourceKey)) + .isPresent() + .get() + .satisfies(response -> assertThat(response.eventType().name()).isEqualTo("CREATED")); + } +} diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java index c73bd8cec..5a665583e 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/global/config/JpaAuditingIntegrationTest.java @@ -19,6 +19,7 @@ import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; import java.nio.charset.StandardCharsets; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -43,7 +44,7 @@ void test_1() throws Exception { TokenItem token = getToken(user); ReviewCreateRequest reviewCreateRequest = - ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); + withoutImages(ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId())); // when MvcResult result = @@ -70,4 +71,17 @@ void test_1() throws Exception { assertEquals(user.getEmail(), savedReview.getCreateBy()); } + + private ReviewCreateRequest withoutImages(ReviewCreateRequest request) { + return new ReviewCreateRequest( + request.alcoholId(), + request.status(), + request.content(), + request.sizeType(), + request.price(), + request.locationInfo(), + List.of(), + request.tastingTagList(), + request.rating()); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java index f53639b63..f8474d2e6 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java @@ -47,6 +47,31 @@ class ReviewIntegrationTest extends IntegrationTestSupport { @Autowired private AlcoholTestFactory alcoholTestFactory; @Autowired private ReviewTestFactory reviewTestFactory; + private ReviewCreateRequest withoutImages(ReviewCreateRequest request) { + return new ReviewCreateRequest( + request.alcoholId(), + request.status(), + request.content(), + request.sizeType(), + request.price(), + request.locationInfo(), + List.of(), + request.tastingTagList(), + request.rating()); + } + + private ReviewModifyRequest withoutImages(ReviewModifyRequest request) { + return new ReviewModifyRequest( + request.content(), + request.status(), + request.price(), + List.of(), + request.sizeType(), + request.tastingTagList(), + request.locationInfo(), + request.rating()); + } + @Nested @DisplayName("리뷰 조회 테스트") class select { @@ -86,7 +111,7 @@ void test_2() throws Exception { TokenItem token = getToken(user); ReviewCreateRequest reviewCreateRequest = - ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); + withoutImages(ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId())); MvcTestResult createResult = mockMvcTester @@ -175,7 +200,7 @@ void test_1() throws Exception { TokenItem token = getToken(user); ReviewCreateRequest reviewCreateRequest = - ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); + withoutImages(ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId())); // when MvcTestResult result = @@ -208,7 +233,7 @@ void test_1() throws Exception { TokenItem token = getToken(user); ReviewCreateRequest reviewCreateRequest = - ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); + withoutImages(ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId())); // 리뷰 생성 MvcTestResult createResult = @@ -273,7 +298,7 @@ void test_1() throws Exception { final Long reviewId = review.getId(); final ReviewModifyRequest request = - ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PUBLIC); + withoutImages(ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PUBLIC)); // when MvcTestResult result = diff --git a/plan/aws-sdk-v2-resource-verify-followup.md b/plan/aws-sdk-v2-resource-verify-followup.md new file mode 100644 index 000000000..30a2d643b --- /dev/null +++ b/plan/aws-sdk-v2-resource-verify-followup.md @@ -0,0 +1,53 @@ +# Plan: AWS SDK v2 Resource Verify 후속 보강 + +## Overview +PR #611 후속으로 이미지 presign 이후 리뷰 저장까지의 운영 안전성을 보강한다. Presigned URL 응답 전 `ResourceLog` CREATED 저장을 완료하고, 리뷰 생성/수정 시 전달된 `viewUrl`이 현재 사용자에게 발급된 리소스인지 저장 전에 검증한다. + +### Assumptions +- 새 브랜치를 만들지 않고 `codex/aws-sdk-v2-migration` 브랜치에서 이어서 작업한다. +- `git.environment-variables`의 기존 unstaged 변경은 건드리지 않는다. +- Mock 프레임워크는 추가하지 않고 기존 Fake/InMemory/Testcontainers 테스트 관습을 사용한다. +- 우선 적용 범위는 리뷰 생성/수정 이미지 URL이며, Help/Profile/Business/Admin 확장이 가능하도록 공통 검증 서비스로 둔다. +- 운영 안전 기준은 임의 URL이나 타 사용자 URL을 저장하지 않는 쪽으로 둔다. + +### Success Criteria +- Presigned URL 응답 직후 CREATED `ResourceLog`가 DB에서 조회된다. +- 리뷰 생성/수정은 저장 전에 이미지 URL의 resourceKey, 로그 존재 여부, 소유자, 저장된 viewUrl, 활성화 가능 상태를 검증한다. +- 타 사용자 viewUrl과 임의 viewUrl은 리뷰 생성/수정 저장 전에 4xx로 거부된다. +- Presign 직후 CREATED 대기 없이 리뷰 등록해도 ResourceLog가 안전하게 ACTIVATED로 전환된다. +- CloudFront URL에 trailing slash가 있어도 viewUrl에 중복 slash가 생기지 않는다. +- `/verify full` 수준의 전체 검증 후 `fix: ...` 커밋을 push한다. + +### Impact Scope +- `bottlenote-mono`: ResourceLog 생성 저장 동기화, 공통 ResourceVerifierService 추가, 리뷰 서비스 저장 전 검증 적용, 파일 예외 코드 보강, CloudFront URL normalize. +- `bottlenote-product-api`: ResourceCommand/ImageUpload/ResourceVerifier 단위 테스트와 이미지 업로드 통합 테스트 보강, 리뷰 통합 테스트의 임의 이미지 URL 의존 제거. +- 스키마, API 응답 필드, 의존성 버전 변경은 포함하지 않는다. + +## Tasks + +### Task 1: ResourceLog 생성 동기화와 CloudFront URL 정규화 +- Acceptance: `saveImageResourceCreated`가 비동기 경합 없이 응답 전 저장을 완료한다. +- Acceptance: CloudFront base URL trailing slash가 viewUrl 중복 slash를 만들지 않는다. +- Verification: `./gradlew compileJava compileTestJava`, 관련 unit 테스트. +- Files: `ResourceCommandService.java`, `ImageUploadService.java`, 관련 테스트. +- Size: M +- Status: [x] done + +### Task 2: 공통 Resource Verify와 리뷰 저장 전 검증 +- Acceptance: viewUrl에서 추출한 resourceKey 기준으로 로그 존재, 사용자 소유, 저장된 viewUrl, 활성화 가능 상태를 저장 전에 검증한다. +- Acceptance: 리뷰 생성/수정에서 임의 URL과 타 사용자 URL이 4xx로 거부된다. +- Verification: 관련 unit/integration 테스트. +- Files: `ResourceVerifierService.java`, `FileExceptionCode.java`, `ReviewService.java`, 관련 테스트. +- Size: M +- Status: [x] done + +### Checkpoint: after Tasks 1-2 +- [x] Compile / type-check passes +- [x] Unit tests pass +- [x] Integration tests cover no-await presign-to-review and unsafe URLs + +## Progress Log +- 2026-05-22: baseline `./gradlew check_rule_test` 성공(50s). +- 2026-05-22: Task 1 완료. `saveImageResourceCreated`의 `@Async`를 제거해 presign 응답 전 CREATED 저장이 완료되도록 했고, CloudFront trailing slash를 normalize했다. +- 2026-05-22: Task 2 완료. 공통 `ResourceVerifierService`를 추가하고 리뷰 생성/수정 저장 전에 resourceKey, 로그 존재, 소유자, viewUrl, 활성화 가능 상태를 검증하도록 적용했다. +- 2026-05-22: `/verify full` 수준 검증 완료. `integration_test` 1차 실패는 기존 감사 테스트의 presign 없는 이미지 fixture가 새 정책과 충돌한 것이 원인이어서 이미지 없는 요청으로 수정했고, 재실행 통과했다.