fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판· 이커머스·페이지·ckeditor5 전반에 반영. - 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합) - 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호) - 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭 - 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹 - 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리 정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글· 대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호" 정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지. Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
This commit is contained in:
@@ -19,6 +19,9 @@
|
||||
- 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다.
|
||||
- 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919)
|
||||
- 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915)
|
||||
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -59,6 +59,14 @@ interface MenuRepositoryInterface
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Menu;
|
||||
|
||||
/**
|
||||
* URL 로 메뉴를 찾습니다.
|
||||
*
|
||||
* @param string $url 메뉴 URL
|
||||
* @return Menu|null 찾은 메뉴 모델 또는 null
|
||||
*/
|
||||
public function findByUrl(string $url): ?Menu;
|
||||
|
||||
/**
|
||||
* 새로운 메뉴를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Services\UserService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
@@ -321,7 +322,10 @@ class ProfileController extends AuthBaseController
|
||||
'user.withdraw_failed'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('user.withdraw_failed', 500, null, ['error' => $e->getMessage()]);
|
||||
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
|
||||
Log::error('User withdraw failed (profile)', ['exception' => $e]);
|
||||
|
||||
return $this->error('user.withdraw_failed', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -75,9 +75,9 @@ class SetLocale
|
||||
* 따라서 Bearer 토큰을 직접 파싱하여 사용자를 가져옵니다.
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return \App\Models\User|null 사용자 또는 null
|
||||
* @return User|null 사용자 또는 null
|
||||
*/
|
||||
private function resolveUser(Request $request): ?\App\Models\User
|
||||
private function resolveUser(Request $request): ?User
|
||||
{
|
||||
// 이미 인증된 경우 (세션 기반 인증)
|
||||
if (Auth::check()) {
|
||||
@@ -88,7 +88,14 @@ class SetLocale
|
||||
$bearerToken = $request->bearerToken();
|
||||
if ($bearerToken) {
|
||||
$token = PersonalAccessToken::findToken($bearerToken);
|
||||
if ($token && $token->tokenable instanceof \App\Models\User) {
|
||||
|
||||
// 만료된 토큰은 인증되지 않은 것으로 취급 (guest 로케일 폴백).
|
||||
// OptionalSanctumMiddleware 와 동일한 만료 검사를 적용한다.
|
||||
if ($token && $token->expires_at && $token->expires_at->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($token && $token->tokenable instanceof User) {
|
||||
return $token->tokenable;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +640,9 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
// 닉네임에 suffix 추가 (있는 경우만, 날짜 없이)
|
||||
if ($this->nickname) {
|
||||
// nickname 은 마이그레이션에서 길이를 명시(50)한 컬럼이다.
|
||||
// 이 접미사는 유일성 토큰(id)이 없다 — 현재 nickname/name 에 unique 인덱스가
|
||||
// 없어 무해하지만, 향후 unique 인덱스를 추가하면 email 과 동일한 충돌
|
||||
// (같은 값 재가입 후 재탈퇴 실패)이 재발하므로 그때 id 부착으로 전환할 것.
|
||||
$this->nickname = $this->appendWithdrawnSuffix($this->nickname, '_탈퇴', self::NICKNAME_MAX_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,17 @@ class MenuRepository implements MenuRepositoryInterface
|
||||
return Menu::where('slug', $slug)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 로 메뉴를 찾습니다.
|
||||
*
|
||||
* @param string $url 메뉴 URL
|
||||
* @return Menu|null 찾은 메뉴 모델 또는 null
|
||||
*/
|
||||
public function findByUrl(string $url): ?Menu
|
||||
{
|
||||
return Menu::where('url', $url)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 메뉴를 생성합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Search;
|
||||
|
||||
/**
|
||||
* 검색 결과의 키워드 하이라이트와 본문 프리뷰 평문화를 담당하는 공유 헬퍼.
|
||||
*
|
||||
* 하이라이트 필드는 소비 측이 HTML 로 렌더하는 계약이므로, 원문을 반드시
|
||||
* 이스케이프한 뒤 검색어만 <mark> 로 감싼다. 이스케이프를 건너뛰면 제목·본문에
|
||||
* 삽입된 태그가 그대로 실행 계약으로 나간다(게시판/상품/페이지 검색 공통).
|
||||
*/
|
||||
class SearchHighlighter
|
||||
{
|
||||
/**
|
||||
* 원문을 HTML 이스케이프한 뒤 검색어만 <mark> 로 감쌉니다.
|
||||
*
|
||||
* @param string|null $text 원문 텍스트(평문)
|
||||
* @param string $keyword 검색어
|
||||
* @return string 이스케이프 완료된 안전한 HTML
|
||||
*/
|
||||
public static function highlight(?string $text, string $keyword): string
|
||||
{
|
||||
if ($text === null || $text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$safe = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
if ($keyword === '') {
|
||||
return $safe;
|
||||
}
|
||||
|
||||
// 키워드도 동일하게 이스케이프해, 이스케이프된 원문과 일관되게 매칭한다.
|
||||
$safeKeyword = htmlspecialchars($keyword, ENT_QUOTES, 'UTF-8');
|
||||
$escapedKeyword = preg_quote($safeKeyword, '/');
|
||||
|
||||
$result = preg_replace('/('.$escapedKeyword.')/iu', '<mark>$1</mark>', $safe);
|
||||
|
||||
// 유효하지 않은 UTF-8 등으로 preg_replace 가 null 을 반환하면 이스케이프본을 유지한다.
|
||||
return $result ?? $safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 본문을 태그 없는 평문으로 변환합니다.
|
||||
*
|
||||
* 엔티티를 먼저 디코드한 뒤 태그를 제거해, 엔티티로 인코딩된 태그가
|
||||
* 평문화 단계에서 실제 태그로 부활하지 못하게 합니다.
|
||||
*
|
||||
* @param string|null $html 본문(HTML 또는 평문)
|
||||
* @return string 태그가 제거된 평문
|
||||
*/
|
||||
public static function toPlainText(?string $html): string
|
||||
{
|
||||
if ($html === null || $html === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', strip_tags($decoded)));
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,11 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
|
||||
Log::error('User create failed', ['exception' => $e]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.create_failed', ['error' => $e->getMessage()])],
|
||||
'general' => [__('user.create_failed')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -307,8 +310,11 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
|
||||
Log::error('User update failed', ['user_id' => $user->id, 'exception' => $e]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.update_failed', ['error' => $e->getMessage()])],
|
||||
'general' => [__('user.update_failed')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -430,8 +436,11 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문(SQL 상태코드·경로 등)을 싣지 않는다.
|
||||
Log::error('User withdraw failed', ['user_id' => $user->id, 'exception' => $e]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.withdraw_failed', ['error' => $e->getMessage()])],
|
||||
'general' => [__('user.withdraw_failed')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -509,8 +518,11 @@ class UserService
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// 원본 예외는 로그로만 남기고, 사용자 응답에는 원문을 싣지 않는다.
|
||||
Log::error('User delete failed', ['user_id' => $user->id, 'exception' => $e]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'general' => [__('user.delete_failed', ['error' => $e->getMessage()])],
|
||||
'general' => [__('user.delete_failed')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
익명화된 이메일에는 사용자 ID 가 포함되어 구조적으로 유일하다 — 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 충돌하지 않는다.
|
||||
|
||||
탈퇴 처리 중 예기치 못한 오류(예: 데이터베이스 예외)가 발생하면 500 과 함께 고정된 안내 메시지(`user.withdraw_failed`)만 반환한다 — 예외 원문(SQL 상태코드·쿼리 등)은 응답에 싣지 않고 서버 로그에만 기록한다(회원에게 노출되는 경로이므로 내부 정보 유출을 차단한다).
|
||||
|
||||
**추가 오류 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- @generated:start:api.search -->
|
||||
- **라우트명**: `api.search`
|
||||
- **컨트롤러**: `App\Http\Controllers\Api\Public\PublicSearchController@search`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
- **인증/권한**: `optional.sanctum` (선택적 인증: 회원/비회원 모두 접근)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -104,6 +104,6 @@ HTTP/1.1 200
|
||||
|
||||
**설명**
|
||||
|
||||
프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증이 필요 없으며 게스트도 사용할 수 있습니다. 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요.
|
||||
프론트엔드 통합 검색(`search/index.json`)이 호출하는 공개 엔드포인트입니다. 인증 없이(게스트) 사용할 수 있고, Bearer 토큰을 보내면 회원으로 해석되어 게시판별 열람 권한이 검색 결과와 `available_boards` 필터 목록에 반영됩니다(`optional.sanctum` — 위조 토큰은 401, 만료 토큰은 게스트로 처리). 코어 컨트롤러는 검색 결과를 직접 생성하지 않고, 검증된 파라미터로 검색 컨텍스트(q/type/sort/page/per_page 및 요청 객체)를 구성한 뒤 `core.search.results` Filter 훅을 실행합니다. 게시판·상품 등 각 검색 대상 모듈이 이 훅에 리스너를 등록해 자신의 카테고리 결과를 추가하고, `core.search.build_response` 훅으로 응답 구조를 완성합니다. 따라서 활성 검색 모듈이 없으면 항상 빈 결과(`total: 0`)가 반환됩니다. 검색 엔진 자체는 Scout + `DatabaseFulltextEngine`(MySQL FULLTEXT) 기반이며, 상세는 `docs/backend/search-system.md`를 참고하세요.
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.0.6] - 2026-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
- 회원 관리(생성·수정·삭제·탈퇴) 실패 안내에서 오류 원문 노출(`:error`)이 제거된 것에 맞춰, 해당 실패 문구의 일본어 번역을 재시도 안내 문구로 갱신했습니다 (`user.create_failed`, `user.update_failed`, `user.delete_failed`, `user.withdraw_failed`).
|
||||
|
||||
### Added
|
||||
|
||||
- 검색엔진 드라이버 라벨 일본어 번역을 추가했습니다 (`settings.drivers.search.*`) — 환경설정 > 드라이버 탭의 검색엔진 선택지가 일본어 로케일에서 표시됩니다.
|
||||
|
||||
@@ -16,11 +16,11 @@ return [
|
||||
'fetch_success' => 'ユーザー情報を正常に取得しました。',
|
||||
'fetch_failed' => 'ユーザー情報の取得に失敗しました。',
|
||||
'create_success' => 'ユーザーが正常に作成されました。',
|
||||
'create_failed' => 'ユーザー作成に失敗しました: :error',
|
||||
'create_failed' => 'ユーザー作成に失敗しました。しばらくしてからもう一度お試しください。',
|
||||
'update_success' => 'ユーザー情報が正常に更新されました。',
|
||||
'update_failed' => 'ユーザー情報の更新に失敗しました: :error',
|
||||
'update_failed' => 'ユーザー情報の更新に失敗しました。しばらくしてからもう一度お試しください。',
|
||||
'delete_success' => 'ユーザーが正常に削除されました。',
|
||||
'delete_failed' => 'ユーザー削除に失敗しました: :error',
|
||||
'delete_failed' => 'ユーザー削除に失敗しました。しばらくしてからもう一度お試しください。',
|
||||
'statistics_success' => 'ユーザー統計を正常に取得しました。',
|
||||
'statistics_failed' => 'ユーザー統計の取得に失敗しました。',
|
||||
'search_success' => 'ユーザー検索が正常に完了しました。',
|
||||
@@ -54,7 +54,7 @@ return [
|
||||
'password_change_success' => 'パスワードが正常に変更されました。',
|
||||
'password_change_failed' => 'パスワード変更に失敗しました。',
|
||||
'withdraw_success' => '会員退会が完了しました。',
|
||||
'withdraw_failed' => '会員退会に失敗しました: :error',
|
||||
'withdraw_failed' => '会員退会に失敗しました。しばらくしてからもう一度お試しください。',
|
||||
'withdraw_admin_forbidden' => '管理者アカウントは退会できません。',
|
||||
'validation' => [
|
||||
'page_integer' => 'ページ番号は数字である必要があります。',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 확정 불가 상태의 주문 옵션 구매확정 시도 안내(`exceptions.order_option_not_confirmable`)의 일본어 번역을 추가했습니다.
|
||||
- 이용할 수 없는 결제수단 선택 시의 주문 거절 안내 일본어 번역을 추가했습니다 (`validation.order.payment_method_unavailable`).
|
||||
- 관리자 주문설정 결제수단 목록의 「지정 PG 삭제됨」 배지 일본어 번역을 추가했습니다.
|
||||
- 배송지 변경 처리 중 서버 오류가 발생했을 때 표시되는 안내 문구의 일본어 번역을 추가했습니다.
|
||||
|
||||
@@ -84,4 +84,5 @@ return [
|
||||
'coupon_issue_not_cancellable' => '未使用状態の発行分のみキャンセルできます。',
|
||||
'country_not_shippable' => '選択された配送先国には配送できない商品です。',
|
||||
'order_shipping_address_update_failed' => '配送先変更処理中にエラーが発生しました。',
|
||||
'order_option_not_confirmable' => '現在の状態では購入確定できない注文オプションです。',
|
||||
];
|
||||
|
||||
+4
-4
@@ -21,11 +21,11 @@ return [
|
||||
'fetch_success' => 'User information retrieved successfully.',
|
||||
'fetch_failed' => 'Failed to retrieve user information.',
|
||||
'create_success' => 'User created successfully.',
|
||||
'create_failed' => 'Failed to create user: :error',
|
||||
'create_failed' => 'Failed to create user. Please try again later.',
|
||||
'update_success' => 'User information updated successfully.',
|
||||
'update_failed' => 'Failed to update user information: :error',
|
||||
'update_failed' => 'Failed to update user information. Please try again later.',
|
||||
'delete_success' => 'User deleted successfully.',
|
||||
'delete_failed' => 'Failed to delete user: :error',
|
||||
'delete_failed' => 'Failed to delete user. Please try again later.',
|
||||
'statistics_success' => 'User statistics retrieved successfully.',
|
||||
'statistics_failed' => 'Failed to retrieve user statistics.',
|
||||
'search_success' => 'User search completed successfully.',
|
||||
@@ -69,7 +69,7 @@ return [
|
||||
|
||||
// Withdraw messages
|
||||
'withdraw_success' => 'Account withdrawal completed successfully.',
|
||||
'withdraw_failed' => 'Failed to withdraw account: :error',
|
||||
'withdraw_failed' => 'Failed to withdraw account. Please try again later.',
|
||||
'withdraw_admin_forbidden' => 'Administrator account cannot be withdrawn.',
|
||||
'withdraw_failed_unknown' => 'Withdrawal failed due to an unknown error.',
|
||||
|
||||
|
||||
+4
-4
@@ -21,11 +21,11 @@ return [
|
||||
'fetch_success' => '사용자 정보를 성공적으로 가져왔습니다.',
|
||||
'fetch_failed' => '사용자 정보를 가져오는데 실패했습니다.',
|
||||
'create_success' => '사용자가 성공적으로 생성되었습니다.',
|
||||
'create_failed' => '사용자 생성에 실패했습니다: :error',
|
||||
'create_failed' => '사용자 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'update_success' => '사용자 정보가 성공적으로 업데이트되었습니다.',
|
||||
'update_failed' => '사용자 정보 업데이트에 실패했습니다: :error',
|
||||
'update_failed' => '사용자 정보 업데이트에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'delete_success' => '사용자가 성공적으로 삭제되었습니다.',
|
||||
'delete_failed' => '사용자 삭제에 실패했습니다: :error',
|
||||
'delete_failed' => '사용자 삭제에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'statistics_success' => '사용자 통계를 성공적으로 가져왔습니다.',
|
||||
'statistics_failed' => '사용자 통계를 가져오는데 실패했습니다.',
|
||||
'search_success' => '사용자 검색을 성공적으로 완료했습니다.',
|
||||
@@ -69,7 +69,7 @@ return [
|
||||
|
||||
// 회원 탈퇴 관련 메시지
|
||||
'withdraw_success' => '회원 탈퇴가 완료되었습니다.',
|
||||
'withdraw_failed' => '회원 탈퇴에 실패했습니다: :error',
|
||||
'withdraw_failed' => '회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
'withdraw_admin_forbidden' => '관리자 계정은 탈퇴할 수 없습니다.',
|
||||
'withdraw_failed_unknown' => '알 수 없는 오류로 탈퇴 처리에 실패했습니다.',
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
- 회원 프로필의 작성글 목록과 "내가 댓글 단 글" 활동 목록에서 다른 사람의 비밀글·블라인드 글 **본문**이 로그인 없이도 나가던 문제를 수정했습니다. 두 목록은 본문 앞부분을 함께 싣는데 그것을 가리는 설정이 실제로는 한 번도 켜지지 않았습니다. 이제 본인이 볼 때만 본문이 보이고, 다른 사람이 볼 때는 비워집니다. 글의 제목과 목록에서의 표시(비밀글·블라인드 배지)는 게시판 목록과 동일하게 그대로 유지됩니다. (KVE-2026-1914)
|
||||
- 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 회원 화면은 작성자 본인만 삭제하도록 막고 있었지만 관리 화면에는 같은 확인이 없었고, 순서 변경은 양쪽 모두 확인이 없었습니다. 순서는 목록 전체에 대한 하나의 값이라 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부합니다. (KVE-2026-1919)
|
||||
- 게시판을 찾을 수 없을 때 비밀글 보호가 통과되던 문제를 수정했습니다. 첨부파일 서빙과 댓글 목록은 부모 글을 찾지 못하면 검사를 건너뛰고 진행해, 게이트가 있어야 할 자리가 비어 있었습니다. 이제 부모 글을 확인할 수 없으면 차단합니다.
|
||||
- 로그인 없이 열리는 목록(최근 글·인기 글)과 관리 대시보드의 요약 목록에서, 열람 권한이 없는 게시판의 글과 블라인드·비활성 게시판의 글이 노출되던 문제를 막았습니다. 이제 이 목록들은 블라인드·비활성 게시판 글을 제외하고, 열람 권한이 있는 게시판의 글만 각 요청자에게 보여 줍니다. 인기 게시판 목록과 통합 검색의 게시판 필터에서도 열람 권한이 없는 비공개 게시판이 더 이상 나타나지 않습니다. 비밀글은 기존과 동일하게 목록에 제목과 🔒 표시가 유지되며 본문만 보호됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 게시판 관리 대시보드(개요·글 추이·최근 글·미처리 신고)에 별도의 열람 권한을 요구하도록 했습니다. 이전에는 관리자이기만 하면 게시판 권한이 전혀 없어도 이 화면의 정보를 볼 수 있었습니다. 미처리 신고 집계는 비활성 게시판을 제외합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 댓글 비밀번호 확인 후 받는 임시 토큰이 실제 검증에 연결되어 있지 않던 문제를 수정했습니다. 이제 이 토큰으로 댓글을 수정·삭제할 수 있으며(비밀번호 재입력 대체), 토큰은 한 번만 사용되고 만료됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- @generated:start:api.modules.sirsoft-board.admin.dashboard.overview -->
|
||||
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.overview`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@overview`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -77,7 +77,7 @@ HTTP/1.1 200
|
||||
<!-- @generated:start:api.modules.sirsoft-board.admin.dashboard.pending-reports -->
|
||||
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.pending-reports`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@pendingReports`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -151,7 +151,7 @@ HTTP/1.1 200
|
||||
<!-- @generated:start:api.modules.sirsoft-board.admin.dashboard.post-graph -->
|
||||
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.post-graph`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@postGraph`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -253,7 +253,7 @@ HTTP/1.1 200
|
||||
<!-- @generated:start:api.modules.sirsoft-board.admin.dashboard.recent-posts -->
|
||||
- **라우트명**: `api.modules.sirsoft-board.admin.dashboard.recent-posts`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Board\Http\Controllers\Admin\DashboardController@recentPosts`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-board.dashboard.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
|
||||
@@ -256,6 +256,33 @@ class Module extends AbstractModule
|
||||
],
|
||||
],
|
||||
],
|
||||
// 대시보드 조회 권한 (type: admin)
|
||||
[
|
||||
'identifier' => 'dashboard',
|
||||
'name' => [
|
||||
'ko' => '게시판 대시보드',
|
||||
'en' => 'Board Dashboard',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '게시판 대시보드 조회 권한',
|
||||
'en' => 'Board dashboard view permissions',
|
||||
],
|
||||
'permissions' => [
|
||||
[
|
||||
'action' => 'view',
|
||||
'name' => [
|
||||
'ko' => '대시보드 조회',
|
||||
'en' => 'View Dashboard',
|
||||
],
|
||||
'description' => [
|
||||
'ko' => '게시판 대시보드(현황/추세/최신글/미처리 신고) 조회',
|
||||
'en' => 'View board dashboard (overview, trend, recent posts, pending reports)',
|
||||
],
|
||||
'type' => 'admin',
|
||||
'roles' => ['admin', 'manager'],
|
||||
],
|
||||
],
|
||||
],
|
||||
// 신고 관리 권한 (type: admin)
|
||||
[
|
||||
'identifier' => 'reports',
|
||||
|
||||
@@ -189,12 +189,22 @@ class CommentController extends PublicBaseController
|
||||
$slug
|
||||
);
|
||||
|
||||
// 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형)
|
||||
if (! $canUpdate && $request->filled('verification_token')) {
|
||||
$canUpdate = $this->commentService->consumeCommentVerifyToken(
|
||||
$slug,
|
||||
$commentId,
|
||||
(string) $request->input('verification_token')
|
||||
);
|
||||
}
|
||||
|
||||
if (! $canUpdate) {
|
||||
return $this->forbidden('sirsoft-board::messages.comment.update_forbidden');
|
||||
}
|
||||
|
||||
// 검증된 필드만 반영 (미검증 입력의 대량 할당 차단). password는 검증용이므로 제거
|
||||
$data = collect($request->validated())->except('password')->toArray();
|
||||
// 검증된 필드만 반영 (미검증 입력의 대량 할당 차단).
|
||||
// password/verification_token 은 본인 확인용이므로 저장 데이터에서 제거
|
||||
$data = collect($request->validated())->except(['password', 'verification_token'])->toArray();
|
||||
$updatedComment = $this->commentService->updateComment($slug, $commentId, $data, $postId);
|
||||
|
||||
return $this->successWithResource(
|
||||
@@ -243,6 +253,16 @@ class CommentController extends PublicBaseController
|
||||
$slug
|
||||
);
|
||||
|
||||
// 비회원 댓글: 평문 비밀번호 재전송 대신 검증 토큰으로도 본인 확인 (게시글과 동형)
|
||||
$verificationToken = request()->input('verification_token');
|
||||
if (! $canDelete && ! empty($verificationToken)) {
|
||||
$canDelete = $this->commentService->consumeCommentVerifyToken(
|
||||
$slug,
|
||||
$commentId,
|
||||
(string) $verificationToken
|
||||
);
|
||||
}
|
||||
|
||||
if (! $canDelete) {
|
||||
return $this->forbidden('sirsoft-board::messages.comment.delete_forbidden');
|
||||
}
|
||||
@@ -288,16 +308,18 @@ class CommentController extends PublicBaseController
|
||||
return $this->error('sirsoft-board::messages.comment.invalid_password', 401);
|
||||
}
|
||||
|
||||
// 검증 성공 시 임시 토큰 생성 (프론트엔드에서 로컬 스토리지에 저장)
|
||||
// 검증 성공 시 1회용 토큰을 발급하고 캐시에 저장한다.
|
||||
// (게시글과 동형 — update/destroy 가 이 토큰을 소비해 평문 비밀번호 재전송을 대체)
|
||||
$verificationToken = Str::random(32);
|
||||
$tokenResult = $this->commentService->storeCommentVerifyToken($slug, $commentId, $verificationToken);
|
||||
|
||||
return $this->success(
|
||||
'sirsoft-board::messages.comment.password_verified',
|
||||
[
|
||||
'verified' => true,
|
||||
'comment_id' => $commentId,
|
||||
'verification_token' => $verificationToken,
|
||||
'expires_at' => now()->addHours(1)->toIso8601String(), // 1시간 유효
|
||||
'verification_token' => $tokenResult['token'],
|
||||
'expires_at' => $tokenResult['expires_at'],
|
||||
]
|
||||
);
|
||||
} catch (ModelNotFoundException) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Extension\HookManager;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
||||
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ class UpdateCommentRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$slug = $this->route('slug');
|
||||
$board = Board::where('slug', $slug)->first();
|
||||
$board = app(BoardRepositoryInterface::class)->findBySlug($slug);
|
||||
|
||||
if (! $board) {
|
||||
return [];
|
||||
@@ -41,6 +41,10 @@ class UpdateCommentRequest extends FormRequest
|
||||
// 비회원 여부 확인 (request()->user()를 사용해야 PermissionMiddleware에서 설정한 사용자를 인식)
|
||||
$isGuest = ! $this->user();
|
||||
|
||||
// 검증 토큰(verify-password 로 발급)이 있으면 평문 비밀번호 재전송을 요구하지 않는다.
|
||||
// (게시글 수정 경로와 동형 — 토큰이 본인 확인을 대체하며, 컨트롤러가 1회 소비한다)
|
||||
$hasVerificationToken = $this->filled('verification_token');
|
||||
|
||||
// 금지 키워드 목록 가져오기 (게시글과 동일하게 게시판 설정 기준)
|
||||
$blockedKeywords = $board->blocked_keywords ?? [];
|
||||
|
||||
@@ -54,8 +58,9 @@ class UpdateCommentRequest extends FormRequest
|
||||
],
|
||||
'is_secret' => ['boolean'],
|
||||
'status' => ['nullable', 'string', Rule::in(PostStatus::values())],
|
||||
// 비회원인 경우 비밀번호 필수 (수정 권한 검증용)
|
||||
'password' => [$isGuest ? 'required' : 'nullable', 'string', 'min:4', 'max:20'],
|
||||
// 비회원인 경우 비밀번호 필수 (수정 권한 검증용). 단, 검증 토큰이 있으면 선택.
|
||||
'password' => [$isGuest && ! $hasVerificationToken ? 'required' : 'nullable', 'string', 'min:4', 'max:20'],
|
||||
'verification_token' => ['nullable', 'string'],
|
||||
];
|
||||
|
||||
// 훅: 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 필터 제공
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Search\SearchCategoryPayload;
|
||||
use App\Search\SearchHighlighter;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Board\Services\BoardService;
|
||||
use Modules\Sirsoft\Board\Services\PostService;
|
||||
@@ -213,7 +214,9 @@ class SearchPostsListener implements HookListenerInterface
|
||||
$type = $context['type'] ?? 'all';
|
||||
|
||||
$boardIds = collect($boards)->pluck('id')->all();
|
||||
$extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter()];
|
||||
// available_boards(필터 드롭다운)는 검색 결과 필터와 동일하게 읽기 권한을 통과한
|
||||
// 게시판만 담는다 — 권한 없는 게시판이 필터 목록으로 노출되지 않도록 컨텍스트 사용자를 전달.
|
||||
$extra = ['available_boards' => $this->boardService->getActiveBoardsListForFilter($context['user'] ?? null)];
|
||||
|
||||
/** 조회 결과 항목을 화면 형태로 가공한다. */
|
||||
$format = fn (iterable $items): array => collect($items)
|
||||
@@ -324,14 +327,9 @@ class SearchPostsListener implements HookListenerInterface
|
||||
$contentPreview = $this->extractContentPreview($post->content, $keyword, 150, $contentMode);
|
||||
$boardSlug = $post->board?->slug ?? '';
|
||||
|
||||
// 텍스트 모드: 태그 문자열이 그대로 보존되므로 htmlspecialchars로 이스케이프 후 하이라이트
|
||||
// HTML 모드: strip_tags 처리된 평문이므로 바로 하이라이트
|
||||
if ($contentMode === 'text') {
|
||||
$escapedPreview = htmlspecialchars($contentPreview, ENT_QUOTES, 'UTF-8');
|
||||
$contentPreviewHighlighted = $this->highlightKeyword($escapedPreview, $keyword);
|
||||
} else {
|
||||
$contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword);
|
||||
}
|
||||
// 하이라이트 필드는 소비 측이 HTML 로 렌더하므로, 공유 헬퍼가 원문을 이스케이프한 뒤
|
||||
// 검색어만 <mark> 로 감싼다. 텍스트 모드의 리터럴 태그 문자열도 여기서 이스케이프된다.
|
||||
$contentPreviewHighlighted = $this->highlightKeyword($contentPreview, $keyword);
|
||||
|
||||
return [
|
||||
'id' => $post->id,
|
||||
@@ -366,13 +364,7 @@ class SearchPostsListener implements HookListenerInterface
|
||||
*/
|
||||
private function highlightKeyword(?string $text, string $keyword): string
|
||||
{
|
||||
if (empty($text) || empty($keyword)) {
|
||||
return $text ?? '';
|
||||
}
|
||||
|
||||
$escapedKeyword = preg_quote($keyword, '/');
|
||||
|
||||
return preg_replace('/('.$escapedKeyword.')/iu', '<mark>$1</mark>', $text);
|
||||
return SearchHighlighter::highlight($text, $keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,9 +385,10 @@ class SearchPostsListener implements HookListenerInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
// HTML 모드: 태그 제거 후 평문 추출, 텍스트 모드: 태그 문자열 그대로 보존
|
||||
// HTML 모드: 태그 제거 후 평문 추출(엔티티 디코드를 태그 제거보다 먼저 수행),
|
||||
// 텍스트 모드: 태그 문자열 그대로 보존(하이라이트 시점에 이스케이프됨)
|
||||
if ($contentMode === 'html') {
|
||||
$plainText = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($content))));
|
||||
$plainText = SearchHighlighter::toPlainText($content);
|
||||
} else {
|
||||
$plainText = trim(preg_replace('/\s+/', ' ', $content));
|
||||
}
|
||||
|
||||
@@ -126,6 +126,21 @@ class BoardRepository implements BoardRepositoryInterface
|
||||
return $board->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판을 영구 삭제합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @return bool 삭제 성공 여부
|
||||
*
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function forceDelete(int $id): bool
|
||||
{
|
||||
$board = $this->findOrFail($id);
|
||||
|
||||
return (bool) $board->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 게시판을 조회합니다.
|
||||
*
|
||||
@@ -222,6 +237,8 @@ class BoardRepository implements BoardRepositoryInterface
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('parent_id')
|
||||
->where('status', 'published')
|
||||
// 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책. 목록/검색/홈과 동일.
|
||||
// 게시판별 열람 권한은 BoardService::getCachedRecentPosts 가 응답 시점에 적용한다.
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit($limit)
|
||||
);
|
||||
|
||||
+10
@@ -68,6 +68,16 @@ interface BoardRepositoryInterface
|
||||
*/
|
||||
public function delete(int $id): bool;
|
||||
|
||||
/**
|
||||
* 게시판을 영구 삭제합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @return bool 삭제 성공 여부
|
||||
*
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
public function forceDelete(int $id): bool;
|
||||
|
||||
/**
|
||||
* 모든 게시판을 조회합니다.
|
||||
*
|
||||
|
||||
@@ -1807,6 +1807,10 @@ class PostRepository implements PostRepositoryInterface
|
||||
return Post::query()
|
||||
->whereNull('deleted_at')
|
||||
->whereNull('parent_id')
|
||||
// 노출 제한 필터 — 미발행(블라인드·삭제)·비활성 게시판 글은 대시보드 최신글에서 제외한다.
|
||||
// 비밀글은 제목 공개 정책(2026-01-02)에 따라 관리자에게 제목을 노출한다(제외하지 않음).
|
||||
->where('status', PostStatus::Published->value)
|
||||
->whereHas('board', fn ($q) => $q->where('is_active', true))
|
||||
->with(['board', 'user'])
|
||||
->orderByDesc('created_at')
|
||||
->limit($limit)
|
||||
|
||||
@@ -668,8 +668,15 @@ class ReportRepository implements ReportRepositoryInterface
|
||||
*/
|
||||
public function getPendingAcrossBoards(int $limit): Collection
|
||||
{
|
||||
return Report::query()
|
||||
$query = Report::query()
|
||||
->whereIn('status', [ReportStatus::Pending, ReportStatus::Review])
|
||||
// 비활성 게시판의 신고는 대시보드 집계에서 제외한다(활성 게시판 스코프).
|
||||
->whereHas('board', fn ($q) => $q->where('is_active', true));
|
||||
|
||||
// 신고 조회 권한 스코프 필터링 (목록 엔드포인트와 동일 게이트)
|
||||
PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view');
|
||||
|
||||
return $query
|
||||
->with(['board', 'author'])
|
||||
->orderByDesc('last_reported_at')
|
||||
->limit($limit)
|
||||
@@ -683,8 +690,13 @@ class ReportRepository implements ReportRepositoryInterface
|
||||
*/
|
||||
public function countPendingAcrossBoards(): int
|
||||
{
|
||||
return Report::query()
|
||||
$query = Report::query()
|
||||
->whereIn('status', [ReportStatus::Pending, ReportStatus::Review])
|
||||
->count();
|
||||
// 목록(getPendingAcrossBoards)과 동일 스코프여야 배지 건수와 목록 건수가 일치한다.
|
||||
->whereHas('board', fn ($q) => $q->where('is_active', true));
|
||||
|
||||
PermissionHelper::applyPermissionScope($query, 'sirsoft-board.reports.view');
|
||||
|
||||
return $query->count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Modules\Sirsoft\Board\Services;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Repositories\MenuRepositoryInterface;
|
||||
use App\Contracts\Repositories\RoleRepositoryInterface;
|
||||
use App\Contracts\Repositories\UserRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
@@ -53,6 +55,9 @@ class BoardService
|
||||
* @param RoleService $roleService 역할 서비스
|
||||
* @param CacheInterface $cache 캐시 드라이버
|
||||
* @param MenuRepositoryInterface $menuRepository 코어 메뉴 리포지토리 (메뉴 조회용)
|
||||
* @param StorageInterface $storage 스토리지 드라이버
|
||||
* @param RoleRepositoryInterface $roleRepository 코어 역할 리포지토리 (게시판별 역할 동기화용)
|
||||
* @param UserRepositoryInterface $userRepository 코어 사용자 리포지토리 (통계·UUID 변환용)
|
||||
*/
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $boardRepository,
|
||||
@@ -64,7 +69,9 @@ class BoardService
|
||||
private RoleService $roleService,
|
||||
private CacheInterface $cache,
|
||||
private MenuRepositoryInterface $menuRepository,
|
||||
private StorageInterface $storage
|
||||
private StorageInterface $storage,
|
||||
private RoleRepositoryInterface $roleRepository,
|
||||
private UserRepositoryInterface $userRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -114,6 +121,7 @@ class BoardService
|
||||
* ID로 게시판을 조회합니다.
|
||||
*
|
||||
* @param int $id 게시판 ID
|
||||
* @return Board 게시판 모델
|
||||
*
|
||||
* @throws ModelNotFoundException
|
||||
*/
|
||||
@@ -153,6 +161,7 @@ class BoardService
|
||||
*
|
||||
* @param string $orderBy 정렬 기준 (기본: created_at)
|
||||
* @param string $orderDirection 정렬 방향 (기본: desc)
|
||||
* @return Collection 활성 게시판 컬렉션
|
||||
*/
|
||||
public function getActiveBoards(
|
||||
string $orderBy = 'created_at',
|
||||
@@ -211,7 +220,7 @@ class BoardService
|
||||
$boardStats = $this->getActiveBoardStats();
|
||||
|
||||
return [
|
||||
'users' => User::count(),
|
||||
'users' => $this->userRepository->getStatistics()['total_users'],
|
||||
'boards' => $boardStats['boards'],
|
||||
'posts' => $boardStats['posts'],
|
||||
'comments' => $boardStats['comments'],
|
||||
@@ -229,12 +238,18 @@ class BoardService
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
|
||||
|
||||
return $this->cache->remember(
|
||||
// 캐시는 사용자와 무관한 안전집합(발행 + 활성 게시판)으로만 채운다.
|
||||
// 비밀글은 제목 공개 정책(2026-02-04)에 따라 제목을 포함한다(본문 미포함 — 사용자 무관 데이터).
|
||||
// 게시판별 열람 권한은 캐시에 담지 않고 응답 시점에 호출자 기준으로 적용해,
|
||||
// 고권한 사용자의 결과가 캐시를 오염시켜 저권한 사용자에게 새는 것을 막는다.
|
||||
$posts = $this->cache->remember(
|
||||
"recent_posts_{$limit}",
|
||||
fn () => $this->getRecentPosts($limit),
|
||||
$ttl,
|
||||
tags: ['board-posts']
|
||||
);
|
||||
|
||||
return $this->filterItemsByBoardReadPermission($posts, 'board_slug');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,17 +259,22 @@ class BoardService
|
||||
*
|
||||
* @param string $period 기간 (today, week, month, year)
|
||||
* @param int $limit 조회 개수
|
||||
* @return array 캐시된 인기글 목록
|
||||
*/
|
||||
public function getCachedPopularPosts(string $period = 'week', int $limit = 20): array
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
|
||||
|
||||
return $this->cache->remember(
|
||||
// 캐시는 안전집합(비밀글 제외 + 발행 + 활성 게시판)으로만 채우고, 게시판별
|
||||
// 열람 권한은 응답 시점에 호출자 기준으로 적용한다(캐시 오염 회피).
|
||||
$posts = $this->cache->remember(
|
||||
"popular_posts_{$period}_{$limit}",
|
||||
fn () => $this->getPopularPosts($period, $limit),
|
||||
$ttl,
|
||||
tags: ['board-posts']
|
||||
);
|
||||
|
||||
return $this->filterItemsByBoardReadPermission($posts, 'board_slug');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,12 +287,16 @@ class BoardService
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.default_ttl', 86400);
|
||||
|
||||
return $this->cache->remember(
|
||||
// 캐시는 활성 게시판 전체로 채우고, 게시판별 열람 권한은 응답 시점에
|
||||
// 호출자 기준으로 적용한다(캐시 오염 회피 — 저권한 사용자에게 고권한 결과가 새지 않도록).
|
||||
$boards = $this->cache->remember(
|
||||
"popular_boards_{$limit}",
|
||||
fn () => $this->getPopularBoards($limit),
|
||||
$ttl,
|
||||
tags: ['board-list']
|
||||
);
|
||||
|
||||
return $this->filterItemsByBoardReadPermission($boards, 'slug');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,13 +543,13 @@ class BoardService
|
||||
HookManager::doAction('sirsoft-board.roles.after_delete', $board->slug);
|
||||
|
||||
// 5. 등록된 메뉴 제거 (addToAdminMenu()로 등록된 경우에만)
|
||||
$menu = Menu::where('url', '/admin/board/'.$board->slug)->first();
|
||||
$menu = $this->menuRepository->findByUrl('/admin/board/'.$board->slug);
|
||||
if ($menu) {
|
||||
$this->menuService->deleteMenu($menu);
|
||||
}
|
||||
|
||||
// 6. 게시판 영구 삭제
|
||||
$board->forceDelete();
|
||||
$this->boardRepository->forceDelete($board->id);
|
||||
});
|
||||
|
||||
Log::info('Board deleted', [
|
||||
@@ -665,10 +689,10 @@ class BoardService
|
||||
$boardNameKo = $board->name['ko'] ?? $board->name['en'] ?? $board->slug;
|
||||
$boardNameEn = $board->name['en'] ?? $board->name['ko'] ?? $board->slug;
|
||||
|
||||
// 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음
|
||||
$managerRole = Role::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.manager"],
|
||||
[
|
||||
// 중복 방어: 이전 삭제 실패 등으로 role이 남아있을 수 있음 (기존 role 은 갱신 없이 재사용 — firstOrCreate 동형)
|
||||
$managerRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.manager")
|
||||
?? $this->roleRepository->create([
|
||||
'identifier' => "sirsoft-board.{$board->slug}.manager",
|
||||
'name' => [
|
||||
'ko' => "{$boardNameKo} 게시판 관리자",
|
||||
'en' => "{$boardNameEn} Board Manager",
|
||||
@@ -679,12 +703,11 @@ class BoardService
|
||||
],
|
||||
'extension_type' => ExtensionOwnerType::Module,
|
||||
'extension_identifier' => 'sirsoft-board',
|
||||
]
|
||||
);
|
||||
]);
|
||||
|
||||
$stepRole = Role::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.step"],
|
||||
[
|
||||
$stepRole = $this->roleRepository->findByIdentifier("sirsoft-board.{$board->slug}.step")
|
||||
?? $this->roleRepository->create([
|
||||
'identifier' => "sirsoft-board.{$board->slug}.step",
|
||||
'name' => [
|
||||
'ko' => "{$boardNameKo} 게시판 스텝",
|
||||
'en' => "{$boardNameEn} Board Step",
|
||||
@@ -695,8 +718,7 @@ class BoardService
|
||||
],
|
||||
'extension_type' => ExtensionOwnerType::Module,
|
||||
'extension_identifier' => 'sirsoft-board',
|
||||
]
|
||||
);
|
||||
]);
|
||||
|
||||
return ['manager' => $managerRole, 'step' => $stepRole];
|
||||
}
|
||||
@@ -713,13 +735,16 @@ class BoardService
|
||||
"sirsoft-board.{$board->slug}.step",
|
||||
];
|
||||
|
||||
$roles = Role::whereIn('identifier', $roleIdentifiers)->get();
|
||||
foreach ($roleIdentifiers as $identifier) {
|
||||
$role = $this->roleRepository->findByIdentifier($identifier);
|
||||
if (! $role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($roles as $role) {
|
||||
// 그누보드7 규정: detach 후 삭제
|
||||
$role->permissions()->detach();
|
||||
$role->users()->detach();
|
||||
$role->delete();
|
||||
$this->roleRepository->delete($role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,7 +782,10 @@ class BoardService
|
||||
];
|
||||
|
||||
foreach ($roleUpdates as $identifier => $attributes) {
|
||||
Role::where('identifier', $identifier)->update($attributes);
|
||||
$role = $this->roleRepository->findByIdentifier($identifier);
|
||||
if ($role) {
|
||||
$this->roleRepository->update($role, $attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +807,7 @@ class BoardService
|
||||
continue;
|
||||
}
|
||||
|
||||
$role = Role::where('identifier', $roleIdentifier)->first();
|
||||
$role = $this->roleRepository->findByIdentifier($roleIdentifier);
|
||||
if (! $role) {
|
||||
Log::warning('게시판 역할을 찾을 수 없습니다.', [
|
||||
'board_slug' => $board->slug,
|
||||
@@ -790,7 +818,7 @@ class BoardService
|
||||
}
|
||||
|
||||
$userUuids = $data[$dataKey] ?? [];
|
||||
$userIds = User::whereIn('uuid', $userUuids)->pluck('id')->toArray();
|
||||
$userIds = $this->userRepository->getIdsByUuids($userUuids);
|
||||
$role->users()->sync($userIds);
|
||||
}
|
||||
}
|
||||
@@ -886,6 +914,7 @@ class BoardService
|
||||
*
|
||||
* @param string $period 기간 (today, week, month, all)
|
||||
* @param int $limit 조회 개수
|
||||
* @return array 인기글 목록
|
||||
*/
|
||||
public function getPopularPosts(string $period = 'week', int $limit = 20): array
|
||||
{
|
||||
@@ -938,7 +967,7 @@ class BoardService
|
||||
$menuUrl = '/admin/board/'.$board->slug;
|
||||
|
||||
// 중복 체크: 동일한 URL의 메뉴가 이미 존재하는지 확인
|
||||
$existingMenu = Menu::where('url', $menuUrl)->first();
|
||||
$existingMenu = $this->menuRepository->findByUrl($menuUrl);
|
||||
|
||||
if ($existingMenu) {
|
||||
throw new MenuAlreadyExistsException(__('sirsoft-board::messages.boards.menu_already_exists'));
|
||||
@@ -1104,18 +1133,49 @@ class BoardService
|
||||
/**
|
||||
* 필터용 전체 활성 게시판 목록을 배열로 반환합니다.
|
||||
*
|
||||
* $user 가 전달되면 그 사용자가 읽기 권한(posts.read)을 가진 게시판만 남긴다.
|
||||
* 통합 검색 필터 드롭다운(available_boards)이 열람 불가 게시판까지 노출하지 않도록,
|
||||
* 검색 결과 필터와 동일한 게이트를 목록 구성에도 적용한다.
|
||||
*
|
||||
* @param User|null $user 기준 사용자 (null 이면 현재 인증 사용자 또는 guest)
|
||||
* @return array<int, array{slug: string, name: string}> 활성 게시판 목록
|
||||
*/
|
||||
public function getActiveBoardsListForFilter(): array
|
||||
public function getActiveBoardsListForFilter(?User $user = null): array
|
||||
{
|
||||
return $this->boardRepository->getActiveBoardsList()
|
||||
->filter(fn ($board) => PermissionHelper::check("sirsoft-board.{$board->slug}.posts.read", $user))
|
||||
->map(fn ($board) => [
|
||||
'slug' => $board->slug,
|
||||
'name' => $board->getLocalizedName(),
|
||||
])
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 목록의 각 항목을 호출자 읽기 권한(posts.read) 기준으로 필터링합니다.
|
||||
*
|
||||
* 공개 엔드포인트(최근/인기 게시글·인기 게시판)의 전역 캐시는 사용자와 무관하게
|
||||
* 안전집합으로만 채워지므로, 게시판별 열람 권한은 캐시가 아니라 응답 시점에
|
||||
* 현재 호출자 기준으로 적용한다. 고권한 사용자의 결과가 캐시에 남아 저권한/비회원
|
||||
* 사용자에게 새는 것을 막는다.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items 캐시된 목록
|
||||
* @param string $slugKey 각 항목에서 게시판 슬러그를 담은 키
|
||||
* @return array<int, array<string, mixed>> 읽기 권한을 통과한 항목만 남긴 목록
|
||||
*/
|
||||
private function filterItemsByBoardReadPermission(array $items, string $slugKey): array
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return array_values(array_filter($items, function ($item) use ($slugKey, $user) {
|
||||
$slug = $item[$slugKey] ?? null;
|
||||
|
||||
return $slug !== null
|
||||
&& PermissionHelper::check("sirsoft-board.{$slug}.posts.read", $user);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경설정 기본값을 기존 게시판에 일괄 적용합니다.
|
||||
*
|
||||
|
||||
@@ -575,6 +575,51 @@ class CommentService
|
||||
$this->cache->put("comment_cooldown_{$slug}_{$identifier}", true, $seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 비밀번호 검증 토큰을 캐시에 저장하고 만료 시각을 반환합니다.
|
||||
*
|
||||
* 게시글(PostService::storeDeleteVerifyToken)과 동형 — 비회원이 비밀번호를
|
||||
* 확인하면 1회용 토큰을 발급해, 이후 수정/삭제 요청에서 평문 비밀번호 재전송 대신
|
||||
* 이 토큰으로 본인 확인을 대체한다.
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
* @param int $commentId 댓글 ID
|
||||
* @param string $token 검증 토큰
|
||||
* @return array{token: string, expires_at: string} 토큰 및 만료 시각
|
||||
*/
|
||||
public function storeCommentVerifyToken(string $slug, int $commentId, string $token): array
|
||||
{
|
||||
$ttl = (int) g7_core_settings('cache.post_verify_token_ttl', 3600);
|
||||
$expiresAt = now()->addSeconds($ttl);
|
||||
$this->cache->put("board_comment_verify_{$slug}_{$commentId}_{$token}", true, $ttl);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'expires_at' => $expiresAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 비밀번호 검증 토큰의 유효성을 확인하고 소비합니다.
|
||||
*
|
||||
* 토큰이 유효하면 즉시 삭제하여 재사용을 방지합니다(단일 사용).
|
||||
*
|
||||
* @param string $slug 게시판 슬러그
|
||||
* @param int $commentId 댓글 ID
|
||||
* @param string $token 검증 토큰
|
||||
* @return bool 토큰 유효 여부
|
||||
*/
|
||||
public function consumeCommentVerifyToken(string $slug, int $commentId, string $token): bool
|
||||
{
|
||||
$key = "board_comment_verify_{$slug}_{$commentId}_{$token}";
|
||||
if (! $this->cache->has($key)) {
|
||||
return false;
|
||||
}
|
||||
$this->cache->forget($key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 작업 이력 배열을 생성합니다.
|
||||
*
|
||||
|
||||
@@ -126,20 +126,24 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
|
||||
->middleware('permission:admin,sirsoft-board.settings.read')
|
||||
->name('admin.settings.show');
|
||||
|
||||
// 대시보드 - 오늘 새 글/댓글 현황 (진입 가드는 코어 core.dashboard.read + admin)
|
||||
// 대시보드 - 오늘 새 글/댓글 현황 (형제 settings 라우트와 동일하게 명시적 permission 가드)
|
||||
Route::get('dashboard/overview', [DashboardController::class, 'overview'])
|
||||
->middleware('permission:admin,sirsoft-board.dashboard.view')
|
||||
->name('admin.dashboard.overview');
|
||||
|
||||
// 대시보드 - 7일 추세 그래프 (막대 + 합계 + 변화율)
|
||||
Route::get('dashboard/post-graph', [DashboardController::class, 'postGraph'])
|
||||
->middleware('permission:admin,sirsoft-board.dashboard.view')
|
||||
->name('admin.dashboard.post-graph');
|
||||
|
||||
// 대시보드 - 최신 게시글
|
||||
Route::get('dashboard/recent-posts', [DashboardController::class, 'recentPosts'])
|
||||
->middleware('permission:admin,sirsoft-board.dashboard.view')
|
||||
->name('admin.dashboard.recent-posts');
|
||||
|
||||
// 대시보드 - 미처리 신고
|
||||
Route::get('dashboard/pending-reports', [DashboardController::class, 'pendingReports'])
|
||||
->middleware('permission:admin,sirsoft-board.dashboard.view')
|
||||
->name('admin.dashboard.pending-reports');
|
||||
|
||||
});
|
||||
|
||||
@@ -33,9 +33,8 @@ class DashboardApiTest extends ModuleTestCase
|
||||
|
||||
BoardStat::query()->delete();
|
||||
|
||||
// isAdmin() 은 admin 타입 권한 보유 여부로 판정되므로 임의 권한을 부여한다.
|
||||
// 대시보드 라우트는 admin 미들웨어만 사용 (별도 permission 가드 없음).
|
||||
$this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read']);
|
||||
// 대시보드 라우트는 admin 미들웨어 + permission:admin,sirsoft-board.dashboard.view 가드를 사용한다.
|
||||
$this->adminUser = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']);
|
||||
$this->normalUser = $this->createUser();
|
||||
|
||||
Board::create([
|
||||
@@ -59,6 +58,11 @@ class DashboardApiTest extends ModuleTestCase
|
||||
$this->actingAs($this->normalUser)->getJson(self::BASE.'/overview')->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=dashboard_permission_gate
|
||||
*
|
||||
* @effects dashboard_requires_view_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function test_overview_returns_today_counts_for_admin(): void
|
||||
{
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Board;
|
||||
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 공개 최근글/인기글의 열람 권한 필터 + 비밀글 가시성 테스트 (N-3 + N-4)
|
||||
*
|
||||
* - N-3: 공개 최근글(GET /boards/posts/recent)은 읽기 권한 없는 게시판의 글을
|
||||
* 익명/저권한 사용자에게 노출하면 안 된다. 비밀글은 제목 공개 정책(2026-02-04)에
|
||||
* 따라 제목을 노출한다(본문만 보호 — 목록/검색/홈과 동일).
|
||||
* - N-4: 공개 인기글(GET /boards/popular)도 읽기 권한 없는 게시판의 글을 제외한다.
|
||||
* (인기글은 별도 컨벤션으로 비밀글 자체를 제외한다 — 2026-01-16.)
|
||||
*/
|
||||
class PublicRecentPostsVisibilityTest extends ModuleTestCase
|
||||
{
|
||||
private Board $readableBoard;
|
||||
|
||||
private Board $hiddenBoard;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Board::where('is_active', true)->update(['is_active' => false]);
|
||||
Cache::flush();
|
||||
|
||||
$this->readableBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '공개', 'en' => 'Readable']]);
|
||||
$this->hiddenBoard = Board::factory()->create(['is_active' => true, 'name' => ['ko' => '비공개', 'en' => 'Hidden']]);
|
||||
|
||||
// 읽기 가능한 게시판: 공개글 + 비밀글
|
||||
$this->insertPost($this->readableBoard->id, '공개글읽기가능', 100, false, PostStatus::Published->value);
|
||||
$this->insertPost($this->readableBoard->id, '비밀글읽기가능', 90, true, PostStatus::Published->value);
|
||||
// 읽기 불가능한 게시판: 공개글
|
||||
$this->insertPost($this->hiddenBoard->id, '숨김게시판글', 200, false, PostStatus::Published->value);
|
||||
|
||||
// 비회원 읽기 권한은 readableBoard 에만 부여
|
||||
$this->grantRead('guest', $this->readableBoard->slug);
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
|
||||
// ========== N-3 최근글 ==========
|
||||
|
||||
/**
|
||||
* @scenario case=recent_readable_filter
|
||||
*
|
||||
* @effects unreadable_board_titles_absent_for_caller
|
||||
*/
|
||||
#[Test]
|
||||
public function guest_recent_posts_shows_readable_titles_including_secret_but_excludes_unreadable_board(): void
|
||||
{
|
||||
$titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20'));
|
||||
|
||||
$this->assertContains('공개글읽기가능', $titles);
|
||||
// 비밀글도 제목은 공개된다(본문만 보호) — 2026-02-04 확정 정책.
|
||||
$this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 최근글에 노출된다(본문만 보호).');
|
||||
$this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 노출되면 안 된다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=recent_readable_filter
|
||||
*
|
||||
* @effects unreadable_board_titles_absent_for_caller
|
||||
*/
|
||||
#[Test]
|
||||
public function low_permission_member_recent_posts_excludes_unreadable_board_titles(): void
|
||||
{
|
||||
$member = $this->memberWithReadOn($this->readableBoard->slug);
|
||||
|
||||
$titles = $this->titles(
|
||||
$this->actingAs($member)->getJson('/api/modules/sirsoft-board/boards/posts/recent?limit=20')
|
||||
);
|
||||
|
||||
$this->assertContains('공개글읽기가능', $titles);
|
||||
$this->assertContains('비밀글읽기가능', $titles, '비밀글도 제목은 노출된다(본문만 보호).');
|
||||
$this->assertNotContains('숨김게시판글', $titles, '읽기 권한 없는 게시판 글은 저권한 회원에게도 노출되면 안 된다.');
|
||||
}
|
||||
|
||||
// ========== N-4 인기글 ==========
|
||||
|
||||
/**
|
||||
* @scenario case=popular_readable_filter
|
||||
*
|
||||
* @effects unreadable_board_titles_absent_for_caller
|
||||
*/
|
||||
#[Test]
|
||||
public function guest_popular_posts_excludes_unreadable_board_titles(): void
|
||||
{
|
||||
$titles = $this->titles($this->getJson('/api/modules/sirsoft-board/boards/popular?limit=20'));
|
||||
|
||||
$this->assertContains('공개글읽기가능', $titles);
|
||||
$this->assertNotContains('숨김게시판글', $titles, '인기글도 읽기 권한 없는 게시판 글을 제외해야 한다.');
|
||||
// 인기글은 비밀글도 원래 제외한다(2026-01-16 컨벤션).
|
||||
$this->assertNotContains('비밀글읽기가능', $titles);
|
||||
}
|
||||
|
||||
// ========== 헬퍼 ==========
|
||||
|
||||
/**
|
||||
* @param TestResponse $response
|
||||
* @return array<int, string> 응답 데이터의 title 목록
|
||||
*/
|
||||
private function titles($response): array
|
||||
{
|
||||
$response->assertStatus(200);
|
||||
|
||||
return array_column($response->json('data'), 'title');
|
||||
}
|
||||
|
||||
private function insertPost(int $boardId, string $title, int $viewCount, bool $isSecret, string $status): void
|
||||
{
|
||||
DB::table('board_posts')->insert([
|
||||
'board_id' => $boardId,
|
||||
'title' => $title,
|
||||
'content' => '내용',
|
||||
'author_name' => '작성자',
|
||||
'view_count' => $viewCount,
|
||||
'is_secret' => $isSecret,
|
||||
'status' => $status,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function grantRead(string $roleIdentifier, string $slug): void
|
||||
{
|
||||
$role = Role::where('identifier', $roleIdentifier)->first();
|
||||
if (! $role) {
|
||||
return;
|
||||
}
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$role->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
}
|
||||
|
||||
private function memberWithReadOn(string $slug): User
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$userRole = Role::where('identifier', 'user')->first();
|
||||
$member->roles()->attach($userRole->id);
|
||||
$this->grantRead('user', $slug);
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
|
||||
return $member;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Comment;
|
||||
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 비회원 댓글 검증 토큰(verification_token) 테스트 (⑮)
|
||||
*
|
||||
* verify-password 로 발급된 토큰이 실제로 수정/삭제 요청에서 소비되어 평문 비밀번호
|
||||
* 재전송을 대체하는지 검증한다. (게시글 verification_token 과 동형)
|
||||
* - verify 로 받은 토큰으로 수정/삭제 성공
|
||||
* - 잘못된 토큰 / 만료 토큰 거부
|
||||
* - 단일 사용(1회 소비 후 재사용 거부)
|
||||
*/
|
||||
class CommentVerifyTokenTest extends BoardTestCase
|
||||
{
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'comment-verify-token';
|
||||
}
|
||||
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $slug,
|
||||
'name' => ['ko' => '댓글 검증 토큰 테스트', 'en' => 'Comment Verify Token'],
|
||||
'is_active' => true,
|
||||
'use_comment' => true,
|
||||
'comment_order' => 'DESC',
|
||||
'min_comment_length' => 2,
|
||||
'max_comment_length' => 1000,
|
||||
'secret_mode' => 'disabled',
|
||||
'blocked_keywords' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private int $postId;
|
||||
|
||||
private int $commentId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 비회원이 verify/수정/삭제에 접근하려면 comments.write + comments.read 권한 필요
|
||||
$this->setGuestPermissions(['posts.read', 'comments.read', 'comments.write']);
|
||||
|
||||
$this->postId = $this->createTestPost([
|
||||
'title' => '댓글 대상 게시글',
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
// 비회원(guest) 댓글 — 비밀번호 보호
|
||||
$this->commentId = $this->createTestComment($this->postId, [
|
||||
'user_id' => null,
|
||||
'author_name' => '비회원',
|
||||
'content' => '원본 댓글 내용',
|
||||
'password' => Hash::make('pw1234'),
|
||||
]);
|
||||
|
||||
$this->resetPermissionMiddlewareCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* 올바른 비밀번호 확인 시 verification_token 이 발급된다.
|
||||
*
|
||||
* @return string 발급된 토큰
|
||||
*/
|
||||
private function issueToken(): string
|
||||
{
|
||||
$response = $this->postJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/comments/{$this->commentId}/verify-password",
|
||||
['password' => 'pw1234']
|
||||
);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('data.verified', true);
|
||||
|
||||
$token = $response->json('data.verification_token');
|
||||
$this->assertNotEmpty($token, 'verify-password 는 verification_token 을 발급해야 한다.');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function verify_password_issues_token(): void
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
$this->assertIsString($token);
|
||||
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{32}$/', $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function update_succeeds_with_valid_token(): void
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
$response = $this->putJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['content' => '토큰으로 수정한 댓글', 'verification_token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertSame(
|
||||
'토큰으로 수정한 댓글',
|
||||
DB::table('board_comments')->where('id', $this->commentId)->value('content')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function delete_succeeds_with_valid_token(): void
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
$response = $this->deleteJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['verification_token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertNotNull(
|
||||
DB::table('board_comments')->where('id', $this->commentId)->value('deleted_at'),
|
||||
'토큰 삭제 후 댓글은 소프트 삭제되어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function update_is_rejected_with_wrong_token(): void
|
||||
{
|
||||
$this->issueToken(); // 실제 토큰은 발급하되, 요청에는 다른 값을 보낸다
|
||||
|
||||
$response = $this->putJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['content' => '잘못된 토큰 수정 시도', 'verification_token' => str_repeat('x', 32)]
|
||||
);
|
||||
|
||||
$response->assertStatus(403);
|
||||
|
||||
$this->assertSame(
|
||||
'원본 댓글 내용',
|
||||
DB::table('board_comments')->where('id', $this->commentId)->value('content'),
|
||||
'잘못된 토큰이면 내용이 변경되면 안 된다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function token_is_single_use(): void
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
// 1회차: 성공
|
||||
$this->putJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['content' => '첫 수정', 'verification_token' => $token]
|
||||
)->assertStatus(200);
|
||||
|
||||
// 2회차: 같은 토큰 재사용 → 소비되어 거부
|
||||
$this->putJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['content' => '두 번째 수정', 'verification_token' => $token]
|
||||
)->assertStatus(403);
|
||||
|
||||
$this->assertSame(
|
||||
'첫 수정',
|
||||
DB::table('board_comments')->where('id', $this->commentId)->value('content'),
|
||||
'소비된 토큰의 재사용은 반영되면 안 된다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=comment_token_lifecycle
|
||||
*
|
||||
* @effects comment_verify_token_single_use_lifecycle
|
||||
*/
|
||||
#[Test]
|
||||
public function expired_token_is_rejected(): void
|
||||
{
|
||||
$token = $this->issueToken();
|
||||
|
||||
// 토큰 TTL(기본 1시간)을 넘겨 이동 → 캐시에서 만료
|
||||
$this->travel(2)->hours();
|
||||
|
||||
try {
|
||||
$this->putJson(
|
||||
"/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$this->postId}/comments/{$this->commentId}",
|
||||
['content' => '만료 토큰 수정 시도', 'verification_token' => $token]
|
||||
)->assertStatus(403);
|
||||
} finally {
|
||||
$this->travelBack();
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
'원본 댓글 내용',
|
||||
DB::table('board_comments')->where('id', $this->commentId)->value('content'),
|
||||
'만료 토큰이면 내용이 변경되면 안 된다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Dashboard;
|
||||
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Enums\PermissionType;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Board\Enums\ReportStatus;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Models\Report;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 대시보드 permission 가드 + 미처리 신고 스코프 테스트 (⑱-1 + N-5)
|
||||
*
|
||||
* - ⑱-1: 대시보드 4종(overview/post-graph/recent-posts/pending-reports)은
|
||||
* permission:admin,sirsoft-board.dashboard.view 가드를 요구한다.
|
||||
* - N-5: 미처리 신고 집계는 비활성 게시판을 제외한다(board-active 스코프).
|
||||
*/
|
||||
class DashboardPermissionTest extends ModuleTestCase
|
||||
{
|
||||
private const BASE = '/api/modules/sirsoft-board/admin/dashboard';
|
||||
|
||||
private const ENDPOINTS = ['overview', 'post-graph', 'recent-posts', 'pending-reports'];
|
||||
|
||||
private User $adminWithout;
|
||||
|
||||
private User $adminWith;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// adminWithout: admin 이지만 dashboard.view 없음.
|
||||
// createAdminUser 는 공유 'admin' 역할에 권한을 붙이므로, 여기서 쓰면 adminWith 가
|
||||
// 그 역할에 dashboard.view 를 추가한 순간 adminWithout 도 갖게 된다(권한 격리 불가).
|
||||
// 따라서 dashboard.view 가 없는 격리 역할을 직접 만든다.
|
||||
$this->adminWithout = $this->createIsolatedAdminWithoutDashboardView();
|
||||
// adminWith: dashboard.view 보유 (공유 admin 역할 사용 — 프로덕션 기본 상태와 동일)
|
||||
$this->adminWith = $this->createAdminUser(['sirsoft-board.boards.read', 'sirsoft-board.dashboard.view']);
|
||||
}
|
||||
|
||||
/**
|
||||
* dashboard.view 가 없는 권한-격리 관리자(고유 역할)를 생성합니다.
|
||||
*
|
||||
* admin 가드(isAdmin) 통과를 위해 admin.access 를, 무해한 admin 권한으로
|
||||
* boards.read 를 부여하되 dashboard.view 는 부여하지 않습니다.
|
||||
*
|
||||
* @return User 대시보드 권한이 없는 관리자
|
||||
*/
|
||||
private function createIsolatedAdminWithoutDashboardView(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$role = Role::create([
|
||||
'identifier' => 'board-dash-guard-'.$user->id,
|
||||
'name' => ['ko' => '대시보드 미보유 관리자', 'en' => 'Admin without dashboard'],
|
||||
]);
|
||||
$user->roles()->attach($role->id);
|
||||
|
||||
foreach (['admin.access', 'sirsoft-board.boards.read'] as $identifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
['name' => ['ko' => $identifier, 'en' => $identifier], 'type' => PermissionType::Admin],
|
||||
);
|
||||
$role->permissions()->syncWithoutDetaching([$permission->id]);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ========== ⑱-1 permission 가드 ==========
|
||||
|
||||
/**
|
||||
* @scenario case=dashboard_permission_gate
|
||||
*
|
||||
* @effects dashboard_requires_view_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function admin_without_dashboard_view_is_forbidden_on_all_endpoints(): void
|
||||
{
|
||||
foreach (self::ENDPOINTS as $endpoint) {
|
||||
$this->actingAs($this->adminWithout)
|
||||
->getJson(self::BASE.'/'.$endpoint)
|
||||
->assertStatus(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=dashboard_permission_gate
|
||||
*
|
||||
* @effects dashboard_requires_view_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function admin_with_dashboard_view_can_access_all_endpoints(): void
|
||||
{
|
||||
foreach (self::ENDPOINTS as $endpoint) {
|
||||
$this->actingAs($this->adminWith)
|
||||
->getJson(self::BASE.'/'.$endpoint)
|
||||
->assertStatus(200);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=dashboard_permission_gate
|
||||
*
|
||||
* @effects dashboard_requires_view_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function unauthenticated_request_is_rejected(): void
|
||||
{
|
||||
foreach (self::ENDPOINTS as $endpoint) {
|
||||
$this->getJson(self::BASE.'/'.$endpoint)->assertStatus(401);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== N-5 미처리 신고 board-active 스코프 ==========
|
||||
|
||||
/**
|
||||
* @scenario case=pending_reports_scope
|
||||
*
|
||||
* @effects pending_reports_scoped_to_active_boards
|
||||
*/
|
||||
#[Test]
|
||||
public function pending_reports_excludes_inactive_board_reports(): void
|
||||
{
|
||||
$activeBoard = Board::factory()->create(['is_active' => true]);
|
||||
$inactiveBoard = Board::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->createPendingReport($activeBoard);
|
||||
$this->createPendingReport($inactiveBoard);
|
||||
|
||||
$response = $this->actingAs($this->adminWith)
|
||||
->getJson(self::BASE.'/pending-reports');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 비활성 게시판 신고는 제외되어 총 1건, 목록도 1건이어야 한다.
|
||||
$this->assertSame(1, $response->json('data.total'), '미처리 신고 총계는 활성 게시판 신고만 세야 한다.');
|
||||
$this->assertCount(1, $response->json('data.items'), '미처리 신고 목록은 활성 게시판 신고만 담아야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 게시판에 미처리(pending) 신고 케이스를 생성합니다.
|
||||
*
|
||||
* @param Board $board 대상 게시판
|
||||
* @return Report 생성된 신고
|
||||
*/
|
||||
private function createPendingReport(Board $board): Report
|
||||
{
|
||||
return Report::create([
|
||||
'board_id' => $board->id,
|
||||
'target_type' => 'post',
|
||||
'target_id' => 1,
|
||||
'status' => ReportStatus::Pending,
|
||||
'last_reported_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Dashboard;
|
||||
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 대시보드 최신글(getRecentAcrossBoards)의 노출 제한 필터 테스트 (⑱-2)
|
||||
*
|
||||
* 관리자 대시보드 최신글은 블라인드·삭제 글과 비활성 게시판 글을 제외한다.
|
||||
* 비밀글은 제목 공개 정책(2026-01-02: 관리자는 제목을 본다)에 따라 노출한다(제외하지 않음).
|
||||
* (status=published + board.is_active=true 컨벤션 — 비밀글 필터는 적용하지 않음)
|
||||
*/
|
||||
class RecentAcrossBoardsVisibilityTest extends ModuleTestCase
|
||||
{
|
||||
private const ENDPOINT = '/api/modules/sirsoft-board/admin/dashboard/recent-posts';
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->admin = $this->createAdminUser(['sirsoft-board.dashboard.view']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=dashboard_recent_visibility
|
||||
*
|
||||
* @effects dashboard_summary_excludes_hidden_posts
|
||||
*/
|
||||
#[Test]
|
||||
public function recent_posts_shows_secret_but_excludes_blinded_and_inactive_board_posts(): void
|
||||
{
|
||||
$activeBoard = Board::factory()->create(['is_active' => true]);
|
||||
$inactiveBoard = Board::factory()->create(['is_active' => false]);
|
||||
|
||||
$this->insertPost($activeBoard->id, '공개 발행글', ['is_secret' => false, 'status' => PostStatus::Published->value]);
|
||||
$this->insertPost($activeBoard->id, '비밀글', ['is_secret' => true, 'status' => PostStatus::Published->value]);
|
||||
$this->insertPost($activeBoard->id, '블라인드글', ['is_secret' => false, 'status' => PostStatus::Blinded->value]);
|
||||
$this->insertPost($inactiveBoard->id, '비활성게시판글', ['is_secret' => false, 'status' => PostStatus::Published->value]);
|
||||
|
||||
$response = $this->actingAs($this->admin)->getJson(self::ENDPOINT.'?limit=20');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$titles = array_column($response->json('data'), 'title');
|
||||
|
||||
$this->assertContains('공개 발행글', $titles, '공개 발행글은 최신글에 노출되어야 한다.');
|
||||
$this->assertContains('비밀글', $titles, '비밀글은 제목 공개 정책에 따라 관리자 대시보드에 노출되어야 한다.');
|
||||
$this->assertNotContains('블라인드글', $titles, '블라인드글은 최신글에서 제외되어야 한다.');
|
||||
$this->assertNotContains('비활성게시판글', $titles, '비활성 게시판 글은 최신글에서 제외되어야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글을 직접 삽입합니다.
|
||||
*
|
||||
* @param int $boardId 게시판 ID
|
||||
* @param string $title 제목
|
||||
* @param array<string, mixed> $attributes 추가 속성
|
||||
*/
|
||||
private function insertPost(int $boardId, string $title, array $attributes = []): void
|
||||
{
|
||||
DB::table('board_posts')->insert(array_merge([
|
||||
'board_id' => $boardId,
|
||||
'title' => $title,
|
||||
'content' => '내용',
|
||||
'author_name' => '작성자',
|
||||
'ip_address' => '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\Search;
|
||||
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Services\BoardService;
|
||||
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
|
||||
/**
|
||||
* 검색 필터/인기 게시판 목록의 게시판별 읽기 권한 필터 테스트 (⑦ + N-2)
|
||||
*
|
||||
* - ⑦: 통합 검색의 available_boards(필터 드롭다운)는 읽기 권한(posts.read)을 통과한
|
||||
* 게시판만 담아야 한다 — getActiveBoardsListForFilter($user) 가 검색 결과 필터와
|
||||
* 동일한 게이트를 목록 구성에도 적용한다.
|
||||
* - N-2: 공개 인기 게시판 목록(GET /boards/popular-boards)도 호출자 읽기 권한을
|
||||
* 통과한 게시판만 노출한다.
|
||||
*/
|
||||
class AvailableBoardsPermissionTest extends ModuleTestCase
|
||||
{
|
||||
private Board $readableBoard;
|
||||
|
||||
private Board $hiddenBoard;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 다른 테스트가 남긴 활성 게시판 잡음 제거 + 캐시 초기화
|
||||
Board::where('is_active', true)->update(['is_active' => false]);
|
||||
Cache::flush();
|
||||
|
||||
$this->readableBoard = Board::factory()->create([
|
||||
'is_active' => true,
|
||||
'name' => ['ko' => '공개 게시판', 'en' => 'Readable Board'],
|
||||
]);
|
||||
$this->hiddenBoard = Board::factory()->create([
|
||||
'is_active' => true,
|
||||
'name' => ['ko' => '비공개 게시판', 'en' => 'Hidden Board'],
|
||||
]);
|
||||
|
||||
// 비회원 읽기 권한은 readableBoard 에만 부여한다.
|
||||
$this->grantGuestRead($this->readableBoard);
|
||||
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* ⑦ getActiveBoardsListForFilter 는 비회원에게 읽기 권한 없는 게시판을 제외한다.
|
||||
*
|
||||
* @scenario case=available_boards_guest
|
||||
*
|
||||
* @effects available_boards_match_read_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function available_boards_excludes_boards_guest_cannot_read(): void
|
||||
{
|
||||
$list = app(BoardService::class)->getActiveBoardsListForFilter(null);
|
||||
|
||||
$slugs = array_column($list, 'slug');
|
||||
|
||||
$this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 필터 목록에 포함되어야 한다.');
|
||||
$this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 필터 목록에서 제외되어야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⑦ 회원 기준으로도 읽기 권한 있는 게시판만 필터 목록에 포함된다.
|
||||
*
|
||||
* @scenario case=available_boards_member
|
||||
*
|
||||
* @effects available_boards_match_read_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function available_boards_filters_by_member_read_permission(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$userRole = Role::where('identifier', 'user')->first();
|
||||
$member->roles()->attach($userRole->id);
|
||||
|
||||
// 회원 역할에 readableBoard 읽기 권한만 부여
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$this->readableBoard->slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$userRole->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
|
||||
$list = app(BoardService::class)->getActiveBoardsListForFilter($member);
|
||||
|
||||
$slugs = array_column($list, 'slug');
|
||||
|
||||
$this->assertContains($this->readableBoard->slug, $slugs);
|
||||
$this->assertNotContains($this->hiddenBoard->slug, $slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* N-2 공개 인기 게시판 목록은 비회원 읽기 권한을 통과한 게시판만 노출한다.
|
||||
*
|
||||
* @scenario case=popular_boards_readable
|
||||
*
|
||||
* @effects popular_boards_exclude_unreadable
|
||||
*/
|
||||
#[Test]
|
||||
public function popular_boards_endpoint_excludes_boards_guest_cannot_read(): void
|
||||
{
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/popular-boards?limit=20');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$slugs = array_column($response->json('data'), 'slug');
|
||||
|
||||
$this->assertContains($this->readableBoard->slug, $slugs, '읽기 권한 있는 게시판은 인기 게시판 목록에 포함되어야 한다.');
|
||||
$this->assertNotContains($this->hiddenBoard->slug, $slugs, '읽기 권한 없는 게시판은 인기 게시판 목록에서 제외되어야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⑦ HTTP 검색 엔드포인트가 Bearer 회원을 해석해 available_boards 에 반영한다.
|
||||
*
|
||||
* 라우트에 optional.sanctum 이 없으면 Bearer 토큰이 있어도 $request->user() 가
|
||||
* null 이라 모든 호출자가 guest 로 판정된다 — 서비스 직접 호출 테스트로는 이
|
||||
* 사각이 보이지 않으므로 실제 HTTP 경로로 검증한다 (actingAs 금지: actingAs 는
|
||||
* 미들웨어 없이도 유저를 심어 라이브와 어긋난다).
|
||||
*
|
||||
* @scenario case=available_boards_bearer_http
|
||||
*
|
||||
* @effects search_route_resolves_bearer_user, available_boards_match_read_permission
|
||||
*/
|
||||
#[Test]
|
||||
public function search_endpoint_resolves_bearer_user_for_available_boards(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$userRole = Role::where('identifier', 'user')->first();
|
||||
$member->roles()->attach($userRole->id);
|
||||
|
||||
// 회원 역할에 두 게시판 읽기 권한 부여 — hiddenBoard 는 회원 역할에만 있으므로
|
||||
// (guest 미보유) Bearer 유저가 해석될 때만 목록에 나타난다.
|
||||
foreach ([$this->hiddenBoard, $this->readableBoard] as $board) {
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$userRole->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
}
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
|
||||
$token = $member->createToken('search-bearer-test')->plainTextToken;
|
||||
|
||||
$response = $this->getJson('/api/search?q=테스트', ['Authorization' => 'Bearer '.$token]);
|
||||
$response->assertStatus(200);
|
||||
|
||||
$slugs = array_column($response->json('data.posts.available_boards') ?? [], 'slug');
|
||||
|
||||
$this->assertContains(
|
||||
$this->hiddenBoard->slug,
|
||||
$slugs,
|
||||
'Bearer 인증 회원의 읽기 권한이 available_boards 에 반영되어야 한다 (검색 라우트 optional.sanctum).'
|
||||
);
|
||||
$this->assertContains($this->readableBoard->slug, $slugs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
|
||||
*
|
||||
* @param Board $board 대상 게시판
|
||||
*/
|
||||
private function grantGuestRead(Board $board): void
|
||||
{
|
||||
$guestRole = Role::where('identifier', 'guest')->first();
|
||||
if (! $guestRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$guestRole->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
@@ -49,6 +52,10 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* 인기 게시글 API가 올바른 구조로 응답하는지 테스트
|
||||
*
|
||||
* @scenario case=popular_readable_filter
|
||||
*
|
||||
* @effects unreadable_board_titles_absent_for_caller
|
||||
*/
|
||||
public function test_popular_returns_correct_structure(): void
|
||||
{
|
||||
@@ -139,6 +146,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: view_count가 다른 게시글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
['board_id' => $board->id, 'title' => 'Post 1', 'content' => 'Content 1', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
|
||||
['board_id' => $board->id, 'title' => 'Post 2', 'content' => 'Content 2', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
|
||||
@@ -164,6 +172,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 오늘과 어제 게시글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
['board_id' => $board->id, 'title' => 'Today Post', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
|
||||
['board_id' => $board->id, 'title' => 'Yesterday Post', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
|
||||
@@ -187,6 +196,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 최근 1주일과 2주 전 게시글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
['board_id' => $board->id, 'title' => 'This Week', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subDays(3), 'updated_at' => now()],
|
||||
['board_id' => $board->id, 'title' => 'Two Weeks Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subWeeks(2), 'updated_at' => now()],
|
||||
@@ -211,6 +221,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 1년 이내/이전 게시글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
|
||||
['board_id' => $board->id, 'title' => 'Six Months Ago', 'content' => 'Content', 'view_count' => 200, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(6), 'updated_at' => now()],
|
||||
@@ -242,6 +253,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 1년 이내/이전 게시글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
['board_id' => $board->id, 'title' => 'Recent', 'content' => 'Content', 'view_count' => 100, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now(), 'updated_at' => now()],
|
||||
['board_id' => $board->id, 'title' => 'Over One Year', 'content' => 'Content', 'view_count' => 300, 'status' => PostStatus::Published->value, 'ip_address' => '127.0.0.1', 'created_at' => now()->subMonths(13), 'updated_at' => now()],
|
||||
@@ -313,6 +325,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 게시글과 댓글 생성
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
// comments_count 컬럼에 직접 값 설정 (캐시 컬럼 방식)
|
||||
$postId = DB::table('board_posts')->insertGetId([
|
||||
'board_id' => $board->id,
|
||||
@@ -343,6 +356,7 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
{
|
||||
// Given: 게스트가 작성한 게시글
|
||||
$board = Board::factory()->create(['is_active' => true]);
|
||||
$this->grantGuestRead($board);
|
||||
DB::table('board_posts')->insert([
|
||||
'board_id' => $board->id,
|
||||
'title' => 'Guest Post',
|
||||
@@ -455,6 +469,10 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
]);
|
||||
}
|
||||
|
||||
// 공개 인기글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다.
|
||||
// 프로덕션 공개 게시판과 동일하게 guest read 권한을 부여한다.
|
||||
$this->grantGuestRead($board);
|
||||
|
||||
for ($i = 0; $i < $postCount; $i++) {
|
||||
DB::table('board_posts')->insert([
|
||||
'board_id' => $board->id,
|
||||
@@ -471,4 +489,28 @@ class BoardPopularApiTest extends ModuleTestCase
|
||||
|
||||
return $board;
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
|
||||
*
|
||||
* 공개 인기글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로,
|
||||
* 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다.
|
||||
*
|
||||
* @param Board $board 대상 게시판
|
||||
*/
|
||||
private function grantGuestRead(Board $board): void
|
||||
{
|
||||
$guestRole = Role::where('identifier', 'guest')->first();
|
||||
if (! $guestRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$guestRole->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
// ModuleTestCase를 수동으로 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Http\Middleware\PermissionMiddleware;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
@@ -178,17 +181,20 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트
|
||||
* 비밀글이 포함되어 is_secret 필드가 반환되는지 테스트.
|
||||
*
|
||||
* 비밀글도 제목은 공개한다(본문만 보호) — 2026-02-04 확정 정책.
|
||||
* (게시판별 열람 권한 필터는 별도 — createBoardWithPosts 가 guest read 권한을 부여한다.)
|
||||
*/
|
||||
public function test_recent_posts_includes_secret_posts_with_is_secret_field(): void
|
||||
{
|
||||
// Given: 비밀글 포함 게시글 생성
|
||||
$this->createBoardWithPosts(3, includingSecret: true);
|
||||
// Given: 공개글 + 비밀글 혼합 생성 (i%2==0 이 비밀글, 게시판에 guest read 권한 부여됨)
|
||||
$this->createBoardWithPosts(4, includingSecret: true);
|
||||
|
||||
// When: API 호출
|
||||
// When: API 호출 (비로그인)
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent');
|
||||
|
||||
// Then: is_secret 필드 포함 및 비밀글 반환
|
||||
// Then: is_secret 필드 포함 및 비밀글도 응답에 포함
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
@@ -199,21 +205,22 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
$this->assertArrayHasKey('is_secret', $post);
|
||||
}
|
||||
|
||||
// 비밀글이 포함되어 있어야 함
|
||||
// 비밀글이 포함되어 있어야 함 (제목 공개 정책 — 비밀글도 목록/최근글에 표시)
|
||||
$secretPosts = array_filter($data, fn ($post) => $post['is_secret'] === true);
|
||||
$this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다');
|
||||
$this->assertNotEmpty($secretPosts, '비밀글이 응답에 포함되어야 합니다(제목 공개).');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀글도 제목이 정상적으로 표시되는지 테스트
|
||||
* 비밀글도 제목이 정상적으로 표시되는지 테스트 (제목 공개 정책 — 본문만 보호).
|
||||
*/
|
||||
public function test_secret_post_title_is_visible_in_recent_posts(): void
|
||||
{
|
||||
// Given: 비밀글이 있는 게시판 생성
|
||||
// Given: 비밀글이 있는 게시판 생성 (guest read 권한 부여)
|
||||
$board = Board::factory()->create([
|
||||
'is_active' => true,
|
||||
'name' => ['ko' => '비밀게시판', 'en' => 'Secret Board'],
|
||||
]);
|
||||
$this->grantGuestRead($board);
|
||||
|
||||
DB::table('board_posts')->insert([
|
||||
'board_id' => $board->id,
|
||||
@@ -227,10 +234,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// When: API 호출
|
||||
// When: API 호출 (비로그인)
|
||||
$response = $this->getJson('/api/modules/sirsoft-board/boards/posts/recent');
|
||||
|
||||
// Then: 비밀글 제목이 보여야 함 (마스킹 안 됨)
|
||||
// Then: 비밀글 제목이 보여야 함 (마스킹 안 됨 — 제목 공개 정책)
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data');
|
||||
|
||||
@@ -270,8 +277,8 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
/**
|
||||
* 게시판과 게시글을 생성하는 헬퍼
|
||||
*
|
||||
* @param int $postCount 생성할 게시글 수
|
||||
* @param bool $includingSecret 비밀글 포함 여부
|
||||
* @param int $postCount 생성할 게시글 수
|
||||
* @param bool $includingSecret 비밀글 포함 여부
|
||||
* @return Board 생성된 게시판
|
||||
*/
|
||||
private function createBoardWithPosts(int $postCount, bool $includingSecret = false): Board
|
||||
@@ -280,6 +287,10 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// 공개 최근글은 게시판별 열람 권한(posts.read)을 통과한 게시판만 노출한다.
|
||||
// 프로덕션에서 공개 게시판은 guest read 권한을 갖도록 생성되므로 동일하게 부여한다.
|
||||
$this->grantGuestRead($board);
|
||||
|
||||
for ($i = 0; $i < $postCount; $i++) {
|
||||
$isSecret = $includingSecret && ($i % 2 === 0);
|
||||
|
||||
@@ -299,4 +310,28 @@ class BoardRecentPostsApiTest extends ModuleTestCase
|
||||
|
||||
return $board;
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판에 비회원(guest) 읽기 권한(posts.read)을 부여합니다.
|
||||
*
|
||||
* 공개 최근글 API 는 게시판별 열람 권한을 응답 시점에 적용하므로,
|
||||
* 공개 노출을 기대하는 테스트 게시판은 프로덕션처럼 guest read 권한을 갖춰야 한다.
|
||||
*
|
||||
* @param Board $board 대상 게시판
|
||||
*/
|
||||
private function grantGuestRead(Board $board): void
|
||||
{
|
||||
$guestRole = Role::where('identifier', 'guest')->first();
|
||||
if (! $guestRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
$perm = Permission::firstOrCreate(
|
||||
['identifier' => "sirsoft-board.{$board->slug}.posts.read"],
|
||||
['name' => ['ko' => 'read', 'en' => 'read'], 'type' => 'user']
|
||||
);
|
||||
$guestRole->permissions()->syncWithoutDetaching([$perm->id]);
|
||||
|
||||
PermissionMiddleware::clearGuestRoleCache();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +265,69 @@ class SearchPostsListenerTest extends ModuleTestCase
|
||||
->once();
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목/본문에 삽입된 태그가 하이라이트 필드에서 이스케이프되는지 확인 (⑧/N-8)
|
||||
*
|
||||
* @scenario case=search_highlight_escape
|
||||
*
|
||||
* @effects highlighted_fields_escaped
|
||||
*/
|
||||
public function test_format_post_result_escapes_markup_in_highlighted_fields(): void
|
||||
{
|
||||
$user = User::factory()->make(['id' => 9999]);
|
||||
|
||||
$board = $this->createBoardStub(1, 'notice', '공지사항');
|
||||
|
||||
$this->boardService
|
||||
->method('getActiveBoardsForSearch')
|
||||
->willReturn(new Collection([$board]));
|
||||
|
||||
$this->boardService
|
||||
->method('getActiveBoardsListForFilter')
|
||||
->willReturn([]);
|
||||
|
||||
$post = (object) [
|
||||
'id' => 1,
|
||||
'title' => '<img src=x onerror=alert(1)> 테스트',
|
||||
// 엔티티로 인코딩된 태그가 html 모드 프리뷰에서 부활하면 안 된다 (N-8).
|
||||
'content' => '<script>alert(1)</script> 테스트 본문',
|
||||
'content_mode' => 'html',
|
||||
'author_name' => '작성자',
|
||||
'created_at' => now(),
|
||||
'view_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'user' => null,
|
||||
'board' => $this->createBoardStub(1, 'notice', '공지사항'),
|
||||
];
|
||||
|
||||
$this->postService
|
||||
->method('searchAcrossBoards')
|
||||
->willReturn($this->boundedPage(new Collection([$post]), 1));
|
||||
|
||||
Gate::before(fn ($u) => $u->id === 9999 ? true : null);
|
||||
|
||||
$result = $this->listener->searchPosts([], [
|
||||
'type' => 'all',
|
||||
'q' => '테스트',
|
||||
'sort' => 'relevance',
|
||||
'page' => 1,
|
||||
'per_page' => 10,
|
||||
'user' => $user,
|
||||
'request' => null,
|
||||
]);
|
||||
|
||||
$item = $result['posts']['items'][0];
|
||||
|
||||
// 제목 하이라이트: 태그 이스케이프 + 검색어만 <mark>
|
||||
$this->assertStringNotContainsString('<img', $item['title_highlighted']);
|
||||
$this->assertStringContainsString('<img', $item['title_highlighted']);
|
||||
$this->assertStringContainsString('<mark>테스트</mark>', $item['title_highlighted']);
|
||||
|
||||
// 본문 프리뷰: 부활한 <script> 없음
|
||||
$this->assertStringNotContainsString('<script>', $item['content_preview']);
|
||||
$this->assertStringNotContainsString('<script>', $item['content_preview_highlighted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* id를 포함하는 Board 스텁 생성
|
||||
*
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
feature: 게시판 보안 수정 묶음 (2026-08 — 공개 피드 가시성 / 대시보드 권한 / 댓글 검증 토큰 / 하이라이트 이스케이프)
|
||||
|
||||
description: |
|
||||
2026-08 비공개 보안 제보 대응으로 sirsoft-board 에 반영된 수정의 회귀 고정.
|
||||
|
||||
- N-3/N-4/⑱-2: 공개 최신글·인기글·대시보드 요약이 열람 권한 없는 게시판 글과
|
||||
블라인드/비활성 게시판 글을 노출하던 문제 — SQL 안전집합(published + active)과
|
||||
응답 시점 열람 권한 필터(캐시는 안전집합만 담아 캐시 오염 회피)의 2층 구조.
|
||||
비밀글은 제목 공개 정책(2026-02-04)에 따라 제목을 노출한다(본문만 보호). 인기글은
|
||||
별도 컨벤션으로 비밀글 자체를 제외한다(2026-01-16).
|
||||
- ⑦/N-2: 통합 검색 available_boards 와 공개 인기 게시판 목록이 열람 권한(posts.read)
|
||||
을 반영하지 않던 문제. 검색 라우트의 optional.sanctum 부재로 Bearer 회원이 guest 로
|
||||
판정되던 결함 포함(HTTP 전 구간 회귀 테스트로 고정 — 서비스 직접 호출은 이 사각을
|
||||
보지 못한다).
|
||||
- ⑱-1/N-5: 관리자 대시보드 4종 라우트의 permission 게이트(sirsoft-board.dashboard.view)
|
||||
부재 + 미처리 신고 집계의 비활성 게시판 스코프 부재.
|
||||
- ⑮: 댓글 verification_token 이 발급만 되고 저장·검증되지 않던 죽은 코드 —
|
||||
캐시 기반 단일 사용 토큰으로 실동작(발급→수정/삭제 소비→재사용·만료·오토큰 거부).
|
||||
- ⑧: 검색 하이라이트가 원문을 이스케이프하지 않던 문제 — 코어 SearchHighlighter 위임.
|
||||
|
||||
axes:
|
||||
case: [recent_readable_filter, popular_readable_filter, popular_boards_readable, available_boards_guest, available_boards_member, available_boards_bearer_http, dashboard_permission_gate, dashboard_recent_visibility, pending_reports_scope, comment_token_lifecycle, search_highlight_escape]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- unreadable_board_titles_absent_for_caller
|
||||
- popular_boards_exclude_unreadable
|
||||
- available_boards_match_read_permission
|
||||
- search_route_resolves_bearer_user
|
||||
- dashboard_requires_view_permission
|
||||
- dashboard_summary_excludes_hidden_posts
|
||||
- pending_reports_scoped_to_active_boards
|
||||
- comment_verify_token_single_use_lifecycle
|
||||
- highlighted_fields_escaped
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Board/PublicRecentPostsVisibilityTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Search/AvailableBoardsPermissionTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Dashboard/DashboardPermissionTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Dashboard/RecentAcrossBoardsVisibilityTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Comment/CommentVerifyTokenTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Unit/Listeners/SearchPostsListenerTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/User/BoardPopularApiTest.php
|
||||
- modules/_bundled/sirsoft-board/tests/Feature/Admin/DashboardApiTest.php
|
||||
@@ -11,6 +11,12 @@
|
||||
- 비밀 상품 문의의 내용이 작성자·관리자가 아닌 사람에게도 노출되던 문제를 막았습니다. 문의 목록은 게시글의 비밀 여부를 서버에서 확인해, 열람 권한이 없는 요청에는 내용·제목·답변·첨부를 가립니다. (목록의 '비밀글 숨김' 옵션과 무관하게 서버가 요청자 신원으로 노출 여부를 결정합니다.) (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 관리자가 숨긴 리뷰의 이미지가 주소만 알면 계속 조회되던 문제를 수정했습니다. 이제 숨김 처리된 리뷰의 이미지는 제공되지 않습니다. 리뷰가 삭제되어 상태를 확인할 수 없는 이미지도 함께 차단합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 상품·주문·리뷰·브랜드·쿠폰·배송정책·추가배송비 관리에 담당 범위 제한을 적용했습니다. 이 권한들은 "본인이 등록한 것만" 처럼 범위를 좁혀 위임할 수 있는데, 그 제한이 실제로는 어느 관리 화면에서도 적용되지 않았습니다. 목록에서 여러 건을 한 번에 처리하는 일괄 기능뿐 아니라, 항목을 하나씩 수정·삭제하는 화면에서도 마찬가지였습니다. 이제 모든 경로에서 대상마다 범위를 확인하며, 일괄 기능은 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||
- 리뷰 작성 시 대상 상품을 요청에 담긴 값이 아니라 실제 구매한 주문 옵션에서 확정하도록 바로잡았습니다. 이전에는 자신의 구매 내역을 근거로 삼되 다른 상품을 대상으로 지정해 리뷰를 남길 수 있었습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 다운로드 대상이 아닌 쿠폰(자동 발급·관리자 직접 발급)이나 이미 유효기간이 지난 쿠폰을, 목록에 보이지 않더라도 주소만 알면 직접 내려받을 수 있던 문제를 수정했습니다. 이제 다운로드 발급 조건과 유효기간을 발급 시점에 함께 확인합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 비회원 구매확정이 배송 준비 등 확정 불가 상태의 주문이나 이미 확정된 주문에도 적용되던 문제를 수정했습니다. 이제 회원·비회원 모두 확정 가능한 상태에서만 한 번 확정됩니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 비회원 환불 예상액 계산과 주문 취소 요청이 입력 항목 검증을 건너뛰던 문제를 수정했습니다. 이제 회원 경로와 동일하게 주문 소속·취소 완료 여부·수량 상한을 확인합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
- 검색 프리셋 관리(조회·저장·수정·삭제)에 별도의 관리 권한을 요구하도록 했습니다.
|
||||
- 상품 통합 검색 결과의 이름·설명 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.presets.index -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.index`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@index`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-ecommerce.products.read`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -103,7 +103,7 @@ HTTP/1.1 200
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.presets.store -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.store`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@store`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-ecommerce.products.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -192,7 +192,7 @@ HTTP/1.1 201
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.presets.destroy -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.destroy`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@destroy`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-ecommerce.products.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
@@ -250,7 +250,7 @@ HTTP/1.1 200
|
||||
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.presets.update -->
|
||||
- **라우트명**: `api.modules.sirsoft-ecommerce.admin.presets.update`
|
||||
- **컨트롤러**: `Modules\Sirsoft\Ecommerce\Http\Controllers\Admin\SearchPresetController@update`
|
||||
- **인증/권한**: `auth:sanctum`
|
||||
- **인증/권한**: `auth:sanctum`, `admin`, `permission:admin,sirsoft-ecommerce.products.update`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* 주문 옵션 구매확정 불가 예외
|
||||
*
|
||||
* 이미 구매확정된 옵션을 다시 확정하려 하거나, 구매확정 가능 상태
|
||||
* (order_settings.confirmable_statuses) 가 아닌 옵션을 확정하려 할 때 발생합니다.
|
||||
* OrderService::confirmOption 이 호출자(회원/비회원)와 무관하게 던지며, 컨트롤러는
|
||||
* 사유 식별자로 422 응답을 만듭니다.
|
||||
*
|
||||
* 사유 식별자는 `exceptions.*` 의 키와 같은 이름을 사용합니다:
|
||||
* - order_option_already_confirmed
|
||||
* - order_option_not_confirmable
|
||||
*/
|
||||
class OrderOptionNotConfirmableException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param string $reason 확정 불가 사유 식별자 (exceptions.* 키와 동일)
|
||||
*/
|
||||
public function __construct(
|
||||
private string $reason
|
||||
) {
|
||||
parent::__construct(
|
||||
__("sirsoft-ecommerce::exceptions.{$reason}")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 불가 사유 식별자 반환
|
||||
*
|
||||
* @return string 사유 식별자
|
||||
*/
|
||||
public function getReason(): string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderOptionNotConfirmableException;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Controllers\Traits\HandlesOrderCreation;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\CreateOrderRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Public\GuestCancelOrderRequest;
|
||||
@@ -436,9 +437,15 @@ class OrderController extends PublicBaseController
|
||||
'sirsoft-ecommerce::messages.order.confirmed',
|
||||
new GuestOrderResource($updatedOrder)
|
||||
);
|
||||
} catch (OrderOptionNotConfirmableException $e) {
|
||||
// 상태 게이트 위반(이미 확정 / 확정 불가 상태) — 도메인 사유이므로 422.
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.'.$e->getReason(),
|
||||
422
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// OrderService::confirmOption 은 도메인 예외를 던지지 않는다 —
|
||||
// 여기에 걸리는 건 전부 서버 결함/인프라 장애다.
|
||||
// 위 typed 예외를 제외한 나머지는 전부 서버 결함/인프라 장애다.
|
||||
Log::error('비회원 구매확정 실패', [
|
||||
'order_number' => $order->order_number,
|
||||
'option_id' => $optionId,
|
||||
|
||||
@@ -12,6 +12,7 @@ use Modules\Sirsoft\Ecommerce\Exceptions\CartOperationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\CartQuantityLimitException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderCancellationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderOptionNotConfirmableException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\User\CancelOrderRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\User\ConfirmOrderOptionRequest;
|
||||
@@ -268,9 +269,15 @@ class OrderController extends AuthBaseController
|
||||
'sirsoft-ecommerce::messages.order.confirmed',
|
||||
['order' => new OrderResource($updatedOrder)]
|
||||
);
|
||||
} catch (OrderOptionNotConfirmableException $e) {
|
||||
// 상태 게이트 위반(이미 확정 / 확정 불가 상태) — 도메인 사유이므로 422.
|
||||
return ResponseHelper::moduleError(
|
||||
'sirsoft-ecommerce',
|
||||
'exceptions.'.$e->getReason(),
|
||||
422
|
||||
);
|
||||
} catch (Exception $e) {
|
||||
// OrderService::confirmOption 은 도메인 예외를 던지지 않는다 —
|
||||
// 여기에 걸리는 건 전부 서버 결함/인프라 장애다.
|
||||
// 위 typed 예외를 제외한 나머지는 전부 서버 결함/인프라 장애다.
|
||||
Log::error('Order option confirm failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'order_id' => $request->route('id'),
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
|
||||
/**
|
||||
* 취소/환불 대상 항목(items) per-item 검증 공통 로직
|
||||
*
|
||||
* 회원(EstimateRefundRequest / CancelOrderRequest)과 비회원
|
||||
* (GuestEstimateRefundRequest / GuestCancelOrderRequest) 경로가 동일한 강도로
|
||||
* 항목을 검증하도록 단일 지점에서 판정합니다. 각 항목이 대상 주문에 속하는지,
|
||||
* 이미 취소된 옵션인지, 취소 수량이 보유 수량을 넘지 않는지 확인합니다.
|
||||
*/
|
||||
trait ValidatesCancelItems
|
||||
{
|
||||
/**
|
||||
* 취소/환불 대상 항목이 주문에 속하고 취소 가능한지 검증합니다.
|
||||
*
|
||||
* @param Validator $validator 검증기
|
||||
* @param Order $order 대상 주문(소유권은 상위에서 이미 검증됨)
|
||||
* @param array<int, array{order_option_id?: int, cancel_quantity?: int}> $items 검증 대상 항목 배열
|
||||
* @return void
|
||||
*/
|
||||
protected function validateCancelItemsAgainstOrder(Validator $validator, Order $order, array $items): void
|
||||
{
|
||||
$order->loadMissing('options');
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$optionId = $item['order_option_id'] ?? null;
|
||||
$option = $order->options->firstWhere('id', $optionId);
|
||||
|
||||
if (! $option) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_not_found')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 취소된 옵션은 제외
|
||||
if ($option->option_status === OrderStatusEnum::CANCELLED) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_already_cancelled')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 취소 수량이 현재 수량을 초과하는지 검증
|
||||
if (($item['cancel_quantity'] ?? 0) > $option->quantity) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.cancel_quantity",
|
||||
__('sirsoft-ecommerce::exceptions.cancel_quantity_exceeds', [
|
||||
'max' => $option->quantity,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -7,6 +7,7 @@ use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCancelItems;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
|
||||
@@ -19,6 +20,8 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
*/
|
||||
class GuestCancelOrderRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCancelItems;
|
||||
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||
*
|
||||
@@ -65,6 +68,13 @@ class GuestCancelOrderRequest extends FormRequest
|
||||
'order_status',
|
||||
$order->getCancelDeniedReason($cancellableStatuses)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 부분취소 items 검증 (회원판 CancelOrderRequest 와 동일 강도)
|
||||
if ($this->has('items') && is_array($this->input('items'))) {
|
||||
$this->validateCancelItemsAgainstOrder($validator, $order, $this->input('items', []));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -102,7 +112,7 @@ class GuestCancelOrderRequest extends FormRequest
|
||||
*/
|
||||
public function getCancelItems(): array
|
||||
{
|
||||
return $this->input('items', []);
|
||||
return $this->validated('items') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+25
-1
@@ -3,8 +3,10 @@
|
||||
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Public;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCancelItems;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,8 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
*/
|
||||
class GuestEstimateRefundRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCancelItems;
|
||||
|
||||
/**
|
||||
* 사용자가 이 요청을 수행할 권한이 있는지 확인
|
||||
*
|
||||
@@ -40,6 +44,26 @@ class GuestEstimateRefundRequest extends FormRequest
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 추가 검증 로직 — 각 항목이 대상 주문에 속하고 취소 가능한지 확인합니다.
|
||||
*
|
||||
* 회원판 EstimateRefundRequest 와 동일 강도. 대상 주문은 미들웨어가 전달한
|
||||
* guest_order attribute 를 사용한다.
|
||||
*
|
||||
* @param Validator $validator 검증기
|
||||
* @return void
|
||||
*/
|
||||
protected function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
if ($validator->errors()->any()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validateCancelItemsAgainstOrder($validator, $this->getOrder(), $this->input('items', []));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 미들웨어가 검증한 대상 주문을 반환합니다.
|
||||
*
|
||||
@@ -63,7 +87,7 @@ class GuestEstimateRefundRequest extends FormRequest
|
||||
*/
|
||||
public function getCancelItems(): array
|
||||
{
|
||||
return $this->input('items', []);
|
||||
return $this->validated('items') ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,10 +8,11 @@ use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCancelItems;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 주문 취소 요청
|
||||
@@ -20,6 +21,8 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
*/
|
||||
class CancelOrderRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCancelItems;
|
||||
|
||||
protected ?Order $order = null;
|
||||
|
||||
/**
|
||||
@@ -65,14 +68,14 @@ class CancelOrderRequest extends FormRequest
|
||||
/**
|
||||
* 추가 검증 로직
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @param Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
protected function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator) {
|
||||
$orderId = $this->route('id');
|
||||
$this->order = Order::find($orderId);
|
||||
$this->order = app(OrderRepositoryInterface::class)->find((int) $orderId);
|
||||
|
||||
if (! $this->order) {
|
||||
abort(ResponseHelper::moduleError(
|
||||
@@ -109,60 +112,15 @@ class CancelOrderRequest extends FormRequest
|
||||
|
||||
// 부분취소 items 검증
|
||||
if ($this->has('items') && is_array($this->input('items'))) {
|
||||
$this->validateCancelItems($validator);
|
||||
$this->validateCancelItemsAgainstOrder($validator, $this->order, $this->input('items', []));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 아이템 목록을 검증합니다.
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
protected function validateCancelItems(Validator $validator): void
|
||||
{
|
||||
$this->order->loadMissing('options');
|
||||
$items = $this->input('items', []);
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$option = $this->order->options->firstWhere('id', $item['order_option_id']);
|
||||
|
||||
if (! $option) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_not_found')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 취소된 옵션은 제외
|
||||
if ($option->option_status === OrderStatusEnum::CANCELLED) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_already_cancelled')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 취소 수량이 현재 수량을 초과하는지 검증
|
||||
if ($item['cancel_quantity'] > $option->quantity) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.cancel_quantity",
|
||||
__('sirsoft-ecommerce::exceptions.cancel_quantity_exceeds', [
|
||||
'max' => $option->quantity,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 실패 시 응답 커스터마이징
|
||||
*
|
||||
* @param Validator $validator
|
||||
* @param Validator $validator
|
||||
* @return void
|
||||
*
|
||||
* @throws ValidationException
|
||||
|
||||
+6
-37
@@ -7,9 +7,10 @@ use Illuminate\Contracts\Validation\Validator;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\RefundPriorityEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Requests\Concerns\ValidatesCancelItems;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 환불 예상금액 조회 요청 (사용자)
|
||||
@@ -18,6 +19,8 @@ use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
*/
|
||||
class EstimateRefundRequest extends FormRequest
|
||||
{
|
||||
use ValidatesCancelItems;
|
||||
|
||||
protected ?Order $order = null;
|
||||
|
||||
/**
|
||||
@@ -59,7 +62,7 @@ class EstimateRefundRequest extends FormRequest
|
||||
}
|
||||
|
||||
$orderId = $this->route('id');
|
||||
$this->order = Order::find($orderId);
|
||||
$this->order = app(OrderRepositoryInterface::class)->find((int) $orderId);
|
||||
|
||||
if (! $this->order) {
|
||||
abort(ResponseHelper::moduleError(
|
||||
@@ -78,41 +81,7 @@ class EstimateRefundRequest extends FormRequest
|
||||
));
|
||||
}
|
||||
|
||||
$this->order->loadMissing('options');
|
||||
$items = $this->input('items', []);
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$option = $this->order->options->firstWhere('id', $item['order_option_id']);
|
||||
|
||||
if (! $option) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_not_found')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 이미 취소된 옵션은 제외
|
||||
if ($option->option_status === OrderStatusEnum::CANCELLED) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.order_option_id",
|
||||
__('sirsoft-ecommerce::exceptions.order_option_already_cancelled')
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 취소 수량이 현재 수량을 초과하는지 검증
|
||||
if ($item['cancel_quantity'] > $option->quantity) {
|
||||
$validator->errors()->add(
|
||||
"items.{$index}.cancel_quantity",
|
||||
__('sirsoft-ecommerce::exceptions.cancel_quantity_exceeds', [
|
||||
'max' => $option->quantity,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->validateCancelItemsAgainstOrder($validator, $this->order, $this->input('items', []));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Search\SearchCategoryPayload;
|
||||
use App\Search\SearchHighlighter;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
@@ -291,13 +292,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
*/
|
||||
private function highlightKeyword(?string $text, string $keyword): string
|
||||
{
|
||||
if (empty($text) || empty($keyword)) {
|
||||
return $text ?? '';
|
||||
}
|
||||
|
||||
$escapedKeyword = preg_quote($keyword, '/');
|
||||
|
||||
return preg_replace('/('.$escapedKeyword.')/iu', '<mark>$1</mark>', $text);
|
||||
return SearchHighlighter::highlight($text, $keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,7 +309,7 @@ class SearchProductsListener implements HookListenerInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
$plainText = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags($content))));
|
||||
$plainText = SearchHighlighter::toPlainText($content);
|
||||
$position = mb_stripos($plainText, $keyword);
|
||||
|
||||
if ($position !== false) {
|
||||
|
||||
@@ -14,6 +14,7 @@ use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderModificationException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderOptionNotConfirmableException;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderProcessingException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
@@ -761,6 +762,23 @@ class OrderService
|
||||
*/
|
||||
public function confirmOption(Order $order, OrderOption $option): OrderOption
|
||||
{
|
||||
// 상태 게이트를 서비스 자체에 내장한다 — 호출자(회원 FormRequest / 비회원 컨트롤러 / 훅)와
|
||||
// 무관하게 동일 강도로 강제한다. FormRequest 검사는 UI 조기 피드백용으로 유지되지만,
|
||||
// 그 경로를 우회하는 호출이 이미 확정된/확정 불가 상태의 옵션을 재확정하는 것을 막는다.
|
||||
if ($option->option_status === OrderStatusEnum::CONFIRMED) {
|
||||
throw new OrderOptionNotConfirmableException('order_option_already_confirmed');
|
||||
}
|
||||
|
||||
$confirmableStatuses = module_setting(
|
||||
'sirsoft-ecommerce',
|
||||
'order_settings.confirmable_statuses',
|
||||
['shipping', 'delivered']
|
||||
);
|
||||
|
||||
if (! in_array($option->option_status->value, $confirmableStatuses, true)) {
|
||||
throw new OrderOptionNotConfirmableException('order_option_not_confirmable');
|
||||
}
|
||||
|
||||
$previousStatus = $order->order_status?->value;
|
||||
$purchaseConfirmed = false;
|
||||
|
||||
|
||||
@@ -137,10 +137,16 @@ class ProductReviewService
|
||||
$orderOption = $this->orderOptionRepository->findOrFail($data['order_option_id']);
|
||||
$optionSnapshot = $orderOption->option_snapshot ?? [];
|
||||
|
||||
// product_id 는 클라이언트 payload 가 아니라 확정된 주문 옵션에서 도출한다(SSoT).
|
||||
// canWrite 가 옵션 소유권/확정만 검증하므로, payload 의 product_id 를 그대로 신뢰하면
|
||||
// 소유하지 않은 상품에 리뷰를 붙일 수 있다. 도출값이 유일한 진실이므로 payload 값은
|
||||
// 옵션과 일치하든 아니든 무시한다.
|
||||
$resolvedProductId = (int) $orderOption->product_id;
|
||||
|
||||
HookManager::doAction('sirsoft-ecommerce.product-review.before_create', $data);
|
||||
|
||||
$review = $this->repository->create([
|
||||
'product_id' => $data['product_id'],
|
||||
'product_id' => $resolvedProductId,
|
||||
'order_option_id' => $data['order_option_id'],
|
||||
'user_id' => $userId,
|
||||
'rating' => $data['rating'],
|
||||
|
||||
@@ -6,6 +6,8 @@ use App\Extension\HookManager;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueCondition;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueMethod;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\CouponNotIssuableException;
|
||||
@@ -322,6 +324,10 @@ class UserCouponService
|
||||
throw new CouponNotIssuableException('not_downloadable');
|
||||
}
|
||||
|
||||
// 다운로드 경로 전용 게이트(발급 방법/조건/유효기간) — getDownloadableCoupons 목록 조건과 대칭.
|
||||
// assertIssuable 은 관리자 직접발급(CouponService::issueDirectly)과 공유하므로 여기에 두지 않는다.
|
||||
$this->assertDownloadable($coupon);
|
||||
|
||||
// 발급 가능 조건 + per_user_limit 검증 (위반 시 사유별 예외)
|
||||
$this->assertIssuable($coupon);
|
||||
$this->assertWithinUserLimit($coupon, $userId);
|
||||
@@ -335,6 +341,32 @@ class UserCouponService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠폰이 사용자 다운로드 경로로 발급 가능한지 검증합니다. 위반 시 사유별 예외를 던집니다.
|
||||
*
|
||||
* 발급 방법(다운로드)·발급 조건(수동)·유효기간(valid_to) 만료 여부를 검사하며,
|
||||
* `CouponRepository::getDownloadableCoupons` 의 목록 조건과 대칭입니다. 이 게이트가
|
||||
* 없으면 자동/관리자 발급 전용 쿠폰이나 만료 쿠폰의 id 를 직접 지정해 다운로드할 수 있습니다.
|
||||
*
|
||||
* @param Coupon $coupon 다운로드 대상 쿠폰
|
||||
*
|
||||
* @throws CouponNotIssuableException 다운로드 불가(발급 방법/조건 불일치) 또는 만료 시
|
||||
*/
|
||||
public function assertDownloadable(Coupon $coupon): void
|
||||
{
|
||||
if ($coupon->issue_method !== CouponIssueMethod::DOWNLOAD) {
|
||||
throw new CouponNotIssuableException('not_downloadable');
|
||||
}
|
||||
|
||||
if ($coupon->issue_condition !== CouponIssueCondition::MANUAL) {
|
||||
throw new CouponNotIssuableException('not_downloadable');
|
||||
}
|
||||
|
||||
if ($coupon->valid_to !== null && $coupon->valid_to->isPast()) {
|
||||
throw new CouponNotIssuableException('expired');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿠폰의 발급 가능 상태(발급중/재고/기간)를 검증합니다. 위반 시 사유별 예외를 던집니다.
|
||||
*
|
||||
|
||||
@@ -65,6 +65,7 @@ return [
|
||||
'order_option_already_cancelled' => 'This order option has already been cancelled.',
|
||||
'order_option_already_confirmed' => 'This order option has already been confirmed.',
|
||||
'order_option_cannot_confirm' => 'Cannot confirm purchase in current status.',
|
||||
'order_option_not_confirmable' => 'This order option cannot be confirmed in its current status.',
|
||||
'cancel_quantity_exceeds' => 'Cancel quantity exceeds current quantity (:max).',
|
||||
|
||||
// Order payment related
|
||||
|
||||
@@ -65,6 +65,7 @@ return [
|
||||
'order_option_already_cancelled' => '이미 취소된 주문 옵션입니다.',
|
||||
'order_option_already_confirmed' => '이미 구매확정된 주문 옵션입니다.',
|
||||
'order_option_cannot_confirm' => '현재 상태에서는 구매확정할 수 없습니다.',
|
||||
'order_option_not_confirmable' => '현재 상태에서는 구매확정할 수 없는 주문 옵션입니다.',
|
||||
'cancel_quantity_exceeds' => '취소 수량이 현재 수량(:max개)을 초과합니다.',
|
||||
|
||||
// 주문 결제 관련
|
||||
|
||||
@@ -261,7 +261,8 @@ Route::post('user/orders', [PublicOrderController::class, 'store'])
|
||||
|
||||
// 비회원 주문 조회 인증 API (주문번호+전화번호+비밀번호 → 조회 토큰 발급)
|
||||
// POST /api/modules/sirsoft-ecommerce/guest/orders/verify
|
||||
// 실패 잠금은 GuestOrderAuthService 가 담당하고, throttle 로 요청 빈도도 제한한다.
|
||||
// 무차별 대입 방어는 라우트 throttle(분당 20회, IP 기준) + 조회 비밀번호 정책(영문+숫자 8자 이상)이
|
||||
// 담당한다. 업계 표준·로그인/IDV 일관성을 고려해 별도 N회 잠금은 두지 않는다(#55 결정).
|
||||
Route::post('guest/orders/verify', [PublicOrderController::class, 'verify'])
|
||||
->middleware('throttle:20,1')
|
||||
->name('guest.orders.verify');
|
||||
@@ -1136,16 +1137,20 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'admin'])->group(function ()
|
||||
// DELETE /api/modules/sirsoft-ecommerce/admin/presets/{preset} - 프리셋 삭제
|
||||
Route::prefix('presets')->group(function () {
|
||||
Route::get('/', [SearchPresetController::class, 'index'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.products.read')
|
||||
->name('admin.presets.index');
|
||||
|
||||
Route::post('/', [SearchPresetController::class, 'store'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.products.update')
|
||||
->name('admin.presets.store');
|
||||
|
||||
Route::put('/{preset}', [SearchPresetController::class, 'update'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.products.update')
|
||||
->whereNumber('preset')
|
||||
->name('admin.presets.update');
|
||||
|
||||
Route::delete('/{preset}', [SearchPresetController::class, 'destroy'])
|
||||
->middleware('permission:admin,sirsoft-ecommerce.products.update')
|
||||
->whereNumber('preset')
|
||||
->name('admin.presets.destroy');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Coupon;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueCondition;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueMethod;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Coupon;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 쿠폰 다운로드 게이트 회귀 테스트 (④⑤)
|
||||
*
|
||||
* downloadCoupon 경로는 getDownloadableCoupons 목록 조건과 대칭인 게이트를 거쳐야 한다.
|
||||
* 발급 방법(다운로드)/발급 조건(수동)이 아니거나 유효기간(valid_to)이 만료된 쿠폰의 id 를
|
||||
* 직접 지정해 다운로드하는 것을 차단하며, 거부 시 발급 카운터가 소모되지 않아야 한다.
|
||||
*/
|
||||
class CouponDownloadGuardTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 다운로드 가능 쿠폰 기본형을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $overrides 오버라이드 속성
|
||||
* @return Coupon
|
||||
*/
|
||||
private function makeCoupon(array $overrides = []): Coupon
|
||||
{
|
||||
return Coupon::create(array_merge([
|
||||
'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
|
||||
'description' => ['ko' => '설명', 'en' => 'Desc'],
|
||||
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
|
||||
'discount_type' => CouponDiscountType::FIXED,
|
||||
'discount_value' => 1000,
|
||||
'min_order_amount' => 10000,
|
||||
'target_scope' => CouponTargetScope::ALL,
|
||||
'issue_method' => CouponIssueMethod::DOWNLOAD,
|
||||
'issue_condition' => CouponIssueCondition::MANUAL,
|
||||
'issue_status' => CouponIssueStatus::ISSUING,
|
||||
'issue_from' => Carbon::now()->subDay(),
|
||||
'issue_to' => Carbon::now()->addMonth(),
|
||||
'per_user_limit' => 1,
|
||||
'total_quantity' => 100,
|
||||
'issued_count' => 0,
|
||||
'valid_type' => 'period',
|
||||
'valid_from' => Carbon::now()->subDay(),
|
||||
'valid_to' => Carbon::now()->addMonth(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자동발급 전용 쿠폰(issue_method=AUTO)은 id 직접 다운로드가 거부되고 카운터가 소모되지 않는다.
|
||||
*
|
||||
* @scenario case=coupon_method_gate
|
||||
*
|
||||
* @effects non_downloadable_coupon_rejected
|
||||
*/
|
||||
public function test_auto_issue_coupon_cannot_be_downloaded_by_id(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$coupon = $this->makeCoupon(['issue_method' => CouponIssueMethod::AUTO]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download");
|
||||
|
||||
$response->assertStatus(400);
|
||||
|
||||
// per_user_limit / issued_count 미소모 + 발급 레코드 미생성
|
||||
$this->assertSame(0, $coupon->fresh()->issued_count);
|
||||
$this->assertDatabaseMissing('ecommerce_promotion_coupon_issues', [
|
||||
'coupon_id' => $coupon->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 직접발급 조건(issue_condition != MANUAL)인 쿠폰도 다운로드가 거부된다.
|
||||
*
|
||||
* @scenario case=coupon_condition_gate
|
||||
*
|
||||
* @effects non_downloadable_coupon_rejected
|
||||
*/
|
||||
public function test_non_manual_condition_coupon_cannot_be_downloaded(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$coupon = $this->makeCoupon(['issue_condition' => CouponIssueCondition::SIGNUP]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download");
|
||||
|
||||
$response->assertStatus(400);
|
||||
|
||||
$this->assertSame(0, $coupon->fresh()->issued_count);
|
||||
$this->assertDatabaseMissing('ecommerce_promotion_coupon_issues', [
|
||||
'coupon_id' => $coupon->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효기간(valid_to)이 만료된 쿠폰은 다운로드가 거부되고 카운터가 소모되지 않는다.
|
||||
*
|
||||
* @scenario case=coupon_expired_gate
|
||||
*
|
||||
* @effects expired_coupon_rejected
|
||||
*/
|
||||
public function test_expired_valid_to_coupon_cannot_be_downloaded(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$coupon = $this->makeCoupon([
|
||||
'valid_from' => Carbon::now()->subMonth(),
|
||||
'valid_to' => Carbon::now()->subDay(), // 유효기간 만료
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download");
|
||||
|
||||
$response->assertStatus(400);
|
||||
|
||||
// 만료 거부 시에도 발급 카운터 미소모
|
||||
$this->assertSame(0, $coupon->fresh()->issued_count);
|
||||
$this->assertDatabaseMissing('ecommerce_promotion_coupon_issues', [
|
||||
'coupon_id' => $coupon->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상적인 다운로드 전용 쿠폰은 여전히 다운로드된다 (게이트 과잉 차단 회귀 방지).
|
||||
*
|
||||
* @scenario case=coupon_valid_download
|
||||
*
|
||||
* @effects valid_manual_download_coupon_succeeds
|
||||
*/
|
||||
public function test_valid_downloadable_coupon_still_succeeds(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$coupon = $this->makeCoupon();
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/modules/sirsoft-ecommerce/user/coupons/{$coupon->id}/download");
|
||||
|
||||
$response->assertStatus(201);
|
||||
$this->assertSame(1, $coupon->fresh()->issued_count);
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -138,10 +138,20 @@ class CouponDirectIssueTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* #4 코어 회귀: downloadCoupon() 추출 후에도 DL- 코드·동일 동작
|
||||
*
|
||||
* @scenario case=coupon_valid_download
|
||||
*
|
||||
* @effects valid_manual_download_coupon_succeeds
|
||||
*/
|
||||
public function test_download_coupon_still_uses_dl_prefix(): void
|
||||
{
|
||||
$coupon = $this->createIssuableCoupon(['per_user_limit' => 0]);
|
||||
// 다운로드 발급은 issue_method=DOWNLOAD + issue_condition=MANUAL 인 쿠폰만 대상이다(④⑤ 게이트).
|
||||
// 종전 픽스처는 DIRECT(관리자 발급) 쿠폰을 다운로드했는데, 이는 게이트 부재 시절의 동작이었다.
|
||||
$coupon = $this->createIssuableCoupon([
|
||||
'per_user_limit' => 0,
|
||||
'issue_method' => CouponIssueMethod::DOWNLOAD->value,
|
||||
'issue_condition' => CouponIssueCondition::MANUAL->value,
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$issue = app(UserCouponService::class)->downloadCoupon($user->id, $coupon->id);
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Order;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 비회원 주문취소 per-item 검증 회귀 테스트 (N-1)
|
||||
*
|
||||
* 비회원 부분취소도 회원판 CancelOrderRequest 와 동일 강도로 각 항목이 대상 주문에
|
||||
* 속하는지, 취소 수량이 보유 수량을 넘지 않는지 검증해야 한다.
|
||||
*/
|
||||
class GuestCancelItemValidationTest extends ModuleTestCase
|
||||
{
|
||||
private const PASSWORD = 'guest12';
|
||||
|
||||
private const PHONE = '010-1234-5678';
|
||||
|
||||
/**
|
||||
* 취소 가능한 비회원 주문 + 유효 토큰을 만들어 [주문, 토큰]을 반환합니다.
|
||||
*
|
||||
* @return array{0: Order, 1: string}
|
||||
*/
|
||||
private function makeGuestOrderWithToken(string $orderNumber): array
|
||||
{
|
||||
$order = Order::factory()->forGuest()->create([
|
||||
'order_number' => $orderNumber,
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
|
||||
]);
|
||||
|
||||
OrderAddress::factory()->shipping()->forOrder($order)->create([
|
||||
'orderer_phone' => self::PHONE,
|
||||
]);
|
||||
|
||||
/** @var GuestOrderAuthService $service */
|
||||
$service = app(GuestOrderAuthService::class);
|
||||
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.2')['token'];
|
||||
|
||||
return [$order, $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 선택 가능한 refund 사유 코드를 반환합니다.
|
||||
*/
|
||||
private function refundReasonCode(): string
|
||||
{
|
||||
return (string) ClaimReason::where('type', 'refund')
|
||||
->where('is_active', true)
|
||||
->where('is_user_selectable', true)
|
||||
->value('code');
|
||||
}
|
||||
|
||||
private function cancelUrl(Order $order): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/cancel";
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 주문에 속한 옵션 id 로 부분취소를 시도하면 422.
|
||||
*
|
||||
* @scenario case=guest_cancel_validation
|
||||
*
|
||||
* @effects guest_cancel_items_validated_per_item
|
||||
*/
|
||||
public function test_foreign_option_id_is_rejected_422(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-GC-1');
|
||||
|
||||
$otherOrder = Order::factory()->forGuest()->create(['order_number' => 'ORD-GC-OTHER']);
|
||||
$foreignOption = OrderOption::factory()->forOrder($otherOrder)->create([
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'quantity' => 3,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
$this->cancelUrl($order),
|
||||
[
|
||||
'reason' => $this->refundReasonCode(),
|
||||
'items' => [['order_option_id' => $foreignOption->id, 'cancel_quantity' => 1]],
|
||||
],
|
||||
['X-Guest-Order-Token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['items.0.order_option_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보유 수량을 초과하는 부분취소 수량은 422.
|
||||
*
|
||||
* @scenario case=guest_cancel_validation
|
||||
*
|
||||
* @effects guest_cancel_items_validated_per_item
|
||||
*/
|
||||
public function test_over_quantity_is_rejected_422(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-GC-2');
|
||||
|
||||
$option = OrderOption::factory()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'quantity' => 2,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
$this->cancelUrl($order),
|
||||
[
|
||||
'reason' => $this->refundReasonCode(),
|
||||
'items' => [['order_option_id' => $option->id, 'cancel_quantity' => 5]],
|
||||
],
|
||||
['X-Guest-Order-Token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['items.0.cancel_quantity']);
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Order;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Exceptions\OrderOptionNotConfirmableException;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\OrderService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 구매확정 상태 게이트 회귀 테스트 (㉙)
|
||||
*
|
||||
* OrderService::confirmOption 자체에 상태 가드가 내장되어 호출자(회원/비회원/훅)와 무관하게
|
||||
* 강제된다: 이미 확정된 옵션 재확정 차단, 확정 가능 상태(confirmable_statuses) 미포함 차단.
|
||||
* 비회원 경로는 이 예외를 422 로 응답한다.
|
||||
*/
|
||||
class GuestConfirmOptionGateTest extends ModuleTestCase
|
||||
{
|
||||
private const PASSWORD = 'guest12';
|
||||
|
||||
private const PHONE = '010-1234-5678';
|
||||
|
||||
/**
|
||||
* 테스트용 주문을 생성합니다 (금액 필드 포함 — 확정 후속 훅 안정성).
|
||||
*
|
||||
* @param array<string, mixed> $overrides 오버라이드 속성
|
||||
*/
|
||||
private function makeOrder(array $overrides = []): Order
|
||||
{
|
||||
return Order::factory()->create(array_merge([
|
||||
'order_status' => OrderStatusEnum::DELIVERED,
|
||||
'subtotal_amount' => 30000,
|
||||
'total_amount' => 30000,
|
||||
'total_paid_amount' => 30000,
|
||||
'total_cancelled_amount' => 0,
|
||||
'cancellation_count' => 0,
|
||||
'promotions_applied_snapshot' => [],
|
||||
'shipping_policy_applied_snapshot' => [],
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 주문 옵션을 생성합니다.
|
||||
*/
|
||||
private function makeOption(Order $order, OrderStatusEnum $status): OrderOption
|
||||
{
|
||||
return OrderOption::factory()->forOrder($order)->create([
|
||||
'quantity' => 1,
|
||||
'unit_price' => 15000,
|
||||
'subtotal_price' => 15000,
|
||||
'subtotal_paid_amount' => 15000,
|
||||
'option_status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원 주문 + 유효 토큰을 만들어 [주문, 토큰]을 반환합니다.
|
||||
*
|
||||
* @return array{0: Order, 1: string}
|
||||
*/
|
||||
private function makeGuestOrderWithToken(string $orderNumber, OrderStatusEnum $status = OrderStatusEnum::DELIVERED): array
|
||||
{
|
||||
$order = $this->makeOrder([
|
||||
'order_number' => $orderNumber,
|
||||
'order_status' => $status,
|
||||
'user_id' => null,
|
||||
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
|
||||
'is_first_order' => false,
|
||||
]);
|
||||
|
||||
OrderAddress::factory()->shipping()->forOrder($order)->create([
|
||||
'orderer_phone' => self::PHONE,
|
||||
]);
|
||||
|
||||
/** @var GuestOrderAuthService $service */
|
||||
$service = app(GuestOrderAuthService::class);
|
||||
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.3')['token'];
|
||||
|
||||
return [$order, $token];
|
||||
}
|
||||
|
||||
private function guestConfirmUrl(Order $order, int $optionId): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/options/{$optionId}/confirm";
|
||||
}
|
||||
|
||||
private function memberConfirmUrl(int $orderId, int $optionId): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/user/orders/{$orderId}/options/{$optionId}/confirm";
|
||||
}
|
||||
|
||||
// ============ 서비스 계층 가드 (호출자 무관) ============
|
||||
|
||||
/**
|
||||
* 이미 확정된 옵션을 다시 확정하면 도메인 예외를 던진다.
|
||||
*
|
||||
* @scenario case=confirm_service_gate
|
||||
*
|
||||
* @effects confirm_rejected_unless_confirmable_status, double_confirm_rejected
|
||||
*/
|
||||
public function test_service_rejects_already_confirmed_option(): void
|
||||
{
|
||||
$order = $this->makeOrder(['order_status' => OrderStatusEnum::CONFIRMED]);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::CONFIRMED);
|
||||
|
||||
try {
|
||||
app(OrderService::class)->confirmOption($order, $option);
|
||||
$this->fail('OrderOptionNotConfirmableException 이 발생해야 한다');
|
||||
} catch (OrderOptionNotConfirmableException $e) {
|
||||
$this->assertSame('order_option_already_confirmed', $e->getReason());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 가능 상태(shipping/delivered)가 아닌 옵션(preparing)은 확정이 거부된다.
|
||||
*
|
||||
* @scenario case=confirm_service_gate
|
||||
*
|
||||
* @effects confirm_rejected_unless_confirmable_status
|
||||
*/
|
||||
public function test_service_rejects_non_confirmable_status(): void
|
||||
{
|
||||
$order = $this->makeOrder(['order_status' => OrderStatusEnum::PREPARING]);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
|
||||
|
||||
try {
|
||||
app(OrderService::class)->confirmOption($order, $option);
|
||||
$this->fail('OrderOptionNotConfirmableException 이 발생해야 한다');
|
||||
} catch (OrderOptionNotConfirmableException $e) {
|
||||
$this->assertSame('order_option_not_confirmable', $e->getReason());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확정 가능 상태(delivered)는 확정되고 after_confirm 훅은 1회만 발화한다.
|
||||
* 이후 재확정은 already_confirmed 로 거부되어 훅이 다시 발화하지 않는다.
|
||||
*
|
||||
* @scenario case=confirm_service_gate
|
||||
*
|
||||
* @effects confirm_hook_fires_once
|
||||
*/
|
||||
public function test_service_confirms_delivered_and_fires_after_confirm_hook_once(): void
|
||||
{
|
||||
$hookCount = 0;
|
||||
HookManager::addAction(
|
||||
'sirsoft-ecommerce.order-option.after_confirm',
|
||||
function () use (&$hookCount) {
|
||||
$hookCount++;
|
||||
}
|
||||
);
|
||||
|
||||
$order = $this->makeOrder(['order_status' => OrderStatusEnum::DELIVERED]);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::DELIVERED);
|
||||
|
||||
$service = app(OrderService::class);
|
||||
|
||||
$service->confirmOption($order, $option);
|
||||
$this->assertSame(1, $hookCount);
|
||||
|
||||
// 재확정 시도 → 예외 + 훅 미발화
|
||||
$option->refresh();
|
||||
try {
|
||||
$service->confirmOption($order->fresh(), $option);
|
||||
$this->fail('재확정은 거부되어야 한다');
|
||||
} catch (OrderOptionNotConfirmableException $e) {
|
||||
$this->assertSame('order_option_already_confirmed', $e->getReason());
|
||||
}
|
||||
|
||||
$this->assertSame(1, $hookCount);
|
||||
}
|
||||
|
||||
// ============ 비회원 HTTP 경로 ============
|
||||
|
||||
/**
|
||||
* 비회원: 배송 전(preparing) 옵션 확정 시 422.
|
||||
*
|
||||
* @scenario case=confirm_guest_http
|
||||
*
|
||||
* @effects confirm_rejected_unless_confirmable_status
|
||||
*/
|
||||
public function test_guest_confirm_preparing_returns_422(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CF-1', OrderStatusEnum::PREPARING);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
|
||||
|
||||
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비회원: 확정 가능 옵션의 이중 확정 시 두 번째 요청은 422.
|
||||
*
|
||||
* @scenario case=confirm_guest_http
|
||||
*
|
||||
* @effects double_confirm_rejected
|
||||
*/
|
||||
public function test_guest_double_confirm_returns_422_on_second(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-CF-2', OrderStatusEnum::DELIVERED);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::DELIVERED);
|
||||
|
||||
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
|
||||
->assertStatus(200);
|
||||
|
||||
$this->postJson($this->guestConfirmUrl($order, $option->id), [], ['X-Guest-Order-Token' => $token])
|
||||
->assertStatus(422);
|
||||
}
|
||||
|
||||
// ============ 회원 HTTP 경로 ============
|
||||
|
||||
/**
|
||||
* 회원: 배송 전(preparing) 옵션 확정 시 422.
|
||||
*
|
||||
* @scenario case=confirm_member_http
|
||||
*
|
||||
* @effects confirm_rejected_unless_confirmable_status
|
||||
*/
|
||||
public function test_member_confirm_preparing_returns_422(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
$order = $this->makeOrder([
|
||||
'user_id' => $user->id,
|
||||
'order_status' => OrderStatusEnum::PREPARING,
|
||||
]);
|
||||
$option = $this->makeOption($order, OrderStatusEnum::PREPARING);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson($this->memberConfirmUrl($order->id, $option->id))
|
||||
->assertStatus(422);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Refund;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderAddress;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Services\GuestOrderAuthService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 비회원 환불예상 per-item 검증 회귀 테스트 (㉒)
|
||||
*
|
||||
* 비회원 환불예상(estimate-refund)도 회원판과 동일 강도로 각 항목이 대상 주문에
|
||||
* 속하는지, 취소 수량이 보유 수량을 넘지 않는지 검증해야 한다.
|
||||
*/
|
||||
class GuestRefundEstimateValidationTest extends ModuleTestCase
|
||||
{
|
||||
private const PASSWORD = 'guest12';
|
||||
|
||||
private const PHONE = '010-1234-5678';
|
||||
|
||||
/**
|
||||
* 비회원 주문과 유효한 조회 토큰을 만들어 [주문, 토큰]을 반환합니다.
|
||||
*
|
||||
* @return array{0: Order, 1: string}
|
||||
*/
|
||||
private function makeGuestOrderWithToken(string $orderNumber): array
|
||||
{
|
||||
$order = Order::factory()->forGuest()->create([
|
||||
'order_number' => $orderNumber,
|
||||
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'guest_lookup_password_hash' => Hash::make(self::PASSWORD),
|
||||
]);
|
||||
|
||||
OrderAddress::factory()->shipping()->forOrder($order)->create([
|
||||
'orderer_phone' => self::PHONE,
|
||||
]);
|
||||
|
||||
/** @var GuestOrderAuthService $service */
|
||||
$service = app(GuestOrderAuthService::class);
|
||||
$token = $service->authenticate($order->order_number, self::PHONE, self::PASSWORD, '10.0.0.1')['token'];
|
||||
|
||||
return [$order, $token];
|
||||
}
|
||||
|
||||
private function estimateUrl(Order $order): string
|
||||
{
|
||||
return "/api/modules/sirsoft-ecommerce/guest/orders/{$order->order_number}/estimate-refund";
|
||||
}
|
||||
|
||||
/**
|
||||
* 다른 주문에 속한 옵션 id 를 넘기면 422 (스코프 위반).
|
||||
*
|
||||
* @scenario case=guest_refund_estimate_validation
|
||||
*
|
||||
* @effects guest_refund_items_validated_per_item
|
||||
*/
|
||||
public function test_foreign_option_id_is_rejected_422(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-EST-1');
|
||||
|
||||
// 다른 주문의 옵션
|
||||
$otherOrder = Order::factory()->forGuest()->create(['order_number' => 'ORD-EST-OTHER']);
|
||||
$foreignOption = OrderOption::factory()->forOrder($otherOrder)->create([
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'quantity' => 3,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
$this->estimateUrl($order),
|
||||
['items' => [['order_option_id' => $foreignOption->id, 'cancel_quantity' => 1]]],
|
||||
['X-Guest-Order-Token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['items.0.order_option_id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보유 수량을 초과하는 취소 수량은 422.
|
||||
*
|
||||
* @scenario case=guest_refund_estimate_validation
|
||||
*
|
||||
* @effects guest_refund_items_validated_per_item
|
||||
*/
|
||||
public function test_over_quantity_is_rejected_422(): void
|
||||
{
|
||||
[$order, $token] = $this->makeGuestOrderWithToken('ORD-EST-2');
|
||||
|
||||
$option = OrderOption::factory()->forOrder($order)->create([
|
||||
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
||||
'quantity' => 2,
|
||||
]);
|
||||
|
||||
$response = $this->postJson(
|
||||
$this->estimateUrl($order),
|
||||
['items' => [['order_option_id' => $option->id, 'cancel_quantity' => 5]]],
|
||||
['X-Guest-Order-Token' => $token]
|
||||
);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['items.0.cancel_quantity']);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Review;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Order;
|
||||
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Services\ProductReviewService;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 리뷰 product_id 스코프 회귀 테스트 (③)
|
||||
*
|
||||
* 리뷰 저장 시 product_id 는 클라이언트 payload 가 아니라 확정된 주문 옵션에서
|
||||
* 도출되어야 한다. payload 의 product_id 를 신뢰하면 소유하지 않은 상품에 리뷰를
|
||||
* 붙일 수 있다.
|
||||
*/
|
||||
class ProductReviewProductIdScopeTest extends ModuleTestCase
|
||||
{
|
||||
/**
|
||||
* 상품 A 의 확정 옵션으로 product_id=B 를 지정해도 리뷰는 옵션의 상품(A)로 저장된다.
|
||||
*
|
||||
* @scenario case=review_product_scope
|
||||
*
|
||||
* @effects review_product_id_derived_from_order_option
|
||||
*/
|
||||
public function test_review_product_id_is_derived_from_order_option_not_payload(): void
|
||||
{
|
||||
$user = $this->createUser();
|
||||
|
||||
$productA = Product::factory()->create();
|
||||
$productB = Product::factory()->create();
|
||||
|
||||
$order = Order::factory()->create(['user_id' => $user->id]);
|
||||
$option = OrderOption::factory()->forOrder($order)->create([
|
||||
'product_id' => $productA->id,
|
||||
'option_status' => OrderStatusEnum::CONFIRMED,
|
||||
'confirmed_at' => now(),
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
/** @var ProductReviewService $service */
|
||||
$service = app(ProductReviewService::class);
|
||||
|
||||
$review = $service->createReview($user->id, [
|
||||
'order_option_id' => $option->id,
|
||||
// 클라이언트가 다른 상품(B)의 id 를 주입 — 무시되어야 한다
|
||||
'product_id' => $productB->id,
|
||||
'rating' => 5,
|
||||
'content' => str_repeat('좋은 상품입니다 ', 3),
|
||||
'content_mode' => 'text',
|
||||
]);
|
||||
|
||||
// 저장된 product_id 는 옵션의 상품(A)여야 한다 (payload 의 B 가 아니라)
|
||||
$this->assertSame($productA->id, $review->product_id);
|
||||
$this->assertNotSame($productB->id, $review->product_id);
|
||||
|
||||
$this->assertDatabaseHas('ecommerce_product_reviews', [
|
||||
'id' => $review->id,
|
||||
'order_option_id' => $option->id,
|
||||
'product_id' => $productA->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\SearchPreset;
|
||||
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 검색 프리셋 라우트 권한 회귀 테스트 (N-6)
|
||||
*
|
||||
* presets 라우트 4종은 형제 그룹(brands/product-labels) 규약대로 products 권한
|
||||
* 미들웨어로 보호되어야 한다. index=products.read, store/update/destroy=products.update.
|
||||
*/
|
||||
class SearchPresetPermissionTest extends ModuleTestCase
|
||||
{
|
||||
private const INDEX_URL = '/api/modules/sirsoft-ecommerce/admin/presets';
|
||||
|
||||
/**
|
||||
* products.read 권한이 없는 관리자는 프리셋 목록 조회가 403.
|
||||
*
|
||||
* @scenario case=presets_permission
|
||||
*
|
||||
* @effects presets_require_product_permission
|
||||
*/
|
||||
public function test_index_forbidden_without_products_read(): void
|
||||
{
|
||||
$admin = $this->createAdminUser();
|
||||
|
||||
$this->actingAs($admin, 'sanctum')
|
||||
->getJson(self::INDEX_URL)
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* products.read 권한이 있는 관리자는 프리셋 목록 조회가 200.
|
||||
*
|
||||
* @scenario case=presets_permission
|
||||
*
|
||||
* @effects presets_require_product_permission
|
||||
*/
|
||||
public function test_index_allowed_with_products_read(): void
|
||||
{
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
|
||||
$this->actingAs($admin, 'sanctum')
|
||||
->getJson(self::INDEX_URL)
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* products.update 권한이 없는 관리자는 프리셋 생성이 403.
|
||||
*
|
||||
* @scenario case=presets_permission
|
||||
*
|
||||
* @effects presets_require_product_permission
|
||||
*/
|
||||
public function test_store_forbidden_without_products_update(): void
|
||||
{
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.products.read']);
|
||||
|
||||
$this->actingAs($admin, 'sanctum')
|
||||
->postJson(self::INDEX_URL, [
|
||||
'name' => '내 프리셋',
|
||||
'conditions' => ['status' => 'active'],
|
||||
])
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
/**
|
||||
* products.update 권한이 있는 관리자는 프리셋 생성이 허용된다.
|
||||
*
|
||||
* @scenario case=presets_permission
|
||||
*
|
||||
* @effects presets_require_product_permission
|
||||
*/
|
||||
public function test_store_allowed_with_products_update(): void
|
||||
{
|
||||
$admin = $this->createAdminUser(['sirsoft-ecommerce.products.update']);
|
||||
|
||||
$response = $this->actingAs($admin, 'sanctum')
|
||||
->postJson(self::INDEX_URL, [
|
||||
'name' => '내 프리셋',
|
||||
'conditions' => ['status' => 'active'],
|
||||
]);
|
||||
|
||||
// 권한 게이트를 통과했으므로 403 이 아니어야 하며, 정상 생성(201)된다.
|
||||
$this->assertNotSame(403, $response->status());
|
||||
$response->assertStatus(201);
|
||||
}
|
||||
}
|
||||
+37
@@ -362,4 +362,41 @@ class SearchProductsListenerTest extends ModuleTestCase
|
||||
$this->assertEquals(0.0, $result['products'][1]['rating_avg']);
|
||||
$this->assertEquals(0, $result['products'][1]['review_count']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 하이라이트 필드가 원문 태그를 이스케이프하는지 확인 (⑧)
|
||||
*
|
||||
* @scenario case=search_highlight_escape
|
||||
*
|
||||
* @effects highlighted_fields_escaped
|
||||
*/
|
||||
public function test_highlight_keyword_escapes_markup(): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this->listener, 'highlightKeyword');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->listener, '<img src=x onerror=alert(1)> 상품', '상품');
|
||||
|
||||
$this->assertStringNotContainsString('<img', $result);
|
||||
$this->assertStringContainsString('<img', $result);
|
||||
$this->assertStringContainsString('<mark>상품</mark>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 프리뷰가 엔티티 인코딩된 태그를 부활시키지 않는지 확인 (N-8)
|
||||
*
|
||||
* @scenario case=preview_entity_no_resurrect
|
||||
*
|
||||
* @effects preview_does_not_resurrect_entities
|
||||
*/
|
||||
public function test_extract_content_preview_does_not_resurrect_entity_encoded_tags(): void
|
||||
{
|
||||
$method = new \ReflectionMethod($this->listener, 'extractContentPreview');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->listener, '<script>alert(1)</script> 상품 설명입니다.', '상품', 150);
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringContainsString('상품', $result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
feature: 이커머스 보안 수정 묶음 (2026-08 — 리뷰 스코프 / 쿠폰 발급 게이트 / 비회원 요청 검증 패리티 / 구매확정 게이트 / 프리셋 권한 / 하이라이트 이스케이프)
|
||||
|
||||
description: |
|
||||
2026-08 비공개 보안 제보 대응으로 sirsoft-ecommerce 에 반영된 수정의 회귀 고정.
|
||||
|
||||
- ③: 리뷰 저장 시 클라이언트 product_id 를 신뢰하던 문제 — 주문 옵션에서 도출(SSoT).
|
||||
- ④/⑤: 쿠폰 다운로드 실행 게이트가 목록 게이트보다 넓던 문제 —
|
||||
issue_method=DOWNLOAD + issue_condition=MANUAL + 미만료(valid_to)를 실행 시점에 검사.
|
||||
- ㉒/N-1: 비회원 환불예상·주문취소 FormRequest 가 회원판 per-item 검증(주문 소속 /
|
||||
취소완료 거부 / 수량 상한)을 누락하던 문제 — ValidatesCancelItems trait 로 4개
|
||||
Request 가 동일 검증을 공유.
|
||||
- ㉙: 구매확정에 상태 게이트가 없어 배송 전/이중 확정이 가능하던 문제 — 서비스 내장
|
||||
게이트(OrderOptionNotConfirmableException) + 비회원/회원 컨트롤러 typed catch 422.
|
||||
- N-6: 관리자 검색 프리셋 라우트의 permission 게이트 부재 — products.read/update 부착.
|
||||
- ⑧/N-8: 검색 하이라이트 이스케이프 + 프리뷰 decode→strip 순서(코어 SearchHighlighter 위임).
|
||||
|
||||
axes:
|
||||
case: [review_product_scope, coupon_method_gate, coupon_condition_gate, coupon_expired_gate, coupon_valid_download, guest_refund_estimate_validation, guest_cancel_validation, confirm_service_gate, confirm_guest_http, confirm_member_http, presets_permission, search_highlight_escape, preview_entity_no_resurrect]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- review_product_id_derived_from_order_option
|
||||
- non_downloadable_coupon_rejected
|
||||
- expired_coupon_rejected
|
||||
- valid_manual_download_coupon_succeeds
|
||||
- guest_refund_items_validated_per_item
|
||||
- guest_cancel_items_validated_per_item
|
||||
- confirm_rejected_unless_confirmable_status
|
||||
- double_confirm_rejected
|
||||
- confirm_hook_fires_once
|
||||
- presets_require_product_permission
|
||||
- highlighted_fields_escaped
|
||||
- preview_does_not_resurrect_entities
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Review/ProductReviewProductIdScopeTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Coupon/CouponDownloadGuardTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponDirectIssueTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Refund/GuestRefundEstimateValidationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/GuestCancelItemValidationTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Order/GuestConfirmOptionGateTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/SearchPreset/SearchPresetPermissionTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SearchProductsListenerTest.php
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
- 미발행(초안) 페이지에 붙은 이미지 첨부의 미리보기가 주소만 알면 누구에게나 열리던 문제를 수정했습니다. 다운로드는 이미 발행 상태를 확인하고 있었지만 미리보기는 확인하지 않아, 발행 전 콘텐츠의 이미지가 새어 나갈 수 있었습니다. 이제 미리보기도 다운로드와 같은 기준을 적용해, 발행된 페이지의 첨부는 누구나, 미발행 페이지의 첨부는 페이지 조회 권한이 있는 관리자만 미리볼 수 있습니다. 편집 중인 초안의 썸네일은 편집 권한이 있는 관리자에게 종전처럼 정상 표시됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1914)
|
||||
- 페이지 일괄 발행/발행취소와 첨부파일 삭제·순서 변경에 담당 범위 제한을 적용했습니다. 페이지를 하나씩 여는 화면에는 이 확인이 있었지만 목록에서 여러 건을 한 번에 처리하는 경로와 첨부 관리 경로에는 없어, "본인이 만든 페이지만" 으로 범위를 좁혀 위임받은 관리자가 담당 밖 페이지까지 발행 상태를 바꾸거나 첨부를 지울 수 있었습니다. 이제 대상마다 같은 기준으로 확인하며, 범위 밖 대상이 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. 범위 제한 없이 위임받은 관리자의 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
|
||||
- 페이지 통합 검색 결과의 제목·본문 미리보기에서 검색어 강조 처리가 원문을 그대로 실어 보내던 것을, 안전하게 변환한 뒤 강조하도록 바로잡았습니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Page\Listeners;
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Enums\TotalRelation;
|
||||
use App\Search\SearchCategoryPayload;
|
||||
use App\Search\SearchHighlighter;
|
||||
use App\Support\Query\BoundedCount;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Modules\Sirsoft\Page\Services\PageService;
|
||||
@@ -259,13 +260,7 @@ class SearchPagesListener implements HookListenerInterface
|
||||
*/
|
||||
private function highlightKeyword(?string $text, string $keyword): string
|
||||
{
|
||||
if (empty($text) || empty($keyword)) {
|
||||
return $text ?? '';
|
||||
}
|
||||
|
||||
$escapedKeyword = preg_quote($keyword, '/');
|
||||
|
||||
return preg_replace('/('.$escapedKeyword.')/iu', '<mark>$1</mark>', $text);
|
||||
return SearchHighlighter::highlight($text, $keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,8 +293,8 @@ class SearchPagesListener implements HookListenerInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
// HTML 태그 제거 후 공백 정규화
|
||||
$plainText = trim(preg_replace('/\s+/', ' ', html_entity_decode(strip_tags((string) $text))));
|
||||
// HTML 태그 제거 후 공백 정규화 (엔티티 디코드를 태그 제거보다 먼저 수행)
|
||||
$plainText = SearchHighlighter::toPlainText((string) $text);
|
||||
$position = mb_stripos($plainText, $keyword);
|
||||
|
||||
if ($position !== false) {
|
||||
|
||||
@@ -346,6 +346,61 @@ class SearchPagesListenerTest extends ModuleTestCase
|
||||
$this->assertStringContainsString('<mark>', $item['title_highlighted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목에 삽입된 태그가 하이라이트 필드에서 이스케이프되는지 확인 (⑧)
|
||||
*
|
||||
* @scenario case=search_highlight_escape
|
||||
*
|
||||
* @effects highlighted_fields_escaped
|
||||
*/
|
||||
public function test_search_pages_escapes_markup_in_highlighted_title(): void
|
||||
{
|
||||
$fakePage = $this->makeFakePage('<img src=x onerror=alert(1)> 이용약관', 'terms', '');
|
||||
|
||||
$this->pageServiceMock
|
||||
->shouldReceive('searchByKeyword')
|
||||
->once()
|
||||
->andReturn($this->boundedPage(new Collection([$fakePage]), 1));
|
||||
|
||||
$result = $this->listener->searchPages([], [
|
||||
'q' => '이용',
|
||||
'type' => 'all',
|
||||
'all_tab_limit' => 5,
|
||||
]);
|
||||
|
||||
$item = $result['pages']['items'][0];
|
||||
$this->assertStringNotContainsString('<img', $item['title_highlighted']);
|
||||
$this->assertStringContainsString('<img', $item['title_highlighted']);
|
||||
$this->assertStringContainsString('<mark>이용</mark>', $item['title_highlighted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티로 인코딩된 태그가 본문 프리뷰에서 부활하지 않는지 확인 (N-8)
|
||||
*
|
||||
* @scenario case=preview_entity_no_resurrect
|
||||
*
|
||||
* @effects preview_does_not_resurrect_entities
|
||||
*/
|
||||
public function test_search_pages_does_not_resurrect_entity_encoded_tags_in_preview(): void
|
||||
{
|
||||
$fakePage = $this->makeFakePage('약관', 'terms', '<script>alert(1)</script> 약관 본문입니다.');
|
||||
|
||||
$this->pageServiceMock
|
||||
->shouldReceive('searchByKeyword')
|
||||
->once()
|
||||
->andReturn($this->boundedPage(new Collection([$fakePage]), 1));
|
||||
|
||||
$result = $this->listener->searchPages([], [
|
||||
'q' => '약관',
|
||||
'type' => 'all',
|
||||
'all_tab_limit' => 5,
|
||||
]);
|
||||
|
||||
$item = $result['pages']['items'][0];
|
||||
$this->assertStringNotContainsString('<script>', $item['content_preview']);
|
||||
$this->assertStringNotContainsString('<script>', $item['content_preview_highlighted']);
|
||||
}
|
||||
|
||||
/**
|
||||
* PageService 예외 발생 시 다른 카테고리는 보존하고 pages 는 failed 로 표면화하는지 확인
|
||||
*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
feature: 페이지 검색 하이라이트 이스케이프 (2026-08 보안 수정)
|
||||
|
||||
description: |
|
||||
2026-08 비공개 보안 제보(⑧/N-8) 대응 — SearchPagesListener 가 검색 하이라이트에서
|
||||
원문 마크업을 이스케이프하지 않고, 프리뷰가 strip_tags → html_entity_decode 순서라
|
||||
엔티티 인코딩된 태그가 부활하던 문제. 코어 app/Search/SearchHighlighter(이스케이프 후
|
||||
<mark> 삽입, decode → strip 순서) 위임으로 board/ecommerce 와 동일 SSoT 를 공유한다.
|
||||
|
||||
axes:
|
||||
case: [search_highlight_escape, preview_entity_no_resurrect]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- highlighted_fields_escaped
|
||||
- preview_does_not_resurrect_entities
|
||||
|
||||
test_files:
|
||||
- modules/_bundled/sirsoft-page/tests/Unit/Listeners/SearchPagesListenerTest.php
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [1.0.2] - 2026-08-12
|
||||
|
||||
### Security
|
||||
|
||||
- 에디터 이미지 업로드의 권한 검사가 클라이언트가 보낸 값에 좌우되어, 그 값이 비어 있으면 검사가 통째로 건너뛰어지던 구조를 제거했습니다. 업로드는 종전처럼 관리자만 할 수 있으며, 이제 그 판정은 전적으로 서버가 합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
|
||||
|
||||
### Added
|
||||
|
||||
- 에디터 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 업로드 이미지를 저장 전에 변환할 수 있습니다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"identifier": "sirsoft-ckeditor5",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"components": {
|
||||
"basic": [],
|
||||
"composite": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -73,7 +73,7 @@ _이 엔드포인트는 표준 envelope(`success`/`message`/`data`)를 사용하
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | query 파라미터 `permission` 이 지정됐고 현재 사용자가 해당 권한을 갖지 못한 경우 — `{"error":{"message":"이미지 업로드 권한이 없습니다."}}` |
|
||||
| 403 | Forbidden | 관리자(admin) 권한이 없는 경우 (라우트의 `admin` 미들웨어) |
|
||||
| 422 | Unprocessable Entity | 검증 규칙 위반 (`upload` 누락 / 파일 아님 / 이미지 아님 / 허용 MIME 아님 / 용량 초과). 응답은 Laravel 기본 `errors` 형식이 아니라 CKEditor 규격 `{"error":{"message":"<첫 번째 오류 메시지>"}}` |
|
||||
| 500 | Internal Server Error | 스토리지 저장 또는 업로드 기록 생성 중 예외 발생 — `{"error":{"message":"이미지 업로드에 실패했습니다."}}` |
|
||||
|
||||
@@ -86,7 +86,7 @@ CKEditor5 의 SimpleUploadAdapter 가 에디터에 드롭/붙여넣은 이미지
|
||||
- **응답 형식이 표준 envelope 가 아니다.** SimpleUploadAdapter 규격상 성공 시 HTTP 201 + 최상위 `{"url": "..."}`, 실패 시 4xx/5xx + `{"error": {"message": "..."}}` 를 반환한다. `ResponseHelper` 를 쓰지 않으므로 `data`/`success` 필드가 없다.
|
||||
이 규격은 우리 코드가 정한 것이 아니다 — 응답을 파싱하는 주체가 CDN 으로 로드되는 CKEditor5 43.3.1 의 `SimpleUploadAdapter`(`resources/js/handlers/initEditor.ts` 의 `editorConfig.simpleUpload`)이므로 파싱 규약을 바꿀 수 없다. 컨트롤러의 각 응답 지점에는 이 사유로 `audit:allow response-helper-bypass` 를 명시해 두었으니, envelope 로 감싸는 "수정" 을 하면 에디터의 이미지 업로드가 조용히 실패한다.
|
||||
- **요청 파라미터**: multipart body 의 `upload` 필드(이미지 파일 1개). 허용 MIME 은 `jpeg,jpg,png,gif,webp`, 최대 크기는 플러그인 설정 `imageMaxSizeMb`(기본 2MB) 로 동적 결정된다. 검증 실패도 CKEditor 규격(`{"error":{"message":...}}`, HTTP 422)으로 응답한다.
|
||||
- **선택 권한 게이트**: query 파라미터 `permission` 이 주어지면, 현재 사용자가 해당 권한을 갖지 못한 경우 403 `{"error":{"message":...}}`. 에디터를 임베드하는 화면이 업로드 권한을 세분화할 때 사용한다.
|
||||
- **인가**: 업로드 권한은 서버측 라우트 게이트(`auth:sanctum` + `admin`)만으로 판정한다. 클라이언트가 검사 대상 권한명을 지정하던 방식(query 파라미터 `permission`)은 제거되었다.
|
||||
- 업로드 성공 시 반환하는 `url` 은 기본적으로 공개 서빙 엔드포인트(`GET /images/{hash}`)의 절대 URL 이다. 단, 공개 자산 디스크(플러그인 설정 `public_asset_disk` > 코어 전역 `drivers.public_asset_disk`)가 직접 URL 을 지원하는 디스크(public / URL 이 설정된 s3 등)로 지정되어 있으면 스트리밍 경로 대신 그 디스크의 직접 URL(CDN)이 반환된다. 디스크 미설정('none' 포함)·URL 미지원 디스크·`core.storage.filter_url` 훅 차단 시에는 기존 스트리밍 경로로 폴백한다.
|
||||
|
||||
**응답 예시** (성공 — CKEditor 규격, envelope 아님)
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"placeholder": "{{extensionPointProps.placeholder ?? ''}}",
|
||||
"readOnly": "{{_computed.isReadOnly ?? extensionPointProps.disabled ?? extensionPointProps.readOnly ?? false}}",
|
||||
"imageUpload": "{{_global.plugins['sirsoft-ckeditor5']?.imageUpload ?? false}}",
|
||||
"uploadPermission": "{{extensionPointProps.uploadPermission ?? ''}}",
|
||||
"height": "{{_global.plugins['sirsoft-ckeditor5']?.editorHeight ?? 400}}",
|
||||
"toolbar": "{{_global.plugins['sirsoft-ckeditor5']?.toolbar ?? 'standard'}}"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// e2e:allow ⑫⑬ 보안 수정 — 클라이언트 지정 업로드 권한 쿼리(죽은 배선) 제거만 수행. 업로드 동작·UI 불변(서버 admin 게이트 유지), 사용자 시나리오 변화 없음.
|
||||
/**
|
||||
* CKEditor5 초기화 핸들러
|
||||
*
|
||||
@@ -65,7 +66,6 @@ interface InitEditorParams {
|
||||
placeholder?: string;
|
||||
readOnly?: boolean | string;
|
||||
imageUpload?: boolean | string;
|
||||
uploadPermission?: string;
|
||||
height?: number | string;
|
||||
toolbar?: string;
|
||||
}
|
||||
@@ -641,14 +641,10 @@ function getPlugins(CKEDITOR: CKEditorGlobal, toolbarType: string, withImageUplo
|
||||
|
||||
/**
|
||||
* 이미지 업로드 URL을 생성합니다.
|
||||
* uploadPermission이 있으면 쿼리스트링으로 추가합니다.
|
||||
* 인가는 서버 라우트 게이트(auth:sanctum + admin)가 전담하므로 쿼리 파라미터는 붙이지 않습니다.
|
||||
*/
|
||||
function buildUploadUrl(uploadPermission: string): string {
|
||||
const base = '/api/plugins/sirsoft-ckeditor5/upload';
|
||||
if (uploadPermission) {
|
||||
return `${base}?permission=${encodeURIComponent(uploadPermission)}`;
|
||||
}
|
||||
return base;
|
||||
function buildUploadUrl(): string {
|
||||
return '/api/plugins/sirsoft-ckeditor5/upload';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -673,7 +669,6 @@ async function createEditorInstance(
|
||||
placeholder: string;
|
||||
readOnly: boolean;
|
||||
imageUpload: boolean;
|
||||
uploadPermission: string;
|
||||
height: number;
|
||||
toolbar: string;
|
||||
containerId: string;
|
||||
@@ -764,7 +759,7 @@ async function createEditorInstance(
|
||||
|
||||
if (options.imageUpload) {
|
||||
editorConfig.simpleUpload = {
|
||||
uploadUrl: buildUploadUrl(options.uploadPermission),
|
||||
uploadUrl: buildUploadUrl(),
|
||||
headers: getUploadHeaders(),
|
||||
};
|
||||
}
|
||||
@@ -1005,7 +1000,6 @@ export async function initEditorHandler(
|
||||
const withImageUpload = params.imageUpload !== undefined
|
||||
? (params.imageUpload === true || params.imageUpload === 'true')
|
||||
: (pluginSettings.imageUpload === true || pluginSettings.imageUpload === 'true');
|
||||
const uploadPermission = params.uploadPermission ?? '';
|
||||
const placeholder = params.placeholder ?? '';
|
||||
const height = params.height !== undefined ? (Number(params.height) || 400) : (Number(pluginSettings.editorHeight) || 400);
|
||||
const toolbarType = (params.toolbar !== undefined ? (params.toolbar as string) : (pluginSettings.toolbar as string)) ?? 'standard';
|
||||
@@ -1055,7 +1049,6 @@ export async function initEditorHandler(
|
||||
placeholder,
|
||||
readOnly: isReadOnly,
|
||||
imageUpload: withImageUpload,
|
||||
uploadPermission,
|
||||
height,
|
||||
toolbar: toolbarType,
|
||||
containerId,
|
||||
|
||||
@@ -39,15 +39,9 @@ class ImageUploadController extends AdminBaseController
|
||||
*/
|
||||
public function upload(ImageUploadRequest $request): JsonResponse
|
||||
{
|
||||
$uploadPermission = $request->query('permission', '');
|
||||
|
||||
// 인가는 라우트 게이트(AdminBaseController: auth:sanctum + admin)가 전담한다.
|
||||
// 클라이언트가 검사 권한명을 지정하던 쿼리 분기는 죽은 인가라 제거했다.
|
||||
$user = $this->getCurrentUser();
|
||||
if ($uploadPermission && $user && ! $user->hasPermission($uploadPermission)) {
|
||||
// audit:allow response-helper-bypass reason: CDN 상위 SimpleUploadAdapter 가 최상위 error.message 를 읽는다 (봉투 불가)
|
||||
return response()->json([
|
||||
'error' => ['message' => __('sirsoft-ckeditor5::messages.upload.forbidden')],
|
||||
], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
$image = $this->imageUploadService->upload(
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\Ckeditor5\Tests\Feature\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Plugins\Sirsoft\Ckeditor5\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* CKEditor5 이미지 업로드 인가 회귀 테스트 (결함 ⑫⑬)
|
||||
*
|
||||
* 인가는 라우트 게이트(AdminBaseController: auth:sanctum + admin)가 전담한다.
|
||||
* 클라이언트 쿼리스트링(`?permission=`)이 검사 권한명을 정하고 빈 값이면 검사를
|
||||
* 통째로 skip 하던 죽은 인가 분기가 제거되었음을 고정한다.
|
||||
*
|
||||
* POST /api/plugins/sirsoft-ckeditor5/upload
|
||||
*/
|
||||
class ImageUploadAuthTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('plugins');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비로그인 요청은 admin 게이트에서 401 로 거부된다.
|
||||
*
|
||||
* @scenario case=guest_upload
|
||||
*
|
||||
* @effects upload_requires_admin
|
||||
*/
|
||||
public function test_upload_rejects_guest(): void
|
||||
{
|
||||
$file = UploadedFile::fake()->image('test.jpg');
|
||||
|
||||
$response = $this->postJson('/api/plugins/sirsoft-ckeditor5/upload', [
|
||||
'upload' => $file,
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
$this->assertDatabaseCount('ckeditor5_image_uploads', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증된 일반 회원(비관리자)은 admin 게이트에서 403 으로 거부된다.
|
||||
*
|
||||
* @scenario case=non_admin_upload
|
||||
*
|
||||
* @effects upload_requires_admin
|
||||
*/
|
||||
public function test_upload_rejects_authenticated_non_admin(): void
|
||||
{
|
||||
$member = User::factory()->create();
|
||||
$file = UploadedFile::fake()->image('test.jpg');
|
||||
|
||||
$response = $this->actingAs($member)->postJson(
|
||||
'/api/plugins/sirsoft-ckeditor5/upload',
|
||||
['upload' => $file]
|
||||
);
|
||||
|
||||
$response->assertForbidden();
|
||||
$this->assertDatabaseCount('ckeditor5_image_uploads', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자는 정상적으로 업로드된다 (기본 동작 보존).
|
||||
*
|
||||
* @scenario case=admin_upload
|
||||
*
|
||||
* @effects upload_requires_admin
|
||||
*/
|
||||
public function test_upload_succeeds_for_admin(): void
|
||||
{
|
||||
$admin = $this->createAdminUser();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 100, 100)->size(500);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(
|
||||
'/api/plugins/sirsoft-ckeditor5/upload',
|
||||
['upload' => $file]
|
||||
);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['url']);
|
||||
$this->assertDatabaseCount('ckeditor5_image_uploads', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 클라이언트가 임의의 `?permission=` 쿼리를 붙여도 응답은 불변이다.
|
||||
*
|
||||
* 과거 죽은 인가 분기에서는 관리자가 보유하지 않은 권한명을 넘기면 403 이 되었으나,
|
||||
* 이제 쿼리는 완전히 무시되고 admin 게이트만 판정하므로 201 이 유지된다.
|
||||
*
|
||||
* @scenario case=permission_param_ignored
|
||||
*
|
||||
* @effects client_permission_param_has_no_effect
|
||||
*/
|
||||
public function test_upload_ignores_client_supplied_permission_query(): void
|
||||
{
|
||||
$admin = $this->createAdminUser();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 100, 100)->size(500);
|
||||
|
||||
$response = $this->actingAs($admin)->postJson(
|
||||
'/api/plugins/sirsoft-ckeditor5/upload?permission=some.permission.the.admin.lacks',
|
||||
['upload' => $file]
|
||||
);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonStructure(['url']);
|
||||
$this->assertDatabaseCount('ckeditor5_image_uploads', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
feature: 에디터 이미지 업로드 인가 고정 (2026-08 보안 수정)
|
||||
|
||||
description: |
|
||||
2026-08 비공개 보안 제보(⑫/⑬) 대응 — 업로드 엔드포인트가 클라이언트 쿼리스트링
|
||||
(?permission=)으로 인가 판정 대상 권한명을 받던 죽은 분기를 제거. 인가는 서버 라우트
|
||||
게이트(auth:sanctum + admin, AdminBaseController)가 전담하고, 클라이언트가 보내는
|
||||
권한 파라미터는 어떤 값이든 판정에 영향을 주지 않는다. 프론트(initEditor.ts /
|
||||
html-editor.json)의 uploadPermission 전달·조립 코드도 제거(dist 재빌드 동반).
|
||||
|
||||
axes:
|
||||
case: [guest_upload, non_admin_upload, admin_upload, permission_param_ignored]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- upload_requires_admin
|
||||
- client_permission_param_has_no_effect
|
||||
|
||||
test_files:
|
||||
- plugins/_bundled/sirsoft-ckeditor5/tests/Feature/Http/Controllers/ImageUploadAuthTest.php
|
||||
+4
-2
@@ -358,8 +358,10 @@ Route::get('attachment/{hash}', [PublicAttachmentController::class, 'download'])
|
||||
->where('hash', '[a-zA-Z0-9]{12}')
|
||||
->name('api.attachment.download');
|
||||
|
||||
// 통합 검색 API (공개)
|
||||
Route::get('search', [PublicSearchController::class, 'search'])->name('api.search');
|
||||
// 통합 검색 API (공개 — Bearer 토큰이 있으면 회원으로 해석해 게시판별 열람 권한을
|
||||
// 검색 결과·available_boards 에 반영한다. 미들웨어가 없으면 $request->user() 가
|
||||
// 항상 null 이라 인증 회원도 guest 수준으로 필터된다.)
|
||||
Route::get('search', [PublicSearchController::class, 'search'])->middleware('optional.sanctum')->name('api.search');
|
||||
|
||||
// 관리자 API (인증 + 관리자 권한 필요, 속도 제한 적용)
|
||||
Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin', 'throttle:'.config('auth.throttle.admin')])->group(function () {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Http;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 코어 서비스의 generic catch 가 예외 원문(SQLSTATE·쿼리·DB명)을 사용자 응답 메시지에
|
||||
* 노출하지 않는지 검증합니다.
|
||||
*
|
||||
* 결함(N-9): UserService 의 catch 블록이 __('...', ['error' => $e->getMessage()]) 로 예외
|
||||
* 원문을 사용자 메시지에 실었다(회원 대상 경로 포함). 원본은 Log 로만 남기고 사용자 메시지는
|
||||
* 원문 없는 고정 i18n 키로 교체한다.
|
||||
*
|
||||
* 범위 주의: Module/Plugin/Template/Settings 의 설치·활성화·제거 실패 메시지는 관리자 전용
|
||||
* 진단 정보로서 예외 원문 노출을 유지한다(exceptions.md — 관리자 면의 진단 원문 노출은 허용).
|
||||
* 따라서 그 키들은 본 테스트의 대상이 아니다.
|
||||
*/
|
||||
class CoreExceptionMessageLeakTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 사용자 생성 실패(DB 예외) 시 예외 메시지에 SQL 원문이 없어야 합니다.
|
||||
*
|
||||
* @scenario case=user_create_failure_masked
|
||||
*
|
||||
* @effects failure_response_omits_sql_text
|
||||
*/
|
||||
public function test_user_create_failure_message_does_not_leak_sql(): void
|
||||
{
|
||||
$existing = User::factory()->create();
|
||||
|
||||
// 동일 이메일로 생성 시도 → unique 제약 위반(QueryException) → generic catch 진입
|
||||
$data = User::factory()->raw(['email' => $existing->email]);
|
||||
|
||||
$service = app(UserService::class);
|
||||
|
||||
try {
|
||||
$service->createUser($data);
|
||||
$this->fail('중복 이메일로 생성이 실패해 ValidationException 이 발생해야 합니다.');
|
||||
} catch (ValidationException $e) {
|
||||
$this->assertMessagesHaveNoSqlLeak($e->errors());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 영향받은 다국어 키가 원문 placeholder(:error) 를 담지 않고 정상 해석되어야 합니다.
|
||||
*
|
||||
* @scenario case=masked_lang_keys
|
||||
*
|
||||
* @effects masked_lang_keys_have_no_error_placeholder
|
||||
*/
|
||||
public function test_affected_lang_keys_have_no_error_placeholder(): void
|
||||
{
|
||||
$keys = [
|
||||
'user.create_failed',
|
||||
'user.update_failed',
|
||||
'user.delete_failed',
|
||||
];
|
||||
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
foreach ($keys as $key) {
|
||||
$message = __($key, [], $locale);
|
||||
|
||||
$this->assertStringNotContainsString(':error', $message, "{$key} ({$locale}) 에 :error placeholder 가 남아 있습니다.");
|
||||
$this->assertNotSame($key, $message, "{$key} ({$locale}) 가 해석되지 않았습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지 집합에 SQL 원문/키워드/placeholder 잔재가 없음을 단언합니다.
|
||||
*
|
||||
* @param array<string, array<int, string>> $errors ValidationException errors 배열
|
||||
*/
|
||||
private function assertMessagesHaveNoSqlLeak(array $errors): void
|
||||
{
|
||||
$messages = collect($errors)->flatten()->implode(' ');
|
||||
|
||||
$this->assertStringNotContainsStringIgnoringCase('SQLSTATE', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('select ', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('insert ', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('update ', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('delete ', $messages);
|
||||
$this->assertStringNotContainsString(':error', $messages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SetLocale 미들웨어가 만료된 Sanctum 토큰을 인정하지 않는지 검증합니다.
|
||||
*
|
||||
* 결함(㉗): resolveUser() 가 토큰의 만료(expires_at) 여부를 검사하지 않아
|
||||
* 만료된 토큰으로도 그 회원의 로케일이 해석되었다. OptionalSanctumMiddleware 와
|
||||
* 동일한 만료 검사를 이식하여 만료 토큰은 guest(null) 로 취급한다.
|
||||
*/
|
||||
class SetLocaleExpiredTokenTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 인증 불필요 공개 라우트 — auth:sanctum 이 만료 토큰을 먼저 차단하지 않도록
|
||||
// SetLocale(api 그룹) 단독 동작을 관찰한다.
|
||||
Route::middleware(['api'])->get('/api/test-expired-locale', function () {
|
||||
return response()->json([
|
||||
'locale' => App::getLocale(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료된 토큰으로 요청하면 그 회원의 언어 설정을 따르지 않고
|
||||
* guest 로케일(Accept-Language)로 폴백해야 합니다.
|
||||
*
|
||||
* @scenario case=expired_token_locale
|
||||
*
|
||||
* @effects expired_token_treated_as_guest_for_locale
|
||||
*/
|
||||
public function test_expired_token_does_not_resolve_member_locale(): void
|
||||
{
|
||||
$user = User::factory()->create(['language' => 'ko']);
|
||||
$newToken = $user->createToken('test-token');
|
||||
$plainTextToken = $newToken->plainTextToken;
|
||||
|
||||
// 토큰 만료 시각을 과거로 직접 세팅 (Sanctum 기본 expiration 은 null 이라 미설정됨)
|
||||
PersonalAccessToken::findToken($plainTextToken)->update([
|
||||
'expires_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$plainTextToken,
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
])->getJson('/api/test-expired-locale');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 회원 언어(ko)를 따르지 않고 Accept-Language(en) 로 폴백해야 한다.
|
||||
$this->assertNotSame('ko', $response->json('locale'));
|
||||
$this->assertSame('en', $response->json('locale'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효한 토큰은 그 회원의 언어 설정을 따라야 합니다 (기존 동작 유지).
|
||||
*
|
||||
* @scenario case=valid_token_locale
|
||||
*
|
||||
* @effects valid_token_applies_member_locale
|
||||
*/
|
||||
public function test_valid_token_resolves_member_locale(): void
|
||||
{
|
||||
$user = User::factory()->create(['language' => 'ko']);
|
||||
$plainTextToken = $user->createToken('test-token')->plainTextToken;
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$plainTextToken,
|
||||
'Accept-Language' => 'en-US,en;q=0.9',
|
||||
])->getJson('/api/test-expired-locale');
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 회원 언어(ko)가 Accept-Language(en)보다 우선한다.
|
||||
$this->assertSame('ko', $response->json('locale'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\User;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Models\User;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 실패 시 SQL 원문이 사용자 응답/예외 메시지에 노출되지 않는지 검증합니다.
|
||||
*
|
||||
* 결함(㉚-2): withdrawUser() catch 블록이 __('user.withdraw_failed', ['error' => $e->getMessage()])
|
||||
* 로 예외 원문(SQLSTATE·쿼리·테이블명 등)을 사용자에게 노출했다. 원본은 Log 로만 남기고
|
||||
* 사용자 메시지는 원문 없는 고정 i18n 키로 교체한다.
|
||||
*/
|
||||
class UserWithdrawErrorLeakTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 탈퇴 처리 중 QueryException 이 발생해도 그 예외 메시지에 SQL 원문이 없어야 합니다.
|
||||
*
|
||||
* @scenario case=user_withdraw_failure_masked
|
||||
*
|
||||
* @effects failure_response_omits_sql_text
|
||||
*/
|
||||
public function test_withdraw_failure_message_does_not_leak_sql(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 탈퇴 트랜잭션 진입 직후(첫 훅) QueryException 을 유발한다.
|
||||
// DDL 로 유발하면 암묵 커밋이 RefreshDatabase savepoint 를 깨므로, 훅에서
|
||||
// 예외를 던져 트랜잭션이 정상 롤백되도록 한다. 예외 메시지에는 실제 SQL 원문
|
||||
// (SQLSTATE·쿼리·테이블명)을 담아, 이 원문이 사용자 메시지로 새지 않음을 검증한다.
|
||||
$sqlOriginal = "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'g7.user_consents' doesn't exist "
|
||||
.'(Connection: mysql, SQL: delete from `user_consents` where `user_id` = '.$user->id.')';
|
||||
|
||||
HookManager::addAction('core.user.before_withdraw', function () use ($sqlOriginal): void {
|
||||
throw new QueryException('mysql', 'delete from `user_consents`', [], new \RuntimeException($sqlOriginal));
|
||||
});
|
||||
|
||||
$service = app(UserService::class);
|
||||
|
||||
try {
|
||||
$service->withdrawUser($user);
|
||||
$this->fail('탈퇴가 실패해 ValidationException 이 발생해야 합니다.');
|
||||
} catch (ValidationException $e) {
|
||||
$messages = collect($e->errors())->flatten()->implode(' ');
|
||||
|
||||
// 원문(SQL 상태코드·쿼리 키워드·테이블명·placeholder 잔재)이 없어야 한다.
|
||||
$this->assertStringNotContainsStringIgnoringCase('SQLSTATE', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('select ', $messages);
|
||||
$this->assertStringNotContainsStringIgnoringCase('delete from', $messages);
|
||||
$this->assertStringNotContainsString('user_consents', $messages);
|
||||
$this->assertStringNotContainsString(':error', $messages);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탈퇴 실패 다국어 키가 원문 placeholder(:error) 를 담지 않아야 합니다.
|
||||
*
|
||||
* @scenario case=masked_lang_keys
|
||||
*
|
||||
* @effects masked_lang_keys_have_no_error_placeholder
|
||||
*/
|
||||
public function test_withdraw_failed_lang_key_has_no_error_placeholder(): void
|
||||
{
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
$message = __('user.withdraw_failed', [], $locale);
|
||||
|
||||
$this->assertStringNotContainsString(':error', $message);
|
||||
// 키가 해석되어 실제 문구가 반환되어야 한다 (키 원문 그대로가 아님).
|
||||
$this->assertNotSame('user.withdraw_failed', $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Search;
|
||||
|
||||
use App\Search\SearchHighlighter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 검색 하이라이트/평문화 공유 헬퍼 회귀 테스트.
|
||||
*
|
||||
* 게시판·상품·페이지 검색 리스너가 공유하는 SSoT 이므로, XSS 이스케이프와
|
||||
* 엔티티-태그 부활 차단을 이 한 곳에서 고정한다.
|
||||
*/
|
||||
class SearchHighlighterTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @scenario case=highlighter_escape
|
||||
*
|
||||
* @effects highlight_escapes_markup_and_marks_keyword
|
||||
*/
|
||||
public function test_highlight_escapes_markup_in_source_text(): void
|
||||
{
|
||||
$result = SearchHighlighter::highlight('<img src=x onerror=alert(1)> 날씨', '날씨');
|
||||
|
||||
// 원문 태그는 이스케이프되어 실행 계약으로 나가지 않는다.
|
||||
$this->assertStringNotContainsString('<img', $result);
|
||||
$this->assertStringContainsString('<img', $result);
|
||||
$this->assertStringNotContainsString('onerror=alert(1)>', $result);
|
||||
// 검색어만 <mark> 로 감싸진다.
|
||||
$this->assertStringContainsString('<mark>날씨</mark>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_escape
|
||||
*
|
||||
* @effects highlight_escapes_markup_and_marks_keyword
|
||||
*/
|
||||
public function test_highlight_escapes_when_keyword_absent(): void
|
||||
{
|
||||
$result = SearchHighlighter::highlight('<script>alert(1)</script>', '없는키워드');
|
||||
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringContainsString('<script>', $result);
|
||||
$this->assertStringNotContainsString('<mark>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_escape
|
||||
*
|
||||
* @effects highlight_escapes_markup_and_marks_keyword
|
||||
*/
|
||||
public function test_highlight_returns_empty_for_null_or_empty(): void
|
||||
{
|
||||
$this->assertSame('', SearchHighlighter::highlight(null, '키워드'));
|
||||
$this->assertSame('', SearchHighlighter::highlight('', '키워드'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_escape
|
||||
*
|
||||
* @effects highlight_escapes_markup_and_marks_keyword
|
||||
*/
|
||||
public function test_highlight_handles_empty_keyword_by_escaping_only(): void
|
||||
{
|
||||
$result = SearchHighlighter::highlight('<b>hello</b>', '');
|
||||
|
||||
$this->assertSame('<b>hello</b>', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_plain_text
|
||||
*
|
||||
* @effects plain_text_never_resurrects_entity_encoded_tags
|
||||
*/
|
||||
public function test_to_plain_text_strips_real_tags(): void
|
||||
{
|
||||
$result = SearchHighlighter::toPlainText('<p>hello <strong>world</strong></p>');
|
||||
|
||||
$this->assertSame('hello world', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_plain_text
|
||||
*
|
||||
* @effects plain_text_never_resurrects_entity_encoded_tags
|
||||
*/
|
||||
public function test_to_plain_text_does_not_resurrect_entity_encoded_tags(): void
|
||||
{
|
||||
// 저장 시점에 엔티티로 인코딩된 태그: strip_tags 를 먼저 돌리면 통과 후 디코드로 부활한다.
|
||||
$encoded = '<script>alert(1)</script> 본문';
|
||||
|
||||
$result = SearchHighlighter::toPlainText($encoded);
|
||||
|
||||
// 디코드 후 태그 제거 순서라 부활한 태그가 남지 않는다.
|
||||
$this->assertStringNotContainsString('<script>', $result);
|
||||
$this->assertStringContainsString('alert(1)', $result);
|
||||
$this->assertStringContainsString('본문', $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @scenario case=highlighter_plain_text
|
||||
*
|
||||
* @effects plain_text_never_resurrects_entity_encoded_tags
|
||||
*/
|
||||
public function test_to_plain_text_returns_empty_for_null(): void
|
||||
{
|
||||
$this->assertSame('', SearchHighlighter::toPlainText(null));
|
||||
$this->assertSame('', SearchHighlighter::toPlainText(''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
feature: 코어 보안 수정 묶음 (2026-08 비공개 제보 대응 — 만료 토큰 로케일 / 실패 응답 마스킹 / 검색 하이라이트 이스케이프)
|
||||
|
||||
description: |
|
||||
2026-08 비공개 보안 제보 대응으로 코어에 반영된 세 수정의 회귀 고정.
|
||||
|
||||
1) 만료 토큰 로케일(㉗): SetLocale 미들웨어가 만료된 Sanctum 토큰을 유효한 회원으로
|
||||
취급해 회원 로케일을 적용하던 문제. 만료 토큰은 guest 와 동일하게 처리한다.
|
||||
2) 실패 응답 마스킹(㉚-2/N-9): UserService create/update/delete/withdraw 실패 시
|
||||
예외 원문(SQLSTATE·쿼리)이 사용자 응답 메시지에 노출되던 문제. 고정 i18n 키만
|
||||
응답하고 원문은 Log::error 로만 기록한다. 대상 lang 키의 :error 치환자 제거 포함.
|
||||
(Module/Plugin/Template/Settings 서비스는 관리자 전용 진단면이라 의도적으로 제외 —
|
||||
exceptions.md "예외 → 응답 매핑" 의 노출 폭 정책)
|
||||
3) 검색 하이라이트 SSoT(⑧/N-8): app/Search/SearchHighlighter — 이스케이프 후
|
||||
<mark> 삽입, 프리뷰는 decode → strip_tags 순서(엔티티 인코딩 태그 부활 방지).
|
||||
board/ecommerce/page 3개 리스너가 이 헬퍼를 공유한다(각 확장 매니페스트 참조).
|
||||
|
||||
axes:
|
||||
case: [expired_token_locale, valid_token_locale, user_create_failure_masked, user_withdraw_failure_masked, masked_lang_keys, highlighter_escape, highlighter_plain_text]
|
||||
|
||||
exclusions: []
|
||||
|
||||
effects:
|
||||
- expired_token_treated_as_guest_for_locale
|
||||
- valid_token_applies_member_locale
|
||||
- failure_response_omits_sql_text
|
||||
- masked_lang_keys_have_no_error_placeholder
|
||||
- highlight_escapes_markup_and_marks_keyword
|
||||
- plain_text_never_resurrects_entity_encoded_tags
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Middleware/SetLocaleExpiredTokenTest.php
|
||||
- tests/Feature/Http/CoreExceptionMessageLeakTest.php
|
||||
- tests/Feature/User/UserWithdrawErrorLeakTest.php
|
||||
- tests/Unit/Search/SearchHighlighterTest.php
|
||||
|
||||
notes: |
|
||||
통합 검색 라우트의 optional.sanctum 부재(인증 회원이 guest 로 판정되던 문제)는
|
||||
board 확장의 security-fixes-2026-08.yaml (available_boards_bearer_http 케이스)이
|
||||
HTTP 전 구간으로 고정한다 — 서비스 직접 호출 테스트는 이 사각을 보지 못한다.
|
||||
Reference in New Issue
Block a user