Files
Gnuboard7/tests/Feature/Settings/OutboundProxySettingTest.php
T
HeuJung a7b7c64573 feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.

게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의
조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질
게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다.

적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께
경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은
OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증
승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은
원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다.

저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
2026-08-20 17:01:27 +09:00

323 lines
12 KiB
PHP

<?php
namespace Tests\Feature\Settings;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Providers\AppServiceProvider;
use App\Services\OutboundProxyTester;
use App\Services\SettingsService;
use Illuminate\Http\Client\Factory;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\Validator;
use ReflectionMethod;
use ReflectionProperty;
use Tests\TestCase;
/**
* 아웃바운드 프록시 설정의 저장·검증·적용을 고정합니다.
*
* 이 설정은 화면 한 곳에서 입력되지만 네 지점을 지나야 실제로 동작합니다 — 검증(FormRequest),
* 분류 저장(고급 탭 → debug 카테고리), 판정(OutboundProxy), 적용(Http 전역 옵션). 어느 한
* 지점이 빠지면 "저장은 되는데 적용되지 않는" 상태가 되고, 저장 응답은 200 이라 화면에도
* API 에도 실패가 드러나지 않습니다.
*
* 축 조합은 각 테스트 메서드의 @scenario 주석이 담당한다.
*
* @effects proxy_setting_persists_to_debug_category
* @effects proxy_setting_rejects_invalid_url
* @effects proxy_applied_to_global_http_options
* @effects proxy_absent_leaves_global_http_options_untouched
* @effects proxy_connection_test_reports_egress_ip
*/
class OutboundProxySettingTest extends TestCase
{
/**
* 고급 탭에서 저장한 프록시 값이 debug 카테고리에 기록되는지 확인합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_setting_persists_to_debug_category
*/
public function test_proxy_settings_saved_from_advanced_tab_are_persisted(): void
{
$saved = app(SettingsService::class)->saveSettings([
'_tab' => 'advanced',
'advanced' => [
'outbound_proxy' => 'socks5h://127.0.0.1:1080',
'outbound_proxy_bypass' => ['g7.dev', 'localhost'],
],
]);
$this->assertTrue($saved, '고급 탭 저장이 실패했습니다.');
$debug = app(ConfigRepositoryInterface::class)->getCategory('debug');
$this->assertSame(
'socks5h://127.0.0.1:1080',
$debug['outbound_proxy'] ?? null,
'debug.outbound_proxy 가 저장되지 않았습니다 — 고급 탭 분류표에서 누락되면 값이 조용히 버려집니다.'
);
$this->assertSame(['g7.dev', 'localhost'], $debug['outbound_proxy_bypass'] ?? null);
}
/**
* 기존 설치본에도 새 키가 기본값으로 노출되는지 확인합니다.
*
* 설정 JSON 은 defaults 와 병합되어 읽히므로 별도 마이그레이션 없이 키가 생겨야 합니다.
*
* @scenario debug_mode=on, proxy_value=empty, bypass_list=empty
*
* @effects proxy_setting_persists_to_debug_category
*/
public function test_new_keys_are_present_through_defaults_merge(): void
{
$debug = app(ConfigRepositoryInterface::class)->getCategory('debug');
$this->assertArrayHasKey('outbound_proxy', $debug);
$this->assertArrayHasKey('outbound_proxy_bypass', $debug);
$this->assertSame('', $debug['outbound_proxy']);
$this->assertSame([], $debug['outbound_proxy_bypass']);
}
/**
* 적용할 수 없는 형태의 주소는 저장 검증에서 막습니다.
*
* @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
*
* @effects proxy_setting_rejects_invalid_url
*/
public function test_invalid_proxy_url_fails_validation(): void
{
$validator = $this->validatorForAdvanced([
'outbound_proxy' => 'ftp://proxy.internal:21',
]);
$this->assertTrue(
$validator->fails(),
'허용 목록에 없는 스킴이 검증을 통과했습니다 — 저장은 되고 적용은 안 되는 상태가 됩니다.'
);
$this->assertArrayHasKey('advanced.outbound_proxy', $validator->errors()->messages());
}
/**
* 유효한 주소와 빈 값은 검증을 통과합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects proxy_setting_rejects_invalid_url
*/
public function test_valid_and_empty_proxy_url_pass_validation(): void
{
foreach (['socks5h://127.0.0.1:1080', 'http://proxy.internal:3128', ''] as $url) {
$validator = $this->validatorForAdvanced(['outbound_proxy' => $url]);
$this->assertFalse(
$validator->errors()->has('advanced.outbound_proxy'),
"유효한 주소 '{$url}' 가 거부됐습니다."
);
}
}
/**
* 예외 목록의 각 항목이 문자열·길이 검증을 받는지 확인합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_setting_rejects_invalid_url
*/
public function test_bypass_list_items_are_validated(): void
{
$validator = $this->validatorForAdvanced([
'outbound_proxy_bypass' => [str_repeat('a', 256)],
]);
$this->assertTrue($validator->fails());
$this->assertArrayHasKey('advanced.outbound_proxy_bypass.0', $validator->errors()->messages());
}
/**
* 판정 결과가 있으면 Http 전역 옵션에 프록시가 실립니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_applied_to_global_http_options
*/
public function test_resolved_proxy_is_applied_to_global_http_options(): void
{
$proxy = [
'http' => 'socks5h://127.0.0.1:1080',
'https' => 'socks5h://127.0.0.1:1080',
'no' => ['g7.dev'],
];
config(['g7.outbound_proxy' => $proxy]);
$this->invokeOutboundProxyConfigurer();
$this->assertSame(
['proxy' => $proxy],
$this->globalHttpOptions(),
'Http 전역 옵션에 프록시가 실리지 않았습니다 — 설정은 저장되어도 실제 요청은 그대로 나갑니다.'
);
}
/**
* 판정 결과가 없으면 Http 전역 옵션을 건드리지 않습니다.
*
* 디버그 모드가 꺼져 있으면 판정이 null 을 돌려주므로, 이 경로가 프록시 미적용을 보장합니다.
*
* @scenario debug_mode=off, proxy_value=valid, bypass_list=empty
*
* @effects proxy_absent_leaves_global_http_options_untouched
*/
public function test_absent_proxy_leaves_global_http_options_untouched(): void
{
foreach ([null, [], ''] as $value) {
config(['g7.outbound_proxy' => $value]);
$this->invokeOutboundProxyConfigurer();
$this->assertSame(
[],
$this->globalHttpOptions(),
'프록시 미적용 상태인데 Http 전역 옵션이 설정됐습니다.'
);
}
}
/**
* 고급 탭 검증기를 구성합니다.
*
* @param array<string, mixed> $advanced advanced 탭 입력값
*/
private function validatorForAdvanced(array $advanced): Validator
{
$request = new SaveSettingsRequest;
$request->merge(['_tab' => 'advanced', 'advanced' => $advanced]);
$request->setContainer(app());
return validator(
$request->all(),
$request->rules(),
$request->messages(),
$request->attributes()
);
}
/**
* AppServiceProvider 의 프록시 적용 로직만 실행합니다.
*/
private function invokeOutboundProxyConfigurer(): void
{
$this->resetGlobalHttpOptions();
$provider = new AppServiceProvider($this->app);
$method = new ReflectionMethod($provider, 'configureOutboundProxy');
$method->setAccessible(true);
$method->invoke($provider);
}
/**
* Http 팩토리의 전역 옵션을 비웁니다.
*/
private function resetGlobalHttpOptions(): void
{
Http::globalOptions([]);
}
/**
* Http 팩토리에 설정된 전역 옵션을 읽습니다.
*
* @return array<string, mixed>
*/
private function globalHttpOptions(): array
{
$factory = $this->app->make(Factory::class);
$property = new ReflectionProperty($factory, 'globalOptions');
$property->setAccessible(true);
return (array) value($property->getValue($factory));
}
/**
* 연결 테스트가 프록시를 거친 출발지 IP 를 보고합니다.
*
* 출발지 IP 는 운영자가 결제사에 등록해야 하는 값이라, 저장하기 전에 알 수 있어야 합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects proxy_connection_test_reports_egress_ip
*/
public function test_connection_test_reports_egress_ip(): void
{
Http::fake(['*' => Http::response('203.0.113.9', 200)]);
$result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080');
$this->assertTrue($result['success']);
$this->assertSame('203.0.113.9', $result['egress_ip']);
$this->assertSame('settings.outbound_proxy_test_success', $result['message_key']);
}
/**
* 연결 테스트는 제출된 값을 검사합니다 — 저장된 설정이나 전역 옵션을 보지 않습니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=normalizable
*
* @effects proxy_connection_test_reports_egress_ip
*/
public function test_connection_test_uses_submitted_proxy_not_stored_settings(): void
{
config(['g7.outbound_proxy' => null]);
Http::globalOptions([]);
Http::fake(['*' => Http::response('203.0.113.9', 200)]);
$result = app(OutboundProxyTester::class)->test('socks5h://10.0.0.9:1080', ['g7.dev']);
// 전역 프록시가 없는 상태에서도 제출값으로 요청이 나갔다.
Http::assertSentCount(1);
$this->assertTrue($result['success']);
// 전역 옵션은 비어 있는 채여야 한다 — 테스트가 전역 상태를 바꾸면 이후 모든 요청이
// 저장하지도 않은 프록시를 타게 된다.
$this->assertSame([], $this->globalHttpOptions());
}
/**
* 적용 불가 주소는 외부 호출 없이 즉시 거부합니다.
*
* @scenario debug_mode=on, proxy_value=invalid, bypass_list=empty
*
* @effects proxy_connection_test_reports_egress_ip
*/
public function test_connection_test_rejects_invalid_url_without_calling_out(): void
{
Http::fake();
$result = app(OutboundProxyTester::class)->test('ftp://proxy.internal:21');
$this->assertFalse($result['success']);
$this->assertSame('settings.outbound_proxy_test_invalid_url', $result['message_key']);
Http::assertNothingSent();
}
/**
* 조회 대상이 없으면 확인 불가로 보고합니다 — 실패와 구분되어야 합니다.
*
* @scenario debug_mode=on, proxy_value=valid, bypass_list=empty
*
* @effects proxy_connection_test_reports_egress_ip
*/
public function test_connection_test_reports_when_no_lookup_target_is_configured(): void
{
config(['core.outbound_proxy.egress_lookup_urls' => []]);
Http::fake();
$result = app(OutboundProxyTester::class)->test('socks5h://127.0.0.1:1080');
$this->assertFalse($result['success']);
$this->assertSame('settings.outbound_proxy_test_no_lookup_url', $result['message_key']);
Http::assertNothingSent();
}
}