Files
Gnuboard7/tests/Feature/Api/Identity/IdentityChallengeShowTest.php
T
HeuJung 9cf9c3ff8c fix(core,board,ecommerce,payments,basic): KVE-2026 보안 게이트 6묶음 + 자격증명 전송로 정합
KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과
그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다.

globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를
직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다.
서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이
그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고,
같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다.

아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩
걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
2026-09-06 00:42:29 +09:00

111 lines
4.3 KiB
PHP

<?php
namespace Tests\Feature\Api\Identity;
use App\Enums\IdentityVerificationStatus;
use App\Models\IdentityVerificationLog;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* GET /api/identity/challenges/{id} 폴링 엔드포인트 테스트.
*
* 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백) 에서 클라이언트가
* verify 즉시 응답을 받지 못할 때 상태를 추적하는 폴링 경로 검증.
*
* @since engine-v1.46.0
*/
class IdentityChallengeShowTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
// PermissionMiddleware 가 IDV request 라우트에 권한 가드 적용 — guest 역할 권한 sync 필수
$this->seed(RolePermissionSeeder::class);
}
public function test_show_returns_404_when_challenge_not_found(): void
{
$response = $this->getJson('/api/identity/challenges/00000000-0000-0000-0000-000000000000');
$response->assertStatus(404);
}
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:id,field:status,field:public_payload
*
* @effects public_safe_fields_present_in_response
*/
public function test_show_returns_public_status_fields(): void
{
// start a challenge
$request = $this->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => 'user@example.com'],
]);
$id = $request->json('data.id');
$response = $this->getJson("/api/identity/challenges/{$id}");
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => ['id', 'status', 'render_hint', 'expires_at', 'public_payload'],
])
->assertJsonPath('data.id', $id);
}
/**
* @scenario source=identity-challenge-status-field-exposure axis=field:attempts,field:max_attempts,field:target_hash
*
* @effects attempts_absent_from_response, max_attempts_absent_from_response, target_hash_absent_from_response, verification_token_absent_from_response, metadata_absent_from_response
*/
public function test_show_does_not_expose_sensitive_fields(): void
{
$request = $this->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => 'user@example.com'],
]);
$id = $request->json('data.id');
$response = $this->getJson("/api/identity/challenges/{$id}");
// 누적 시도 횟수(attempts)·코드 본체·target_hash·verification_token 등 노출 금지.
// 이 엔드포인트는 권한 가드 없는 공개 폴링용이므로, 남의 인증이 몇 번 실패했는지를
// 추론할 단서를 응답에 담지 않는다.
$response->assertJsonMissingPath('data.attempts')
->assertJsonMissingPath('data.target_hash')
->assertJsonMissingPath('data.verification_token')
->assertJsonMissingPath('data.metadata');
// 상한(max_attempts)은 정책 상수라 노출한다 — 화면이 남은 횟수를 표시하려면 필요하고,
// 상수 자체로는 그 인증 건이 몇 번 실패했는지가 드러나지 않는다.
// (Service::getStatus 의 판정과 같은 계약. 한쪽만 바뀌면 이 단언이 먼저 깨진다.)
$this->assertIsInt($response->json('data.max_attempts'));
}
public function test_show_reflects_status_transitions(): void
{
$request = $this->postJson('/api/identity/challenges', [
'purpose' => 'sensitive_action',
'target' => ['email' => 'user@example.com'],
]);
$id = $request->json('data.id');
// processing 상태로 강제 변경 (provider 가 비동기 검증 중인 흐름)
IdentityVerificationLog::whereKey($id)->update([
'status' => IdentityVerificationStatus::Processing->value,
]);
$response = $this->getJson("/api/identity/challenges/{$id}");
$response->assertStatus(200)
->assertJsonPath('data.status', IdentityVerificationStatus::Processing->value);
}
}