From 0d2742756fb69be2d9b283090e9ddbe24957bd9a Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 26 Jul 2026 05:01:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai-character):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20API=20Phase=201=20=EA=B8=B0=EB=B0=98=EC=9D=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260724_AI캐릭터_관리자_API/plan-task.md | 1538 +++++++++++++++++ docs/20260724_AI캐릭터_관리자_API/prd.md | 255 +++ .../repository/ChatCharacterRepository.kt | 9 + .../sodalive/common/ExceptionHandlerFilter.kt | 30 +- .../sodalive/configs/SecurityConfig.kt | 111 +- .../vividnext/sodalive/configs/WebConfig.kt | 49 +- .../vividnext/sodalive/jwt/TokenProvider.kt | 12 +- .../AiCharacterAdminTargetResolver.kt | 43 + .../error/AiCharacterAdminApiException.kt | 8 + .../AiCharacterAdminErrorResponseWriter.kt | 35 + .../error/AiCharacterAdminExceptionHandler.kt | 102 ++ .../AiCharacterAdminSecurityErrorHandler.kt | 83 + .../sodalive/jwt/TokenProviderTest.kt | 138 ++ ...acterAdminAccessDeniedErrorContractTest.kt | 79 + .../AiCharacterAdminAuthorizationTest.kt | 130 ++ .../AiCharacterAdminErrorContractTest.kt | 907 ++++++++++ ...AiCharacterAdminLoginJwtIntegrationTest.kt | 298 ++++ ...acterAdminTargetResolverIntegrationTest.kt | 152 ++ .../AiCharacterAdminTargetResolverTest.kt | 145 ++ 19 files changed, 4108 insertions(+), 16 deletions(-) create mode 100644 docs/20260724_AI캐릭터_관리자_API/plan-task.md create mode 100644 docs/20260724_AI캐릭터_관리자_API/prd.md create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt diff --git a/docs/20260724_AI캐릭터_관리자_API/plan-task.md b/docs/20260724_AI캐릭터_관리자_API/plan-task.md new file mode 100644 index 00000000..7ea8ced6 --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/plan-task.md @@ -0,0 +1,1538 @@ +# AI 캐릭터 관리자 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `ADMIN`이 AI 캐릭터용 Member로 로그인하지 않고 `characterId` 기준으로 캐릭터, 콘텐츠, 시리즈, 커뮤니티, FanTalk 답변을 안전하게 대리 관리하는 신규 v2 관리자 API를 구현한다. + +**Architecture:** 신규 외부 경계는 `/api/v2/admin/ai-characters` 하위 controller/facade/application에 둔다. 공통 target resolver가 `characterId -> ChatCharacter.creatorMember`를 해석하고 `CREATOR + AI_CHARACTER` 불변식과 ownership을 먼저 검증한 뒤, 각 domain vertical slice가 기존 entity/repository/S3/CloudFront/event 컴포넌트를 테스트로 고정해 선택적으로 재사용한다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring Security, JPA/Hibernate, QueryDSL, MySQL, Gradle Wrapper, JUnit5. + +--- + +## Source of Truth +- 요구사항 원본: `.omx/specs/deep-interview-ai-character-admin-api.md` +- 2026-07-24 후속 확정 정책: 신규 prefix는 JWT `ROLE_ADMIN` + 현재 DB `Member.role == ADMIN` 이중 인가를 적용하고, + stale ADMIN claim은 403으로 거부한다. 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status + + `ApiResponse.error` + `Accept-Language` 기반 KO/EN/JA message를 반환한다. 이 후속 정책이 원본과 충돌하면 후속 정책을 + 우선한다. +- 2026-07-25 후속 확정 정책: 신규 prefix의 CORS는 현재 코드에 정의된 캐릭터 관리자 frontend Origin + `http://localhost:8888`, `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 + 허용한다. 기존 범용 관리자 frontend와 creator frontend Origin은 허용하지 않는다. +- 2026-07-25 2차 리뷰 후속 확정 정책: 공유 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자 + Origin의 합집합을 path-specific으로 허용한다. CORS 정책 거부 403 body는 API 오류 envelope 계약에서 제외한다. 신규 prefix의 + 406은 `common.error.invalid_request`, `MissingPathVariableException`은 500 `common.error.unknown`으로 처리하고, 405 `Allow`와 + 415 `Accept` 표준 header를 유지한다. +- 문서 작성 규칙: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/테스트스타일.md` +- 기존 AI 캐릭터 연결 문서: `docs/20260611_AI캐릭터_크리에이터기능_최소연결/{prd.md,plan-task.md}` + +## Endpoint Contract Summary + +신규 API prefix는 `/api/v2/admin/ai-characters`로 한다. 기존 `/admin/*`, `/creator-admin/*`, 공개 +`/api/v2/creator-channels/*`의 성공·오류 status/body/message 계약은 변경하지 않는다. + +모든 성공 응답은 기존 관례처럼 `ApiResponse.ok(...)` wrapper를 사용한다. API application/controller/security filter 오류는 +오류 의미에 맞는 HTTP status와 `ApiResponse.error(...)` wrapper를 사용한다. + +`characterId`는 target resource endpoint의 외부 대상 식별자다. 캐릭터 목록/검색은 선택된 target이 없어 `characterId`를 받지 않고, 캐릭터 생성은 새 `ChatCharacter`를 만드는 endpoint라 path `characterId`를 받지 않는다. + +모든 목록/검색 endpoint는 `page` 기본값 0, `size` 기본값 20, 최소 20, 최대 50 보정을 적용하고 경계값 테스트를 둔다. + +```json +{ + "success": true, + "message": null, + "data": {} +} +``` + +공통 API 오류 응답은 `success=false`, 현지화된 `message`, `data=null`, `errorProperty=null`을 포함하고 2xx로 normalize하지 않는다. +`Accept-Language: ko|en|ja`에 따라 KO/EN/JA를 반환하며, 없거나 지원하지 않는 언어는 KO로 fallback한다. security filter 단계도 +MVC interceptor에 의존하지 않고 header를 직접 해석한다. + +| 오류 | HTTP status | message key | +|---|---:|---| +| JWT 없음·잘못됨·만료·폐기 | 401 | `common.error.bad_credentials` | +| JWT role 비ADMIN | 403 | `common.error.access_denied` | +| JWT ADMIN + 현재 DB role 비ADMIN stale claim | 403 | `common.error.access_denied` | +| request binding·target 미존재·creatorMember 누락·role/memberKind 불변식 위반 | 400 | `common.error.invalid_request` | +| 신규 prefix 미매핑 경로 | 404 | `common.error.invalid_request` | +| 지원하지 않는 HTTP method | 405 | `common.error.invalid_request` | +| 지원하지 않는 응답 media type | 406 | `common.error.invalid_request` | +| 지원하지 않는 요청 media type | 415 | `common.error.invalid_request` | +| `MissingPathVariableException`·예상하지 못한 서버 오류 | 500 | `common.error.unknown` | + +405 응답은 표준 `Allow` header를, 415 응답은 표준 `Accept` header를 유지한다. + +```json +{ + "success": false, + "message": "Invalid request.", + "data": null, + "errorProperty": null +} +``` + +신규 prefix는 캐릭터 관리자 Origin `http://localhost:8888`, `https://test-character-admin.sodalive.net`, +`https://character-admin.sodalive.net`만 허용한다. 공유 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 +관리자 Origin의 합집합만 path-specific으로 허용하며, 다른 legacy/public 경로의 허용 범위는 변경하지 않는다. 허용되지 않은 +Origin, method 또는 header가 Spring CORS 계층에서 403으로 정책 거부되면 handler 진입 전 종료되는 브라우저 보안 경계이므로 +그 응답의 body, content type, 현지화 및 `ApiResponse.error` envelope는 외부 계약으로 고정하지 않는다. + +Phase 2~6에서 추가되는 domain/client/server 오류는 구현 전에 각 Task에서 정확한 비2xx status와 KO/EN/JA message key를 +고정하고 같은 envelope를 적용한다. 신규 prefix 전용 오류 처리는 legacy/public endpoint에 적용하지 않는다. + +#### 캐릭터 목록/검색 + +`GET /api/v2/admin/ai-characters?search=루나&page=0&size=20` + +Query parameters: + +```json +{ + "search": "루나", + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "characterId": 101, + "name": "루나", + "description": "달빛을 좋아하는 AI 캐릭터", + "imageUrl": "https://cdn.example.com/characters/luna.png", + "creatorMemberId": 9001, + "creatorNickname": "루나", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z" + } + ] +} +``` + +#### 캐릭터 상세 + +`GET /api/v2/admin/ai-characters/{characterId}` + +Response `data`: + +```json +{ + "characterId": 101, + "name": "루나", + "description": "달빛을 좋아하는 AI 캐릭터", + "imageUrl": "https://cdn.example.com/characters/luna.png", + "creatorMemberId": 9001, + "creatorNickname": "루나", + "creatorProfileImageUrl": "https://cdn.example.com/characters/luna.png", + "creatorIntroduce": "달빛을 좋아하는 AI 캐릭터", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 캐릭터 생성 + +`POST /api/v2/admin/ai-characters` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "name": "루나", + "description": "달빛을 좋아하는 AI 캐릭터", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": true + } +} +``` + +Response `data`: 캐릭터 상세와 동일하다. + +#### 캐릭터 수정/비활성화 + +`PUT /api/v2/admin/ai-characters/{characterId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "name": "루나", + "description": "수정된 소개", + "originalWorkId": 31, + "externalCharacterId": "ext-luna-001", + "isActive": false + } +} +``` + +Response `data`: 캐릭터 상세와 동일하다. `isActive=false`는 soft delete 의미다. + +#### 오디오 콘텐츠 목록/검색 + +`GET /api/v2/admin/ai-characters/{characterId}/audio-contents?search=밤&status=OPEN&page=0&size=20` + +Query parameters: + +```json +{ + "search": "밤", + "status": "OPEN", + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "contentId": 501, + "title": "밤 산책", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", + "price": 1000, + "isAdult": false, + "isActive": true, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "status": "OPEN" + } + ] +} +``` + +#### 오디오 콘텐츠 상세 + +`GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + +Response `data`: + +```json +{ + "contentId": 501, + "title": "밤 산책", + "description": "조용한 밤 산책 오디오", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "audioSignedUrl": "https://cdn.example.com/signed/audio/501.m4a?Expires=...", + "price": 1000, + "isAdult": false, + "isActive": true, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "status": "OPEN", + "seriesIds": [701], + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 오디오 콘텐츠 생성 + +`POST /api/v2/admin/ai-characters/{characterId}/audio-contents` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "coverImage": "File", + "audioFile": "File", + "request": { + "title": "밤 산책", + "description": "조용한 밤 산책 오디오", + "price": 1000, + "isAdult": false, + "isActive": true, + "releaseDateUtc": "2026-07-25T00:00:00Z", + "seriesIds": [701] + } +} +``` + +Response `data`: 오디오 콘텐츠 상세와 동일하다. + +#### 오디오 콘텐츠 수정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "coverImage": "File | optional", + "audioFile": "File | optional", + "request": { + "title": "밤 산책 수정", + "description": "수정된 설명", + "price": 1200, + "isAdult": false, + "isActive": false, + "releaseDateUtc": null, + "seriesIds": [701] + } +} +``` + +Response `data`: 오디오 콘텐츠 상세와 동일하다. + +#### 시리즈 목록 + +`GET /api/v2/admin/ai-characters/{characterId}/series?page=0&size=20` + +Query parameters: + +```json +{ + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "seriesId": 701, + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "coverImageUrl": "https://cdn.example.com/series/701.png", + "genreId": 3, + "isAdult": false, + "state": "OPEN", + "isActive": true, + "orders": 1 + } + ] +} +``` + +#### 시리즈 상세 + +`GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` + +Response `data`: + +```json +{ + "seriesId": 701, + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "coverImageUrl": "https://cdn.example.com/series/701.png", + "publishedDaysOfWeek": ["MONDAY", "WEDNESDAY"], + "genreId": 3, + "keywords": ["밤", "산책"], + "isAdult": false, + "state": "OPEN", + "isActive": true, + "writer": "루나", + "studio": "소다라이브", + "orders": 1 +} +``` + +#### 시리즈 생성 + +`POST /api/v2/admin/ai-characters/{characterId}/series` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "title": "루나의 밤", + "introduction": "밤을 주제로 한 시리즈", + "publishedDaysOfWeek": ["MONDAY", "WEDNESDAY"], + "genreId": 3, + "keywords": ["밤", "산책"], + "isAdult": false, + "state": "OPEN", + "writer": "루나", + "studio": "소다라이브" + } +} +``` + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 수정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/series/{seriesId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "request": { + "title": "루나의 밤 수정", + "introduction": "수정된 소개", + "publishedDaysOfWeek": ["FRIDAY"], + "genreId": 3, + "keywords": ["밤"], + "isAdult": false, + "state": "OPEN", + "isActive": false, + "writer": "루나", + "studio": "소다라이브" + } +} +``` + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 콘텐츠 조회 + +`GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents?search=밤&page=0&size=20` + +Query parameters: + +```json +{ + "search": "밤", + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "contentId": 501, + "title": "밤 산책", + "coverImageUrl": "https://cdn.example.com/audio/501-cover.png", + "isAdult": false, + "orders": 1 + } + ] +} +``` + +#### 시리즈 콘텐츠 연결 + +`POST /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` + +Request body: + +```json +{ + "contentIds": [501, 502] +} +``` + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 콘텐츠 연결 해제 + +`DELETE /api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` + +Request body 없음. + +Response `data`: 시리즈 상세와 동일하다. + +#### 시리즈 순서 변경 + +`PUT /api/v2/admin/ai-characters/{characterId}/series/orders` + +Request body: + +```json +{ + "seriesIds": [701, 702, 703] +} +``` + +Response `data`: 시리즈 목록과 동일하다. + +#### 커뮤니티 게시글 목록 + +`GET /api/v2/admin/ai-characters/{characterId}/community-posts?page=0&size=20` + +Query parameters: + +```json +{ + "page": 0, + "size": 20 +} +``` + +Response `data`: + +```json +{ + "totalCount": 1, + "page": 0, + "size": 20, + "hasNext": false, + "items": [ + { + "postId": 801, + "content": "오늘의 소식입니다.", + "imageUrl": "https://cdn.example.com/community/801.png", + "audioSignedUrl": null, + "price": 0, + "isAdult": false, + "isFixed": true, + "fixedAtUtc": "2026-07-24T00:00:00Z", + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z" + } + ] +} +``` + +#### 커뮤니티 게시글 등록 + +`POST /api/v2/admin/ai-characters/{characterId}/community-posts` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "audioFile": "File | optional", + "request": { + "content": "오늘의 소식입니다.", + "price": 0, + "isAdult": false, + "isFixed": false, + "isActive": true + } +} +``` + +Response `data`: + +```json +{ + "postId": 801, + "content": "오늘의 소식입니다.", + "imageUrl": "https://cdn.example.com/community/801.png", + "audioSignedUrl": null, + "price": 0, + "isAdult": false, + "isFixed": false, + "fixedAtUtc": null, + "isActive": true, + "createdAtUtc": "2026-07-24T00:00:00Z", + "updatedAtUtc": "2026-07-24T00:00:00Z" +} +``` + +#### 커뮤니티 게시글 수정/고정/soft delete + +`PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` + +Content-Type: `multipart/form-data` + +Form fields: + +```json +{ + "image": "File | optional", + "audioFile": "File | optional", + "request": { + "content": "수정된 소식입니다.", + "price": 0, + "isAdult": false, + "isFixed": false, + "isActive": false + } +} +``` + +Response `data`: 커뮤니티 게시글 등록 응답과 동일하다. `isActive=false`이면 `isFixed=false`, `fixedAtUtc=null`이어야 한다. + +#### FanTalk 답변 작성 + +`POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` + +Request body: + +```json +{ + "content": "응원해줘서 고마워요!" +} +``` + +Response `data`: + +```json +{ + "fanTalkId": 901, + "replyId": 902, + "creatorMemberId": 9001, + "content": "응원해줘서 고마워요!", + "createdAtUtc": "2026-07-24T00:00:00Z" +} +``` + +--- + +### Phase 1: 공통 ADMIN 인증과 AI 캐릭터 target resolver 기반 + +#### 공통 Task 실행 규칙 +- 각 구현 Task는 `RED: 실패 테스트 작성/실패 확인`, `GREEN: 최소 구현/통과 확인`, `REFACTOR: 정리/회귀 확인`을 포함한다. +- 테스트 작성이 현실적으로 불가능한 검증 전용 Task는 `TDD 예외 사유`와 `대체 검증 방법`을 Task에 명시한다. +- 기존 business method 재사용 전 특성화/회귀 테스트는 신규 v2 use-case RED 테스트와 분리한다. 특성화 테스트는 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해 baseline을 고정하고, 그 결과를 신규 v2 RED 기대값으로 옮긴다. +- Phase 2~6의 모든 신규 오류 RED는 정확한 비2xx status, `ApiResponse.error` shape, `Accept-Language` KO/EN/JA message를 + 함께 검증한다. 새 오류 분기는 status, message key, 3개 언어 message, 테스트가 모두 정해지기 전 완료 처리하지 않는다. + +#### 목표 +모든 신규 API가 공유할 JWT ADMIN + 현재 DB ADMIN 이중 인가, prefix 전용 오류 envelope/i18n, `characterId` 기반 target 해석, +`CREATOR + AI_CHARACTER` 불변식, ownership no-side-effect 검증 기반을 만든다. + +#### 범위와 비범위 +- 포함: 신규 v2 admin package 골격, JWT role + 현재 DB role 이중 인가, prefix 전용 security/application 오류 처리, 공통 target + resolver/use-case, no-side-effect 테스트 fixture. +- 제외: 캐릭터/콘텐츠/시리즈/커뮤니티/FanTalk 실제 domain 기능 구현. + +#### 선행 Phase 및 의존성 +- 선행 Phase 없음. +- 기존 `ChatCharacter.creatorMember`, `MemberRole.CREATOR`, `MemberKind.AI_CHARACTER`가 존재해야 한다. + +#### API endpoint와 request/response contract +- 모든 후속 endpoint에 공통 적용한다. +- target resource request는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 principal과 path/query/body의 + `characterId`를 받는다. 캐릭터 목록/검색과 생성은 Endpoint Contract Summary의 예외를 따른다. +- resolver output은 내부 전용 `AiCharacterAdminTarget(characterId, chatCharacter, creatorMember)`로 계획한다. +- Phase 1 API 실패 응답은 Endpoint Contract Summary의 400/401/403/404/405/406/415/500과 + `ApiResponse.error`/KO·EN·JA 계약을 따르며 domain side effect가 없어야 한다. Spring CORS 정책 거부 403 body는 해당 + envelope 계약의 예외다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `ChatCharacterRepository`에 `characterId`로 `creatorMember`를 `LEFT JOIN FETCH`하는 query를 추가해 반환 직후 + 연관 객체가 초기화되도록 한다. +- Service/Application: `AiCharacterAdminTargetResolver` 생성. +- Controller/Facade: `kr.co.vividnext.sodalive.v2.api.admin.aicharacter` 하위 신규 controller/facade 패키지 생성. + +#### DB migration +- 없음. 신규 DDL, 신규 migration 파일을 만들지 않는다. + +#### transaction과 concurrency 고려사항 +- target resolver는 read-only transaction으로 동작한다. +- 후속 write use-case는 target 검증을 write transaction 시작부에서 먼저 수행한다. +- target 검증 실패는 transaction rollback-only가 필요 없는 사전 실패로 끝나야 한다. + +#### 보안 및 개인정보 위험 +- JWT `ROLE_ADMIN`만 신뢰하지 않고 `TokenProvider`가 이미 조회한 `MemberAdapter.member.role`을 신규 prefix 인가에서 함께 + 확인한다. `TokenProvider`의 전역 authority 계산은 변경하지 않는다. +- JWT ADMIN + 현재 DB 비ADMIN stale claim과 `MemberAdapter`가 아닌 principal은 target resolver 실행 전에 403으로 거부한다. +- `creatorMember`를 인증 principal로 교체하지 않는다. +- `creatorMemberId`를 관리자 입력값으로 신뢰하지 않는다. +- Phase 1 resolver의 잘못된 target 요청은 Hibernate 통계로 DB insert/update/delete 0건을 검증한다. resolver는 S3, 외부 API, + 이벤트 발행 의존성을 갖지 않으며 Phase 2~6 write slice에서 각 외부 부작용 0건을 별도 검증한다. + +#### acceptance criteria +- JWT ADMIN + 현재 DB ADMIN 요청만 유효한 AI character target을 resolver로 해석할 수 있다. +- JWT 없음·잘못됨·만료·폐기는 401, JWT 비ADMIN 또는 현재 DB 비ADMIN은 403, character 미존재·creatorMember 미존재·target + role/memberKind 불일치는 400이다. +- 위 API 오류는 모두 `ApiResponse.error`와 `Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 정책 거부 403 + body는 해당 envelope 계약의 예외다. +- 405는 `Allow`, 415는 `Accept` header를 유지하고, 지원하지 않는 응답 media type은 406 + `common.error.invalid_request`, `MissingPathVariableException`은 500 `common.error.unknown`으로 반환한다. +- Phase 1 resolver 실패는 DB insert/update/delete가 0건이다. S3, 외부 API, 이벤트 부작용은 해당 의존성이 처음 도입되는 + Phase 2~6 write slice에서 검증한다. + +#### targeted test +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` +- Integration Test: + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` +- RED: resolver 미구현 상태에서 `shouldResolveAiCharacterCreatorMemberForAdminTarget`, `shouldRejectMissingCharacterWithoutSideEffect`, `shouldRejectHumanCreatorMemberWithoutSideEffect` 테스트를 작성해 실패를 확인한다. +- GREEN: resolver와 최소 repository query를 구현해 resolver 테스트를 통과시킨다. +- Controller 권한 테스트: `AiCharacterAdminAuthorizationTest`에서 JWT role × 현재 DB role 매트릭스와 stale claim을 고정하고, + Phase 2~6 controller test에서 신규 endpoint 전체가 같은 이중 인가를 공유하는지 검증한다. +- 오류 계약 테스트: `AiCharacterAdminErrorContractTest`에서 400/401/403/404/405/406/415/500, 405 `Allow`, 415 `Accept`, JWT + filter 예외, KO/EN/JA body와 legacy fallback을 검증한다. CORS는 미매핑 fallback뿐 아니라 실제 mapped endpoint와 + `/admin/member/login`, `/member/logout`의 허용·거부 Origin/preflight를 검증하고, 정책 거부 403 body에는 envelope를 요구하지 + 않는다. +- Run: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` + +#### 전체 회귀 테스트 영향 +- 기존 endpoint를 건드리지 않아야 한다. +- 신규 package 추가로 component scan과 security 설정 충돌이 없어야 한다. + +#### rollback 전략 +- 신규 v2 admin controller/facade/resolver/error/security package와 관련 테스트를 제거한다. +- `SecurityConfig.kt`, `WebConfig.kt`, `ExceptionHandlerFilter.kt`, `TokenProvider.kt`, `ChatCharacterRepository.kt`의 Phase 1 변경을 + 함께 되돌린다. +- DB rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin target resolver` + +- [x] **Task 1.1: resolver RED 테스트 작성** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` + - RED: 유효 target, missing character, wrong role, wrong memberKind, missing creatorMember, cross-owner fixture를 먼저 작성하고 실패를 확인한다. + - GREEN: 구현 전 Task라 production code를 변경하지 않는다. + - REFACTOR: fixture 중복만 정리하고 테스트 의미는 약화하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` + - 검증 기록: 무엇: resolver RED 테스트. 왜: resolver와 `findByIdWithCreatorMember` 미구현을 실제 실패로 고정하기 위해. 어떻게: + 위 명령을 실행했다. 결과: `compileTestKotlin`이 두 미구현 항목으로 실패했다. + +- [x] **Task 1.2: resolver 최소 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` + - RED: Task 1.1 실패 테스트가 같은 실패 이유로 남아 있음을 확인한다. + - GREEN: `characterId`로 `ChatCharacter`와 `creatorMember`를 조회하고 strict validation을 적용한다. + - REFACTOR: resolver/repository naming과 예외 메시지를 인접 v2 관례에 맞추고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest` + - 검증 기록: 무엇: resolver 최소 구현과 repository/ownership 통합 검증. 왜: mock 기반 테스트만으로 실제 조회 동작을 확인할 수 + 없었기 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest`와 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest`를 실행했다. + 결과: 두 실행 모두 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.3: ADMIN 권한 controller smoke 테스트** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt` + - RED: 최소 sample controller 또는 Phase 2 첫 controller 기준 anonymous/non-admin 접근 실패와 admin 접근 성공 테스트를 먼저 작성해 실패를 확인한다. + - GREEN: 공통 security 설정 또는 controller annotation을 최소 구현해 테스트를 통과시킨다. + - REFACTOR: Phase 2~6의 모든 신규 endpoint controller test가 같은 권한 매트릭스를 따르도록 test helper를 정리한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` + - 검증 기록: 무엇: sample route ADMIN 권한 smoke. 왜: 신규 prefix의 ADMIN rule 적용 전후를 확인하기 위해. 어떻게: 위 명령으로 + 권한 테스트를 실행했다. 결과: 적용 전 권한 실패를 확인했고, `/api/v2/admin/ai-characters/**` ADMIN rule 적용 후 + `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.4: JWT claim + 현재 DB ADMIN 이중 인가** + - Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberAdapter.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt` + - RED: `UsernamePasswordAuthenticationToken(MemberAdapter(currentMember), token, jwtAuthorities)` fixture로 JWT ADMIN + DB ADMIN + 200, JWT 비ADMIN + DB ADMIN 403, JWT ADMIN + DB 비ADMIN stale claim 403, ADMIN authority + 비`MemberAdapter` principal + 403을 작성한다. stale claim은 현재 구현에서 200이므로 이 실패를 확인한다. + - GREEN: 신규 prefix의 matcher 하나에서 아래 세 조건을 AND로 묶고 `MemberAdapter`, legacy matcher는 변경하지 않는다. + `TokenProvider`는 JWT subject 누락/비숫자 값이 500으로 누수되지 않도록 `common.error.bad_credentials`로만 보정하며, + 전역 authority 계산과 token 저장소 검증 의미는 변경하지 않는다. + + ```kotlin + .antMatchers("/api/v2/admin/ai-characters/**") + .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" + ) + ``` + + - REFACTOR: production `SecurityConfig` matcher의 비확산은 full-context `AiCharacterAdminAuthorizationTest`의 + `/phase1-legacy-sample`로 확인한다. 기존 `AdminAgentReadControllerSecurityTest`, `AdminContentControllerSecurityTest`는 각자의 + 자체 security chain을 사용하는 controller 회귀 증거로 구분하며 production matcher 비확산의 증거로 해석하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest` + - 검증 기록: 무엇: JWT claim과 현재 DB ADMIN 이중 인가. 왜: stale ADMIN claim과 비`MemberAdapter` principal이 허용되면 안 + 되기 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest`를 + 실행했다. 결과: 6개 중 해당 2개가 403 기대 대비 200으로 실패한 RED를 확인했고, 세 조건을 AND로 적용한 뒤 같은 명령이 + `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.5: 신규 prefix 오류 envelope/status/i18n 계약** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt` + - Confirm: `src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt` + - RED: legacy anonymous/revoked-token/지원하지 않는 method 오류의 현재 status/body를 먼저 통과하는 특성화 baseline으로 + 고정한다. 신규 prefix의 anonymous·잘못된 JWT·폐기 JWT 401, JWT/DB role 불충족 403, invalid request/target 400, 미매핑 경로 + 404, 지원하지 않는 method 405, 지원하지 않는 media type 415, 예상하지 못한 controller/JWT filter 오류 500을 KO/EN/JA로 + parameterized 검증한다. 확정된 캐릭터 관리자 Origin의 404/405/415와 실제 JWT header를 요청하는 미매핑 경로 + preflight에 CORS 응답 header가 적용되는지 확인한다. 허용된 Origin의 API 오류는 `success=false`, localized `message`, JSON + content type을 확인하고, 기존 범용 관리자와 creator frontend Origin의 CORS 정책 거부는 403과 CORS 허용 header 부재만 + 확인한다. 현재 `sendError`, hardcoded KO, 기본 Spring error body 때문에 실패하는 것을 확인한다. + - GREEN: `AiCharacterAdminErrorResponseWriter`가 `Lang.fromAcceptLanguage`와 `SodaMessageSource`로 `ApiResponse.error`를 만들고 + JSON을 기록하게 한다. `AiCharacterAdminSecurityErrorHandler`는 401 `common.error.bad_credentials`와 403 + `common.error.access_denied`를 위 writer에 위임한다. `SecurityConfig`는 신규 prefix matcher에만 이 handler를 선택하고 기존 + `JwtAuthenticationEntryPoint`/`JwtAccessDeniedHandler`를 fallback으로 유지한다. `ExceptionHandlerFilter`도 신규 prefix에서 잡은 + JWT 예외만 신규 401 handler로 위임하고 legacy branch는 그대로 둔다. + - GREEN: target resolver는 400 + `common.error.invalid_request`를 가진 `AiCharacterAdminApiException`을 던진다. URI matcher로 신규 + prefix에만 적용되는 `AiCharacterAdminExceptionHandler`는 controller 선택 전 오류까지 처리해 명시적 API 예외, request binding + 400, method 405, media type 415, controller `AccessDeniedException` 403, 예상하지 못한 오류 500을 각각 정확한 status와 + localized `ApiResponse.error`로 반환한다. + 낮은 우선순위의 prefix fallback handler는 미매핑 경로를 404로 반환하고 캐릭터 관리자 Origin 전용 CORS 설정을 적용한다. + 단순 + `SodaException` 교체나 전역 + `SodaExceptionHandler` 변경은 하지 않는다. + - GREEN: `ExceptionHandlerFilter`가 잡은 폐기 JWT 등 알려진 인증 실패만 401로 보내고, JWT 처리 중 예상하지 못한 예외는 위 + prefix exception handler에 위임해 500 `common.error.unknown`으로 반환한다. + - REFACTOR: raw message key 노출, MVC `LangInterceptor` 의존, legacy 오류 응답 변경이 없는지 확인하고 신규/legacy contract + 테스트를 함께 재실행한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAccessDeniedErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` + - 검증 기록: 무엇: 신규 prefix 오류 envelope/status/i18n 계약. 왜: 신규 401/403/400/500 응답이 기존 body·message와 달랐기 + 때문이다. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest`를 + 실행했다. 결과: 최초 15개 중 13개의 status/content type/message 불일치 RED를 확인했고, prefix 기반 handler와 writer 적용 후 + 44개 invocation이 모두 통과해 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.6: Phase 1 코드 리뷰 후속 보완** + - Modify: `docs/20260724_AI캐릭터_관리자_API/prd.md`, + `docs/20260724_AI캐릭터_관리자_API/plan-task.md`, + `src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt` + - RED: 서명과 subject는 유효하지만 `auth` claim이 누락, 공백 또는 빈 분할 항목을 포함한 JWT가 + `common.error.bad_credentials`로 거부되지 않는 테스트를 작성하고 실패를 확인한다. + - GREEN: `TokenProvider`가 `auth` claim을 authority로 변환하기 전에 문자열 타입, 공백 여부와 각 분할 항목을 검증하고 + 잘못된 claim은 `common.error.bad_credentials`로 거부하는 최소 구현을 적용한다. + - REFACTOR: Phase 1 테스트 전용 controller를 각 테스트 클래스 내부 nested class로 이동하고 `@TestComponent`로 + component scan에서 제외한 뒤 해당 테스트 context에만 명시적으로 import한다. 테스트 fixture 범위만 바꾸는 구조 + 정리이므로 별도 동작 RED 대신 targeted/full-context 회귀와 application context 시작 성공으로 검증한다. + - CORS: 현재 코드의 캐릭터 관리자 Origin `http://localhost:8888`, + `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용하고 기존 범용 + 관리자/creator Origin은 거부하는 정책으로 PRD/plan과 CORS 계약 테스트를 동기화한다. + - 2차 리뷰 RED: 신규 prefix의 실제 mapped endpoint와 공유 `/admin/member/login`, `/member/logout`에서 캐릭터 관리자 Origin + 요청/preflight가 허용되지 않는 실패를 확인한다. 응답 media type 협상 실패 406, 405 `Allow` header, 415 `Accept` header, + `MissingPathVariableException` 500 계약 테스트를 추가해 현재 동작과의 불일치를 확인한다. + - 2차 리뷰 GREEN: 신규 prefix는 캐릭터 관리자 Origin만 허용하는 기존 정책을 유지하고, 두 공유 인증 경로에만 기존 전역 + Origin과 캐릭터 관리자 Origin의 합집합을 적용한다. `AiCharacterAdminExceptionHandler`는 406을 + `common.error.invalid_request`, `MissingPathVariableException`을 500 `common.error.unknown`으로 분류하고 405/415 표준 + header를 보존한다. Spring CORS 정책 거부 403 body는 localized `ApiResponse.error` envelope 계약에서 제외한다. + - 2차 리뷰 REFACTOR: 실제 mapped endpoint, 두 공유 인증 경로와 미매핑 fallback의 허용·거부 Origin/preflight를 함께 + 회귀하고, path-specific CORS 확장이 다른 legacy/public 경로로 확산되지 않았는지 확인한다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` + - 검증 기록: 무엇: malformed `auth` claim, CORS, HTTP 오류, 테스트 fixture 격리 후속 보완. 왜: claim 누수와 실제 mapped/shared + path CORS·405/406/415/500 계약 누락을 해소하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest`를 실행했다. 결과: malformed claim 7개 invocation RED 후 + 9개가 통과했고, fallback 보완 후 targeted+legacy 136개가 모두 통과해 `BUILD SUCCESSFUL`이었다. 추가 + 로그인/로그아웃 CORS 보완 뒤 `AiCharacterAdminLoginJwtIntegrationTest`도 `BUILD SUCCESSFUL`이었다. + +- [x] **Task 1.7: Phase 1 후속 리뷰 전체 반영** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, + `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt`, + `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt` + - RED: full-context `AiCharacterAdminLoginJwtIntegrationTest`에 허용 Origin의 `PROPFIND` 400/i18n/CORS, raw double slash 400/CORS, + 미허용 Origin 403/`Access-Control-Allow-Origin` 부재와 legacy `RequestRejectedException` 유지 계약을 추가하고, 신규 prefix 세 + 요청이 `RequestRejectedException`으로 실패하며 legacy fallback은 통과하는 production-before RED를 확인한다. + - GREEN: `SecurityConfig`가 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하고, handler는 + 신규 prefix에만 400/CORS 계약을 적용한다. `setUnsafeAllowAnyHttpMethod(true)` 없이 + 허용된 캐릭터 관리자 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`를, + 미허용 Origin에는 기존 Spring CORS 정책과 같은 body 계약 없는 403을 반환한다. legacy/public은 + `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을 유지한다. Spring 5.3의 비표준 method enum + 한계는 CORS 검사 request에만 `GET` wrapper를 사용해 우회하고 실제 firewall method 허용 범위는 확장하지 않는다. + - REFACTOR: `AiCharacterAdminErrorContractTest`, `AiCharacterAdminAuthorizationTest`, + `AiCharacterAdminAccessDeniedErrorContractTest`를 production `@SpringBootTest` + MockMvc + EmbeddedRedis full context로 전환한다. + 기존 ErrorContract의 표준 `POST` -> GET-only mapping 405, `Allow: GET`, CORS 계약도 full context에서 회귀한다. + `AiCharacterAdminLoginJwtIntegrationTest`는 `MemberTokenRepository.deleteAll()`을 `@AfterEach`에 실행해 Redis token fixture를 + cleanup한다. `AiCharacterAdminTargetResolverIntegrationTest`는 repository 조회 직후 + `Hibernate.isInitialized(found.creatorMember)`를 단언해 production `LEFT JOIN FETCH`가 실제 회귀 방지에 필요함을 고정한다. + Phase 1 production에는 Bean Validation provider를 추가하지 않고, `MethodArgumentNotValidException`은 test-only endpoint에서 + 의존성 없이 직접 던져 handler 분기를 검증한다. 이 Task의 실행 명령과 결과는 먼저 이 Task 아래에 기록하고, phase/전체 + aggregate만 문서 하단 검증 기록에 누적한다. + - Verify: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` + - 검증 기록(RED): 무엇: 신규 prefix firewall와 legacy fallback 계약. 왜: production firewall 거부가 신규 API 오류 계약 밖으로 + 탈출하는지 확인하기 위해. 어떻게: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest`를 + 실행했다. 결과: 17개 중 신규 prefix 3개가 `RequestRejectedException`으로 실패했고 legacy fallback 테스트는 통과했다. + - 검증 기록(GREEN): 무엇: prefix-aware global `RequestRejectedHandler`. 왜: 신규 prefix만 400/i18n/CORS로 변환하고 legacy/public + 동작을 보존하기 위해. 어떻게: RED와 동일한 명령을 실행했다. 결과: 17/17, `BUILD SUCCESSFUL`을 확인했다. + - 검증 기록(REFACTOR): 무엇: 네 core controller security/error 클래스의 production full-context 계약과 Redis fixture 격리. + 왜: slice 설정이 아닌 실제 security/CORS/filter 구성을 검증하기 위해. 어떻게: + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAccessDeniedErrorContractTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest`를 + 실행했다. 결과: 115/115, failure/error 0, `BUILD SUCCESSFUL`을 확인했다. + - 검증 기록(FETCH JOIN): 무엇: repository 조회 직후 `creatorMember` 초기화의 non-vacuous 회귀 계약. 왜: resolver transaction + 내부 접근만으로 fetch join 누락이 가려지는 것을 막기 위해. 어떻게: production query의 `LEFT JOIN FETCH`를 임시로 + `LEFT JOIN`으로 바꾸고 + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest`를 실행했다. + 결과: 4개 중 fetch 테스트 1개가 line 66에서 실패해 `BUILD FAILED`(38초)을 확인했다. 즉시 `LEFT JOIN FETCH`를 복원했고, + 복원 상태는 하단 최신 canonical 154/154에 포함되어 통과했다. + - 검증 기록(직접 400 분기): 무엇: malformed JSON의 `HttpMessageNotReadableException`, test-only endpoint에서 의존성 없이 직접 + 던진 `MethodArgumentNotValidException`, 실제 multipart 필수 part 누락의 `MissingServletRequestPartException` 각 KO/EN/JA 총 + 9 invocation. 왜: exact `resolvedException` 타입과 localized 400 envelope를 각 handler 분기에서 직접 고정하기 위해. 어떻게: + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest' --rerun-tasks`를 실행했다. + 결과: 98/98, failure/error/skipped 0, `BUILD SUCCESSFUL`을 확인했고 production/build dependency 변경은 없었다. + +--- + +### Phase 2: AI 캐릭터 관리 vertical slice + +#### 목표 +AI 캐릭터 목록/검색/상세/생성/수정/비활성화를 신규 ADMIN v2 API로 제공하고 레거시 관리자 동작 parity를 고정한다. + +#### 범위와 비범위 +- 포함: character CRUD API, 외부 캐릭터 API 연동, 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, creatorMember 생성/표시 정보 동기화 parity. +- 제외: hard delete, cascade delete, 기존 legacy admin endpoint 변경. + +#### 선행 Phase 및 의존성 +- Phase 1 resolver와 ADMIN 권한 기반이 선행되어야 한다. +- 기존 `ChatCharacterService`, `ChatCharacterCreatorMemberService`, image/S3/event 관련 컴포넌트 동작을 특성화해야 한다. + +#### API endpoint와 request/response contract +- `GET /api/v2/admin/ai-characters?search=&page=&size=` -> `AiCharacterAdminListResponse(totalCount, items, page, size, hasNext)` +- `GET /api/v2/admin/ai-characters/{characterId}` -> `AiCharacterAdminDetailResponse` +- `POST /api/v2/admin/ai-characters` multipart `image?`, `request: CreateAiCharacterAdminRequest` -> detail +- `PUT /api/v2/admin/ai-characters/{characterId}` multipart `image?`, `request: UpdateAiCharacterAdminRequest` -> detail +- `UpdateAiCharacterAdminRequest.isActive=false`는 soft delete 의미다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: character 목록/검색용 query adapter 추가 가능. +- Service: 신규 `AiCharacterAdminCharacterFacade/ApplicationService`에서 기존 하위 service를 선택적으로 재사용한다. +- DTO: 신규 admin v2 전용 request/response DTO 생성. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 생성/수정은 단일 transaction에서 character, relation, creatorMember 표시 정보 동기화를 완료한다. +- 외부 API/S3/event 순서는 기존 레거시 동작 특성화 결과를 따른다. +- 중복 이름 검증은 기존 정책을 유지하며 동시 생성 시 DB/서비스 레벨 실패가 부분 저장을 남기지 않아야 한다. + +#### 보안 및 개인정보 위험 +- 목록/상세 응답에 AI creatorMember 로그인 credential, token, private storage path를 노출하지 않는다. +- ADMIN 외 접근을 허용하지 않는다. + +#### acceptance criteria +- 목록/검색/상세는 AI 캐릭터 관리자 화면에 필요한 필드를 반환한다. +- 생성/수정은 레거시 관리자와 동일한 business side effect를 만든다. +- 비활성화는 `isActive=false`이며 row와 연결 Member/콘텐츠를 삭제하지 않는다. + +#### targeted test +- Characterization: `LegacyChatCharacterAdminCharacterizationTest`에서 기존 character admin create/update/soft delete 결과와 외부 API·S3·event failure order, transaction/compensation 계약을 통과 상태로 고정한다. +- V2 RED/GREEN: `AiCharacterAdminCharacterControllerTest`, `AiCharacterAdminCharacterServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` + +#### 전체 회귀 테스트 영향 +- 기존 `AdminChatCharacterController` 및 public character 조회 응답이 변하지 않아야 한다. +- creatorMember 동기화 기존 테스트가 계속 통과해야 한다. + +#### rollback 전략 +- 신규 character admin v2 route/facade만 제거한다. +- 이미 생성/수정된 정상 데이터는 기존 관리자와 같은 domain 데이터라 별도 schema rollback이 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin character slice` + +- [ ] **Task 2.1: 기존 character parity 특성화 baseline 고정** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/LegacyChatCharacterAdminCharacterizationTest.kt` + - CHARACTERIZE: 기존 character admin 구현을 대상으로 중복 이름, 외부 API·S3·event 호출/실패 순서와 transaction/compensation, original work 연결, 언어 감지/번역 이벤트, creatorMember 표시 정보 동기화, `isActive=false` 및 연결 Member/콘텐츠 미삭제 결과를 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: fixture와 assertion naming만 정리하고 parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.LegacyChatCharacterAdminCharacterizationTest` + +- [ ] **Task 2.2: character controller/facade/DTO 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/character/*` + - RED: Task 2.1 baseline에서 옮긴 `AiCharacterAdminCharacterServiceTest`와 controller 권한/페이지네이션 경계 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - RED: 현재 신규 dependency 금지 제약에 따라 기존 Spring binding 또는 수동 validation 전략을 우선하고, 실제 DTO의 invalid + 요청 통합 계약을 추가한다. Bean Validation provider가 반드시 필요하면 구현 전에 PRD/계획과 dependency 허용 범위를 + 명시적으로 변경하고 승인을 받는다. + - GREEN: endpoint contract summary의 character endpoint를 구현하고 목록/검색 `page/size` 기본값·최소·최대 보정을 적용한다. + - REFACTOR: 신규 DTO가 legacy/public DTO를 외부 계약으로 재노출하지 않는지 확인하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character.*'` + +--- + +### Phase 3: 오디오 콘텐츠 관리와 signed URL vertical slice + +#### 목표 +선택한 AI 캐릭터 소유 오디오 콘텐츠 목록/검색/상세/생성/수정/soft delete와 관리자 재생용 signed URL을 제공한다. + +#### 범위와 비범위 +- 포함: 콘텐츠 owner 검증, 기존 파일 처리/가격/공개/예약/번역/알림 parity, `AudioContentCloudFront` 재사용, private path 비노출. +- 제외: 콘텐츠 구매/좋아요/댓글, content upload/processing pipeline 변경, community audio 30분 정책 통합. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- 콘텐츠 생성/수정/delete 기존 동작 특성화 테스트. + +#### API endpoint와 request/response contract +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents` +- `GET /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` +- `POST /api/v2/admin/ai-characters/{characterId}/audio-contents` +- `PUT /api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` +- response item/detail에는 `contentId`, `title`, `status`, `isActive`, `releaseDate`, `coverImageUrl`, `audioSignedUrl?`, 가격/성인/공개 상태 등 기존 관리자 화면 필드를 포함한다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `AudioContent` owner-scoped query adapter 추가 가능. +- Service: 신규 content admin application service에서 기존 creator/admin content service를 테스트로 고정 후 선택 위임 또는 최소 추출한다. +- CloudFront: 기존 `AudioContentCloudFront` 그대로 주입해 사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- write transaction 시작 직후 target과 `content.member.id == creatorMember.id`를 검증한다. +- S3 업로드, 이벤트 발행 순서는 기존 동작 parity를 따른다. +- soft delete는 기존 콘텐츠 삭제 동작처럼 `isActive=false`, 필요한 경우 `releaseDate=null`을 유지한다. + +#### 보안 및 개인정보 위험 +- private S3 object path, signed key material을 응답하지 않는다. +- 다른 캐릭터 content ID 접근은 4xx와 no side effect다. + +#### acceptance criteria +- target 캐릭터 소유 콘텐츠만 조회/변경된다. +- signed URL 만료 계산이 기존 creator admin policy와 동일하다는 테스트가 있다. +- signed URL 만료 계산·path 처리에서 실제 기존 구현에서 관찰되는 edge case가 특성화 테스트로 고정된다. +- invalid target/ownership 실패 시 DB/S3/event side effect가 없다. + +#### targeted test +- Characterization: `LegacyCreatorAdminAudioContentCharacterizationTest`, `AudioContentCloudFrontCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminAudioContentServiceTest`, `AiCharacterAdminAudioSignedUrlTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` + +#### 전체 회귀 테스트 영향 +- 기존 `CreatorAdminContentController`, `AdminContentController`, public content 조회 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 content v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin content slice` + +- [ ] **Task 3.1: 기존 콘텐츠와 signed URL 특성화 baseline 고정** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/LegacyCreatorAdminAudioContentCharacterizationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AudioContentCloudFrontCharacterizationTest.kt` + - CHARACTERIZE: 기존 creator/admin content 구현을 대상으로 검증, 파일 처리, 가격, 공개/예약, 번역/알림, soft delete, upload/processing pipeline 결과를 고정한다. + - CHARACTERIZE: 기존 creator admin signed URL 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case, private path 비노출 계약을 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 두 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: 테스트 fixture만 정리하고 content/signed URL parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*CharacterizationTest'` + +- [ ] **Task 3.2: content controller/facade/DTO 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/*` + - RED: Task 3.1 baseline에서 옮긴 content parity, cross-character content 접근, signed URL/private path 계약 테스트와 controller 권한/페이지네이션 경계 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - GREEN: content endpoint와 owner-scoped query/write를 구현하고 목록/검색 `page/size` 기본값·최소·최대 보정을 적용한다. + - REFACTOR: signed URL/private path mapping과 기존 pipeline 재사용 경계를 정리하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.*'` + +--- + +### Phase 4: 시리즈 관리 vertical slice + +#### 목표 +선택한 AI 캐릭터 소유 시리즈 CRUD, soft delete, 콘텐츠 연결/해제/검색/순서 관리를 owner-safe v2 경로로 제공한다. + +#### 범위와 비범위 +- 포함: 시리즈 목록/상세/생성/수정/soft delete, 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, owner-scoped 순서 변경, 기존 creator series behavior parity 특성화. +- 제외: 기존 `CreatorAdminContentSeriesController.updateSeriesOrders(ids)` 계약 변경. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- Phase 3 content owner query를 재사용할 수 있다. +- 기존 creator series 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior를 통과하는 특성화 테스트로 먼저 고정해야 한다. + +#### API endpoint와 request/response contract +- `GET/POST/PUT /api/v2/admin/ai-characters/{characterId}/series...` +- `GET /series/{seriesId}/contents` query: `search?`, `page`, `size` +- `POST /series/{seriesId}/contents` request: `AddAiCharacterAdminSeriesContentsRequest(contentIds: List)` +- `DELETE /series/{seriesId}/contents/{contentId}` +- `PUT /series/orders` request: `UpdateAiCharacterAdminSeriesOrdersRequest(seriesIds: List)` + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `Series`와 `SeriesContent` owner-scoped query/update adapter 추가. +- Service: 신규 series application service에서 기존 CRUD 핵심을 테스트 후 재사용하되, 모든 write 전에 series/content owner를 검증한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 순서 변경은 동일 owner의 모든 series ID를 한 transaction에서 검증 후 갱신한다. +- 콘텐츠 연결/해제는 series와 content owner를 모두 검증한 뒤 수행한다. +- 동시에 순서 변경 요청이 들어오면 마지막 transaction 결과가 반영되는 기존 단순 정책을 유지하되 cross-owner 갱신은 절대 허용하지 않는다. + +#### 보안 및 개인정보 위험 +- ID-only order update로 다른 creator series를 변경하지 못해야 한다. +- 연결 가능한 content 검색은 target owner 범위로 제한한다. + +#### acceptance criteria +- 기존 creator series의 CRUD, soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 변경 behavior parity가 특성화 테스트로 고정된다. +- 모든 시리즈/콘텐츠 ID는 target creatorMember 소유일 때만 변경된다. +- soft delete는 `isActive=false`이며 활성 조회에서 제외된다. +- 기존 owner-less order update 취약 경로가 신규 v2 API에는 없다. + +#### targeted test +- Characterization: `LegacyCreatorAdminSeriesCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminSeriesServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'` + +#### 전체 회귀 테스트 영향 +- 기존 creator-admin series endpoint 계약은 유지한다. +- 기존 series query/order 테스트가 있으면 함께 실행한다. + +#### rollback 전략 +- 신규 series v2 admin route/facade를 제거한다. +- 기존 data model 변경이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin series slice` + +- [ ] **Task 4.1: 기존 series parity 특성화 baseline 고정** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/LegacyCreatorAdminSeriesCharacterizationTest.kt` + - CHARACTERIZE: 기존 creator series 구현을 대상으로 목록/상세/생성/수정/soft delete, inactive 조회 정책, 콘텐츠 연결/해제, 조회/검색, 순서 변경의 결과·검증·side effect를 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: fixture와 assertion naming만 정리하고 series parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest` + +- [ ] **Task 4.2: series controller/facade/DTO 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/series/*` + - RED: Task 4.1 baseline에서 옮긴 series parity, cross-owner detail/update/delete/order, cross-owner content attach, 시리즈 콘텐츠 검색, pagination 경계값 테스트와 controller 권한 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - GREEN: owner-scoped series endpoint와 시리즈 콘텐츠 `search/page/size` 조회를 구현한다. + - REFACTOR: 기존 owner-less order update를 참조하지 않는지 확인하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.*'` + +--- + +### Phase 5: 커뮤니티 게시글 관리 vertical slice + +#### 목표 +선택한 AI 캐릭터 소유 커뮤니티 게시글 등록, 수정, 고정/해제, soft delete와 관리자 조회를 제공한다. + +#### 범위와 비범위 +- 포함: owner-scoped community query/write, 최대 고정 3개, soft delete 시 fixed 상태 제거, 이미지/오디오/유료 게시글 검증, 기존 알림/최근 소식 side effect parity. +- 제외: 구매/좋아요/댓글 관리, public community 조회 정책 변경. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- 기존 community write behavior 특성화 테스트. + +#### API endpoint와 request/response contract +- `GET /api/v2/admin/ai-characters/{characterId}/community-posts` +- `POST /api/v2/admin/ai-characters/{characterId}/community-posts` +- `PUT /api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` +- update request는 `isFixed`, `isActive`, 본문/이미지/오디오/가격 필드를 포함한다. + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: creatorMember owner-scoped community post query adapter 추가 가능. +- Service: 신규 community application service에서 target owner 검증 후 기존 핵심 로직을 선택 재사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- 고정 게시글 수 검증과 고정 처리는 같은 transaction에서 수행한다. +- soft delete는 같은 transaction에서 `isActive=false`, `isFixed=false`, `fixedAt=null`을 함께 적용한다. +- 동시 고정 요청은 기존 최대 3개 정책이 깨지지 않도록 repository count와 update 순서를 테스트한다. + +#### 보안 및 개인정보 위험 +- 다른 character/HUMAN creator 게시글 수정, 고정, soft delete를 차단한다. +- 유료 게시글의 접근 정책과 파일 경로 노출 정책을 기존 동작과 맞춘다. + +#### acceptance criteria +- target creatorMember 소유 게시글만 조회/변경된다. +- 최대 고정 수 3개 정책이 유지된다. +- soft delete된 게시글은 fixed 상태와 fixedAt이 제거된다. +- invalid target/ownership 실패 시 DB/S3/event side effect가 없다. + +#### targeted test +- Characterization: `LegacyCommunityPostCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminCommunityPostServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'` + +#### 전체 회귀 테스트 영향 +- 기존 v2 community 조회와 legacy community write 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 community v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin community slice` + +- [ ] **Task 5.1: 기존 community behavior 특성화 baseline 고정** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/LegacyCommunityPostCharacterizationTest.kt` + - CHARACTERIZE: 기존 community 구현을 대상으로 max fixed count, image/audio/paid post validation, notification/recent-news side effect, fixed post soft delete clearing의 결과와 실패 계약을 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: media fixture와 event spy 중복만 정리하고 side-effect parity baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.LegacyCommunityPostCharacterizationTest` + +- [ ] **Task 5.2: community controller/facade/DTO 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/*` + - RED: Task 5.1 baseline에서 옮긴 community parity, cross-owner update, invalid target no-side-effect 테스트와 controller 권한/페이지네이션 경계 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - GREEN: owner-scoped community endpoint를 구현하고 목록 `page/size` 기본값·최소·최대 보정을 적용한다. + - REFACTOR: public community 조회 DTO/로직을 무비판적으로 복제하지 않았는지 확인하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'` + +--- + +### Phase 6: FanTalk 답변 vertical slice + +#### 목표 +선택한 AI 캐릭터가 자신의 활성 root FanTalk에만 creator reply를 작성하는 v2 관리자 API를 제공한다. + +#### 범위와 비범위 +- 포함: root FanTalk 존재/활성/owner 검증, creator reply 저장, 언어 감지와 기존 응답 의미 parity. +- 제외: FanTalk 원글 작성, nested reply, 구매/댓글형 기능, AI 캐릭터 일반 사용자 활동. + +#### 선행 Phase 및 의존성 +- Phase 1 target resolver. +- 기존 FanTalk 저장 엔티티와 응답 DTO 의미 특성화. + +#### API endpoint와 request/response contract +- `POST /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` +- Request: `CreateAiCharacterAdminFanTalkReplyRequest(content: String)` +- Response: `AiCharacterAdminFanTalkReplyResponse(fanTalkId, replyId, creatorMemberId, content, createdAtUtc)` + +#### entity, repository, service 변경 +- Entity: 변경 없음. +- Repository: `CreatorCheers` 또는 FanTalk repository에 root/active/creator owner 조회 adapter 추가 가능. +- Service: 신규 FanTalk reply application service 구현. target 검증 후 기존 저장/언어 감지 로직을 필요한 만큼 재사용한다. + +#### DB migration +- 없음. + +#### transaction과 concurrency 고려사항 +- root FanTalk 조회와 reply 저장은 같은 transaction에서 수행한다. +- 중복 답변 허용 여부는 기존 domain 정책을 따른다. 기존 정책이 없다면 이번 API는 별도 중복 차단을 추가하지 않는다. + +#### 보안 및 개인정보 위험 +- 다른 character FanTalk, HUMAN creator FanTalk, inactive FanTalk, nested parent에는 답변하지 않는다. +- AI character Member 로그인/impersonation 없이 writer/creator만 해석된 creatorMember로 저장한다. + +#### acceptance criteria +- target AI character는 자신의 활성 root FanTalk에만 답변할 수 있다. +- cross-character, nested parent, inactive/missing FanTalk는 4xx이며 reply 저장과 이벤트 발행이 없다. +- 저장된 답변의 writer/creator는 해석된 creatorMember와 일관된다. + +#### targeted test +- Characterization: `LegacyFanTalkReplyCharacterizationTest`. +- V2 RED/GREEN: `AiCharacterAdminFanTalkReplyServiceTest`. +- Run: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'` + +#### 전체 회귀 테스트 영향 +- 기존 FanTalk 조회/작성 관련 테스트가 통과해야 한다. + +#### rollback 전략 +- 신규 FanTalk reply v2 admin route/facade를 제거한다. +- 신규 DDL이 없으므로 schema rollback은 없다. + +#### 권장 commit 경계 +- `feat: add ai character admin fan talk reply slice` + +- [ ] **Task 6.1: 기존 FanTalk reply 의미 특성화 baseline 고정** + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/LegacyFanTalkReplyCharacterizationTest.kt` + - CHARACTERIZE: 기존 FanTalk 구현을 대상으로 valid root reply의 언어 감지, 응답 DTO 의미, writer/creator 저장 결과를 고정한다. + - BASELINE: 신규 v2 production code 변경 전에 특성화 테스트가 기존 구현을 대상으로 통과함을 확인한다. + - REFACTOR: FanTalk root/reply fixture만 정리하고 언어 감지·응답·writer/creator baseline은 변경하지 않는다. + - Verify: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.LegacyFanTalkReplyCharacterizationTest` + +- [ ] **Task 6.2: FanTalk reply controller/facade/DTO 구현** + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/fantalk/*` + - RED: Task 6.1 baseline에서 옮긴 FanTalk reply parity, cross-character/nested/inactive/missing reject, writer/creator 일관성, no-side-effect 테스트와 controller 권한 테스트를 작성하고 신규 v2 미구현으로 실패함을 확인한다. + - GREEN: root/active/owner 검증 후 reply 저장 endpoint를 구현한다. + - REFACTOR: nested reply 금지와 writer/creator mapping을 정리하고 회귀 테스트를 재실행한다. + - Verify: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*'` + +--- + +### Phase 7: Final Integration & Quality Gate + +#### 목표 +신규 AI 캐릭터 관리자 API 전체가 source spec을 충족하고 기존 legacy/public 계약을 회귀시키지 않았음을 검증한다. + +#### 범위와 비범위 +- 포함: 전체 targeted test, 기존 회귀 테스트, ktlint, dependency/DDL/API contract 점검, 문서 검증 기록 누적. +- 제외: 신규 기능 추가, unrelated refactor. + +#### 선행 Phase 및 의존성 +- Phase 1~6 완료. + +#### API endpoint와 request/response contract +- Endpoint Contract Summary의 모든 endpoint가 구현되어야 한다. +- 모든 신규 endpoint가 JWT ADMIN + 현재 DB ADMIN 이중 인가와 공통 오류 envelope/i18n을 공유해야 한다. +- legacy/public endpoint URI와 성공·오류 status/body/message diff가 없어야 한다. + +#### entity, repository, service 변경 +- 신규 변경 없음. Phase 1~6 변경의 누락 import, unused code, package 의존 방향만 정리한다. +- 신규 v2 application/domain이 기존 controller 또는 v2 API response DTO를 역참조하지 않는지 점검한다. + +#### DB migration +- 없음. 새 DDL/migration 파일이 없는지 확인한다. + +#### transaction과 concurrency 고려사항 +- 각 write slice의 transaction 시작부 target/ownership 검증이 유지되는지 점검한다. +- 동시성 관련 targeted test가 실패 없이 통과해야 한다. + +#### 보안 및 개인정보 위험 +- JWT 또는 현재 DB role이 비ADMIN인 접근, stale ADMIN claim, AI login/token/impersonation, private path 노출, cross-owner write가 + 없는지 전체 점검한다. + +#### acceptance criteria +- Phase별 targeted test가 모두 통과한다. +- 모든 신규 endpoint에서 stale ADMIN claim은 403이고, 등록된 모든 API 오류 분기는 정확한 비2xx status + + `ApiResponse.error` + KO/EN/JA message를 반환한다. Spring CORS 정책 거부 403 body는 envelope 계약의 예외다. +- legacy/public 401/403/domain 오류의 status/body/message가 특성화 baseline과 동일하다. +- `./gradlew test`가 통과한다. +- Kotlin 파일 변경이 있으면 `./gradlew ktlintCheck`가 통과한다. +- 신규 dependency와 신규 DDL이 없다. +- source spec acceptance criteria 25개를 각 Phase 결과와 대조해 누락이 없다. + +#### targeted test +- Run: + ```bash + ./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + ./gradlew test + ./gradlew ktlintCheck + ``` + +#### 전체 회귀 테스트 영향 +- legacy `/admin/*`, `/creator-admin/*`, public `/api/v2/*` 관련 테스트 전체가 회귀 범위다. +- 기존 성공·오류 request/response 계약 변경이 없음을 controller/DTO diff와 legacy 오류 특성화 테스트로 확인한다. + +#### rollback 전략 +- 신규 `/api/v2/admin/ai-characters` controller bean 비활성화 또는 신규 package 제거로 기능 표면을 되돌린다. +- DB schema 변경이 없으므로 rollback은 code revert 중심이다. + +#### 권장 commit 경계 +- `test: verify ai character admin api integration` + +- [ ] **Task 7.1: 전체 targeted/regression test 실행** + - TDD 예외 사유: 구현 완료 후 검증 전용 Task라 신규 실패 테스트를 작성하지 않는다. + - 대체 검증 방법: Phase 1~6 targeted test, `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`, 기존 admin + security test와 전체 회귀 테스트를 실행한다. + - REFACTOR: 실패가 있으면 관련 Phase Task로 되돌려 최소 수정 후 다시 실행한다. + - Verify: 위 targeted command와 `./gradlew test`를 실행하고 결과를 이 문서 하단 검증 기록에 남긴다. + +- [ ] **Task 7.2: API contract와 변경 범위 점검** + - TDD 예외 사유: diff/architecture 검증 전용 Task라 신규 실패 테스트를 작성하지 않는다. + - 대체 검증 방법: legacy/public 성공·오류 status/body/message 특성화 baseline 통과, controller/DTO schema 변경 없음, 신규 + dependency 없음, 신규 DDL 없음, 신규 v2 application/domain에서 기존 controller와 v2 response DTO 역참조 없음. + - REFACTOR: 불필요한 import, 역방향 의존, 관련 없는 변경을 제거하고 diff를 다시 확인한다. + - Verify: `git diff --name-only`, `./gradlew ktlintCheck` + +--- + +## 검증 기록 +- 계획 작성 단계: 코드 변경 없음. +- 문서 규칙 확인: `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`, `docs/agent-guides/테스트스타일.md`, `docs/prd/sample-prd.md`를 확인하고 `docs/20260724_AI캐릭터_관리자_API/{prd.md,plan-task.md}` 형식으로 작성했다. +- 금지어 확인: 계획 문서 금지어 검색 명령 실행 결과 없음. +- Phase 항목 확인: `rg -n "^### Phase|#### 목표|#### 범위와 비범위|#### 선행 Phase|#### API endpoint|#### entity, repository, service 변경|#### DB migration|#### transaction과 concurrency|#### 보안 및 개인정보|#### acceptance criteria|#### targeted test|#### 전체 회귀 테스트 영향|#### rollback 전략|#### 권장 commit 경계|Final Integration" "docs/20260724_AI캐릭터_관리자_API/plan-task.md"`로 Phase 1~7 전체에 필수 항목이 있음을 확인했다. +- 명령 유효성 확인: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- Markdown diagnostics: `.md` 확장자용 LSP 서버가 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가. +- Endpoint Contract 보강: frontend HTTP 호출 기준으로 query parameter, request body, multipart form fields, response `data` JSON 예시를 추가했다. +- Endpoint Contract 보강 검증: 계획 문서 금지어 검색 결과 없음. `.md` 확장자용 LSP 서버가 없어 diagnostics는 실행 불가. +- 문서 동기화 보강: source spec 기준 `characterId` 예외, 시리즈 콘텐츠 검색, endpoint별 ADMIN 권한 테스트, parity RED 범위, 의존 방향 검증, PRD 가드레일, pagination 경계값, RED/GREEN/REFACTOR task 규칙을 반영했다. +- 문서 동기화 검증: `Read`로 `prd.md`와 `plan-task.md`의 반영 라인을 확인했다. `git status --short` 결과 `docs/20260724_AI캐릭터_관리자_API/`는 현재 untracked 디렉터리로 표시된다. `./gradlew tasks --all`은 이번 세션에서 120초, 300초 제한 모두 초과해 종료 결과를 확인하지 못했다. +- 문서 동기화 보강(2차): 기존 legacy/creator-admin 구현을 먼저 통과하는 특성화 baseline과 신규 v2 RED를 분리했고, character/content/series/community/FanTalk 재사용·parity 경계에 반영했다. 시리즈 CRUD·연결·해제·조회·순서 behavior parity와 signed URL 만료 계산식·edge case 특성화를 PRD와 Task에 명시했다. +- 문서 동기화 재검증(4번 제외): source spec acceptance criteria 25개 추적 검사 25/25, actor/scope/non-goal/architecture 가드레일 11/11을 확인했고 `git diff --check` 결과 문제가 없었다. plan 상단의 실행 skill 지침은 사용자 요청에 따라 변경·판정 범위에서 제외했다. +- 기존 기록은 보존한다. 각 Task의 실행 명령과 결과 요약은 해당 Task 아래에 누적하고, phase/전체 회귀·전체 빌드·포맷·문서 범위 확인만 이 섹션에 누적한다. +- Phase 1 RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` 실행 결과 `AiCharacterAdminTargetResolver`와 `ChatCharacterRepository.findByIdWithCreatorMember` 미구현으로 `compileTestKotlin` 실패를 확인했다. +- Phase 1 GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverTest` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 ADMIN 권한 RED/GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest`에서 sample route 권한 실패를 확인한 뒤 `/api/v2/admin/ai-characters/**` ADMIN rule을 적용해 `BUILD SUCCESSFUL`을 확인했다. +- Phase 1 targeted 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 lint 검증: `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 LSP diagnostics: 현재 도구 목록에 LSP diagnostics tool이 노출되어 있지 않아 실행하지 못했고, 대신 Kotlin compile/test와 `ktlintCheck`로 대체 검증했다. +- Phase 1 reviewer gate: 1차 리뷰에서 invalid target 4xx 미충족, production SecurityConfig 미검증, cross-owner fixture 부족을 지적받아 수정했고, 재리뷰 결과 남은 blocking finding 없음으로 승인받았다. +- Phase 1 추가 리뷰 반영: mock 기반 resolver 테스트만으로 실제 repository/ownership 동작을 검증하지 못한다는 지적에 따라 `AiCharacterAdminTargetResolverIntegrationTest`를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminTargetResolverIntegrationTest` 실행 결과 `BUILD SUCCESSFUL`. +- Phase 1 no-side-effect 근거 보정: 현재 검증은 resolver 자체가 DB row를 변경하지 않는다는 통합 테스트와 resolver가 S3/외부 API/event 의존성을 갖지 않는다는 구조에 한정한다. Phase 2~6 write vertical slice의 S3, 외부 API, 이벤트 no-side-effect는 각 slice 테스트에서 별도로 검증한다. +- Phase 1 후속 정책 반영 전 한계: 기존 ADMIN smoke는 JWT authority만 검증했고 현재 DB role/stale claim 및 신규 prefix 오류 + envelope/i18n을 검증하지 않았다. 2026-07-24 후속 확정 정책은 Task 1.4~1.5에서 RED/GREEN으로 보완한다. +- Phase 1 후속 정책 문서 갱신: JWT ADMIN + 현재 DB ADMIN 이중 인가, stale claim 403, 신규 prefix의 비2xx + `ApiResponse.error`/KO·EN·JA 계약, legacy 오류 응답 불변 조건을 PRD와 계획에 반영하고 미완료 Task 1.4~1.5를 추가했다. +- Phase 1 후속 정책 문서 자체 검토: 금지어/미확정 문구 검색 결과 없음, 요구사항 추적 검색으로 stale claim·오류 envelope·i18n· + legacy fallback·Task 1.4~1.5 반영을 확인했고 `git diff --check` 결과 문제가 없었다. +- Phase 1 후속 정책 명령 유효성: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`. +- Task 1.4 RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` + 실행 결과 6개 중 stale ADMIN claim과 비`MemberAdapter` principal이 403 기대 대비 200으로 통과해 2개 실패함을 확인했다. +- Task 1.4 GREEN: 신규 prefix의 단일 access 식에서 JWT ADMIN, `MemberAdapter` principal, 현재 DB ADMIN을 AND로 검증한 뒤 + 동일 테스트 실행 결과 `BUILD SUCCESSFUL`. +- Task 1.4 reviewer gate: 별도 read-only 리뷰에서 스펙 준수와 코드 품질 모두 승인됐고 Critical/Important/Minor finding이 + 없음을 확인했다. +- Task 1.5 최초 RED: legacy 오류 baseline 2개는 통과했고 신규 401/403/400/500 계약은 15개 중 13개가 status/content type/ + message 불일치로 실패함을 확인했다. target resolver의 기존 `ResponseStatusException`을 신규 API 예외로 교체하는 테스트도 + 새 예외 미구현 상태의 `compileTestKotlin` 실패로 RED를 확인했다. +- Task 1.5 확장 RED: handler 선택 전 오류와 filter 내부 장애까지 포함해 오류 계약 39개를 실행한 결과 신규 prefix의 404/405/ + 415 및 예상하지 못한 JWT filter 오류 500에 해당하는 12개만 실패했고 legacy baseline은 통과했다. 미등록 message key fallback은 + 44개 중 해당 KO/EN/JA 3개 실패로 별도 RED를 확인했다. +- Task 1.5 GREEN: URI prefix 기반 exception resolver, 낮은 우선순위 404 fallback mapping, prefix 전용 security handler, + known 인증 실패 401/그 외 filter 예외 500 분리, 미등록 message key의 localized unknown fallback을 구현했다. + `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` 실행 결과 44개 invocation + 모두 통과해 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.5 기존 slice 회귀: production `SecurityConfig`를 import하는 기존 WebMvc test 5개와 신규 authorization/error contract + test를 함께 실행한 결과 `BUILD SUCCESSFUL`을 확인했다. 신규 writer/security handler/exception resolver는 `SecurityConfig`의 + 명시적 bean으로 등록해 slice와 실제 application 구성을 동일하게 유지했다. +- Task 1.5 reviewer gate: 최초 read-only 리뷰의 405/415 handler-less 경로, 비인증 filter 예외의 401 오분류, 기존 WebMvc slice + 빈 누락, 미매핑 404 지적을 모두 보완했다. 재리뷰 결과 Critical/Important/Minor finding 없이 승인됐다. +- Phase 1 후속 정책 최종 targeted/legacy 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 `BUILD SUCCESSFUL`(28초)을 확인했다. +- Phase 1 후속 정책 전체 회귀: 최신 작업 트리에서 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`(4분 22초)을 확인했다. +- Phase 1 후속 정책 lint: 최신 작업 트리에서 `./gradlew ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`(17초)을 확인했다. +- Phase 1 후속 정책 diff 무결성: `git diff --check` 통과, conflict marker 없음, build 설정·dependency·DDL 변경 없음을 확인했다. +- Task 1.5 최종 리뷰 보완 RED/GREEN: JWT parse 사이 만료 `JwtException` 401과 인증 저장소 장애 500을 추가했을 때 50개 중 + 해당 6개 실패를 확인한 뒤 known credential failure만 401로 분류해 50개 모두 통과했다. 필수 request header 누락 400은 + 53개 중 해당 KO/EN/JA 3개 실패를 확인한 뒤 `ServletRequestBindingException`을 400으로 분류해 모두 통과했다. +- Task 1.5 CORS RED/GREEN: 허용된 관리자 Origin의 404/405/415와 실제 `authorization,content-type` header를 요청하는 미매핑 + prefix preflight를 추가했을 때 57개 중 해당 4개 실패를 확인했다. fallback mapping에 기존 전역 설정과 동일한 CORS 설정을 + 적용한 뒤 57개 모두 통과했다. +- Phase 1 no-side-effect 검증 강화: `AiCharacterAdminTargetResolverIntegrationTest`에서 Hibernate statistics를 초기화한 뒤 + invalid target resolver 호출과 flush 후 entity insert/update/delete가 각각 0건임을 직접 검증했다. resolver 단위·통합 및 오류 + 계약 테스트를 함께 실행한 결과 `BUILD SUCCESSFUL`(27초)을 확인했다. +- Phase 1 최종 reviewer gate: 인증 예외 분류, request binding, fallback CORS, DB no-write, 문서/rollback을 독립 read-only로 + 재검토한 결과 Critical/Important/Minor finding이 없음을 확인했다. +- Phase 1 최신 targeted/legacy 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 `BUILD SUCCESSFUL`(28초)을 확인했다. +- Phase 1 최신 전체 회귀: 최종 코드 작업 트리에서 `./gradlew test` 실행 결과 `BUILD SUCCESSFUL`(4분 17초)을 확인했다. +- Phase 1 최신 lint: import 순서 1건을 수정한 뒤 `./gradlew ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`(10초)을 확인했다. +- Phase 1 최신 diff 무결성: `git diff --check`와 `git diff --cached --check`가 모두 통과했고 conflict marker가 없으며 build + 설정, dependency, DDL 변경이 없음을 확인했다. +- Phase 1 코드 리뷰 차단 이슈 보완: 리뷰에서 지적된 staged/untracked 누락을 재확인한 결과 최신 작업 트리는 Phase 1 신규 테스트 + 2개(`AiCharacterAdminAccessDeniedErrorContractTest`, `AiCharacterAdminLoginJwtIntegrationTest`)와 `AccessDeniedException` 403 수정이 + 모두 변경 세트에 포함되어 있음을 확인했다. `TokenProvider` subject parsing 보정과 관련 테스트, rollback 범위를 Task 1.4/1.5와 + Phase 1 rollback 전략에 반영했다. +- Phase 1 코드 리뷰 차단 이슈 재검증: `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' + --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'` 단독 실행 결과 `BUILD SUCCESSFUL`을 확인했다. 이전 QA의 Gradle + `TestOutputStore` EOF는 동시/강제 실행 환경에서 발생한 결과 저장소 문제로 보며, 현재 단독 fresh rerun에서는 재현되지 않았다. +- Phase 1 코드 리뷰 차단 이슈 최종 검증: 문서 보정 후 `git status --short --untracked-files=all`에서 untracked 파일이 없고 + `git diff --name-only` 결과가 비어 있음을 확인했다. `git diff --cached --check`, `git diff --check`, + `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests 'kr.co.vividnext.sodalive.jwt.TokenProviderTest'`, + `./gradlew test --tests 'kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest' --tests 'kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest'`, + `./gradlew ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 CORS 계약 재확인: 현재 `WebConfig` 기준 전용 Origin + `http://localhost:8888`, `https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net` 세 개를 + PRD/plan에 명시했다. preflight 테스트는 세 Origin 모두 허용하고 기존 범용 관리자/creator Origin 네 개를 + 거부하는지 고정했다. +- Task 1.6 JWT RED/GREEN: `TokenProviderTest` 9개 중 추가한 누락·빈 값·빈 분할 항목·비문자열 `auth` claim + 7개 invocation이 실패하는 RED를 확인했다. claim을 authority로 변환하기 전 검증해 + `common.error.bad_credentials`로 변환한 뒤 9개 모두 통과했다. +- Task 1.6 테스트 fixture 격리: 전용 controller 6개를 각 테스트의 nested `@TestComponent`로 이동하고 + `@Import`로만 등록했다. 초기 nested 이동 후 명시 등록이 누락된 WebMvc 요청 37개가 404로 실패한 것을 + 확인한 뒤 보정했고, 독립 재리뷰의 component scan 지적을 `@TestComponent`로 해소했다. +- Task 1.6 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 103개 모두 통과했다. 기존 + `AdminAgentReadControllerSecurityTest`, `AdminContentControllerSecurityTest` 보안 회귀도 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 최종 전체 회귀/lint: 최종 소스 상태에서 `./gradlew test`는 `BUILD SUCCESSFUL`(4분 55초), + `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(19초)를 확인했다. +- Task 1.6 최종 reviewer/diff gate: 독립 read-only 재리뷰 결과 CORS, malformed `auth`, fixture scan 격리에 + 잔여 finding이 없음을 확인했다. `git diff --check`, `git diff --cached --check`가 통과했고 conflict marker와 + untracked 파일이 없음을 확인했다. +- Task 1.6 2차 리뷰 CORS RED/GREEN: 실제 mapped endpoint, 공유 `/admin/member/login`, `/member/logout`의 캐릭터 관리자 + Origin 요청/preflight를 추가했을 때 `AiCharacterAdminLoginJwtIntegrationTest` 6개 중 3개 실패를 확인했다. 두 공유 인증 + exact path에만 기존 전역 Origin과 캐릭터 관리자 Origin 합집합을 적용한 뒤, 미등록 Origin 거부까지 포함한 7개가 모두 + 통과했다. 신규 prefix의 기존 범용 관리자 Origin 거부와 실제 로그인·로그아웃도 함께 검증했다. +- Task 1.6 2차 리뷰 HTTP 오류 RED/GREEN: 406, 405 `Allow`, 415 `Accept`/`Accept-Patch`, + `MissingPathVariableException` 500 계약을 추가해 신규 13개 실패를 확인했다. `AiCharacterAdminExceptionHandler`에 Spring 기본 + HTTP 의미를 보존하는 최소 분기와 header 처리를 추가한 뒤 `AiCharacterAdminErrorContractTest` 80개가 모두 통과했다. +- Task 1.6 2차 리뷰 최종 targeted/레거시 회귀: `TokenProviderTest`와 `aicharacter.*` 114개, 기존 + `AdminAgentReadControllerSecurityTest`와 `AdminContentControllerSecurityTest` 11개를 함께 실행해 총 125개 모두 통과했고 + `BUILD SUCCESSFUL`(42초)을 확인했다. +- Task 1.6 2차 리뷰 전체 회귀/lint: 최종 소스 상태에서 `./gradlew test`는 `BUILD SUCCESSFUL`(5분 4초), + `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(20초)을 확인했다. +- Task 1.6 2차 리뷰 최종 gate: 공유 인증 exact path가 전역 fallback보다 먼저 적용되고 신규 prefix/fallback은 전용 Origin을 + 유지하는지 독립 read-only로 재검토했다. `git diff --check`, `git diff --cached --check`가 통과했고 conflict marker와 untracked + 파일이 없음을 확인했다. +- Task 1.6 후속 리뷰 mapped write preflight 보완: 기존 신규 prefix write preflight 검증이 fallback + `/api/v2/admin/ai-characters/unmapped-path`만 타는 한계를 확인했다. 테스트 controller에 실제 mapped + `/api/v2/admin/ai-characters/error-contract/write-preflight`의 `POST`/`PUT`/`PATCH`/`DELETE` 매핑을 추가하고, + 해당 경로 preflight에서 `Access-Control-Allow-Origin`과 `Access-Control-Allow-Methods`를 함께 검증하도록 보완했다. +- Task 1.6 후속 리뷰 fallback write preflight 보완: actual mapping과 fallback이 서로 다른 CORS 설정을 사용하므로, + mapped endpoint 검증과 별도로 fallback `/api/v2/admin/ai-characters/unmapped-path`에서도 `POST`/`PUT`/`PATCH`/`DELETE` + preflight의 `Access-Control-Allow-Origin`과 `Access-Control-Allow-Methods`를 검증하도록 보완했다. +- Task 1.6 후속 리뷰 추가 계약 보완: wrong-role `USER` + `AI_CHARACTER` target은 resolver가 400으로 거부하고 + DB insert/update/delete 없이 기존 role/memberKind를 유지하는지 고정했다. 캐릭터 관리자 Origin이 공유 인증 외 + legacy/public 경로로 확산되지 않는지 확인했고, 406 Not Acceptable 응답도 허용 Origin에서는 localized `ApiResponse`와 + `Access-Control-Allow-Origin`을 함께 반환하는지 검증했다. +- Task 1.6 후속 리뷰 계약 테스트 현황: mapped write preflight 보완 후 `AiCharacterAdminErrorContractTest` 85개, + `AiCharacterAdminLoginJwtIntegrationTest` 8개, `AiCharacterAdminTargetResolverIntegrationTest` 4개 기준으로 + 후속 리뷰 항목을 회귀했다. +- Task 1.6 후속 리뷰 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests + kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 targeted+legacy 132개가 모두 통과했고 + `BUILD SUCCESSFUL`(40초)을 확인했다. +- Task 1.6 후속 리뷰 전체 회귀/lint: mapped write preflight 보완 후 소스 상태에서 `./gradlew test`는 전체 1,259개 기준 + `BUILD SUCCESSFUL`(4분 53초), `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(37초)을 확인했다. +- Task 1.6 후속 리뷰 fallback 보완 후 계약 테스트 현황: fallback write preflight 보완 후 `AiCharacterAdminErrorContractTest` 89개, + `AiCharacterAdminLoginJwtIntegrationTest` 8개, `AiCharacterAdminTargetResolverIntegrationTest` 4개 기준으로 후속 리뷰 항목을 회귀했다. +- Task 1.6 후속 리뷰 fallback 보완 후 최종 targeted/레거시 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests + kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 targeted+legacy 136개가 모두 통과했고 + `BUILD SUCCESSFUL`(1분 38초)을 확인했다. +- Task 1.6 후속 리뷰 fallback 보완 후 전체 회귀/lint: fallback write preflight 보완 후 최종 소스 상태에서 `./gradlew test`는 + 전체 1,263개 기준 `BUILD SUCCESSFUL`(5분 58초), `./gradlew ktlintCheck --rerun-tasks`는 `BUILD SUCCESSFUL`(35초)을 확인했다. +- Task 1.6 추가 코드 리뷰 보완: 실제 logout 후 동일 JWT로 신규 prefix 보호 경로를 호출하면 localized 401을 반환하는지 + `AiCharacterAdminLoginJwtIntegrationTest`에 통합 테스트로 고정했다. 공유 `/admin/member/login`, `/member/logout` CORS preflight는 + `WebConfig`의 기존 전역 Origin과 캐릭터 관리자 Origin 합집합 전체를 허용하는 parameterized test로 확장했다. Phase 1 targeted + Run 명령에는 `TokenProviderTest`를 포함하도록 보정했다. +- Task 1.6 추가 코드 리뷰 보완 검증: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminLoginJwtIntegrationTest` + 실행 결과 `BUILD SUCCESSFUL`을 확인했다. +- Task 1.6 추가 코드 리뷰 보완 최종 회귀/lint: `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` 실행 결과 `BUILD SUCCESSFUL`(44초), `./gradlew ktlintCheck` 실행 결과 + `BUILD SUCCESSFUL`(25초)을 확인했다. +- Phase 1 최신 canonical fresh targeted/legacy 회귀: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 145/145, failure/error 0, + `BUILD SUCCESSFUL`(3분 12초)을 확인했다. +- Phase 1 최신 전체 fresh 회귀: `./gradlew test --rerun-tasks` 실행 결과 1,272/1,272, failure/error 0, + `BUILD SUCCESSFUL`(6분 49초)을 확인했다. +- Phase 1 최신 lint: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 `BUILD SUCCESSFUL`(17초)을 확인했다. +- Phase 1 최신 명령 유효성: `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`이며 `test`, `ktlintCheck` task가 존재함을 + 확인했다. +- Phase 1 최종 보강 후 canonical fresh targeted/legacy 회귀: `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests + 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*' --tests kr.co.vividnext.sodalive.admin.partner.agent.read.AdminAgentReadControllerSecurityTest --tests + kr.co.vividnext.sodalive.admin.content.AdminContentControllerSecurityTest` 실행 결과 9개 XML class, 154/154, + failure/error/skipped 0, `BUILD SUCCESSFUL`(5분)을 확인했다. +- Phase 1 최종 보강 후 전체 fresh 회귀: `./gradlew test --rerun-tasks` 실행 결과 243개 XML class, 1,281/1,281, + failure/error/skipped 0, `BUILD SUCCESSFUL`(10분 2초)을 확인했다. +- Phase 1 최종 보강 후 lint: `./gradlew ktlintCheck --rerun-tasks` 실행 결과 7개 task가 실행됐고 + `BUILD SUCCESSFUL`(29초)을 확인했다. diff --git a/docs/20260724_AI캐릭터_관리자_API/prd.md b/docs/20260724_AI캐릭터_관리자_API/prd.md new file mode 100644 index 00000000..2c3d321a --- /dev/null +++ b/docs/20260724_AI캐릭터_관리자_API/prd.md @@ -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 +- 없음. diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index 51cd9808..bafe0430 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -142,6 +142,15 @@ interface ChatCharacterRepository : JpaRepository { ) fun findByIdInWithTagMappings(@Param("ids") ids: List): List + @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 existsByCreatorMemberId(creatorMemberId: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt index a8ab7ee1..e5b9ff8d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/common/ExceptionHandlerFilter.kt @@ -1,12 +1,22 @@ package kr.co.vividnext.sodalive.common 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.servlet.HandlerExceptionResolver import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest 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( request: HttpServletRequest, response: HttpServletResponse, @@ -15,6 +25,18 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe try { filterChain.doFilter(request, response) } 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.contentType = "application/json" response.characterEncoding = "UTF-8" @@ -23,4 +45,10 @@ class ExceptionHandlerFilter(private val objectMapper: ObjectMapper) : OncePerRe 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") + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index 99ff4572..65eaad1d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -2,13 +2,19 @@ package kr.co.vividnext.sodalive.configs import com.fasterxml.jackson.databind.ObjectMapper 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.JwtAuthenticationEntryPoint import kr.co.vividnext.sodalive.jwt.JwtFilter 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.Configuration +import org.springframework.core.Ordered 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.web.builders.HttpSecurity 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.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.security.web.AuthenticationEntryPoint 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.util.matcher.AntPathRequestMatcher +import org.springframework.security.web.util.matcher.RequestMatcher +import org.springframework.web.HttpRequestHandler +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping @Configuration @EnableWebSecurity @@ -35,25 +49,94 @@ class SecurityConfig( } @Bean - fun webSecurityCustomizer(): WebSecurityCustomizer { + fun webSecurityCustomizer( + aiCharacterAdminSecurityErrorHandler: AiCharacterAdminSecurityErrorHandler + ): WebSecurityCustomizer { return WebSecurityCustomizer { web: WebSecurity -> web + .requestRejectedHandler(aiCharacterAdminSecurityErrorHandler) .ignoring() .antMatchers("/h2-console/**", "/favicon.ico", "/error") } } @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 aiCharacterAdminRequestMatcher = AntPathRequestMatcher(AI_CHARACTER_ADMIN_PATH) + val authenticationEntryPoints = linkedMapOf( + aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler + ) + val delegatingAuthenticationEntryPoint = DelegatingAuthenticationEntryPoint(authenticationEntryPoints).apply { + setDefaultEntryPoint(authenticationEntryPoint) + } + val accessDeniedHandlers = linkedMapOf( + aiCharacterAdminRequestMatcher to aiCharacterAdminSecurityErrorHandler + ) + val delegatingAccessDeniedHandler = RequestMatcherDelegatingAccessDeniedHandler( + accessDeniedHandlers, + accessDeniedHandler + ) return http .cors() .and() .csrf().disable() .exceptionHandling() - .authenticationEntryPoint(authenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(delegatingAuthenticationEntryPoint) + .accessDeniedHandler(delegatingAccessDeniedHandler) .and() .headers() .frameOptions() @@ -63,7 +146,15 @@ class SecurityConfig( .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java) - .addFilterBefore(ExceptionHandlerFilter(objectMapper), JwtFilter::class.java) + .addFilterBefore( + ExceptionHandlerFilter( + objectMapper, + aiCharacterAdminRequestMatcher, + aiCharacterAdminSecurityErrorHandler, + aiCharacterAdminExceptionHandler + ), + JwtFilter::class.java + ) .authorizeRequests() .antMatchers("/member/check/email").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/following").permitAll() .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 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .anyRequest().authenticated() .and() .build() } + + companion object { + private const val AI_CHARACTER_ADMIN_PATH = "/api/v2/admin/ai-characters/**" + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt index fe0b9c9e..aeb47fe2 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/WebConfig.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.configs import kr.co.vividnext.sodalive.common.CountryInterceptor import kr.co.vividnext.sodalive.i18n.LangInterceptor 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.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @@ -18,15 +19,49 @@ class WebConfig( } 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("/**") - .allowedOrigins( - "http://localhost:8888", - "https://creator.sodalive.net", - "https://test-creator.sodalive.net", - "https://test-admin.sodalive.net", - "https://admin.sodalive.net" - ) + .allowedOrigins(*ALLOWED_ORIGINS.toTypedArray()) .allowedMethods("*") .allowCredentials(true) } + + companion object { + private val ALLOWED_ORIGINS = listOf( + "http://localhost:8888", + "https://creator.sodalive.net", + "https://test-creator.sodalive.net", + "https://test-admin.sodalive.net", + "https://admin.sodalive.net" + ) + + 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 + } + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt index f851665a..41a4d481 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/jwt/TokenProvider.kt @@ -83,15 +83,21 @@ class TokenProvider( .parseClaimsJws(token) .body - val authorities = claims[AUTHORITIES_KEY].toString().split(",").map { SimpleGrantedAuthority(it) } - val memberToken = tokenRepository.findByIdOrNull(id = claims.subject.toLong()) + val memberId = claims.subject?.toLongOrNull() + ?: 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") if (!memberToken.tokenSet.contains(token)) { 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") val principal = MemberAdapter(member) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt new file mode 100644 index 00000000..5cfa8b0f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/application/AiCharacterAdminTargetResolver.kt @@ -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 +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt new file mode 100644 index 00000000..7e729f8c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminApiException.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt new file mode 100644 index 00000000..dec17323 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminErrorResponseWriter.kt @@ -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 { + 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))) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt new file mode 100644 index 00000000..26c91eb7 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/error/AiCharacterAdminExceptionHandler.kt @@ -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 + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt new file mode 100644 index 00000000..7bce71a5 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/security/AiCharacterAdminSecurityErrorHandler.kt @@ -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" + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt new file mode 100644 index 00000000..240f66f6 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/jwt/TokenProviderTest.kt @@ -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()) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt new file mode 100644 index 00000000..a25e2360 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAccessDeniedErrorContractTest.kt @@ -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 { + throw AccessDeniedException("admin only") + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt new file mode 100644 index 00000000..0135a8bc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminAuthorizationTest.kt @@ -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 = ApiResponse.ok("ok") + } + + @TestComponent + @RestController + class AiCharacterAdminLegacySampleController { + @GetMapping("/phase1-legacy-sample") + fun sample(): ApiResponse = ApiResponse.ok("legacy-ok") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt new file mode 100644 index 00000000..f94ccbbd --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminErrorContractTest.kt @@ -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 = ApiResponse.ok("ok") + + @GetMapping("/invalid-target") + fun invalidTarget(): ApiResponse { + targetResolver.resolve(404L) + return ApiResponse.ok("unexpected") + } + + @GetMapping("/binding") + fun binding(@RequestParam value: Long): ApiResponse = ApiResponse.ok(value) + + @GetMapping("/required-header") + fun requiredHeader(@RequestHeader("X-Required") value: String): ApiResponse = ApiResponse.ok(value) + + @GetMapping("/unexpected") + fun unexpected(): ApiResponse { + throw IllegalStateException("internal detail must not be exposed") + } + + @GetMapping("/missing-message-key") + fun missingMessageKey(): ApiResponse { + throw AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "missing.message.key") + } + + @PostMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun mediaType(): ApiResponse = ApiResponse.ok("ok") + + @PatchMapping("/media-type", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun patchMediaType(): ApiResponse = ApiResponse.ok("ok") + + @PostMapping("/write-preflight") + fun postWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @PutMapping("/write-preflight") + fun putWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @PatchMapping("/write-preflight") + fun patchWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @DeleteMapping("/write-preflight") + fun deleteWritePreflight(): ApiResponse = ApiResponse.ok("ok") + + @GetMapping("/missing-path-variable") + fun missingPathVariable(@PathVariable("missing") value: Long): ApiResponse = ApiResponse.ok(value) + + @PostMapping("/body-binding", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun bodyBinding(@RequestBody request: BodyBindingRequest): ApiResponse = ApiResponse.ok(request.value) + + @PostMapping("/method-argument-not-valid", consumes = [MediaType.APPLICATION_JSON_VALUE]) + fun methodArgumentNotValid(@RequestBody request: BodyBindingRequest): ApiResponse { + 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 = ApiResponse.ok(value) + + @PostMapping("/multipart-error") + fun multipartError(): ApiResponse { + throw MultipartException("malformed multipart request") + } + + @PostMapping("/max-upload-size-error") + fun maxUploadSizeError(): ApiResponse { + throw MaxUploadSizeExceededException(1L) + } + } + + @TestComponent + @RestController + class AiCharacterAdminLegacyErrorContractSampleController { + @GetMapping("/phase1-legacy-error-contract") + fun sample(): ApiResponse = ApiResponse.ok("legacy-ok") + } +} + +data class BodyBindingRequest( + val value: Long +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt new file mode 100644 index 00000000..e4ffa4fe --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminLoginJwtIntegrationTest.kt @@ -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 { + 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 = ApiResponse.ok("ok") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt new file mode 100644 index 00000000..82c6d463 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverIntegrationTest.kt @@ -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 + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt new file mode 100644 index 00000000..3d49fc56 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/AiCharacterAdminTargetResolverTest.kt @@ -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 } + } +}