Files
Gnuboard7/tests/Feature/Api/Public/DualExtensionServingTest.php
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

205 lines
7.7 KiB
PHP

<?php
namespace Tests\Feature\Api\Public;
use App\Enums\ExtensionStatus;
use App\Http\Controllers\Api\Public\AssetProbeController;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 자산 URL 이중 모드 서빙 계약 테스트 (이슈 #486 단위 A).
*
* 라우트가 두 형태로 등록되었다는 것만으로는 부족하다 — 확장자 없는 형태가
* 확장자 형태와 **같은 응답**을 주고 **같은 권한 가드**를 받아야 실제로 대체재가 된다.
* 여기서는 그 등가성을 실제 HTTP 요청으로 검증한다.
*/
class DualExtensionServingTest extends TestCase
{
use RefreshDatabase;
/**
* 감지 프로브의 확장자 형태가 매직 토큰을 반환해야 한다.
*/
public function test_프로브_확장자_형태가_매직_토큰을_반환한다(): void
{
$response = $this->get('/api/system/asset-probe.js');
$response->assertOk();
$this->assertStringContainsString(AssetProbeController::PROBE_TOKEN, $response->getContent());
$this->assertStringContainsString('javascript', $response->headers->get('Content-Type'));
}
/**
* 감지 프로브의 대조군(확장자 없는 형태)도 동일한 토큰을 반환해야 한다.
*/
public function test_프로브_대조군이_동일한_토큰을_반환한다(): void
{
$response = $this->get('/api/system/asset-probe');
$response->assertOk();
$this->assertStringContainsString(AssetProbeController::PROBE_TOKEN, $response->getContent());
}
/**
* 프로브는 캐시되지 않아야 한다.
*
* 캐시되면 관리자가 서버 설정을 고친 뒤 재감지해도 옛 판정이 반복된다.
*/
public function test_프로브_응답이_캐시되지_않는다(): void
{
foreach (['/api/system/asset-probe.js', '/api/system/asset-probe'] as $uri) {
$cacheControl = $this->get($uri)->headers->get('Cache-Control');
$this->assertStringContainsString('no-store', $cacheControl, "{$uri} 가 no-store 아님");
}
}
/**
* 템플릿 자산은 경로 세그먼트 형태와 `?file=` 형태가 동일한 내용을 반환해야 한다.
*/
public function test_템플릿_자산이_두_형태에서_동일한_내용을_반환한다(): void
{
$assetPath = 'js/components.iife.js';
if (! file_exists(base_path("templates/sirsoft-basic/dist/{$assetPath}"))) {
$this->markTestSkipped('sirsoft-basic 빌드 산출물이 없어 자산 서빙 등가성을 검증할 수 없습니다.');
}
// 자산 서빙은 활성 상태의 템플릿 레코드를 요구한다 (TemplateService::getAssetFilePath)
$this->activateTemplate('sirsoft-basic');
$viaSegment = $this->get("/api/templates/assets/sirsoft-basic/{$assetPath}");
$viaQuery = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode($assetPath));
$viaSegment->assertOk();
$viaQuery->assertOk();
$this->assertSame(
$viaSegment->streamedContent(),
$viaQuery->streamedContent(),
'경로 세그먼트 형태와 ?file= 형태의 응답 본문이 다르다'
);
}
/**
* `?file=` 형태도 경로 검증을 그대로 통과시켜서는 안 된다.
*
* 확장자 없는 모드가 보안 검증 우회 통로가 되면 안 된다.
*/
public function test_쿼리_형태에도_경로_탈출_방어가_적용된다(): void
{
$response = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode('../../../.env'));
$this->assertNotSame(200, $response->getStatusCode(), '경로 탈출이 차단되지 않았다');
}
/**
* 허용되지 않은 확장자는 `?file=` 형태에서도 거부되어야 한다.
*/
public function test_쿼리_형태에도_확장자_화이트리스트가_적용된다(): void
{
$response = $this->get('/api/templates/assets/sirsoft-basic?file='.urlencode('config.php'));
$this->assertNotSame(200, $response->getStatusCode(), '허용되지 않은 확장자가 서빙되었다');
}
/**
* 관리자 편집기 엔드포인트는 두 형태 모두 권한 가드를 받아야 한다.
*
* 확장자 없는 형태에 미들웨어가 빠지면 무인증 우회 통로가 열린다.
*/
public function test_편집기_엔드포인트가_두_형태_모두_무인증을_거부한다(): void
{
$uris = [
'/api/admin/templates/sirsoft-basic/editor/components.json',
'/api/admin/templates/sirsoft-basic/editor/components',
'/api/admin/templates/sirsoft-basic/editor/component-styles.css',
'/api/admin/templates/sirsoft-basic/editor/component-styles',
];
foreach ($uris as $uri) {
$status = $this->getJson($uri)->getStatusCode();
$this->assertContains(
$status,
[401, 403],
"{$uri} 가 무인증 요청을 거부하지 않았다 (상태코드 {$status})"
);
}
}
/**
* 권한을 가진 관리자는 두 형태 모두에서 동일한 응답을 받아야 한다.
*/
public function test_편집기_엔드포인트가_두_형태에서_동일한_응답을_반환한다(): void
{
$token = $this->createEditorAdminToken();
$viaExtension = $this->withHeaders(['Authorization' => "Bearer {$token}"])
->getJson('/api/admin/templates/sirsoft-basic/editor/components.json');
$viaExtensionless = $this->withHeaders(['Authorization' => "Bearer {$token}"])
->getJson('/api/admin/templates/sirsoft-basic/editor/components');
$this->assertSame(
$viaExtension->getStatusCode(),
$viaExtensionless->getStatusCode(),
'두 형태의 상태코드가 다르다'
);
$this->assertSame(
$viaExtension->getContent(),
$viaExtensionless->getContent(),
'두 형태의 응답 본문이 다르다'
);
}
/**
* 자산 서빙을 위해 활성 상태의 템플릿 레코드를 만듭니다.
*
* @param string $identifier 템플릿 식별자
*/
private function activateTemplate(string $identifier): void
{
Template::updateOrCreate(['identifier' => $identifier], [
'vendor' => 'sirsoft',
'name' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'],
]);
}
/**
* 레이아웃 편집 권한을 가진 관리자 토큰을 생성합니다.
*
* @return string Sanctum 평문 토큰
*/
private function createEditorAdminToken(): string
{
$permission = Permission::firstOrCreate([
'identifier' => 'core.templates.layouts.edit',
], [
'name' => '레이아웃 편집',
'display_name' => '레이아웃 편집',
'type' => 'admin',
]);
$role = Role::firstOrCreate(['identifier' => 'super-admin'], [
'name' => 'Super Admin',
'display_name' => 'Super Admin',
'is_default' => false,
]);
$role->permissions()->syncWithoutDetaching([$permission->id]);
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
return $user->createToken('admin')->plainTextToken;
}
}