0) ob_end_clean(); * * 본 테스트는 install-process.php 의 폴링 분기에 동일한 출력 처리 비활성화가 echo * 이전에 존재하는지 정적 검증한다. PHP 8.3 에서는 회귀하지 않으므로 정적 검사로만 * 회귀 차단 가능 (런타임 검사는 SAPI 종속이라 PHPUnit 환경에서 불가). */ class PollingResponseFlushTest extends TestCase { private string $sourcePath; private string $source; protected function setUp(): void { parent::setUp(); $this->sourcePath = dirname(__DIR__, 3) . '/public/install/api/install-process.php'; $this->assertFileExists($this->sourcePath, 'install-process.php 가 존재해야 한다.'); $this->source = (string) file_get_contents($this->sourcePath); $this->assertNotSame('', $this->source); } public function test_polling_branch_disables_zlib_output_compression(): void { $pollingBranch = $this->extractPollingBranch(); $this->assertMatchesRegularExpression( '/@?ini_set\(\s*[\'"]zlib\.output_compression[\'"]\s*,\s*(false|[\'"]\s*off\s*[\'"]|0)\s*\)/i', $pollingBranch, '폴링 분기는 echo 이전에 zlib.output_compression 을 비활성화해야 한다 — ' . 'PHP 8.5 + mod_deflate/zlib 환경에서 Content-Length 와 wire body 불일치로 fetch 가 hang.' ); } public function test_polling_branch_disables_output_buffering_ini(): void { $pollingBranch = $this->extractPollingBranch(); $this->assertMatchesRegularExpression( '/@?ini_set\(\s*[\'"]output_buffering[\'"]\s*,\s*[\'"]\s*off\s*[\'"]\s*\)/i', $pollingBranch, '폴링 분기는 echo 이전에 output_buffering ini 를 off 로 설정해야 한다 — ' . 'php.ini output_buffering 이 활성화된 SAPI 환경에서 응답 즉시 송신 보장.' ); } public function test_polling_branch_disables_apache_gzip(): void { $pollingBranch = $this->extractPollingBranch(); $this->assertMatchesRegularExpression( '/apache_setenv\(\s*[\'"]no-gzip[\'"]\s*,\s*1\s*\)/i', $pollingBranch, '폴링 분기는 echo 이전에 apache_setenv(no-gzip, 1) 를 호출해야 한다 — ' . 'Apache mod_deflate 가 응답을 압축하면 Content-Length 가 무력화됨.' ); } public function test_polling_branch_emits_padding_to_force_fcgid_flush(): void { $pollingBranch = $this->extractPollingBranch(); $this->assertMatchesRegularExpression( '/str_repeat\(\s*[\'"]\s*[\'"]\s*,\s*(6553[6-9]|65[6-9]\d{2}|6[6-9]\d{3}|[789]\d{4}|[1-9]\d{5,})\s*\)/', $pollingBranch, '폴링 분기는 echo 직후 65536 바이트 이상의 padding(str_repeat(공백)) 을 출력해야 한다 — ' . 'mod_fcgid (FcgidOutputBufferSize default 64KB) 가 응답을 즉시 flush 하도록 강제. ' . '워커가 10분간 인라인 실행되는 동안 install-process.php 응답이 클라이언트에 도달하지 못해 ' . 'PollingMonitor 가 시작조차 못 하는 회귀를 차단.' ); } public function test_padding_position_is_after_json_echo_and_before_flush(): void { $pollingBranch = $this->extractPollingBranch(); $echoPos = strpos($pollingBranch, 'echo $responseJson;'); $this->assertNotFalse($echoPos); $hasMatch = preg_match( '/str_repeat\(\s*[\'"]\s*[\'"]\s*,\s*\d+\s*\)/', $pollingBranch, $matches, PREG_OFFSET_CAPTURE ); $this->assertSame(1, $hasMatch, 'padding str_repeat 호출이 분기 내에 존재해야 한다.'); $paddingPos = $matches[0][1]; $flushPos = strpos($pollingBranch, '@flush()', $echoPos); $this->assertNotFalse($flushPos); $this->assertGreaterThan( $echoPos, $paddingPos, 'padding 은 echo $responseJson 이후에 출력되어야 한다 (그래야 응답 본문이 먼저 wire 에 들어감).' ); $this->assertLessThan( $flushPos, $paddingPos, 'padding 은 flush() 이전에 출력되어야 한다 (flush 시점에 64KB 임계 도달해야 mod_fcgid 가 송출).' ); } public function test_content_length_remains_json_byte_count_only(): void { $pollingBranch = $this->extractPollingBranch(); // padding 은 wire 에는 들어가지만 Content-Length 는 JSON 바이트 수만 명시해야 한다. // 그래야 브라우저가 Content-Length 만큼만 읽고 fetch resolve 후 padding 은 무시. $this->assertMatchesRegularExpression( '/header\(\s*[\'"]Content-Length:\s*[\'"]\s*\.\s*strlen\(\s*\$responseJson\s*\)\s*\)/', $pollingBranch, 'Content-Length 헤더는 padding 을 제외한 $responseJson 의 바이트 수만 명시해야 한다.' ); } public function test_output_disable_calls_precede_echo_response(): void { $pollingBranch = $this->extractPollingBranch(); $echoPos = strpos($pollingBranch, 'echo $responseJson;'); $this->assertNotFalse($echoPos, '폴링 분기에 `echo $responseJson;` 문장이 존재해야 한다.'); // zlib.output_compression 비활성화 위치 $hasMatch = preg_match( '/@?ini_set\(\s*[\'"]zlib\.output_compression[\'"]/', $pollingBranch, $matches, PREG_OFFSET_CAPTURE ); $this->assertSame(1, $hasMatch, 'zlib.output_compression 비활성화가 분기 내에 존재해야 한다.'); $zlibPos = $matches[0][1]; $this->assertLessThan( $echoPos, $zlibPos, 'zlib.output_compression 비활성화는 echo 이전에 실행되어야 한다.' ); // apache_setenv(no-gzip) $hasMatch = preg_match( '/apache_setenv\(\s*[\'"]no-gzip[\'"]/', $pollingBranch, $matches, PREG_OFFSET_CAPTURE ); $this->assertSame(1, $hasMatch, 'apache_setenv(no-gzip) 가 분기 내에 존재해야 한다.'); $noGzipPos = $matches[0][1]; $this->assertLessThan( $echoPos, $noGzipPos, 'apache_setenv(no-gzip, 1) 는 echo 이전에 실행되어야 한다.' ); } /** * install-process.php 의 `if ($installationMode === 'polling') { ... }` 본문 추출. */ private function extractPollingBranch(): string { $start = strpos($this->source, "if (\$installationMode === 'polling')"); $this->assertNotFalse($start, "폴링 분기 if 문을 찾을 수 없습니다."); // 매칭되는 닫는 중괄호 찾기 $braceStart = strpos($this->source, '{', $start); $this->assertNotFalse($braceStart); $depth = 0; $len = strlen($this->source); for ($i = $braceStart; $i < $len; $i++) { $ch = $this->source[$i]; if ($ch === '{') { $depth++; } elseif ($ch === '}') { $depth--; if ($depth === 0) { return substr($this->source, $braceStart, $i - $braceStart + 1); } } } $this->fail('폴링 분기의 닫는 중괄호를 찾을 수 없습니다.'); } }