이슈 본연: API 레퍼런스의 미채움 마커 5종(실측 제외/TODO/필드 없음/대표 에러 없음)
1,194건을 코드에서 읽어 전수 채우고(0건), api:docgen 재생성이 사람이 채운 내용을
손실·열화시키던 멱등성 결함 4종(CRLF, 표 파이프 이스케이프, 에러 서술 보존, 중복
라우트명 키)을 근본 수정. --check 를 실측 제외와 드리프트가 구분되도록 재정의.
ParameterDescriber/ResourceFieldDescriber 에 leaf 폴백·SEO 스코프·공통 사전 확장.
파생 결함(문서 실측 중 발견): ResponseHelper 기본 메시지 키 6종이 존재하지 않는
messages.* 를 가리켜 응답 message 가 번역문 대신 키 문자열로 노출되던 문제를 common.*
으로 정정하고, 코어 7 + gdpr/marketing/pay_kginicis/verification/ecommerce 확장의
호출부와 누락 lang 키(게시판 10키 + 이커머스 category_images 4키 포함)를 ko/en/ja
전수 정정. 중복 그룹 키 2건은 기존 키로 호출부 통합.
보안: GET /api/identity/challenges/{id} 는 권한 가드 없는 공개 폴링 엔드포인트인데
Service::getStatus 가 attempts/max_attempts 를 응답에 담아 남은 시도 횟수를 추론할 수
있었다. 두 필드를 제거하고, 챌린지 화면(admin_basic/basic)의 서버 attempts 참조를
제거(남은 횟수 UI 는 query fallback + verify 실패 시 로컬 증가로 유지).
부수 정리(변경셋에 들어온 board 컨트롤러의 audit 사전 결함): FormRequest 4개 신설로
base Request 주입 제거, CommentController 의 Board 직접 호출을 BoardService 위임으로
전환, PHPDoc @return 보강. board 스위트에서 발견한 stale test 3건(seed SSoT 불일치,
admin 계정 user 역할 누락)도 같은 세션에서 정정.
버전 정렬(공개 release 기준): 코어 7.0.3→7.0.4, sirsoft-board 1.0.1→1.0.2,
verification_kginicis 1.0.0→1.0.1, admin_basic 1.0.1→1.0.4.
368 lines
14 KiB
PHP
368 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Helpers;
|
|
|
|
use App\Helpers\ResponseHelper;
|
|
use Illuminate\Support\Facades\App;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* ResponseHelper 단위 테스트
|
|
*
|
|
* 디버그 모드에 따른 예외 정보 노출/차단을 검증합니다.
|
|
*/
|
|
class ResponseHelperTest extends TestCase
|
|
{
|
|
/**
|
|
* error() + \Throwable + debug=true → message에 예외 메시지 포함 + debug 키 존재
|
|
*/
|
|
public function test_error_with_throwable_in_debug_mode_includes_debug_info(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$exception = new \RuntimeException('Test exception message');
|
|
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertStringContainsString('Test exception message', $data['message']);
|
|
$this->assertArrayHasKey('debug', $data);
|
|
$this->assertEquals(\RuntimeException::class, $data['debug']['exception']);
|
|
$this->assertEquals('Test exception message', $data['debug']['message']);
|
|
$this->assertArrayHasKey('file', $data['debug']);
|
|
$this->assertArrayHasKey('line', $data['debug']);
|
|
$this->assertArrayHasKey('trace', $data['debug']);
|
|
$this->assertLessThanOrEqual(10, count($data['debug']['trace']));
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* error() + \Throwable + debug=false → 번역 메시지만, debug 키 미존재
|
|
*/
|
|
public function test_error_with_throwable_in_production_hides_debug_info(): void
|
|
{
|
|
config(['app.debug' => false]);
|
|
|
|
$exception = new \RuntimeException('Sensitive error detail');
|
|
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertStringNotContainsString('Sensitive error detail', $data['message']);
|
|
$this->assertArrayNotHasKey('debug', $data);
|
|
$this->assertArrayNotHasKey('errors', $data);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* error() + string + 500 + debug=false → errors 키 미존재 (프로덕션 보안)
|
|
*/
|
|
public function test_error_with_string_500_in_production_hides_errors(): void
|
|
{
|
|
config(['app.debug' => false]);
|
|
|
|
$response = ResponseHelper::error('messages.failed', 500, 'Internal DB error');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertArrayNotHasKey('errors', $data);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* error() + string + 500 + debug=true → errors 키 존재
|
|
*/
|
|
public function test_error_with_string_500_in_debug_mode_shows_errors(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$response = ResponseHelper::error('messages.failed', 500, 'Internal DB error');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertArrayHasKey('errors', $data);
|
|
$this->assertEquals('Internal DB error', $data['errors']);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* error() + array + 422 → debug 무관하게 errors 존재 (기존 validation 동작 유지)
|
|
*/
|
|
public function test_error_with_array_422_always_shows_errors(): void
|
|
{
|
|
config(['app.debug' => false]);
|
|
|
|
$validationErrors = ['email' => ['이메일 형식이 올바르지 않습니다.']];
|
|
$response = ResponseHelper::error('messages.validation_failed', 422, $validationErrors);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertArrayHasKey('errors', $data);
|
|
$this->assertEquals($validationErrors, $data['errors']);
|
|
$this->assertEquals(422, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* error() + string + 400 + debug=false → errors 존재 (500 미만은 guard 미적용)
|
|
*/
|
|
public function test_error_with_string_400_in_production_shows_errors(): void
|
|
{
|
|
config(['app.debug' => false]);
|
|
|
|
$response = ResponseHelper::error('messages.failed', 400, 'Bad request detail');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertArrayHasKey('errors', $data);
|
|
$this->assertEquals('Bad request detail', $data['errors']);
|
|
}
|
|
|
|
/**
|
|
* serverError() + \Throwable + debug=true → message + debug 상세
|
|
*/
|
|
public function test_server_error_with_throwable_in_debug_mode(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$exception = new \InvalidArgumentException('Invalid config value');
|
|
$response = ResponseHelper::serverError('messages.error_occurred', $exception);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertStringContainsString('Invalid config value', $data['message']);
|
|
$this->assertArrayHasKey('debug', $data);
|
|
$this->assertEquals(\InvalidArgumentException::class, $data['debug']['exception']);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* serverError() + \Throwable + debug=false → 번역 메시지만
|
|
*/
|
|
public function test_server_error_with_throwable_in_production(): void
|
|
{
|
|
config(['app.debug' => false]);
|
|
|
|
$exception = new \InvalidArgumentException('Sensitive config detail');
|
|
$response = ResponseHelper::serverError('messages.error_occurred', $exception);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertStringNotContainsString('Sensitive config detail', $data['message']);
|
|
$this->assertArrayNotHasKey('debug', $data);
|
|
$this->assertArrayNotHasKey('error', $data);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* moduleError() + \Throwable → error() 위임 동작 확인 (domain 전달)
|
|
*/
|
|
public function test_module_error_with_throwable_delegates_to_error(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$exception = new \RuntimeException('Module specific error');
|
|
$response = ResponseHelper::moduleError(
|
|
'sirsoft-ecommerce',
|
|
'messages.error_occurred',
|
|
500,
|
|
$exception
|
|
);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertStringContainsString('Module specific error', $data['message']);
|
|
$this->assertArrayHasKey('debug', $data);
|
|
$this->assertEquals(500, $response->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* serverError() + string + debug=true → 기존 동작 유지 (error 키에 포함)
|
|
*/
|
|
public function test_server_error_with_string_in_debug_mode_keeps_legacy_behavior(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$response = ResponseHelper::serverError('messages.error_occurred', 'Some error string');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertArrayHasKey('error', $data);
|
|
$this->assertEquals('Some error string', $data['error']);
|
|
$this->assertArrayNotHasKey('debug', $data);
|
|
}
|
|
|
|
/**
|
|
* error() + \Throwable + messageParams + debug=true → message 중복 방지 + debug 존재
|
|
*/
|
|
public function test_error_with_throwable_and_message_params_no_duplicate(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
$exception = new \RuntimeException('DB connection failed');
|
|
$response = ResponseHelper::error(
|
|
'user.create_failed',
|
|
500,
|
|
$exception,
|
|
['error' => $exception->getMessage()]
|
|
);
|
|
$data = $response->getData(true);
|
|
|
|
// messageParams가 있으면 message에 예외 메시지를 중복 concatenate 하지 않음
|
|
$this->assertArrayHasKey('debug', $data);
|
|
$this->assertEquals('DB connection failed', $data['debug']['message']);
|
|
// :error가 messageParams로 치환되고, Throwable concatenate는 생략
|
|
$this->assertStringContainsString('DB connection failed', $data['message']);
|
|
$this->assertSame(
|
|
1,
|
|
substr_count($data['message'], 'DB connection failed')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* error() + null errors → errors/debug 키 모두 미존재
|
|
*/
|
|
public function test_error_with_null_errors_has_no_extra_keys(): void
|
|
{
|
|
$response = ResponseHelper::error('messages.failed', 400);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertFalse($data['success']);
|
|
$this->assertArrayNotHasKey('errors', $data);
|
|
$this->assertArrayNotHasKey('debug', $data);
|
|
}
|
|
|
|
/**
|
|
* formatException trace는 최대 10프레임
|
|
*/
|
|
public function test_debug_trace_is_limited_to_10_frames(): void
|
|
{
|
|
config(['app.debug' => true]);
|
|
|
|
// 깊은 호출 스택을 만들기 위해 재귀 예외 생성
|
|
$exception = $this->createDeepException(20);
|
|
$response = ResponseHelper::error('messages.failed', 500, $exception);
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertArrayHasKey('debug', $data);
|
|
$this->assertLessThanOrEqual(10, count($data['debug']['trace']));
|
|
}
|
|
|
|
/**
|
|
* 깊은 호출 스택의 예외를 생성합니다.
|
|
*
|
|
* @param int $depth 호출 깊이
|
|
* @return \RuntimeException 생성된 예외
|
|
*/
|
|
private function createDeepException(int $depth): \RuntimeException
|
|
{
|
|
if ($depth <= 0) {
|
|
return new \RuntimeException('Deep exception');
|
|
}
|
|
|
|
return $this->createDeepException($depth - 1);
|
|
}
|
|
|
|
/**
|
|
* 회귀: ResponseHelper::trans() 가 App::getLocale() 결과를 그대로 사용한다.
|
|
*
|
|
* 이전 구현은 자체 화이트리스트 ['ko', 'en'] 으로 ja 를 거부하고
|
|
* config('app.locale') fallback 으로 떨어뜨렸다. 이 회귀 테스트는
|
|
* SetLocale 미들웨어가 이미 set 한 App::getLocale() 결과(ja 등)가
|
|
* 다국어 응답에 반영됨을 보장한다.
|
|
*/
|
|
public function test_trans_uses_current_app_locale_not_hardcoded_whitelist(): void
|
|
{
|
|
// 임시 키를 ko/ja 양쪽에 등록 (lang/ja/* 는 활성 언어팩이 폴백 등록)
|
|
App::setLocale('ja');
|
|
app('translator')->addLines(['testdomain.greet' => 'こんにちは'], 'ja');
|
|
app('translator')->addLines(['testdomain.greet' => '안녕'], 'ko');
|
|
|
|
$response = ResponseHelper::success('testdomain.greet');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertSame('こんにちは', $data['message']);
|
|
}
|
|
|
|
/**
|
|
* 회귀: App::getLocale() 가 빌트인 ko/en 외 로케일이어도 ResponseHelper 가 그 값을 신뢰한다.
|
|
*/
|
|
public function test_trans_respects_non_builtin_locale_when_app_locale_set(): void
|
|
{
|
|
App::setLocale('zh-CN');
|
|
app('translator')->addLines(['testdomain.welcome' => '欢迎'], 'zh-CN');
|
|
|
|
$response = ResponseHelper::success('testdomain.welcome');
|
|
$data = $response->getData(true);
|
|
|
|
$this->assertSame('欢迎', $data['message']);
|
|
}
|
|
|
|
/**
|
|
* 회귀: 기본 메시지 키가 실제로 번역되어야 한다.
|
|
*
|
|
* `messages.success` 는 `lang/{ko,en}/messages.php` 가 존재하지 않아 번역되지 않았고,
|
|
* `__()` 가 미해석 키 문자열을 그대로 반환해 API 응답에 `"message": "messages.success"` 가
|
|
* 그대로 나갔다 (코어 7곳 + gdpr/marketing/pay_kginicis/verification_kginicis 등 43개 호출).
|
|
* 정상 키는 `common.success` 다 (ko: "성공적으로 처리되었습니다." / en: "Successfully processed.").
|
|
*/
|
|
public function test_default_success_message_key_is_translated(): void
|
|
{
|
|
foreach (['ko', 'en'] as $locale) {
|
|
App::setLocale($locale);
|
|
|
|
$data = ResponseHelper::success()->getData(true);
|
|
|
|
$this->assertNotSame(
|
|
'messages.success',
|
|
$data['message'],
|
|
"[{$locale}] 기본 메시지 키가 번역되지 않고 키 문자열 그대로 응답에 나갔다"
|
|
);
|
|
|
|
// 미해석 lang 키는 `도메인.키` 형태 그대로 반환된다 (공백 없는 dot notation).
|
|
// 번역문은 문장이므로 공백을 포함한다.
|
|
$this->assertDoesNotMatchRegularExpression(
|
|
'/^[a-z_]+(\.[a-z_]+)+$/',
|
|
$data['message'],
|
|
"[{$locale}] 번역문이 아니라 미해석 lang 키로 보인다: {$data['message']}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 회귀: 코드베이스에 미해석 lang 키(`messages.success`)를 넘기는 호출이 없어야 한다.
|
|
*
|
|
* ResponseHelper 기본값만 고치면 명시적으로 그 키를 넘기던 호출부는 여전히 미해석 키를 낸다.
|
|
*/
|
|
public function test_no_caller_passes_unresolvable_messages_success_key(): void
|
|
{
|
|
$roots = array_filter([
|
|
base_path('app'),
|
|
...glob(base_path('modules/_bundled/*/src')),
|
|
...glob(base_path('plugins/_bundled/*/src')),
|
|
], 'is_dir');
|
|
|
|
$offenders = [];
|
|
|
|
foreach ($roots as $root) {
|
|
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
|
|
|
|
foreach ($files as $file) {
|
|
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
|
continue;
|
|
}
|
|
|
|
$content = (string) file_get_contents($file->getPathname());
|
|
|
|
if (str_contains($content, "'messages.success'")) {
|
|
$offenders[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file->getPathname());
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$offenders,
|
|
"번역되지 않는 'messages.success' 키를 넘기는 호출이 남아 있다 (정상 키: 'common.success'):\n - "
|
|
.implode("\n - ", $offenders)
|
|
);
|
|
}
|
|
}
|