feat(ai-character): 관리자 API Phase 1 기반을 추가한다
This commit is contained in:
1538
docs/20260724_AI캐릭터_관리자_API/plan-task.md
Normal file
1538
docs/20260724_AI캐릭터_관리자_API/plan-task.md
Normal file
File diff suppressed because it is too large
Load Diff
255
docs/20260724_AI캐릭터_관리자_API/prd.md
Normal file
255
docs/20260724_AI캐릭터_관리자_API/prd.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# PRD: AI 캐릭터 관리자 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산을 대리 관리하는 신규 v2 관리자 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈, 커뮤니티, FanTalk 답변을 관리해야 한다.
|
||||||
|
- 기존 기능은 `creatorMember.id` 기반으로 흩어져 있으며, 관리자 frontend가 레거시 endpoint를 조합하면 권한, 소유권, soft delete 의미가 일관되지 않을 수 있다.
|
||||||
|
- 기존 creator/admin service 일부에는 소유권 검증이 약한 경로가 있어, 단순 위임만으로는 다른 캐릭터나 HUMAN creator 자원을 변경할 위험이 있다.
|
||||||
|
- 기존 legacy/public API 계약은 유지해야 하므로 신규 관리자 표면은 별도 v2 경계로 제공되어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 신규 prefix `/api/v2/admin/ai-characters/**`는 JWT `auth` claim의 `ROLE_ADMIN`과 JWT subject로 조회한 현재 DB
|
||||||
|
`Member.role == ADMIN`을 모두 만족하는 요청만 허용한다.
|
||||||
|
- 모든 신규 target endpoint는 외부 대상 식별자로 `characterId`를 받고, 서버가 `ChatCharacter.creatorMember`를 내부 행위자로 해석한다. 단, 캐릭터 목록/검색은 아직 선택된 target이 없어 `characterId`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다.
|
||||||
|
- target 해석 시 `ChatCharacter` 존재, `creatorMember` 존재, `creatorMember.role == CREATOR`, `creatorMember.memberKind == AI_CHARACTER`를 모두 검증한다.
|
||||||
|
- 검증 실패 시 4xx로 거부하고 DB, S3, 외부 캐릭터 API, 이벤트 발행 등 후속 부작용을 만들지 않는다.
|
||||||
|
- 캐릭터, 오디오 콘텐츠, 시리즈, 커뮤니티 게시글, FanTalk 답변, 오디오 signed URL을 신규 관리자 API에서 관리한다.
|
||||||
|
- 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다.
|
||||||
|
단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용
|
||||||
|
Origin만 path-specific으로 확장한다.
|
||||||
|
- 내부 구현은 신규 v2 controller/facade/application 경계를 두고, 기존 entity/repository/S3/CloudFront/event 컴포넌트는 테스트로 고정한 뒤 선택적으로 재사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 PRD는 관리자 API backend 요구사항과 구현 계획만 포함하며, 관리자 UI/frontend 구현은 포함하지 않는다.
|
||||||
|
- AI 캐릭터용 Member의 access token, refresh token, 임시 세션, impersonation 로그인은 만들지 않는다.
|
||||||
|
- `creatorMemberId`를 관리자 frontend의 필수 입력으로 노출하지 않는다.
|
||||||
|
- HUMAN creator를 이 API로 대리 관리하지 않는다.
|
||||||
|
- 위 두 공유 인증 경로의 CORS 허용 Origin 확장 외 기존 legacy/public endpoint 변경, 폐기, deprecation, schema 변경은 포함하지
|
||||||
|
않는다.
|
||||||
|
- 기존 external character API business contract 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
|
||||||
|
- 기존 soft delete 의미 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
|
||||||
|
- 물리 삭제와 연관 데이터 cascade 삭제는 포함하지 않는다.
|
||||||
|
- 신규 DB schema/DDL 또는 `ChatCharacter`-`Member` 관계 모델 변경은 포함하지 않는다.
|
||||||
|
- 라이브, DM, 후원, 정산, 알림 설정, 랭킹 관리, 콘텐츠 구매/좋아요/댓글, 커뮤니티 구매/좋아요/댓글 관리는 포함하지 않는다.
|
||||||
|
- FanTalk 원글 작성, 일반 사용자 대리 작성, nested reply 작성은 포함하지 않는다.
|
||||||
|
- `AudioContentCloudFront` 복사/이동, signed URL 신규 dependency 추가는 포함하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 운영자: AI 캐릭터를 대신해 캐릭터 프로필, 콘텐츠, 시리즈, 커뮤니티 게시글, FanTalk 답변을 관리하는 관리자
|
||||||
|
- 관리자 frontend: 신규 v2 AI 캐릭터 관리자 API만으로 In-Scope 작업을 수행해야 하는 클라이언트
|
||||||
|
- 서버 개발자: 기존 creator 기능을 회귀시키지 않으면서 AI 캐릭터 대리 관리 경계를 유지해야 하는 개발자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 운영자는 AI 캐릭터 목록을 검색하고 상세 정보를 확인한 뒤 생성, 수정, 비활성화하고 싶다.
|
||||||
|
- 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다.
|
||||||
|
- 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다.
|
||||||
|
- 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다.
|
||||||
|
- 운영자는 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성하게 하고 싶다.
|
||||||
|
- 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 공통 인증, 인가, target 해석
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 모든 신규 prefix endpoint는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립적으로 모두 검증한다.
|
||||||
|
- JWT가 없거나 잘못됐거나 만료·폐기된 경우는 401, JWT role과 현재 DB role 중 하나라도 ADMIN이 아닌 경우는 403으로
|
||||||
|
처리한다.
|
||||||
|
- JWT에는 `ROLE_ADMIN`이 남아 있지만 현재 DB role이 강등된 stale claim도 403으로 거부한다.
|
||||||
|
- 모든 domain write/read는 `characterId`로 `ChatCharacter`를 조회한 뒤 연결된 `creatorMember`를 사용한다.
|
||||||
|
- `creatorMember`는 도메인 소유권/작성자 판단에만 사용하고 Spring Security principal로 교체하지 않는다.
|
||||||
|
- `creatorMember` 누락, role 불일치, memberKind 불일치 요청은 4xx로 거부한다.
|
||||||
|
- 요청 중 누락 Member 생성, role/memberKind 자동 보정 같은 lazy repair는 하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- stale claim을 포함한 인증·인가 실패는 target resolver와 domain use-case 실행 전에 종료되어야 한다.
|
||||||
|
- 유효하지 않은 `characterId` 요청은 DB write, S3 upload/delete, 외부 캐릭터 API 호출, 이벤트 발행 없이 실패해야 한다.
|
||||||
|
- 다른 AI 캐릭터 또는 HUMAN creator 소유 resource ID는 조회/수정/삭제/연결/답변 모두 거부해야 한다.
|
||||||
|
|
||||||
|
### Feature B. AI 캐릭터 관리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 목록 조회, 검색, 상세 조회, 생성, 수정, 삭제 의미의 비활성화(`isActive=false`)를 제공한다.
|
||||||
|
- 레거시 플랫폼 관리자와 중복 이름 검증, 외부 캐릭터 API 연동, 대표 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, AI 캐릭터용 `creatorMember` 생성 및 표시 정보 동기화 동작 parity를 유지한다.
|
||||||
|
- 삭제는 soft delete이며 row, 연결 Member, 콘텐츠를 물리 삭제하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 중복 이름, 외부 캐릭터 API 실패, 이미지 저장 실패는 기존 관리자 동작을 특성화 테스트로 고정한 뒤 유지한다.
|
||||||
|
- 비활성화 실패 시 일부 관계만 변경된 상태로 남기지 않는다.
|
||||||
|
|
||||||
|
### Feature C. 오디오 콘텐츠 관리 및 signed URL
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 캐릭터 소유 콘텐츠 목록/검색/상세 조회, 생성, 수정, 기존 삭제 동작에 따른 soft delete를 제공한다.
|
||||||
|
- 기존 크리에이터 콘텐츠 관리의 검증, 파일 처리, content upload/processing pipeline, 가격, 공개/예약, 번역/알림 등 business behavior parity를 유지한다.
|
||||||
|
- 관리자 화면 재생용 signed URL을 콘텐츠 목록/상세 응답에 제공한다.
|
||||||
|
- signed URL은 공통 `AudioContentCloudFront`를 재사용하고 기존 크리에이터 관리자와 같은 만료 정책을 따른다.
|
||||||
|
- 기존 signed URL 구현을 재사용하기 전에 creator admin 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case를 통과하는 특성화 테스트로 고정한다.
|
||||||
|
- 응답에 private object path나 서명 키 정보를 노출하지 않는다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 콘텐츠 소유자가 target `creatorMember`와 다르면 조회/수정/삭제 모두 거부한다.
|
||||||
|
- 커뮤니티 오디오의 기존 30분 signed URL 정책은 이 콘텐츠 재생 정책과 임의 통합하지 않는다.
|
||||||
|
|
||||||
|
### Feature D. 시리즈 관리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 목록/상세 조회, 생성, 수정, `isActive=false` soft delete를 제공한다.
|
||||||
|
- 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, 순서 관리를 제공한다.
|
||||||
|
- 기존 creator series 관리의 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 관리 behavior를 먼저 통과하는 특성화 테스트로 고정하고 신규 v2 경로에서 parity를 유지한다.
|
||||||
|
- 시리즈와 연결 콘텐츠는 모두 동일한 `creatorMember` 소유여야 한다.
|
||||||
|
- 기존 `updateSeriesOrders(ids)`처럼 소유권 없는 ID-only 갱신은 신규 v2 경로에서 허용하지 않는다.
|
||||||
|
- 시리즈 콘텐츠 조회는 관리자 연결 작업을 위해 검색어 기반 필터를 제공한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 순서 변경 요청의 모든 series/content ID는 target character 소유 검증을 통과해야 한다.
|
||||||
|
- inactive series는 일반 활성 조회에서 제외한다.
|
||||||
|
|
||||||
|
### Feature E. 커뮤니티 게시글 관리
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 등록, 수정, 공지 고정/해제(`isFixed`), 수정 요청의 `isActive=false` soft delete를 제공한다.
|
||||||
|
- soft delete 시 현재 동작처럼 `isFixed=false`, `fixedAt=null`을 적용한다.
|
||||||
|
- 기존 최대 고정 게시글 수 3개, 이미지/오디오/유료 게시글 검증, 알림/최근 소식 side effect를 유지한다.
|
||||||
|
- 관리자 UI에 필요한 조회는 기존 v2 커뮤니티 조회 로직을 무비판적으로 복제하지 않고 신규 관리자 facade/endpoint에서 안전하게 재사용하거나 최소 query adapter를 둔다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 고정 게시글이 이미 3개인 상태에서 추가 고정은 기존 정책대로 실패한다.
|
||||||
|
- soft delete된 고정 게시글은 고정 상태와 시간이 반드시 제거되어야 한다.
|
||||||
|
|
||||||
|
### Feature F. FanTalk 답변
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성한다.
|
||||||
|
- 요청은 `characterId`와 대상 root `fanTalkId`를 포함한다.
|
||||||
|
- 대상 FanTalk가 존재하고 활성 상태이며, 대상 creator가 해석된 `creatorMember`와 일치하는 root 글인지 검증한다.
|
||||||
|
- 언어 감지와 기존 응답 DTO 의미 등 검증 가능한 business behavior를 유지하고, 저장된 답변의 writer/creator는 해석된 `creatorMember`와 일관되어야 한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 다른 캐릭터의 FanTalk, reply에 대한 nested reply, 비활성 FanTalk, 미존재 FanTalk에는 답변하지 않는다.
|
||||||
|
- 실패 시 reply 저장과 이벤트 발행이 없어야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API Expectations
|
||||||
|
- 신규 endpoint prefix는 기존 공개 `/api/v2/creator-channels/*`와 legacy `/admin/*`, `/creator-admin/*`를 변경하지 않기 위해 `/api/v2/admin/ai-characters`를 기본안으로 한다.
|
||||||
|
- 성공 응답은 `ApiResponse.ok(...)`, API application/controller/security filter 오류는 오류 의미에 맞는 HTTP status와
|
||||||
|
`ApiResponse.error(...)`를 사용한다.
|
||||||
|
- 이 API 오류 응답은 `success=false`와 현지화된 `message`를 포함하며 2xx로 normalize하지 않는다.
|
||||||
|
- `Accept-Language: ko|en|ja`에 따라 KO/EN/JA 메시지를 반환하고, 없거나 지원하지 않는 언어는 KO로 fallback한다.
|
||||||
|
- security filter 단계의 오류도 MVC interceptor에 의존하지 않고 `Accept-Language`를 직접 해석해 동일한 응답 계약을 따른다.
|
||||||
|
- 신규 prefix는 캐릭터 관리자 frontend Origin `http://localhost:8888`,
|
||||||
|
`https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용한다.
|
||||||
|
- 기존 범용 관리자 frontend와 creator frontend Origin을 캐릭터 관리자 Origin 대신 허용하지 않는다.
|
||||||
|
- 공유 인증 경로 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 위 캐릭터 관리자 Origin의 합집합만 허용한다.
|
||||||
|
이 path-specific 확장은 다른 legacy/public 경로의 CORS 허용 범위를 변경하지 않는다.
|
||||||
|
- 위 관리자 Origin의 신규 prefix 오류와 preflight는 404 fallback 및 실제 mapped endpoint의 405/406/415 경로를 포함해 기존
|
||||||
|
전역 CORS 응답 계약을 유지하며, 두 공유 인증 경로에서도 허용·거부 Origin을 검증한다.
|
||||||
|
- 허용되지 않은 Origin, method 또는 header를 Spring CORS 계층에서 정책 거부하는 경우는 handler 진입 전 403으로 종료되는
|
||||||
|
브라우저 보안 경계다. 이 403의 body, content type, 현지화 및 `ApiResponse.error` envelope는 신규 API 오류 계약의 예외로
|
||||||
|
두고 외부 계약으로 고정하지 않는다.
|
||||||
|
- 표준 HTTP method가 MVC까지 도달했지만 해당 mapping이 없으면 기존 Spring MVC의 405와 `Allow` header를 유지한다.
|
||||||
|
- `StrictHttpFirewall`이 신규 prefix에서 비표준 HTTP method 또는 위험 URL을 `RequestRejectedException`으로 거부하면,
|
||||||
|
캐릭터 관리자 허용 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`를
|
||||||
|
반환한다. 허용되지 않은 Origin은 기존 Spring CORS 정책과 같이 body 계약 없는 403으로 종료한다.
|
||||||
|
- `SecurityConfig`는 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하되 신규 prefix만 위
|
||||||
|
400/CORS 계약으로 처리하고, legacy/public은 `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을
|
||||||
|
유지한다. Spring 5.3의 비표준 method enum 한계 때문에 CORS 검사 request만 `GET` wrapper를 사용하며 실제 firewall method
|
||||||
|
허용 범위는 확장하지 않고 `setUnsafeAllowAnyHttpMethod(true)`도 사용하지 않는다.
|
||||||
|
- Phase 1 공통 오류는 인증 정보 없음·잘못됨·만료·폐기 401 `common.error.bad_credentials`, JWT 또는 현재 DB role의
|
||||||
|
ADMIN 불충족 403 `common.error.access_denied`, request/target 미존재·불변식 위반 400, 신규 prefix 미매핑 경로 404, 지원하지
|
||||||
|
않는 HTTP method 405, 응답 media type 406, 요청 media type 415를 `common.error.invalid_request`로 고정한다. 405는 표준
|
||||||
|
`Allow` header를, 415는 표준 `Accept` header를 유지한다. controller mapping의 필수 path variable 선언이 누락된
|
||||||
|
`MissingPathVariableException`과 예상하지 못한 controller/JWT filter 오류는 500 `common.error.unknown`으로 고정한다.
|
||||||
|
- malformed JSON의 `HttpMessageNotReadableException`, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part
|
||||||
|
누락의 `MissingServletRequestPartException`은 각각 400 `common.error.invalid_request`와 KO/EN/JA `ApiResponse.error`를 반환한다.
|
||||||
|
- 이후 phase의 domain/client/server 오류는 각 task에서 정확한 HTTP status와 KO/EN/JA message key를 먼저 정의하고 같은
|
||||||
|
envelope를 적용한다.
|
||||||
|
- 신규 prefix 전용 오류 처리는 legacy/public endpoint의 기존 성공·오류 응답에 적용하지 않는다.
|
||||||
|
- page 기반 조회는 기존 v2 탭 API 관례를 따라 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 기본안으로 하며, 경계값 보정은 구현 task와 테스트에 포함한다.
|
||||||
|
- multipart 생성/수정은 기존 admin/creator-admin 관례대로 파일 part와 `request` JSON string part를 사용한다.
|
||||||
|
- request/response DTO는 신규 v2 AI character admin API 전용 DTO로 두고 legacy/public DTO를 외부 계약으로 재노출하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technical Constraints
|
||||||
|
- Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper를 유지한다.
|
||||||
|
- 신규 dependency를 추가하지 않는다.
|
||||||
|
- 신규 DB schema/DDL을 만들지 않는다.
|
||||||
|
- 기존 v2 API 조립 계층과 domain/application 의존 방향을 따른다.
|
||||||
|
- controller 내부 호출, 서버 내부 legacy HTTP 호출, 기존 controller 역참조는 하지 않는다.
|
||||||
|
- 신규 v2 application/domain 계층은 기존 controller와 v2 API response DTO를 역참조하지 않는다.
|
||||||
|
- 기존 business method를 재사용하기 전 특성화/회귀 테스트를 작성한다.
|
||||||
|
- 특성화/회귀 테스트는 신규 v2 use-case의 미구현 RED 테스트와 분리하고, 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해야 한다.
|
||||||
|
- 단순 복사-붙여넣기 대신 필요한 최소 추출 또는 v2 use-case 재개발을 선택한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Metrics
|
||||||
|
- 신규 endpoint별 또는 controller slice별 JWT role × 현재 DB role 인가 매트릭스와 stale ADMIN claim 403 테스트 존재 여부
|
||||||
|
- 신규 prefix의 각 API 오류 분기에 정확한 HTTP status, `ApiResponse.error`, KO/EN/JA와 405 `Allow`/415 `Accept` header 테스트
|
||||||
|
존재 여부
|
||||||
|
- 신규 prefix 실제 mapped endpoint 및 공유 인증 경로의 허용·거부 Origin/preflight 테스트 존재 여부
|
||||||
|
- 신규 prefix의 표준 method 미매핑 405 `Allow` 유지와 `RequestRejectedException` 400/i18n/`ApiResponse.error`/허용 Origin CORS
|
||||||
|
header, 미허용 Origin body 계약 없는 403 테스트 존재 여부
|
||||||
|
- legacy/public firewall 동작 불변 및 `setUnsafeAllowAnyHttpMethod(true)` 미사용 확인 여부
|
||||||
|
- core controller security/error 계약의 production `@SpringBootTest` full-context 실행과 Redis token fixture cleanup 확인 여부
|
||||||
|
- target resolver 조회 직후 `creatorMember` 초기화와 fetch join 제거 시 실패하는 non-vacuous 회귀 테스트 존재 여부
|
||||||
|
- `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `MissingServletRequestPartException`의 exact exception
|
||||||
|
type과 KO/EN/JA 400 envelope 직접 검증 여부
|
||||||
|
- target/ownership 실패 시 no-side-effect 테스트 존재 여부
|
||||||
|
- character/content/series/community/FanTalk slice별 targeted test 통과 여부
|
||||||
|
- signed URL TTL 계산식·edge case parity 및 private path 비노출 테스트 통과 여부
|
||||||
|
- 기존 legacy/public endpoint의 성공·오류 status/body/message 회귀 테스트 통과 여부
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Acceptance Criteria
|
||||||
|
- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 요청만 유효한 AI character 대상으로 신규 endpoint를
|
||||||
|
호출할 수 있다.
|
||||||
|
- 비로그인 또는 잘못된 JWT 요청은 401이고, JWT 비ADMIN + DB ADMIN과 JWT ADMIN + DB 비ADMIN stale claim은 모두 403이다.
|
||||||
|
- 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status, `ApiResponse.error`,
|
||||||
|
`Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 계층의 정책 거부 403 body는 이 envelope 계약의 예외다.
|
||||||
|
- 지원하지 않는 HTTP method는 405와 `Allow` header, 응답 media type은 406, 요청 media type은 415와 `Accept` header를
|
||||||
|
반환하고, `MissingPathVariableException`은 500 `common.error.unknown`을 반환한다.
|
||||||
|
- 표준 HTTP method가 MVC에 도달한 뒤 mapping이 없을 때는 기존 405와 `Allow` header를 유지한다. 신규 prefix의 비표준 HTTP
|
||||||
|
method 또는 위험 URL이 `StrictHttpFirewall`에서 `RequestRejectedException`으로 거부되면 허용된 캐릭터 관리자 Origin에는
|
||||||
|
CORS header와 현지화된 400 `common.error.invalid_request` `ApiResponse.error`를, 미허용 Origin에는 body 계약 없는 403을
|
||||||
|
반환한다. legacy/public firewall 동작은 변하지 않고 `setUnsafeAllowAnyHttpMethod(true)`는 사용하지 않는다.
|
||||||
|
- 신규 prefix는 캐릭터 관리자 Origin만 허용하고, `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자
|
||||||
|
Origin의 합집합을 허용한다. 실제 mapped endpoint와 공유 인증 경로의 CORS 허용·거부가 테스트로 고정된다.
|
||||||
|
- core controller security/error 계약은 production `@SpringBootTest` full context에서 검증하고 Redis token fixture를 테스트 후
|
||||||
|
정리해 다음 테스트에 남기지 않는다.
|
||||||
|
- target resolver의 repository 조회 결과는 반환 직후 `creatorMember`가 초기화되어 있어야 하며, fetch join 제거 시 실패하는
|
||||||
|
회귀 테스트로 고정한다.
|
||||||
|
- malformed JSON, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part 누락은 각각 정확한 MVC exception
|
||||||
|
type과 현지화된 400 `ApiResponse.error` 계약을 만족한다.
|
||||||
|
- character 미존재, creatorMember 미존재, role 불일치, memberKind 불일치 요청은 4xx이며 아무 side effect도 남기지 않는다.
|
||||||
|
- 다른 character 소유 resource ID를 사용한 조회/수정/삭제/연결/답변은 4xx로 거부된다.
|
||||||
|
- 캐릭터 생성/수정/비활성화는 레거시 관리자 behavior parity를 유지한다.
|
||||||
|
- 콘텐츠 생성/수정/soft delete와 signed URL 응답은 기존 creator/admin behavior parity를 유지한다.
|
||||||
|
- 시리즈 CRUD, 콘텐츠 연결/해제/순서 변경은 target character 소유 범위를 벗어나지 않는다.
|
||||||
|
- 커뮤니티 생성/수정/고정/해제/soft delete는 target creatorMember 소유 게시글에만 적용된다.
|
||||||
|
- FanTalk 답변은 target AI character 자신의 활성 root FanTalk에만 저장된다.
|
||||||
|
- 기존 legacy/public endpoint 테스트가 통과하고, 두 공유 인증 경로의 CORS 허용 Origin 확장 외 성공·오류
|
||||||
|
status/body/message를 포함한 request/response contract가 변경되지 않는다.
|
||||||
|
- 신규 dependency, 신규 DDL, 관련 없는 리팩터링이 없다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open Questions
|
||||||
|
- 없음.
|
||||||
@@ -142,6 +142,15 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||||||
)
|
)
|
||||||
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
|
fun findByIdInWithTagMappings(@Param("ids") ids: List<Long>): List<ChatCharacter>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT c FROM ChatCharacter c
|
||||||
|
LEFT JOIN FETCH c.creatorMember
|
||||||
|
WHERE c.id = :id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fun findByIdWithCreatorMember(@Param("id") id: Long): ChatCharacter?
|
||||||
|
|
||||||
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
||||||
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
package kr.co.vividnext.sodalive.common
|
package kr.co.vividnext.sodalive.common
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import io.jsonwebtoken.JwtException
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||||
import org.springframework.web.filter.OncePerRequestFilter
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
import org.springframework.web.servlet.HandlerExceptionResolver
|
||||||
import javax.servlet.FilterChain
|
import javax.servlet.FilterChain
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
import javax.servlet.http.HttpServletResponse
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRequestFilter() {
|
class ExceptionHandlerFilter(
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val apiRequestMatcher: RequestMatcher,
|
||||||
|
private val apiAuthenticationEntryPoint: AuthenticationEntryPoint,
|
||||||
|
private val apiExceptionResolver: HandlerExceptionResolver
|
||||||
|
) : OncePerRequestFilter() {
|
||||||
override fun doFilterInternal(
|
override fun doFilterInternal(
|
||||||
request: HttpServletRequest,
|
request: HttpServletRequest,
|
||||||
response: HttpServletResponse,
|
response: HttpServletResponse,
|
||||||
@@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
|
|||||||
try {
|
try {
|
||||||
filterChain.doFilter(request, response)
|
filterChain.doFilter(request, response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (apiRequestMatcher.matches(request)) {
|
||||||
|
if (isAuthenticationFailure(e)) {
|
||||||
|
val authenticationException = e as? BadCredentialsException
|
||||||
|
?: BadCredentialsException("Authentication failed", e)
|
||||||
|
apiAuthenticationEntryPoint.commence(request, response, authenticationException)
|
||||||
|
} else {
|
||||||
|
val resolved = apiExceptionResolver.resolveException(request, response, null, e)
|
||||||
|
if (resolved == null) throw e
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
response.status = 401
|
response.status = 401
|
||||||
response.contentType = "application/json"
|
response.contentType = "application/json"
|
||||||
response.characterEncoding = "UTF-8"
|
response.characterEncoding = "UTF-8"
|
||||||
@@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe
|
|||||||
response.writer.write(json)
|
response.writer.write(json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isAuthenticationFailure(exception: Exception): Boolean {
|
||||||
|
return exception is JwtException ||
|
||||||
|
exception is BadCredentialsException ||
|
||||||
|
(exception is SodaException && exception.messageKey == "common.error.bad_credentials")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ package kr.co.vividnext.sodalive.configs
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
|
import kr.co.vividnext.sodalive.common.ExceptionHandlerFilter
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler
|
||||||
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint
|
||||||
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
import kr.co.vividnext.sodalive.jwt.JwtFilter
|
||||||
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminExceptionHandler
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security.AiCharacterAdminSecurityErrorHandler
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.core.Ordered
|
||||||
import org.springframework.http.HttpMethod
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.builders.WebSecurity
|
import org.springframework.security.config.annotation.web.builders.WebSecurity
|
||||||
@@ -17,8 +23,16 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
|
|||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler
|
||||||
|
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler
|
||||||
|
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||||
|
import org.springframework.web.HttpRequestHandler
|
||||||
|
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -35,25 +49,94 @@ class SecurityConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun webSecurityCustomizer(): WebSecurityCustomizer {
|
fun webSecurityCustomizer(
|
||||||
|
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler
|
||||||
|
): WebSecurityCustomizer {
|
||||||
return WebSecurityCustomizer { web: WebSecurity ->
|
return WebSecurityCustomizer { web: WebSecurity ->
|
||||||
web
|
web
|
||||||
|
.requestRejectedHandler(aiCharacterAdminSecurityErrorHandler)
|
||||||
.ignoring()
|
.ignoring()
|
||||||
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
|
.antMatchers("/h2-console/**", "/favicon.ico", "/error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
fun aiCharacterAdminErrorResponseWriter(messageSource: SodaMessageSource): AiCharacterAdminErrorResponseWriter {
|
||||||
|
return AiCharacterAdminErrorResponseWriter(objectMapper, messageSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun aiCharacterAdminSecurityErrorHandler(
|
||||||
|
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||||
|
): AiCharacterAdminSecurityErrorHandler {
|
||||||
|
return AiCharacterAdminSecurityErrorHandler(
|
||||||
|
responseWriter,
|
||||||
|
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH),
|
||||||
|
WebConfig.createAiCharacterAdminCorsConfiguration()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun aiCharacterAdminExceptionHandler(
|
||||||
|
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||||
|
): AiCharacterAdminExceptionHandler {
|
||||||
|
return AiCharacterAdminExceptionHandler(
|
||||||
|
responseWriter,
|
||||||
|
AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun aiCharacterAdminFallbackHandlerMapping(
|
||||||
|
responseWriter: AiCharacterAdminErrorResponseWriter
|
||||||
|
): SimpleUrlHandlerMapping {
|
||||||
|
val notFoundHandler = HttpRequestHandler { request, response ->
|
||||||
|
responseWriter.write(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"common.error.invalid_request"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return SimpleUrlHandlerMapping(
|
||||||
|
mapOf(AI_CHARACTER_ADMIN_PATH to notFoundHandler),
|
||||||
|
Ordered.LOWEST_PRECEDENCE - 2
|
||||||
|
).apply {
|
||||||
|
setCorsConfigurations(
|
||||||
|
mapOf(AI_CHARACTER_ADMIN_PATH to WebConfig.createAiCharacterAdminCorsConfiguration())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun filterChain(
|
||||||
|
http: HttpSecurity,
|
||||||
|
aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler,
|
||||||
|
aiCharacterAdminExceptionHandler: AiCharacterAdminExceptionHandler
|
||||||
|
): SecurityFilterChain {
|
||||||
val jwtFilter = JwtFilter(tokenProvider)
|
val jwtFilter = JwtFilter(tokenProvider)
|
||||||
|
val aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH)
|
||||||
|
val authenticationEntryPoints = linkedMapOf<RequestMatcher, AuthenticationEntryPoint>(
|
||||||
|
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
|
||||||
|
)
|
||||||
|
val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply {
|
||||||
|
setDefaultEntryPoint(authenticationEntryPoint)
|
||||||
|
}
|
||||||
|
val accessDeniedHandlers = linkedMapOf<RequestMatcher, AccessDeniedHandler>(
|
||||||
|
aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler
|
||||||
|
)
|
||||||
|
val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler(
|
||||||
|
accessDeniedHandlers,
|
||||||
|
accessDeniedHandler
|
||||||
|
)
|
||||||
|
|
||||||
return http
|
return http
|
||||||
.cors()
|
.cors()
|
||||||
.and()
|
.and()
|
||||||
.csrf().disable()
|
.csrf().disable()
|
||||||
.exceptionHandling()
|
.exceptionHandling()
|
||||||
.authenticationEntryPoint(authenticationEntryPoint)
|
.authenticationEntryPoint(delegatingAuthenticationEntryPoint)
|
||||||
.accessDeniedHandler(accessDeniedHandler)
|
.accessDeniedHandler(delegatingAccessDeniedHandler)
|
||||||
.and()
|
.and()
|
||||||
.headers()
|
.headers()
|
||||||
.frameOptions()
|
.frameOptions()
|
||||||
@@ -63,7 +146,15 @@ class SecurityConfig(
|
|||||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
.and()
|
.and()
|
||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
|
||||||
.addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java)
|
.addFilterBefore(
|
||||||
|
ExceptionHandlerFilter(
|
||||||
|
objectMapper,
|
||||||
|
aiCharacterAdminRequestMatcher,
|
||||||
|
aiCharacterAdminSecurityErrorHandler,
|
||||||
|
aiCharacterAdminExceptionHandler
|
||||||
|
),
|
||||||
|
JwtFilter::class.java
|
||||||
|
)
|
||||||
.authorizeRequests()
|
.authorizeRequests()
|
||||||
.antMatchers("/member/check/email").permitAll()
|
.antMatchers("/member/check/email").permitAll()
|
||||||
.antMatchers("/member/check/nickname").permitAll()
|
.antMatchers("/member/check/nickname").permitAll()
|
||||||
@@ -108,10 +199,20 @@ class SecurityConfig(
|
|||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll()
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated()
|
||||||
|
.antMatchers(AI_CHARACTER_ADMIN_PATH)
|
||||||
|
.access(
|
||||||
|
"hasRole('ADMIN') and " +
|
||||||
|
"principal instanceof T(kr.co.vividnext.sodalive.member.MemberAdapter) and " +
|
||||||
|
"principal.member.role == T(kr.co.vividnext.sodalive.member.MemberRole).ADMIN"
|
||||||
|
)
|
||||||
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
// 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수
|
||||||
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
.antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
.and()
|
.and()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs
|
|||||||
import kr.co.vividnext.sodalive.common.CountryInterceptor
|
import kr.co.vividnext.sodalive.common.CountryInterceptor
|
||||||
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
import kr.co.vividnext.sodalive.i18n.LangInterceptor
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
@@ -18,15 +19,49 @@ class WebConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun addCorsMappings(registry: CorsRegistry) {
|
override fun addCorsMappings(registry: CorsRegistry) {
|
||||||
|
listOf("/admin/member/login", "/member/logout").forEach { path ->
|
||||||
|
registry.addMapping(path)
|
||||||
|
.allowedOrigins(*AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS.toTypedArray())
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowCredentials(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.addMapping("/api/v2/admin/ai-characters/**")
|
||||||
|
.allowedOrigins(*AI_CHARACTER_ADMIN_ALLOWED_ORIGINS.toTypedArray())
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowCredentials(true)
|
||||||
|
|
||||||
registry.addMapping("/**")
|
registry.addMapping("/**")
|
||||||
.allowedOrigins(
|
.allowedOrigins(*ALLOWED_ORIGINS.toTypedArray())
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowCredentials(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val ALLOWED_ORIGINS = listOf(
|
||||||
"http://localhost:8888",
|
"http://localhost:8888",
|
||||||
"https://creator.sodalive.net",
|
"https://creator.sodalive.net",
|
||||||
"https://test-creator.sodalive.net",
|
"https://test-creator.sodalive.net",
|
||||||
"https://test-admin.sodalive.net",
|
"https://test-admin.sodalive.net",
|
||||||
"https://admin.sodalive.net"
|
"https://admin.sodalive.net"
|
||||||
)
|
)
|
||||||
.allowedMethods("*")
|
|
||||||
.allowCredentials(true)
|
private val AI_CHARACTER_ADMIN_ALLOWED_ORIGINS = listOf(
|
||||||
|
"http://localhost:8888",
|
||||||
|
"https://test-character-admin.sodalive.net",
|
||||||
|
"https://character-admin.sodalive.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val AI_CHARACTER_ADMIN_SHARED_AUTH_ALLOWED_ORIGINS =
|
||||||
|
(ALLOWED_ORIGINS + AI_CHARACTER_ADMIN_ALLOWED_ORIGINS).distinct()
|
||||||
|
|
||||||
|
internal fun createAiCharacterAdminCorsConfiguration(): CorsConfiguration {
|
||||||
|
return CorsConfiguration().apply {
|
||||||
|
applyPermitDefaultValues()
|
||||||
|
allowedOrigins = AI_CHARACTER_ADMIN_ALLOWED_ORIGINS
|
||||||
|
allowedMethods = listOf(CorsConfiguration.ALL)
|
||||||
|
allowCredentials = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,15 +83,21 @@ class TokenProvider(
|
|||||||
.parseClaimsJws(token)
|
.parseClaimsJws(token)
|
||||||
.body
|
.body
|
||||||
|
|
||||||
val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) }
|
val memberId = claims.subject?.toLongOrNull()
|
||||||
val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong())
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
val authorityNames = (claims[AUTHORITIES_KEY] as? String)
|
||||||
|
?.split(",")
|
||||||
|
?.takeIf { names -> names.all { it.isNotBlank() } }
|
||||||
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
val authorities = authorityNames.map { SimpleGrantedAuthority(it) }
|
||||||
|
val memberToken = tokenRepository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
|
|
||||||
if (!memberToken.tokenSet.contains(token)) {
|
if (!memberToken.tokenSet.contains(token)) {
|
||||||
throw SodaException(messageKey = "common.error.bad_credentials")
|
throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
val member = repository.findByIdOrNull(id = claims.subject.toLong())
|
val member = repository.findByIdOrNull(id = memberId)
|
||||||
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
?: throw SodaException(messageKey = "common.error.bad_credentials")
|
||||||
val principal = MemberAdapter(member)
|
val principal = MemberAdapter(member)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AiCharacterAdminTargetResolver(
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository
|
||||||
|
) {
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
fun resolve(characterId: Long): AiCharacterAdminTarget {
|
||||||
|
val chatCharacter = chatCharacterRepository.findByIdWithCreatorMember(characterId)
|
||||||
|
?: throw invalidTarget()
|
||||||
|
val creatorMember = chatCharacter.creatorMember
|
||||||
|
|
||||||
|
if (creatorMember?.role != MemberRole.CREATOR || creatorMember.memberKind != MemberKind.AI_CHARACTER) {
|
||||||
|
throw invalidTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
return AiCharacterAdminTarget(
|
||||||
|
characterId = characterId,
|
||||||
|
chatCharacter = chatCharacter,
|
||||||
|
creatorMember = creatorMember
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidTarget(): AiCharacterAdminApiException {
|
||||||
|
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AiCharacterAdminTarget(
|
||||||
|
val characterId: Long,
|
||||||
|
val chatCharacter: ChatCharacter,
|
||||||
|
val creatorMember: Member
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
|
class AiCharacterAdminApiException(
|
||||||
|
val status: HttpStatus,
|
||||||
|
val messageKey: String
|
||||||
|
) : RuntimeException(messageKey)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
class AiCharacterAdminErrorResponseWriter(
|
||||||
|
private val objectMapper: ObjectMapper,
|
||||||
|
private val messageSource: SodaMessageSource
|
||||||
|
) {
|
||||||
|
fun createResponse(request: HttpServletRequest, messageKey: String): ApiResponse<Any> {
|
||||||
|
val lang = Lang.fromAcceptLanguage(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))
|
||||||
|
val message = messageSource.getMessage(messageKey, lang)
|
||||||
|
?: messageSource.getMessage("common.error.unknown", lang)
|
||||||
|
return ApiResponse.error(message = message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun write(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
status: HttpStatus,
|
||||||
|
messageKey: String
|
||||||
|
) {
|
||||||
|
response.status = status.value()
|
||||||
|
response.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||||
|
response.characterEncoding = Charsets.UTF_8.name()
|
||||||
|
response.writer.write(objectMapper.writeValueAsString(createResponse(request, messageKey)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.core.Ordered
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
|
import org.springframework.security.access.AccessDeniedException
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||||
|
import org.springframework.validation.BindException
|
||||||
|
import org.springframework.web.HttpMediaTypeNotAcceptableException
|
||||||
|
import org.springframework.web.HttpMediaTypeNotSupportedException
|
||||||
|
import org.springframework.web.HttpRequestMethodNotSupportedException
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||||
|
import org.springframework.web.bind.MissingPathVariableException
|
||||||
|
import org.springframework.web.bind.ServletRequestBindingException
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||||
|
import org.springframework.web.multipart.MultipartException
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||||
|
import org.springframework.web.servlet.HandlerExceptionResolver
|
||||||
|
import org.springframework.web.servlet.ModelAndView
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
class AiCharacterAdminExceptionHandler(
|
||||||
|
private val responseWriter: AiCharacterAdminErrorResponseWriter,
|
||||||
|
private val requestMatcher: RequestMatcher
|
||||||
|
) : HandlerExceptionResolver, Ordered {
|
||||||
|
private val logger = LoggerFactory.getLogger(this::class.java)
|
||||||
|
|
||||||
|
override fun getOrder(): Int = Ordered.HIGHEST_PRECEDENCE
|
||||||
|
|
||||||
|
override fun resolveException(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
handler: Any?,
|
||||||
|
exception: Exception
|
||||||
|
): ModelAndView? {
|
||||||
|
if (!requestMatcher.matches(request)) return null
|
||||||
|
|
||||||
|
val error = resolveError(exception)
|
||||||
|
if (error.status.is5xxServerError) {
|
||||||
|
logger.error("Unexpected AI character admin API error", exception)
|
||||||
|
} else {
|
||||||
|
logger.debug("AI character admin API request error", exception)
|
||||||
|
}
|
||||||
|
writeStandardHeaders(request, response, exception)
|
||||||
|
responseWriter.write(request, response, error.status, error.messageKey)
|
||||||
|
return ModelAndView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveError(exception: Exception): ResolvedError {
|
||||||
|
return when (exception) {
|
||||||
|
is AiCharacterAdminApiException -> ResolvedError(exception.status, exception.messageKey)
|
||||||
|
is AccessDeniedException -> ResolvedError(HttpStatus.FORBIDDEN, "common.error.access_denied")
|
||||||
|
is HttpRequestMethodNotSupportedException -> invalidRequest(HttpStatus.METHOD_NOT_ALLOWED)
|
||||||
|
is HttpMediaTypeNotSupportedException -> invalidRequest(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
is HttpMediaTypeNotAcceptableException -> invalidRequest(HttpStatus.NOT_ACCEPTABLE)
|
||||||
|
is MissingPathVariableException -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
|
||||||
|
is BindException,
|
||||||
|
is HttpMessageNotReadableException,
|
||||||
|
is MethodArgumentNotValidException,
|
||||||
|
is MethodArgumentTypeMismatchException,
|
||||||
|
is ServletRequestBindingException,
|
||||||
|
is MultipartException,
|
||||||
|
is MissingServletRequestPartException -> invalidRequest(HttpStatus.BAD_REQUEST)
|
||||||
|
else -> ResolvedError(HttpStatus.INTERNAL_SERVER_ERROR, "common.error.unknown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeStandardHeaders(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
exception: Exception
|
||||||
|
) {
|
||||||
|
when (exception) {
|
||||||
|
is HttpRequestMethodNotSupportedException -> {
|
||||||
|
exception.supportedMethods?.let { response.setHeader(HttpHeaders.ALLOW, it.joinToString(", ")) }
|
||||||
|
}
|
||||||
|
is HttpMediaTypeNotSupportedException -> {
|
||||||
|
if (exception.supportedMediaTypes.isNotEmpty()) {
|
||||||
|
val supportedMediaTypes = MediaType.toString(exception.supportedMediaTypes)
|
||||||
|
response.setHeader(HttpHeaders.ACCEPT, supportedMediaTypes)
|
||||||
|
if (request.method == HttpMethod.PATCH.name) {
|
||||||
|
response.setHeader(HttpHeaders.ACCEPT_PATCH, supportedMediaTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidRequest(status: HttpStatus): ResolvedError {
|
||||||
|
return ResolvedError(status, "common.error.invalid_request")
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ResolvedError(
|
||||||
|
val status: HttpStatus,
|
||||||
|
val messageKey: String
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.security
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminErrorResponseWriter
|
||||||
|
import org.springframework.http.HttpMethod
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.access.AccessDeniedException
|
||||||
|
import org.springframework.security.core.AuthenticationException
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler
|
||||||
|
import org.springframework.security.web.firewall.DefaultRequestRejectedHandler
|
||||||
|
import org.springframework.security.web.firewall.RequestRejectedException
|
||||||
|
import org.springframework.security.web.firewall.RequestRejectedHandler
|
||||||
|
import org.springframework.security.web.util.matcher.RequestMatcher
|
||||||
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
|
import org.springframework.web.cors.DefaultCorsProcessor
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletRequestWrapper
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
class AiCharacterAdminSecurityErrorHandler(
|
||||||
|
private val responseWriter: AiCharacterAdminErrorResponseWriter,
|
||||||
|
private val aiCharacterAdminRequestMatcher: RequestMatcher,
|
||||||
|
private val aiCharacterAdminCorsConfiguration: CorsConfiguration
|
||||||
|
) : AuthenticationEntryPoint, AccessDeniedHandler, RequestRejectedHandler {
|
||||||
|
private val defaultRequestRejectedHandler = DefaultRequestRejectedHandler()
|
||||||
|
private val corsProcessor = DefaultCorsProcessor()
|
||||||
|
|
||||||
|
override fun commence(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
authException: AuthenticationException
|
||||||
|
) {
|
||||||
|
responseWriter.write(
|
||||||
|
request = request,
|
||||||
|
response = response,
|
||||||
|
status = HttpStatus.UNAUTHORIZED,
|
||||||
|
messageKey = "common.error.bad_credentials"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handle(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
accessDeniedException: AccessDeniedException
|
||||||
|
) {
|
||||||
|
responseWriter.write(
|
||||||
|
request = request,
|
||||||
|
response = response,
|
||||||
|
status = HttpStatus.FORBIDDEN,
|
||||||
|
messageKey = "common.error.access_denied"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handle(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
requestRejectedException: RequestRejectedException
|
||||||
|
) {
|
||||||
|
if (!aiCharacterAdminRequestMatcher.matches(request)) {
|
||||||
|
defaultRequestRejectedHandler.handle(request, response, requestRejectedException)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spring 5.3 CORS processor only recognizes HttpMethod enums, so use GET for nonstandard method checks.
|
||||||
|
val corsRequest = if (HttpMethod.resolve(request.method) == null) {
|
||||||
|
object : HttpServletRequestWrapper(request) {
|
||||||
|
override fun getMethod(): String = HttpMethod.GET.name
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
if (!corsProcessor.processRequest(aiCharacterAdminCorsConfiguration, corsRequest, response)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseWriter.write(
|
||||||
|
request = request,
|
||||||
|
response = response,
|
||||||
|
status = HttpStatus.BAD_REQUEST,
|
||||||
|
messageKey = "common.error.invalid_request"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package kr.co.vividnext.sodalive.jwt
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Jwts
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm
|
||||||
|
import io.jsonwebtoken.io.Decoders
|
||||||
|
import io.jsonwebtoken.security.Keys
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberToken
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
class TokenProviderTest {
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
private lateinit var memberRepository: MemberRepository
|
||||||
|
private lateinit var tokenRepository: MemberTokenRepository
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
memberRepository = Mockito.mock(MemberRepository::class.java)
|
||||||
|
tokenRepository = Mockito.mock(MemberTokenRepository::class.java)
|
||||||
|
tokenProvider = TokenProvider(
|
||||||
|
secret = SECRET,
|
||||||
|
tokenValidityInSeconds = 3600,
|
||||||
|
repository = memberRepository,
|
||||||
|
tokenRepository = tokenRepository
|
||||||
|
)
|
||||||
|
tokenProvider.afterPropertiesSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("subject가 없는 서명 JWT는 bad_credentials로 변환된다")
|
||||||
|
fun shouldRejectSignedJwtWithoutSubjectAsBadCredentials() {
|
||||||
|
val token = signedToken(subject = null)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
tokenProvider.getAuthentication(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("숫자가 아닌 subject의 서명 JWT는 bad_credentials로 변환된다")
|
||||||
|
fun shouldRejectSignedJwtWithNonNumericSubjectAsBadCredentials() {
|
||||||
|
val token = signedToken(subject = "not-a-number")
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
tokenProvider.getAuthentication(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("auth claim이 없는 서명 JWT는 bad_credentials로 변환된다")
|
||||||
|
fun shouldRejectSignedJwtWithoutAuthoritiesClaimAsBadCredentials() {
|
||||||
|
val token = signedToken(subject = MEMBER_ID.toString(), authorities = null)
|
||||||
|
allowStoredToken(token)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
tokenProvider.getAuthentication(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["", " ", "ROLE_ADMIN,", ",ROLE_ADMIN", "ROLE_ADMIN,,ROLE_USER"])
|
||||||
|
@DisplayName("비어 있는 authority를 포함한 서명 JWT는 bad_credentials로 변환된다")
|
||||||
|
fun shouldRejectSignedJwtWithBlankAuthorityAsBadCredentials(authorities: String) {
|
||||||
|
val token = signedToken(subject = MEMBER_ID.toString(), authorities = authorities)
|
||||||
|
allowStoredToken(token)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
tokenProvider.getAuthentication(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("문자열이 아닌 auth claim의 서명 JWT는 bad_credentials로 변환된다")
|
||||||
|
fun shouldRejectSignedJwtWithNonStringAuthoritiesClaimAsBadCredentials() {
|
||||||
|
val token = signedToken(subject = MEMBER_ID.toString(), authorities = 1)
|
||||||
|
allowStoredToken(token)
|
||||||
|
|
||||||
|
val exception = assertThrows(SodaException::class.java) {
|
||||||
|
tokenProvider.getAuthentication(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals("common.error.bad_credentials", exception.messageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allowStoredToken(token: String) {
|
||||||
|
val member = Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
).apply { id = MEMBER_ID }
|
||||||
|
Mockito.`when`(tokenRepository.findById(MEMBER_ID))
|
||||||
|
.thenReturn(Optional.of(MemberToken(MEMBER_ID, mutableSetOf(token))))
|
||||||
|
Mockito.`when`(memberRepository.findById(MEMBER_ID)).thenReturn(Optional.of(member))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signedToken(subject: String?, authorities: Any? = "ROLE_ADMIN"): String {
|
||||||
|
val builder = Jwts.builder()
|
||||||
|
.signWith(Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET)), SignatureAlgorithm.HS512)
|
||||||
|
.setExpiration(Date(System.currentTimeMillis() + 60_000))
|
||||||
|
|
||||||
|
if (subject != null) {
|
||||||
|
builder.setSubject(subject)
|
||||||
|
}
|
||||||
|
if (authorities != null) {
|
||||||
|
builder.claim("auth", authorities)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.compact()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MEMBER_ID = 1L
|
||||||
|
private val SECRET = Base64.getEncoder().encodeToString("a".repeat(64).toByteArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestComponent
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.security.access.AccessDeniedException
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@Import(
|
||||||
|
AiCharacterAdminAccessDeniedErrorContractTest.AiCharacterAdminAccessDeniedSampleController::class
|
||||||
|
)
|
||||||
|
class AiCharacterAdminAccessDeniedErrorContractTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,권한이 없습니다.",
|
||||||
|
"en,You do not have permission.",
|
||||||
|
"ja,権限がありません。"
|
||||||
|
)
|
||||||
|
@DisplayName("신규 prefix controller AccessDeniedException은 403 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForControllerAccessDenied(language: String, message: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/access-denied-contract")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication()))
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value(message))
|
||||||
|
.andExpect(jsonPath("$.data").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.errorProperty").doesNotExist())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAuthentication() =
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = MemberRole.ADMIN
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
|
||||||
|
)
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
class AiCharacterAdminAccessDeniedSampleController {
|
||||||
|
@GetMapping("/api/v2/admin/ai-characters/access-denied-contract")
|
||||||
|
fun accessDenied(): ApiResponse<String> {
|
||||||
|
throw AccessDeniedException("admin only")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestComponent
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@Import(
|
||||||
|
AiCharacterAdminAuthorizationTest.AiCharacterAdminAuthorizationSampleController::class,
|
||||||
|
AiCharacterAdminAuthorizationTest.AiCharacterAdminLegacySampleController::class
|
||||||
|
)
|
||||||
|
class AiCharacterAdminAuthorizationTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 API는 비회원 요청을 거부한다")
|
||||||
|
fun shouldRejectAnonymousUser() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/sample")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 API는 JWT role이 ADMIN이 아니면 현재 DB role이 ADMIN이어도 거부한다")
|
||||||
|
fun shouldRejectNonAdminJwtRole() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/sample")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 API는 JWT가 ADMIN이어도 현재 DB role이 강등되면 거부한다")
|
||||||
|
fun shouldRejectStaleAdminClaimAfterCurrentRoleDowngrade() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/sample")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 API는 ADMIN authority가 있어도 MemberAdapter principal이 아니면 거부한다")
|
||||||
|
fun shouldRejectAdminAuthorityWithoutMemberAdapterPrincipal() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/sample")
|
||||||
|
.with(user("admin").roles("ADMIN"))
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 관리자 API는 JWT와 현재 DB role이 모두 ADMIN이면 허용한다")
|
||||||
|
fun shouldAllowCurrentAdminWithAdminJwtRole() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/sample")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").value("ok"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("현재 DB role 추가 검증은 신규 AI 캐릭터 관리자 prefix 밖에 적용하지 않는다")
|
||||||
|
fun shouldKeepLegacyAuthenticatedRuleOutsideAiCharacterAdminPrefix() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/phase1-legacy-sample")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = currentRole
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
|
||||||
|
)
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/admin/ai-characters/sample")
|
||||||
|
class AiCharacterAdminAuthorizationSampleController {
|
||||||
|
@GetMapping
|
||||||
|
fun sample(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
class AiCharacterAdminLegacySampleController {
|
||||||
|
@GetMapping("/phase1-legacy-sample")
|
||||||
|
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,907 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import io.jsonwebtoken.Claims
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException
|
||||||
|
import io.jsonwebtoken.Header
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
import kr.co.vividnext.sodalive.jwt.TokenProvider
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.hamcrest.Matchers.nullValue
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestComponent
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.core.MethodParameter
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||||
|
import org.springframework.security.authentication.InternalAuthenticationServiceException
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.ResultActions
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.validation.BeanPropertyBindingResult
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.PatchMapping
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException
|
||||||
|
import org.springframework.web.multipart.MultipartException
|
||||||
|
import org.springframework.web.multipart.support.MissingServletRequestPartException
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
@Import(
|
||||||
|
AiCharacterAdminErrorContractTest.AiCharacterAdminErrorContractSampleController::class,
|
||||||
|
AiCharacterAdminErrorContractTest.AiCharacterAdminLegacyErrorContractSampleController::class
|
||||||
|
)
|
||||||
|
class AiCharacterAdminErrorContractTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
|
||||||
|
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var tokenProvider: TokenProvider
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private lateinit var chatCharacterRepository: ChatCharacterRepository
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,로그인 정보를 확인해주세요.",
|
||||||
|
"en,Please check your login information.",
|
||||||
|
"ja,ログイン情報を確認してください。"
|
||||||
|
)
|
||||||
|
@DisplayName("비회원 401 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForAnonymousRequest(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(401, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,권한이 없습니다.",
|
||||||
|
"en,You do not have permission.",
|
||||||
|
"ja,権限がありません。"
|
||||||
|
)
|
||||||
|
@DisplayName("비ADMIN 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForNonAdminRequest(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(403, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,권한이 없습니다.",
|
||||||
|
"en,You do not have permission.",
|
||||||
|
"ja,権限がありません。"
|
||||||
|
)
|
||||||
|
@DisplayName("stale ADMIN claim 403 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForStaleAdminClaim(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.USER)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(403, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,로그인 정보를 확인해주세요.",
|
||||||
|
"en,Please check your login information.",
|
||||||
|
"ja,ログイン情報を確認してください。"
|
||||||
|
)
|
||||||
|
@DisplayName("잘못된 JWT의 401 오류도 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForInvalidJwt(language: String, message: String) {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Authorization", "Bearer invalid-token")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(401, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,로그인 정보를 확인해주세요.",
|
||||||
|
"en,Please check your login information.",
|
||||||
|
"ja,ログイン情報を確認してください。"
|
||||||
|
)
|
||||||
|
@DisplayName("폐기된 JWT filter 예외도 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForRevokedJwt(language: String, message: String) {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("revoked-token")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("revoked-token"))
|
||||||
|
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Authorization", "Bearer revoked-token")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(401, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,로그인 정보를 확인해주세요.",
|
||||||
|
"en,Please check your login information.",
|
||||||
|
"ja,ログイン情報を確認してください。"
|
||||||
|
)
|
||||||
|
@DisplayName("JWT 검증 직후 만료된 token도 401 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseWhenJwtExpiresBetweenParses(language: String, message: String) {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("expired-between-parses")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("expired-between-parses"))
|
||||||
|
.thenThrow(
|
||||||
|
ExpiredJwtException(
|
||||||
|
Mockito.mock(Header::class.java),
|
||||||
|
Mockito.mock(Claims::class.java),
|
||||||
|
"expired"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Authorization", "Bearer expired-between-parses")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(401, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"en,An unknown error occurred. try again.",
|
||||||
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
|
)
|
||||||
|
@DisplayName("인증 저장소 장애는 500 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForAuthenticationInfrastructureError(language: String, message: String) {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("authentication-service-error")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("authentication-service-error"))
|
||||||
|
.thenThrow(InternalAuthenticationServiceException("authentication backend unavailable"))
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Authorization", "Bearer authentication-service-error")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(500, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"en,An unknown error occurred. try again.",
|
||||||
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
|
)
|
||||||
|
@DisplayName("JWT filter의 예상하지 못한 오류는 500 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnexpectedJwtFilterError(language: String, message: String) {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("filter-error-token")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("filter-error-token"))
|
||||||
|
.thenThrow(IllegalStateException("internal detail must not be exposed"))
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Authorization", "Bearer filter-error-token")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(500, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("잘못된 target 400 오류는 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForInvalidTarget(language: String, message: String) {
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("잘못된 request binding은 400 ApiResponse와 i18n 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForInvalidRequestBinding(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/binding")
|
||||||
|
.param("value", "not-a-number")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("읽을 수 없는 JSON body는 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnreadableJson(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/body-binding")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertEquals(HttpMessageNotReadableException::class.java, result.andReturn().resolvedException?.javaClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("MethodArgumentNotValidException은 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMethodArgumentNotValid(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/method-argument-not-valid")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"value\":1}")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertEquals(MethodArgumentNotValidException::class.java, result.andReturn().resolvedException?.javaClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("필수 multipart part 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMissingMultipartPart(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
multipart("/api/v2/admin/ai-characters/error-contract/required-part")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
assertEquals(MissingServletRequestPartException::class.java, result.andReturn().resolvedException?.javaClass)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("필수 request header 누락은 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMissingRequiredHeader(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/required-header")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"en,An unknown error occurred. try again.",
|
||||||
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
|
)
|
||||||
|
@DisplayName("예상하지 못한 오류는 500 ApiResponse와 i18n 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnexpectedError(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/unexpected")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(500, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"en,An unknown error occurred. try again.",
|
||||||
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
|
)
|
||||||
|
@DisplayName("등록되지 않은 message key는 status를 유지하고 unknown 메시지로 fallback한다")
|
||||||
|
fun shouldFallbackToLocalizedUnknownMessageForMissingMessageKey(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/missing-message-key")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("지원하지 않는 HTTP method는 405 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnsupportedMethod(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(405, message)
|
||||||
|
result.andExpect(header().string(HttpHeaders.ALLOW, "GET"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("지원하지 않는 media type은 415 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnsupportedMediaType(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/media-type")
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.content("text")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(415, message)
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PATCH의 지원하지 않는 media type은 Accept와 Accept-Patch header를 반환한다")
|
||||||
|
fun shouldReturnAcceptHeadersForUnsupportedPatchMediaType() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
patch("/api/v2/admin/ai-characters/error-contract/media-type")
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.content("text")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(415, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCEPT_PATCH, MediaType.APPLICATION_JSON_VALUE))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("지원하지 않는 응답 media type은 406 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForNotAcceptableMediaType(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.accept(MediaType.APPLICATION_XML)
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(406, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 응답 media type 406에는 CORS header와 ApiResponse가 함께 반환된다")
|
||||||
|
fun shouldApplyCorsToNotAcceptableMediaTypeError() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.accept(MediaType.APPLICATION_XML)
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(406, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||||
|
"en,An unknown error occurred. try again.",
|
||||||
|
"ja,不明なエラーが発生しました。恐れ入りますが、もう一度お試しください。"
|
||||||
|
)
|
||||||
|
@DisplayName("누락된 path variable은 500 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMissingPathVariable(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/missing-path-variable")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(500, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("multipart 파싱 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMultipartError(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("multipart 용량 초과 오류는 400 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForMaxUploadSizeError(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/max-upload-size-error")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ko,잘못된 요청입니다.",
|
||||||
|
"en,Invalid request.",
|
||||||
|
"ja,無効なリクエストです。"
|
||||||
|
)
|
||||||
|
@DisplayName("신규 prefix의 미매핑 경로는 404 ApiResponse와 요청 언어 메시지를 반환한다")
|
||||||
|
fun shouldReturnLocalizedApiResponseForUnmappedPath(language: String, message: String) {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header("Accept-Language", language)
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(404, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 미매핑 경로 404에는 CORS header와 ApiResponse가 함께 반환된다")
|
||||||
|
fun shouldApplyCorsToUnmappedPathError() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(404, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 method 405에는 CORS header와 ApiResponse가 함께 반환된다")
|
||||||
|
fun shouldApplyCorsToUnsupportedMethodError() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(405, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 지원하지 않는 media type 415에는 CORS header와 ApiResponse가 함께 반환된다")
|
||||||
|
fun shouldApplyCorsToUnsupportedMediaTypeError() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/media-type")
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.content("text")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(415, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 multipart 오류 400에는 CORS header와 ApiResponse가 함께 반환된다")
|
||||||
|
fun shouldApplyCorsToMultipartError() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/multipart-error")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, "Invalid request.")
|
||||||
|
result.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(
|
||||||
|
strings = [
|
||||||
|
"http://localhost:8888",
|
||||||
|
"https://test-character-admin.sodalive.net",
|
||||||
|
"https://character-admin.sodalive.net"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DisplayName("캐릭터 관리자 Origin의 신규 prefix preflight를 CORS filter가 허용한다")
|
||||||
|
fun shouldAllowCorsPreflightForAiCharacterAdminPrefix(origin: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header(HttpHeaders.ORIGIN, origin)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
|
||||||
|
@DisplayName("캐릭터 관리자 Origin의 실제 mapped endpoint write preflight를 CORS filter가 허용한다")
|
||||||
|
fun shouldAllowCorsPreflightForMappedAiCharacterAdminWriteMethods(method: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/error-contract/write-preflight")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = ["POST", "PUT", "PATCH", "DELETE"])
|
||||||
|
@DisplayName("캐릭터 관리자 Origin의 fallback write preflight를 CORS filter가 허용한다")
|
||||||
|
fun shouldAllowCorsPreflightForFallbackAiCharacterAdminWriteMethods(method: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, method))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "authorization, content-type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(
|
||||||
|
strings = [
|
||||||
|
"https://creator.sodalive.net",
|
||||||
|
"https://test-creator.sodalive.net",
|
||||||
|
"https://admin.sodalive.net",
|
||||||
|
"https://test-admin.sodalive.net"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DisplayName("신규 prefix preflight는 범용 관리자와 creator Origin을 허용하지 않는다")
|
||||||
|
fun shouldRejectOtherAdminOriginPreflightForAiCharacterAdminPrefix(origin: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header(HttpHeaders.ORIGIN, origin)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("신규 prefix 일반 요청도 creator Origin이면 CORS 단계에서 거부된다")
|
||||||
|
fun shouldNotApplyCreatorOriginCorsToAiCharacterAdminError() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/unmapped-path")
|
||||||
|
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("비ADMIN 요청은 request body 역직렬화 전에 403으로 거부된다")
|
||||||
|
fun shouldRejectNonAdminBeforeRequestBodyBinding() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/api/v2/admin/ai-characters/error-contract/body-binding")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"value\":\"not-a-number\"}")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.USER, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(403, "You do not have permission.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("인접 prefix에는 AI 캐릭터 관리자 전용 fallback handler가 적용되지 않는다")
|
||||||
|
fun shouldNotApplyAiCharacterAdminHandlerToAdjacentPrefix() {
|
||||||
|
mockMvc.perform(
|
||||||
|
delete("/api/v2/admin/ai-characters-shadow")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isNotFound)
|
||||||
|
.andExpect(content().string(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Accept-Language가 없으면 신규 security 오류 메시지는 한국어로 fallback한다")
|
||||||
|
fun shouldFallbackToKoreanForMissingAcceptLanguage() {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/success")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(401, "로그인 정보를 확인해주세요.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("지원하지 않는 Accept-Language는 신규 MVC 오류 메시지를 한국어로 fallback한다")
|
||||||
|
fun shouldFallbackToKoreanForUnsupportedAcceptLanguage() {
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
|
||||||
|
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/error-contract/invalid-target")
|
||||||
|
.header("Accept-Language", "fr")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
|
||||||
|
result.andExpectApiError(400, "잘못된 요청입니다.")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("신규 prefix 밖 비회원 오류는 기존 빈 401 응답을 유지한다")
|
||||||
|
fun shouldKeepLegacyAnonymousErrorResponse() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/phase1-legacy-error-contract")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(content().string(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("신규 prefix 밖 JWT filter 예외는 기존 한국어 ApiResponse를 유지한다")
|
||||||
|
fun shouldKeepLegacyRevokedJwtErrorResponse() {
|
||||||
|
Mockito.`when`(tokenProvider.validateToken("legacy-revoked-token")).thenReturn(true)
|
||||||
|
Mockito.`when`(tokenProvider.getAuthentication("legacy-revoked-token"))
|
||||||
|
.thenThrow(SodaException(messageKey = "common.error.bad_credentials"))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/phase1-legacy-error-contract")
|
||||||
|
.header("Authorization", "Bearer legacy-revoked-token")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("로그인 정보를 확인해주세요."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("신규 prefix 밖 지원하지 않는 HTTP method 오류는 기존 200 응답을 유지한다")
|
||||||
|
fun shouldKeepLegacyUnsupportedMethodErrorResponse() {
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/phase1-legacy-error-contract")
|
||||||
|
.header("Accept-Language", "en")
|
||||||
|
.with(authentication(createAuthentication(MemberRole.ADMIN, MemberRole.ADMIN)))
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("알 수 없는 오류가 발생했습니다. 다시 시도해 주세요."))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultActions.andExpectApiError(httpStatus: Int, message: String) {
|
||||||
|
andExpect(status().`is`(httpStatus))
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value(message))
|
||||||
|
.andExpect(jsonPath("$.data").value(nullValue()))
|
||||||
|
.andExpect(jsonPath("$.errorProperty").value(nullValue()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createAuthentication(jwtRole: MemberRole, currentRole: MemberRole) =
|
||||||
|
UsernamePasswordAuthenticationToken(
|
||||||
|
MemberAdapter(
|
||||||
|
Member(
|
||||||
|
email = "admin@example.com",
|
||||||
|
password = "password",
|
||||||
|
nickname = "admin",
|
||||||
|
role = currentRole
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"token",
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_${jwtRole.name}"))
|
||||||
|
)
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/admin/ai-characters/error-contract")
|
||||||
|
class AiCharacterAdminErrorContractSampleController(
|
||||||
|
private val targetResolver: AiCharacterAdminTargetResolver
|
||||||
|
) {
|
||||||
|
@GetMapping("/success")
|
||||||
|
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@GetMapping("/invalid-target")
|
||||||
|
fun invalidTarget(): ApiResponse<String> {
|
||||||
|
targetResolver.resolve(404L)
|
||||||
|
return ApiResponse.ok("unexpected")
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/binding")
|
||||||
|
fun binding(@RequestParam value: Long): ApiResponse<Long> = ApiResponse.ok(value)
|
||||||
|
|
||||||
|
@GetMapping("/required-header")
|
||||||
|
fun requiredHeader(@RequestHeader("X-Required") value: String): ApiResponse<String> = ApiResponse.ok(value)
|
||||||
|
|
||||||
|
@GetMapping("/unexpected")
|
||||||
|
fun unexpected(): ApiResponse<String> {
|
||||||
|
throw IllegalStateException("internal detail must not be exposed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/missing-message-key")
|
||||||
|
fun missingMessageKey(): ApiResponse<String> {
|
||||||
|
throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "missing.message.key")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
|
fun mediaType(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@PatchMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
|
fun patchMediaType(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@PostMapping("/write-preflight")
|
||||||
|
fun postWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@PutMapping("/write-preflight")
|
||||||
|
fun putWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@PatchMapping("/write-preflight")
|
||||||
|
fun patchWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@DeleteMapping("/write-preflight")
|
||||||
|
fun deleteWritePreflight(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
|
||||||
|
@GetMapping("/missing-path-variable")
|
||||||
|
fun missingPathVariable(@PathVariable("missing") value: Long): ApiResponse<Long> = ApiResponse.ok(value)
|
||||||
|
|
||||||
|
@PostMapping("/body-binding", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
|
fun bodyBinding(@RequestBody request: BodyBindingRequest): ApiResponse<Long> = ApiResponse.ok(request.value)
|
||||||
|
|
||||||
|
@PostMapping("/method-argument-not-valid", consumes = [MediaType.APPLICATION_JSON_VALUE])
|
||||||
|
fun methodArgumentNotValid(@RequestBody request: BodyBindingRequest): ApiResponse<Long> {
|
||||||
|
val method = AiCharacterAdminErrorContractSampleController::class.java
|
||||||
|
.getDeclaredMethod("methodArgumentNotValid", BodyBindingRequest::class.java)
|
||||||
|
val bindingResult = BeanPropertyBindingResult(request, "request")
|
||||||
|
bindingResult.rejectValue("value", "invalid")
|
||||||
|
throw MethodArgumentNotValidException(MethodParameter(method, 0), bindingResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/required-part", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||||
|
fun requiredPart(@RequestPart("file") value: String): ApiResponse<String> = ApiResponse.ok(value)
|
||||||
|
|
||||||
|
@PostMapping("/multipart-error")
|
||||||
|
fun multipartError(): ApiResponse<String> {
|
||||||
|
throw MultipartException("malformed multipart request")
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/max-upload-size-error")
|
||||||
|
fun maxUploadSizeError(): ApiResponse<String> {
|
||||||
|
throw MaxUploadSizeExceededException(1L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
class AiCharacterAdminLegacyErrorContractSampleController {
|
||||||
|
@GetMapping("/phase1-legacy-error-contract")
|
||||||
|
fun sample(): ApiResponse<String> = ApiResponse.ok("legacy-ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BodyBindingRequest(
|
||||||
|
val value: Long
|
||||||
|
)
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import com.jayway.jsonpath.JsonPath
|
||||||
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.assertThrows
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.boot.test.context.TestComponent
|
||||||
|
import org.springframework.context.annotation.Import
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
|
||||||
|
import org.springframework.security.web.firewall.RequestRejectedException
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
@Import(AiCharacterAdminLoginJwtIntegrationTest.AiCharacterAdminLoginJwtSampleController::class)
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminLoginJwtIntegrationTest @Autowired constructor(
|
||||||
|
private val mockMvc: MockMvc,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val memberTokenRepository: MemberTokenRepository,
|
||||||
|
private val passwordEncoder: PasswordEncoder
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
|
||||||
|
private const val GENERAL_ADMIN_ORIGIN = "https://admin.sodalive.net"
|
||||||
|
private const val UNTRUSTED_ORIGIN = "https://not-allowed.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun cleanUpMemberTokens() {
|
||||||
|
memberTokenRepository.deleteAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자 로그인 JWT는 신규 AI 캐릭터 관리자 prefix를 통과하고 현재 DB role 강등 시 거부된다")
|
||||||
|
fun shouldAuthorizeAiCharacterAdminPrefixWithActualAdminLoginJwtAndCurrentRole() {
|
||||||
|
val admin = memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
|
||||||
|
val token = loginAdmin()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data").value("ok"))
|
||||||
|
|
||||||
|
admin.role = MemberRole.USER
|
||||||
|
memberRepository.saveAndFlush(admin)
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("You do not have permission."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 Spring context에서 신규 prefix 비회원 오류는 localized ApiResponse 401이다")
|
||||||
|
fun shouldReturnLocalizedUnauthorizedApiResponseInSpringContext() {
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.with(anonymous())
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("Please check your login information."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("실제 Spring context에서 신규 prefix는 캐릭터 관리자 Origin preflight만 허용한다")
|
||||||
|
fun shouldAllowOnlyCharacterAdminOriginPreflightInSpringContext() {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.ORIGIN, GENERAL_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(
|
||||||
|
strings = [
|
||||||
|
"http://localhost:8888",
|
||||||
|
"https://creator.sodalive.net",
|
||||||
|
"https://test-creator.sodalive.net",
|
||||||
|
"https://test-admin.sodalive.net",
|
||||||
|
GENERAL_ADMIN_ORIGIN,
|
||||||
|
"https://test-character-admin.sodalive.net",
|
||||||
|
CHARACTER_ADMIN_ORIGIN
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@DisplayName("공유 관리자 로그인과 로그아웃은 기존 관리자와 캐릭터 관리자 Origin을 허용한다")
|
||||||
|
fun shouldAllowAdminOriginsForSharedAuthenticationEndpoints(origin: String) {
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/admin/member/login")
|
||||||
|
.header(HttpHeaders.ORIGIN, origin)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "content-type")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
options("/member/logout")
|
||||||
|
.header(HttpHeaders.ORIGIN, origin)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("공유 관리자 로그인과 로그아웃은 미등록 Origin을 거부한다")
|
||||||
|
fun shouldRejectUntrustedOriginForSharedAuthenticationEndpoints() {
|
||||||
|
listOf("/admin/member/login", "/member/logout").forEach { path ->
|
||||||
|
mockMvc.perform(
|
||||||
|
options(path)
|
||||||
|
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("캐릭터 관리자 Origin은 공유 인증 외 legacy/public 경로로 확산되지 않는다")
|
||||||
|
fun shouldNotApplyCharacterAdminCorsToOtherEndpoints() {
|
||||||
|
listOf("/api/v2/home/recommendations/ai-characters", "/api/v2/admin/ai-characters-shadow").forEach { path ->
|
||||||
|
mockMvc.perform(
|
||||||
|
options(path)
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("캐릭터 관리자 Origin에서 기존 관리자 로그인과 공통 로그아웃을 호출한다")
|
||||||
|
fun shouldLoginAndLogoutFromCharacterAdminOrigin() {
|
||||||
|
memberRepository.saveAndFlush(createMember(MemberRole.ADMIN))
|
||||||
|
val token = loginAdmin()
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
post("/member/logout")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
get("/api/v2/admin/ai-characters/login-jwt/success")
|
||||||
|
.header(HttpHeaders.AUTHORIZATION, "Bearer $token")
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isUnauthorized)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("Please check your login information."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 비표준 method는 localized 400 ApiResponse와 CORS를 반환한다")
|
||||||
|
fun shouldReturnLocalizedBadRequestForRejectedMethodFromCharacterAdminOrigin() {
|
||||||
|
mockMvc.perform(
|
||||||
|
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
|
||||||
|
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
.andExpect(jsonPath("$.message").value("Invalid request."))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("허용된 캐릭터 관리자 Origin의 위험 URL은 400 ApiResponse와 CORS를 반환한다")
|
||||||
|
fun shouldReturnBadRequestForRejectedDangerousUrlFromCharacterAdminOrigin() {
|
||||||
|
mockMvc.perform(
|
||||||
|
request("GET", URI.create("/api/v2/admin/ai-characters//login-jwt/success"))
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isBadRequest)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||||
|
.andExpect(jsonPath("$.success").value(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("미허용 Origin의 신규 prefix 비표준 method는 CORS 정책 403으로 종료한다")
|
||||||
|
fun shouldRejectRejectedMethodFromUntrustedOriginWithoutCorsHeader() {
|
||||||
|
mockMvc.perform(
|
||||||
|
request("PROPFIND", URI.create("/api/v2/admin/ai-characters/login-jwt/success"))
|
||||||
|
.header(HttpHeaders.ORIGIN, UNTRUSTED_ORIGIN)
|
||||||
|
)
|
||||||
|
.andExpect(status().isForbidden)
|
||||||
|
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy 경로의 비표준 method는 기존 firewall fallback을 유지한다")
|
||||||
|
fun shouldKeepLegacyFirewallFallbackForRejectedMethod() {
|
||||||
|
assertThrows<RequestRejectedException> {
|
||||||
|
mockMvc.perform(request("PROPFIND", URI.create("/api/v2/home/recommendations")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginAdmin(): String {
|
||||||
|
val result = mockMvc.perform(
|
||||||
|
post("/admin/member/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
|
||||||
|
.content("""{"email":"admin-jwt@test.com","password":"password"}""")
|
||||||
|
)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
return JsonPath.read(result.response.contentAsString, "$.data.token")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(role: MemberRole): Member {
|
||||||
|
return Member(
|
||||||
|
email = "admin-jwt@test.com",
|
||||||
|
password = passwordEncoder.encode("password"),
|
||||||
|
nickname = "admin-jwt",
|
||||||
|
role = role
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestComponent
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v2/admin/ai-characters/login-jwt")
|
||||||
|
class AiCharacterAdminLoginJwtSampleController {
|
||||||
|
@GetMapping("/success")
|
||||||
|
fun success(): ApiResponse<String> = ApiResponse.ok("ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.hibernate.Hibernate
|
||||||
|
import org.hibernate.SessionFactory
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.test.context.ContextConfiguration
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
import javax.persistence.EntityManagerFactory
|
||||||
|
|
||||||
|
@SpringBootTest(properties = ["spring.jpa.properties.hibernate.generate_statistics=true"])
|
||||||
|
@Transactional
|
||||||
|
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
|
||||||
|
class AiCharacterAdminTargetResolverIntegrationTest @Autowired constructor(
|
||||||
|
private val resolver: AiCharacterAdminTargetResolver,
|
||||||
|
private val memberRepository: MemberRepository,
|
||||||
|
private val chatCharacterRepository: ChatCharacterRepository,
|
||||||
|
private val entityManager: EntityManager,
|
||||||
|
private val entityManagerFactory: EntityManagerFactory
|
||||||
|
) {
|
||||||
|
@Test
|
||||||
|
@DisplayName("요청한 캐릭터와 연결된 AI 캐릭터 Member를 실제 DB에서 해석한다")
|
||||||
|
fun shouldResolveRequestedCharacterCreatorMemberFromDatabase() {
|
||||||
|
val requestedMember = memberRepository.save(createMember("requested"))
|
||||||
|
val otherMember = memberRepository.save(createMember("other"))
|
||||||
|
val requestedCharacter = chatCharacterRepository.save(createCharacter("requested", requestedMember))
|
||||||
|
val otherCharacter = chatCharacterRepository.save(createCharacter("other", otherMember))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val target = resolver.resolve(requestedCharacter.id!!)
|
||||||
|
|
||||||
|
assertEquals(requestedCharacter.id, target.characterId)
|
||||||
|
assertEquals(requestedCharacter.id, target.chatCharacter.id)
|
||||||
|
assertEquals(requestedMember.id, target.creatorMember.id)
|
||||||
|
assertEquals(otherCharacter.id, chatCharacterRepository.findByCreatorMemberId(otherMember.id!!)?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("캐릭터 조회 시 creatorMember를 함께 조회한다")
|
||||||
|
fun shouldFetchCreatorMemberWithCharacter() {
|
||||||
|
val member = memberRepository.save(createMember("fetch"))
|
||||||
|
val character = chatCharacterRepository.save(createCharacter("fetch", member))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val found = chatCharacterRepository.findByIdWithCreatorMember(character.id!!)
|
||||||
|
|
||||||
|
assertEquals(character.id, found?.id)
|
||||||
|
assertTrue(found != null && Hibernate.isInitialized(found.creatorMember))
|
||||||
|
assertEquals(member.id, found?.creatorMember?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("유효하지 않은 creator를 거부하고 DB insert/update/delete를 만들지 않는다")
|
||||||
|
fun shouldRejectInvalidCreatorWithoutDatabaseMutation() {
|
||||||
|
val humanMember = memberRepository.save(createMember("human", memberKind = MemberKind.HUMAN))
|
||||||
|
val character = chatCharacterRepository.save(createCharacter("human", humanMember))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
val memberCount = memberRepository.count()
|
||||||
|
val characterCount = chatCharacterRepository.count()
|
||||||
|
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||||
|
statistics.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(character.id!!)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
assertEquals(0L, statistics.entityInsertCount)
|
||||||
|
assertEquals(0L, statistics.entityUpdateCount)
|
||||||
|
assertEquals(0L, statistics.entityDeleteCount)
|
||||||
|
assertEquals(memberCount, memberRepository.count())
|
||||||
|
assertEquals(characterCount, chatCharacterRepository.count())
|
||||||
|
assertEquals(MemberKind.HUMAN, memberRepository.findById(humanMember.id!!).orElseThrow().memberKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("AI 캐릭터 Member라도 CREATOR role이 아니면 거부하고 DB를 변경하지 않는다")
|
||||||
|
fun shouldRejectWrongRoleAiCharacterMemberWithoutDatabaseMutation() {
|
||||||
|
val userMember = memberRepository.save(createMember("user", role = MemberRole.USER))
|
||||||
|
val character = chatCharacterRepository.save(createCharacter("user", userMember))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
val memberCount = memberRepository.count()
|
||||||
|
val characterCount = chatCharacterRepository.count()
|
||||||
|
val statistics = entityManagerFactory.unwrap(SessionFactory::class.java).statistics
|
||||||
|
statistics.clear()
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(character.id!!)
|
||||||
|
}
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val savedMember = memberRepository.findById(userMember.id!!).orElseThrow()
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
assertEquals(0L, statistics.entityInsertCount)
|
||||||
|
assertEquals(0L, statistics.entityUpdateCount)
|
||||||
|
assertEquals(0L, statistics.entityDeleteCount)
|
||||||
|
assertEquals(memberCount, memberRepository.count())
|
||||||
|
assertEquals(characterCount, chatCharacterRepository.count())
|
||||||
|
assertEquals(MemberRole.USER, savedMember.role)
|
||||||
|
assertEquals(MemberKind.AI_CHARACTER, savedMember.memberKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCharacter(name: String, creatorMember: Member): ChatCharacter {
|
||||||
|
return ChatCharacter(
|
||||||
|
characterUUID = "character-$name-${System.nanoTime()}",
|
||||||
|
name = "character-$name",
|
||||||
|
description = "description-$name",
|
||||||
|
systemPrompt = "system-prompt-$name"
|
||||||
|
).apply {
|
||||||
|
this.creatorMember = creatorMember
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(
|
||||||
|
name: String,
|
||||||
|
role: MemberRole = MemberRole.CREATOR,
|
||||||
|
memberKind: MemberKind = MemberKind.AI_CHARACTER
|
||||||
|
): Member {
|
||||||
|
return Member(
|
||||||
|
email = if (memberKind == MemberKind.HUMAN) "$name@example.com" else null,
|
||||||
|
password = if (memberKind == MemberKind.HUMAN) "password" else "",
|
||||||
|
nickname = "member-$name",
|
||||||
|
role = role,
|
||||||
|
memberKind = memberKind
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
|
||||||
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
|
||||||
|
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertSame
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.DisplayName
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
|
class AiCharacterAdminTargetResolverTest {
|
||||||
|
private val chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java)
|
||||||
|
private val resolver = AiCharacterAdminTargetResolver(chatCharacterRepository)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("유효한 AI 캐릭터 target에서 creatorMember를 해석한다")
|
||||||
|
fun shouldResolveAiCharacterCreatorMemberForAdminTarget() {
|
||||||
|
val creatorMember = createMember(id = 9001L)
|
||||||
|
val character = createCharacter(id = 101L, creatorMember = creatorMember)
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(character)
|
||||||
|
|
||||||
|
val target = resolver.resolve(101L)
|
||||||
|
|
||||||
|
assertEquals(101L, target.characterId)
|
||||||
|
assertSame(character, target.chatCharacter)
|
||||||
|
assertSame(creatorMember, target.creatorMember)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("존재하지 않는 캐릭터를 부작용 없이 거부한다")
|
||||||
|
fun shouldRejectMissingCharacterWithoutSideEffect() {
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(404L)).thenReturn(null)
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(404L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(404L)
|
||||||
|
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("CREATOR가 아닌 creatorMember를 부작용 없이 거부한다")
|
||||||
|
fun shouldRejectWrongRoleWithoutSideEffect() {
|
||||||
|
val member = createMember(id = 9001L, role = MemberRole.USER)
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
|
||||||
|
.thenReturn(createCharacter(id = 101L, creatorMember = member))
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(101L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
|
||||||
|
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("HUMAN creatorMember를 부작용 없이 거부한다")
|
||||||
|
fun shouldRejectHumanCreatorMemberWithoutSideEffect() {
|
||||||
|
val member = createMember(id = 9001L, memberKind = MemberKind.HUMAN)
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
|
||||||
|
.thenReturn(createCharacter(id = 101L, creatorMember = member))
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(101L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
|
||||||
|
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("creatorMember가 없는 캐릭터를 부작용 없이 거부한다")
|
||||||
|
fun shouldRejectMissingCreatorMemberWithoutSideEffect() {
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L))
|
||||||
|
.thenReturn(createCharacter(id = 101L, creatorMember = null))
|
||||||
|
|
||||||
|
val exception = assertThrows(AiCharacterAdminApiException::class.java) {
|
||||||
|
resolver.resolve(101L)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatus.BAD_REQUEST, exception.status)
|
||||||
|
assertEquals("common.error.invalid_request", exception.messageKey)
|
||||||
|
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
|
||||||
|
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("여러 캐릭터 fixture 중 요청한 캐릭터만 해석한다")
|
||||||
|
fun shouldResolveOnlyRequestedCharacterFixture() {
|
||||||
|
val creatorMember = createMember(id = 9001L)
|
||||||
|
val otherCreatorMember = createMember(id = 9002L)
|
||||||
|
val requestedCharacter = createCharacter(id = 101L, creatorMember = creatorMember)
|
||||||
|
val otherCharacter = createCharacter(id = 202L, creatorMember = otherCreatorMember)
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(101L)).thenReturn(requestedCharacter)
|
||||||
|
Mockito.`when`(chatCharacterRepository.findByIdWithCreatorMember(202L)).thenReturn(otherCharacter)
|
||||||
|
|
||||||
|
val target = resolver.resolve(101L)
|
||||||
|
|
||||||
|
assertEquals(101L, target.characterId)
|
||||||
|
assertEquals(9001L, target.creatorMember.id)
|
||||||
|
assertSame(requestedCharacter, target.chatCharacter)
|
||||||
|
Mockito.verify(chatCharacterRepository).findByIdWithCreatorMember(101L)
|
||||||
|
Mockito.verifyNoMoreInteractions(chatCharacterRepository)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createCharacter(id: Long, creatorMember: Member?): ChatCharacter {
|
||||||
|
return ChatCharacter(
|
||||||
|
characterUUID = "character-$id",
|
||||||
|
name = "character-$id",
|
||||||
|
description = "description-$id",
|
||||||
|
systemPrompt = "system-prompt-$id"
|
||||||
|
).apply {
|
||||||
|
this.id = id
|
||||||
|
this.creatorMember = creatorMember
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMember(
|
||||||
|
id: Long,
|
||||||
|
role: MemberRole = MemberRole.CREATOR,
|
||||||
|
memberKind: MemberKind = MemberKind.AI_CHARACTER
|
||||||
|
): Member {
|
||||||
|
return Member(
|
||||||
|
email = if (memberKind == MemberKind.HUMAN) "human-$id@example.com" else null,
|
||||||
|
password = "password",
|
||||||
|
nickname = "member-$id",
|
||||||
|
role = role,
|
||||||
|
memberKind = memberKind
|
||||||
|
).apply { this.id = id }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user