## 소스맵 노출 차단 (공개 이슈 )
배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.
- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
$productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
(--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).
실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).
## 관리자 환경설정 탭 지연 로딩
코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.
## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)
- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
(리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
(pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
컨벤션대로 skip 가드.
## 테스트
- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트
버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
516 lines
20 KiB
PHP
516 lines
20 KiB
PHP
<?php
|
|
|
|
namespace Plugins\Sirsoft\PayNhnkcp\Tests\Unit\Services;
|
|
|
|
use App\Services\PluginSettingsService;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException;
|
|
use Plugins\Sirsoft\PayNhnkcp\Services\KcpSoapService;
|
|
use Plugins\Sirsoft\PayNhnkcp\Services\NhnKcpApiService;
|
|
use Plugins\Sirsoft\PayNhnkcp\Tests\PluginTestCase;
|
|
|
|
class NhnKcpApiServiceTest extends PluginTestCase
|
|
{
|
|
private const TEST_SITE_CD = 'T0000';
|
|
|
|
private const TEST_SITE_KEY = 'TEST_SITE_KEY_0000';
|
|
|
|
private function makeService(array $settingsOverrides = []): NhnKcpApiService
|
|
{
|
|
$defaults = [
|
|
'is_test_mode' => true,
|
|
'test_site_cd' => self::TEST_SITE_CD,
|
|
'test_site_key' => self::TEST_SITE_KEY,
|
|
'live_site_cd' => '',
|
|
'live_site_key' => '',
|
|
];
|
|
|
|
$settingsMock = $this->createMock(PluginSettingsService::class);
|
|
$settingsMock->method('get')
|
|
->willReturn(array_merge($defaults, $settingsOverrides));
|
|
|
|
return new NhnKcpApiService($settingsMock);
|
|
}
|
|
|
|
/**
|
|
* KCP CLI 실행을 외부 의존 없이 검증하기 위한 임시 바이너리 디렉터리 생성.
|
|
*
|
|
* @return array{service: NhnKcpApiService, dir: string, log: string}
|
|
*/
|
|
private function makeServiceWithStubbedCli(string $response, array $settingsOverrides = []): array
|
|
{
|
|
// CLI 스텁은 `#!/bin/sh` 스크립트라 Windows exec() 가 실행하지 못한다
|
|
// (.exe 로 이름만 바꿔도 "not compatible with the version of Windows" 로 거부됨).
|
|
// Windows 분기(executeCliWindows)는 별도 검증 대상이므로 여기서는 건너뛴다.
|
|
if (DIRECTORY_SEPARATOR === '\\') {
|
|
$this->markTestSkipped('Linux CLI 스텁 기반 테스트 — Windows 분기 별도 검증.');
|
|
}
|
|
|
|
$service = $this->makeService($settingsOverrides);
|
|
$dir = sys_get_temp_dir().'/nhnkcp_cli_'.uniqid('', true);
|
|
$this->assertTrue(mkdir($dir), '임시 CLI 디렉터리 생성 실패');
|
|
|
|
$logPath = $dir.'/argv.log';
|
|
$responsePath = $dir.'/response.txt';
|
|
file_put_contents($responsePath, $response);
|
|
|
|
$script = "#!/bin/sh\n"
|
|
."printf '%s\\n' \"\$@\" > ".escapeshellarg($logPath)."\n"
|
|
.'cat '.escapeshellarg($responsePath)."\n";
|
|
|
|
foreach (['pp_cli', 'pp_cli_x64'] as $binName) {
|
|
$binPath = $dir.'/'.$binName;
|
|
file_put_contents($binPath, $script);
|
|
chmod($binPath, 0755);
|
|
}
|
|
|
|
$reflection = new \ReflectionClass($service);
|
|
$property = $reflection->getProperty('binDir');
|
|
$property->setAccessible(true);
|
|
$property->setValue($service, $dir);
|
|
|
|
return [
|
|
'service' => $service,
|
|
'dir' => $dir,
|
|
'log' => $logPath,
|
|
];
|
|
}
|
|
|
|
private function removeStubbedCliDirectory(string $dir): void
|
|
{
|
|
foreach (glob($dir.'/*') ?: [] as $path) {
|
|
@unlink($path);
|
|
}
|
|
|
|
@rmdir($dir);
|
|
}
|
|
|
|
private function capturedCliArgs(string $logPath): string
|
|
{
|
|
$lines = file($logPath, FILE_IGNORE_NEW_LINES);
|
|
$this->assertIsArray($lines, 'CLI 인자 로그를 읽을 수 있어야 함');
|
|
|
|
return $lines[1] ?? '';
|
|
}
|
|
|
|
public function test_get_site_cd_returns_test_site_cd_in_test_mode(): void
|
|
{
|
|
$service = $this->makeService();
|
|
|
|
$this->assertEquals(self::TEST_SITE_CD, $service->getSiteCd());
|
|
}
|
|
|
|
public function test_kcp_soap_service_falls_back_to_test_site_cd_when_escrow_test_site_cd_is_empty(): void
|
|
{
|
|
$settingsMock = $this->createMock(PluginSettingsService::class);
|
|
$settingsMock->method('get')->willReturn([
|
|
'is_test_mode' => true,
|
|
'test_site_cd' => self::TEST_SITE_CD,
|
|
'escrow_test_site_cd' => '',
|
|
]);
|
|
|
|
$service = new KcpSoapService($settingsMock);
|
|
|
|
$this->assertSame(self::TEST_SITE_CD, $service->getEscrowSiteCd());
|
|
}
|
|
|
|
public function test_get_site_cd_returns_live_site_cd_in_live_mode(): void
|
|
{
|
|
$service = $this->makeService([
|
|
'is_test_mode' => false,
|
|
'live_site_cd' => 'SR123456',
|
|
'live_site_key' => 'live_site_key_value',
|
|
]);
|
|
|
|
$this->assertEquals('SR123456', $service->getSiteCd());
|
|
}
|
|
|
|
public function test_use_stored_credentials_restores_payment_time_mode_and_site_cd(): void
|
|
{
|
|
$service = $this->makeService([
|
|
'is_test_mode' => true,
|
|
'test_site_cd' => 'T0000',
|
|
'live_site_cd' => 'SR999999',
|
|
'live_site_key' => 'live_key_value',
|
|
]);
|
|
|
|
$service->useStoredCredentials(false, 'SR123456');
|
|
|
|
$this->assertEquals('SR123456', $service->getSiteCd());
|
|
$this->assertStringContainsString('pay.kcp.co.kr', $service->getJsUrl());
|
|
$this->assertStringNotContainsString('testpay', $service->getJsUrl());
|
|
}
|
|
|
|
public function test_get_js_url_returns_test_url_in_test_mode(): void
|
|
{
|
|
$service = $this->makeService();
|
|
|
|
$this->assertStringContainsString('testpay.kcp.co.kr', $service->getJsUrl());
|
|
}
|
|
|
|
public function test_get_js_url_returns_live_url_in_live_mode(): void
|
|
{
|
|
$service = $this->makeService([
|
|
'is_test_mode' => false,
|
|
'live_site_cd' => 'SR123456',
|
|
'live_site_key' => 'live_key',
|
|
]);
|
|
|
|
$jsUrl = $service->getJsUrl();
|
|
$this->assertStringContainsString('pay.kcp.co.kr', $jsUrl);
|
|
$this->assertStringNotContainsString('testpay', $jsUrl);
|
|
}
|
|
|
|
public function test_get_transaction_calls_correct_url_with_auth_headers(): void
|
|
{
|
|
$service = $this->makeService();
|
|
|
|
$tno = 'KCP_TNO_TEST_001';
|
|
$ordrIdxx = 'ORD-TEST-12345';
|
|
|
|
Http::fake([
|
|
'stgapi.kcp.co.kr/*' => Http::response([
|
|
'res_cd' => '0000',
|
|
'res_msg' => '정상처리',
|
|
'tno' => $tno,
|
|
'ordr_idxx' => $ordrIdxx,
|
|
'app_no' => 'APP12345',
|
|
], 200),
|
|
]);
|
|
|
|
$result = $service->getTransaction($tno, $ordrIdxx);
|
|
|
|
$this->assertEquals('0000', $result['res_cd']);
|
|
$this->assertEquals($tno, $result['tno']);
|
|
|
|
Http::assertSent(function ($request) use ($tno, $ordrIdxx) {
|
|
return str_contains($request->url(), 'stgapi.kcp.co.kr')
|
|
&& str_contains($request->url(), urlencode($tno))
|
|
&& $request->hasHeader('Authorization')
|
|
&& $request->hasHeader('X-Kcp-Site-Code')
|
|
&& $request->hasHeader('X-Kcp-Timestamp')
|
|
&& $request->hasHeader('X-Kcp-Signature')
|
|
&& $request['ordr_idxx'] === $ordrIdxx;
|
|
});
|
|
}
|
|
|
|
public function test_get_transaction_uses_basic_auth_with_site_credentials(): void
|
|
{
|
|
$service = $this->makeService();
|
|
|
|
Http::fake([
|
|
'stgapi.kcp.co.kr/*' => Http::response(['res_cd' => '0000'], 200),
|
|
]);
|
|
|
|
$service->getTransaction('TNO_001', 'ORD-001');
|
|
|
|
$expectedAuthValue = 'Basic '.base64_encode(self::TEST_SITE_CD.':'.self::TEST_SITE_KEY);
|
|
|
|
Http::assertSent(function ($request) use ($expectedAuthValue) {
|
|
return $request->header('Authorization')[0] === $expectedAuthValue;
|
|
});
|
|
}
|
|
|
|
public function test_get_transaction_throws_on_http_error(): void
|
|
{
|
|
$service = $this->makeService();
|
|
|
|
Http::fake([
|
|
'stgapi.kcp.co.kr/*' => Http::response(null, 500),
|
|
]);
|
|
|
|
$this->expectException(\Exception::class);
|
|
$this->expectExceptionMessageMatches('/HTTP 500/');
|
|
|
|
$service->getTransaction('TNO_ERR', 'ORD-ERR');
|
|
}
|
|
|
|
public function test_cancel_payment_calls_cli_with_full_cancel_params(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli(
|
|
'res_cd=0000'.chr(31).'res_msg=취소완료'.chr(31).'tno=KCP_TNO_CANCEL_001'
|
|
);
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
$tno = 'KCP_TNO_CANCEL_001';
|
|
$ordrIdxx = 'ORD-TEST-CANCEL';
|
|
$cancelAmt = 50000;
|
|
$cancelMsg = '고객 요청';
|
|
|
|
try {
|
|
$result = $service->cancelPayment($tno, $ordrIdxx, $cancelAmt, $cancelMsg, false);
|
|
|
|
$this->assertEquals('0000', $result['res_cd']);
|
|
|
|
$args = $this->capturedCliArgs($stub['log']);
|
|
$this->assertStringContainsString('tx_cd=00200000', $args);
|
|
$this->assertStringContainsString('ordr_idxx='.$ordrIdxx, $args);
|
|
$this->assertStringContainsString('modx_data=mod_data=tno='.$tno, $args);
|
|
$this->assertStringContainsString('mod_type=STSC', $args);
|
|
$this->assertStringContainsString('mod_desc='.$cancelMsg, $args);
|
|
$this->assertStringNotContainsString('mod_mny=', $args);
|
|
$this->assertStringNotContainsString('rem_mny=', $args);
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_cancel_payment_calls_cli_with_partial_cancel_params(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli('res_cd=0000'.chr(31).'res_msg=부분취소완료');
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
try {
|
|
$service->cancelPayment('TNO_PART', 'ORD-PART', 10000, '부분취소', true, 50000);
|
|
|
|
$args = $this->capturedCliArgs($stub['log']);
|
|
$this->assertStringContainsString('mod_type=RN07', $args);
|
|
$this->assertStringContainsString('mod_mny=10000', $args);
|
|
$this->assertStringContainsString('rem_mny=50000', $args);
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_register_escrow_delivery_uses_test_site_cd_when_escrow_test_site_cd_is_empty(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli(
|
|
'res_cd=0000'.chr(31).'res_msg=배송등록완료',
|
|
['escrow_test_site_cd' => '']
|
|
);
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
try {
|
|
$service->registerEscrowDelivery('TNO_ESCROW', 'ORD-ESCROW', '1234567890', '04');
|
|
|
|
$args = $this->capturedCliArgs($stub['log']);
|
|
$this->assertStringContainsString('site_cd='.self::TEST_SITE_CD, $args);
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_cancel_payment_throws_on_non_0000_res_cd(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli('res_cd=9999'.chr(31).'res_msg=취소 실패');
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
try {
|
|
$this->expectException(NhnKcpApiException::class);
|
|
$this->expectExceptionMessage('취소 실패');
|
|
|
|
$service->cancelPayment('TNO_FAIL', 'ORD-FAIL', 50000, '고객 요청');
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_cancel_payment_throws_on_cli_fallback_error(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli('');
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
try {
|
|
$this->expectException(NhnKcpApiException::class);
|
|
$this->expectExceptionMessage('연동 모듈 호출 오류');
|
|
|
|
$service->cancelPayment('TNO_CLI_ERR', 'ORD-CLI-ERR', 50000, '오류');
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_cli_debug_logs_do_not_include_raw_response_or_site_key(): void
|
|
{
|
|
$source = file_get_contents((new \ReflectionClass(NhnKcpApiService::class))->getFileName());
|
|
$this->assertIsString($source);
|
|
|
|
$this->assertStringNotContainsString("'res_data' =>", $source);
|
|
$this->assertStringNotContainsString("'output_lines' =>", $source);
|
|
$this->assertStringNotContainsString("['command' => \$command]", $source);
|
|
$this->assertStringContainsString("'res_cd' => \$result['res_cd'] ?? null", $source);
|
|
}
|
|
|
|
public function test_cancel_payment_rejects_unsafe_cancel_message_before_cli_exec(): void
|
|
{
|
|
$stub = $this->makeServiceWithStubbedCli('res_cd=0000');
|
|
/** @var NhnKcpApiService $service */
|
|
$service = $stub['service'];
|
|
|
|
try {
|
|
$this->expectException(NhnKcpApiException::class);
|
|
$this->expectExceptionMessageMatches('/control character/');
|
|
|
|
$service->cancelPayment('TNO_UNSAFE', 'ORD-UNSAFE', 50000, "고객 요청\nINJECT");
|
|
} finally {
|
|
$this->removeStubbedCliDirectory($stub['dir']);
|
|
}
|
|
}
|
|
|
|
public function test_get_transaction_uses_live_api_url_in_live_mode(): void
|
|
{
|
|
$service = $this->makeService([
|
|
'is_test_mode' => false,
|
|
'live_site_cd' => 'SR123456',
|
|
'live_site_key' => 'live_key_value',
|
|
]);
|
|
|
|
Http::fake([
|
|
'api.kcp.co.kr/*' => Http::response(['res_cd' => '0000'], 200),
|
|
]);
|
|
|
|
$service->getTransaction('TNO_LIVE', 'ORD-LIVE');
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'api.kcp.co.kr')
|
|
&& ! str_contains($request->url(), 'stgapi');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 결제 직전 CLI 바이너리 실행 권한 자가 복구 (9502 회귀 차단).
|
|
*
|
|
* 시나리오: plugin:update 후 활성 디렉토리 pp_cli/pp_cli_x64 가 _bundled 권한 (0664) 으로
|
|
* 회귀해 실행 권한이 사라진 상태에서 결제 시도. ensureCliExecutable() 가 결제 직전
|
|
* 자동으로 chmod 0755 + stat 캐시 무효화를 수행하여 9502 발생을 차단해야 한다.
|
|
*
|
|
* 단위 테스트는 ensureCliExecutable() 만 reflection 으로 직접 호출하고
|
|
* (실제 KCP CLI 실행은 외부 의존이라 단위 범위 밖) 권한 변경 + 멱등성 + 실패 분기
|
|
* 3가지 동작을 검증한다.
|
|
*/
|
|
public function test_ensure_cli_executable_promotes_0664_to_0755(): void
|
|
{
|
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
|
$this->markTestSkipped('파일 모드 비트가 Windows 와 호환되지 않음.');
|
|
}
|
|
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'kcp_cli_test_');
|
|
$this->assertNotFalse($tmpFile);
|
|
file_put_contents($tmpFile, '#!/bin/sh'.PHP_EOL.'exit 0'.PHP_EOL);
|
|
chmod($tmpFile, 0664);
|
|
$this->assertFalse(is_executable($tmpFile), 'precondition: 0664 는 실행 권한 없음');
|
|
|
|
try {
|
|
$service = $this->makeService();
|
|
$method = (new \ReflectionClass($service))->getMethod('ensureCliExecutable');
|
|
$method->setAccessible(true);
|
|
$method->invoke($service, $tmpFile);
|
|
|
|
clearstatcache(true, $tmpFile);
|
|
$this->assertTrue(is_executable($tmpFile), 'chmod 0755 로 자가 복구되어야 함');
|
|
$mode = substr(sprintf('%o', fileperms($tmpFile)), -4);
|
|
$this->assertSame('0755', $mode, "권한이 0755 로 정확히 설정되어야 함 (현재: {$mode})");
|
|
} finally {
|
|
@unlink($tmpFile);
|
|
}
|
|
}
|
|
|
|
public function test_ensure_cli_executable_is_idempotent_when_already_executable(): void
|
|
{
|
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
|
$this->markTestSkipped('파일 모드 비트가 Windows 와 호환되지 않음.');
|
|
}
|
|
|
|
$tmpFile = tempnam(sys_get_temp_dir(), 'kcp_cli_test_');
|
|
$this->assertNotFalse($tmpFile);
|
|
file_put_contents($tmpFile, '#!/bin/sh'.PHP_EOL.'exit 0'.PHP_EOL);
|
|
chmod($tmpFile, 0755);
|
|
|
|
try {
|
|
$service = $this->makeService();
|
|
$method = (new \ReflectionClass($service))->getMethod('ensureCliExecutable');
|
|
$method->setAccessible(true);
|
|
// 호출이 예외 없이 통과해야 하며, 권한도 그대로여야 함.
|
|
$method->invoke($service, $tmpFile);
|
|
|
|
clearstatcache(true, $tmpFile);
|
|
$mode = substr(sprintf('%o', fileperms($tmpFile)), -4);
|
|
$this->assertSame('0755', $mode, '이미 실행 가능한 파일의 권한은 변경되지 않아야 함');
|
|
} finally {
|
|
@unlink($tmpFile);
|
|
}
|
|
}
|
|
|
|
public function test_ensure_cli_executable_throws_when_binary_missing(): void
|
|
{
|
|
$service = $this->makeService();
|
|
$method = (new \ReflectionClass($service))->getMethod('ensureCliExecutable');
|
|
$method->setAccessible(true);
|
|
|
|
$this->expectException(NhnKcpApiException::class);
|
|
$this->expectExceptionMessageMatches('/CLI 바이너리/');
|
|
|
|
$method->invoke($service, '/tmp/nonexistent_kcp_cli_'.uniqid());
|
|
}
|
|
|
|
/**
|
|
* KCP CLI 가 디스크에 결제 로그를 남기지 않도록 (PCI DSS 평문 카드번호 저장 방지) 보장.
|
|
*
|
|
* 기존 동작: log_path 가 빈 문자열 → CLI 가 기본 경로(bin/log/)에 verbose 로그 작성.
|
|
* TX_ENDED 라인에 card_no, app_no, tno 등 결제 메타 평문 누적.
|
|
* 수정 동작: gnuboard5 검증 패턴 — 의도적으로 존재하지 않는 경로(/home100/kcp)를
|
|
* 전달하면 CLI 가 디렉토리 생성 실패 → 로그 작성 단계 silent skip.
|
|
* 추가로 log_level=1 (오류만) 로 낮춰 미래에 누가 log_path 를 살려도 카드번호 같은
|
|
* verbose 페이로드가 자동으로 차단되도록 이중 안전장치.
|
|
*/
|
|
public function test_log_path_is_intentionally_nonexistent_to_disable_disk_logging(): void
|
|
{
|
|
$reflection = new \ReflectionClass(NhnKcpApiService::class);
|
|
$this->assertTrue(
|
|
$reflection->hasConstant('LOG_DISABLED_PATH'),
|
|
'LOG_DISABLED_PATH 상수가 정의되어 있어야 함 (디스크 로깅 차단 의도 명시)',
|
|
);
|
|
$this->assertEquals(
|
|
'/home100/kcp',
|
|
$reflection->getConstant('LOG_DISABLED_PATH'),
|
|
'gnuboard5 의 검증된 패턴(/home100/kcp) 과 일치해야 함',
|
|
);
|
|
$this->assertFalse(
|
|
is_dir($reflection->getConstant('LOG_DISABLED_PATH')),
|
|
'경로가 실제 시스템에 존재하지 않아야 (로그 작성 silent skip 보장)',
|
|
);
|
|
}
|
|
|
|
public function test_log_level_is_minimal_to_prevent_pii_leak(): void
|
|
{
|
|
$reflection = new \ReflectionClass(NhnKcpApiService::class);
|
|
$logLevel = $reflection->getConstant('LOG_LEVEL');
|
|
|
|
$this->assertSame(
|
|
'1',
|
|
$logLevel,
|
|
'LOG_LEVEL=1 (오류만) 이어야 함 — verbose(3) 는 응답 페이로드에 카드번호 평문 포함하므로 PCI DSS 위반 위험',
|
|
);
|
|
}
|
|
|
|
public function test_cli_args_include_disabled_log_path_constant(): void
|
|
{
|
|
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
|
|
$this->markTestSkipped('Linux CLI 경로 테스트 — Windows 분기 별도 검증.');
|
|
}
|
|
|
|
$reflection = new \ReflectionClass(NhnKcpApiService::class);
|
|
$source = file_get_contents($reflection->getFileName());
|
|
|
|
// executeCliLinux 와 executeCliWindows 모두 LOG_DISABLED_PATH 참조해야 함.
|
|
$logPathReferences = substr_count($source, "'log_path=' . self::LOG_DISABLED_PATH");
|
|
$this->assertGreaterThanOrEqual(
|
|
2,
|
|
$logPathReferences,
|
|
'두 OS 분기(executeCliLinux + executeCliWindows) 모두 LOG_DISABLED_PATH 를 사용해야 함',
|
|
);
|
|
|
|
// 기존 빈 값 패턴(log_path=,)이 남아있지 않아야 함.
|
|
$this->assertStringNotContainsString(
|
|
"'log_path=,'",
|
|
$source,
|
|
'빈 log_path 패턴이 남아있으면 CLI 가 기본 경로(bin/log/)에 로그 작성 회귀',
|
|
);
|
|
}
|
|
}
|