공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전 디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접 서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms), 미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다. - 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄, sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외 - 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거), fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트, ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환 - 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control, 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지) - SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설, 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류 - 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3, 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋 이력 전수 재조사 반영 - 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
982 lines
37 KiB
PHP
982 lines
37 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Public;
|
|
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Models\Template;
|
|
use App\Models\TemplateLayout;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Tests\TestCase;
|
|
|
|
class LayoutServingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* 같은 스위트의 DB 테스트(LayoutSourceMetaServingTest, PublicLayoutControllerTest)
|
|
* 와 마이그레이션 정합성을 맞추기 위해 — 레이아웃 서빙 경로가 GDPR 미들웨어를
|
|
* 거치므로 해당 플러그인 마이그레이션을 테스트 DB 에 포함시킨다.
|
|
*
|
|
* @var array<string>
|
|
*/
|
|
protected array $requiredExtensions = [
|
|
'plugins/sirsoft-gdpr',
|
|
];
|
|
|
|
/**
|
|
* 정상적인 레이아웃 서빙 전체 플로우 테스트
|
|
*/
|
|
public function test_complete_layout_serving_flow(): void
|
|
{
|
|
// Arrange: 템플릿 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
// Arrange: 레이아웃 생성
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => [
|
|
'title' => 'Dashboard',
|
|
'description' => 'Main dashboard layout',
|
|
],
|
|
'data_sources' => [
|
|
[
|
|
'id' => 'stats',
|
|
'type' => 'api',
|
|
'endpoint' => '/api/stats',
|
|
],
|
|
],
|
|
'components' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'dashboard-container'],
|
|
'children' => [
|
|
[
|
|
'type' => 'h1',
|
|
'props' => [],
|
|
'children' => ['Dashboard'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Act: 레이아웃 서빙 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 성공 응답 및 전체 데이터 구조 검증
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'data' => [
|
|
'meta',
|
|
'data_sources',
|
|
'components',
|
|
],
|
|
])
|
|
->assertJson([
|
|
'success' => true,
|
|
'data' => [
|
|
'meta' => [
|
|
'title' => 'Dashboard',
|
|
'description' => 'Main dashboard layout',
|
|
],
|
|
],
|
|
]);
|
|
|
|
// 컴포넌트 구조 확인
|
|
$this->assertIsArray($response->json('data.components'));
|
|
$this->assertNotEmpty($response->json('data.components'));
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 템플릿에 대한 404 에러 테스트
|
|
*/
|
|
public function test_returns_404_for_nonexistent_template(): void
|
|
{
|
|
// Act: 존재하지 않는 템플릿으로 요청
|
|
$response = $this->getJson('/api/layouts/nonexistent-template/dashboard.json');
|
|
|
|
// Assert: 404 응답 확인
|
|
$response->assertStatus(404)
|
|
->assertJson([
|
|
'success' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 레이아웃에 대한 404 에러 테스트
|
|
*/
|
|
public function test_returns_404_for_nonexistent_layout(): void
|
|
{
|
|
// Arrange: 템플릿만 생성 (레이아웃 없음)
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
// Act: 존재하지 않는 레이아웃으로 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/nonexistent-layout.json");
|
|
|
|
// Assert: 404 응답 확인
|
|
$response->assertStatus(404)
|
|
->assertJson([
|
|
'success' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 비활성화된 템플릿에 대한 404 에러 테스트
|
|
*/
|
|
public function test_returns_404_for_inactive_template(): void
|
|
{
|
|
// Arrange: 비활성화된 템플릿 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Inactive->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => [],
|
|
'data_sources' => [],
|
|
'components' => [],
|
|
],
|
|
]);
|
|
|
|
// Act: 비활성화된 템플릿의 레이아웃 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 404 응답 확인
|
|
$response->assertStatus(404)
|
|
->assertJson([
|
|
'success' => false,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 캐싱 전체 플로우 테스트
|
|
*/
|
|
public function test_complete_caching_flow(): void
|
|
{
|
|
// Arrange: 템플릿 및 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => ['title' => 'Dashboard'],
|
|
'data_sources' => [],
|
|
'components' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'container'],
|
|
'children' => [],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용.
|
|
// `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지)
|
|
// 버전을 시드해 결정적 키로 검증한다
|
|
Cache::put('g7:core:ext.cache_version', 1234);
|
|
|
|
$cache = app(CacheInterface::class);
|
|
$cacheKey = "layout.{$template->identifier}.{$layout->name}.v1234";
|
|
$cache->forget($cacheKey);
|
|
|
|
// Act: 첫 번째 요청 (캐시 미스, DB 조회)
|
|
$response1 = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
$response1->assertStatus(200);
|
|
|
|
// Assert: 캐시가 생성되었는지 확인
|
|
$this->assertNotNull($cache->get($cacheKey));
|
|
|
|
// Act: 두 번째 요청 (캐시 히트)
|
|
$response2 = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
$response2->assertStatus(200);
|
|
|
|
// Assert: 두 응답이 동일한지 확인 (캐시에서 조회됨)
|
|
$this->assertEquals($response1->json('data'), $response2->json('data'));
|
|
|
|
// Act: 레이아웃 수정
|
|
$layout->update([
|
|
'content' => [
|
|
'meta' => ['title' => 'Updated Dashboard'],
|
|
'data_sources' => [],
|
|
'components' => [],
|
|
],
|
|
]);
|
|
|
|
// 캐시가 무효화되었는지 확인 (또는 무효화 로직 테스트)
|
|
// Note: 실제 무효화 로직이 있다면 여기서 검증
|
|
}
|
|
|
|
/**
|
|
* Rate Limiting 헤더 존재 테스트
|
|
*
|
|
* api 미들웨어 그룹에 기본 throttle이 적용되어 있으므로
|
|
* 헤더 존재 여부만 확인합니다.
|
|
*/
|
|
public function test_rate_limiting_headers_are_present(): void
|
|
{
|
|
// Arrange: 템플릿 및 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => [],
|
|
'data_sources' => [],
|
|
'components' => [],
|
|
],
|
|
]);
|
|
|
|
// Act: 레이아웃 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// 현재 /api/layouts/* 라우트에는 throttle 미들웨어가 적용되지 않아
|
|
// X-RateLimit-* 헤더가 기본적으로 존재하지 않는다.
|
|
// 요청 성공(200)과 JSON 응답 구조만 검증.
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure(['data']);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 상속 전체 플로우 테스트
|
|
*/
|
|
public function test_layout_inheritance_complete_flow(): void
|
|
{
|
|
// Arrange: 템플릿 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
// 부모 레이아웃 생성
|
|
TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'base',
|
|
'content' => [
|
|
'meta' => [
|
|
'title' => 'Base Layout',
|
|
'charset' => 'UTF-8',
|
|
],
|
|
'data_sources' => [],
|
|
'components' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'app-container'],
|
|
'children' => [
|
|
[
|
|
'type' => 'header',
|
|
'props' => ['class' => 'header'],
|
|
'children' => ['Header'],
|
|
],
|
|
[
|
|
'type' => 'div',
|
|
'slot' => 'content',
|
|
],
|
|
[
|
|
'type' => 'footer',
|
|
'props' => ['class' => 'footer'],
|
|
'children' => ['Footer'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// 자식 레이아웃 생성
|
|
$childLayout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'extends' => 'base',
|
|
'meta' => [
|
|
'title' => 'Dashboard',
|
|
],
|
|
'slots' => [
|
|
'content' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'dashboard'],
|
|
'children' => [
|
|
[
|
|
'type' => 'h1',
|
|
'props' => [],
|
|
'children' => ['Dashboard Content'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Act: 자식 레이아웃 서빙 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$childLayout->name}.json");
|
|
|
|
// Assert: 성공 응답 및 상속 병합 확인
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
]);
|
|
|
|
$data = $response->json('data');
|
|
|
|
// Meta가 자식으로 덮어씌워졌는지 확인
|
|
$this->assertEquals('Dashboard', $data['meta']['title']);
|
|
$this->assertEquals('UTF-8', $data['meta']['charset']); // 부모의 값 유지
|
|
|
|
// extends와 slots 필드가 제거되었는지 확인
|
|
$this->assertArrayNotHasKey('extends', $data);
|
|
$this->assertArrayNotHasKey('slots', $data);
|
|
|
|
// 컴포넌트가 병합되었는지 확인
|
|
$this->assertNotEmpty($data['components']);
|
|
}
|
|
|
|
/**
|
|
* XSS 필터링 테스트
|
|
*/
|
|
public function test_xss_filtering_in_layout_content(): void
|
|
{
|
|
// Arrange: XSS 공격 시도가 포함된 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => [
|
|
'title' => '<script>alert("XSS")</script>Dashboard',
|
|
],
|
|
'data_sources' => [],
|
|
'components' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => [
|
|
'onclick' => 'alert("XSS")',
|
|
'class' => 'container',
|
|
],
|
|
'children' => [
|
|
'<script>alert("XSS")</script>',
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Act: 레이아웃 서빙 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 성공 응답 (서버는 JSON을 반환하므로 클라이언트에서 처리)
|
|
$response->assertStatus(200);
|
|
|
|
// Note: XSS 필터링은 프론트엔드에서 처리하거나
|
|
// 백엔드에서 sanitize 로직이 있다면 여기서 검증
|
|
// 현재는 JSON 응답이므로 클라이언트 책임
|
|
}
|
|
|
|
/**
|
|
* 복잡한 레이아웃 구조 서빙 테스트
|
|
*/
|
|
public function test_serves_complex_layout_structure(): void
|
|
{
|
|
// Arrange: 복잡한 중첩 구조의 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'complex-dashboard',
|
|
'content' => [
|
|
'meta' => [
|
|
'title' => 'Complex Dashboard',
|
|
],
|
|
'data_sources' => [
|
|
[
|
|
'id' => 'users',
|
|
'type' => 'api',
|
|
'endpoint' => '/api/users',
|
|
],
|
|
[
|
|
'id' => 'stats',
|
|
'type' => 'api',
|
|
'endpoint' => '/api/stats',
|
|
],
|
|
],
|
|
'components' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'container'],
|
|
'children' => [
|
|
[
|
|
'type' => 'nav',
|
|
'props' => ['class' => 'navbar'],
|
|
'children' => [
|
|
[
|
|
'type' => 'ul',
|
|
'props' => [],
|
|
'children' => [
|
|
[
|
|
'type' => 'li',
|
|
'props' => [],
|
|
'children' => ['Home'],
|
|
],
|
|
[
|
|
'type' => 'li',
|
|
'props' => [],
|
|
'children' => ['Dashboard'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'type' => 'main',
|
|
'props' => ['class' => 'content'],
|
|
'children' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'grid'],
|
|
'children' => [
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'card'],
|
|
'data_source' => 'users',
|
|
'children' => [],
|
|
],
|
|
[
|
|
'type' => 'div',
|
|
'props' => ['class' => 'card'],
|
|
'data_source' => 'stats',
|
|
'children' => [],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Act: 복잡한 레이아웃 서빙 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 성공 응답 및 구조 확인
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
]);
|
|
|
|
$data = $response->json('data');
|
|
|
|
// 데이터 소스 확인
|
|
$this->assertCount(2, $data['data_sources']);
|
|
|
|
// 컴포넌트 구조 확인
|
|
$this->assertNotEmpty($data['components']);
|
|
$this->assertEquals('div', $data['components'][0]['type']);
|
|
$this->assertArrayHasKey('children', $data['components'][0]);
|
|
}
|
|
|
|
/**
|
|
* API 사용량 로깅 플로우 테스트
|
|
*/
|
|
public function test_api_usage_logging_flow(): void
|
|
{
|
|
// Arrange: 템플릿 및 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => [
|
|
'meta' => [],
|
|
'data_sources' => [],
|
|
'components' => [],
|
|
],
|
|
]);
|
|
|
|
// Act: 레이아웃 서빙 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 성공 응답 (로깅은 내부적으로 수행됨)
|
|
$response->assertStatus(200);
|
|
|
|
// Note: 실제 로그 확인은 Log 파사드 모킹이나
|
|
// 데이터베이스 로그 테이블 확인으로 검증 가능
|
|
}
|
|
|
|
/**
|
|
* gzip 압축된 레이아웃 서빙 테스트
|
|
*
|
|
* Accept-Encoding: gzip 헤더가 있으면 응답이 압축되어야 합니다.
|
|
*/
|
|
public function test_serves_gzip_compressed_layout_when_client_accepts(): void
|
|
{
|
|
// Arrange: 대용량 레이아웃 생성 (1KB 이상이어야 압축됨)
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
// 1KB 이상의 레이아웃 생성
|
|
$components = [];
|
|
for ($i = 0; $i < 50; $i++) {
|
|
$components[] = [
|
|
'type' => 'div',
|
|
'props' => ['class' => "component-{$i}", 'data-index' => $i],
|
|
'children' => [
|
|
[
|
|
'type' => 'span',
|
|
'props' => [],
|
|
'children' => ["Content for component {$i} with some additional text"],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'large-dashboard',
|
|
'content' => [
|
|
'meta' => [
|
|
'title' => 'Large Dashboard',
|
|
'description' => 'A dashboard with many components for testing gzip compression',
|
|
],
|
|
'data_sources' => [],
|
|
'components' => $components,
|
|
],
|
|
]);
|
|
|
|
// Act: gzip 지원 헤더와 함께 요청
|
|
$response = $this->withHeaders([
|
|
'Accept-Encoding' => 'gzip, deflate, br',
|
|
])->get("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 응답 성공 및 gzip 압축 확인
|
|
$response->assertStatus(200);
|
|
$this->assertEquals('gzip', $response->headers->get('Content-Encoding'));
|
|
|
|
// 압축된 데이터가 gzip 매직 넘버로 시작하는지 확인
|
|
$content = $response->getContent();
|
|
$this->assertStringStartsWith("\x1f\x8b", $content);
|
|
|
|
// 압축 해제 후 JSON 검증
|
|
$decompressed = gzdecode($content);
|
|
$this->assertNotFalse($decompressed);
|
|
|
|
$jsonData = json_decode($decompressed, true);
|
|
$this->assertTrue($jsonData['success']);
|
|
$this->assertArrayHasKey('data', $jsonData);
|
|
$this->assertCount(50, $jsonData['data']['components']);
|
|
}
|
|
|
|
/**
|
|
* Accept-Encoding 헤더가 없으면 압축하지 않아야 합니다.
|
|
*/
|
|
public function test_does_not_compress_layout_when_client_does_not_accept_gzip(): void
|
|
{
|
|
// Arrange: 대용량 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$components = [];
|
|
for ($i = 0; $i < 50; $i++) {
|
|
$components[] = [
|
|
'type' => 'div',
|
|
'props' => ['class' => "component-{$i}"],
|
|
'children' => ["Content {$i}"],
|
|
];
|
|
}
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'large-dashboard',
|
|
'content' => [
|
|
'meta' => ['title' => 'Large Dashboard'],
|
|
'data_sources' => [],
|
|
'components' => $components,
|
|
],
|
|
]);
|
|
|
|
// Act: Accept-Encoding 헤더 없이 요청
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 압축되지 않은 응답
|
|
$response->assertStatus(200);
|
|
$this->assertNull($response->headers->get('Content-Encoding'));
|
|
|
|
// JSON 직접 파싱 가능
|
|
$response->assertJson(['success' => true]);
|
|
$this->assertCount(50, $response->json('data.components'));
|
|
}
|
|
|
|
/**
|
|
* 작은 레이아웃은 압축하지 않아야 합니다 (1KB 미만).
|
|
*/
|
|
public function test_does_not_compress_small_layout(): void
|
|
{
|
|
// Arrange: 작은 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'tiny',
|
|
'content' => [
|
|
'meta' => [],
|
|
'data_sources' => [],
|
|
'components' => [],
|
|
],
|
|
]);
|
|
|
|
// Act: gzip 지원 헤더와 함께 요청
|
|
$response = $this->withHeaders([
|
|
'Accept-Encoding' => 'gzip',
|
|
])->get("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
// Assert: 작은 응답은 압축되지 않음
|
|
$response->assertStatus(200);
|
|
$this->assertNull($response->headers->get('Content-Encoding'));
|
|
}
|
|
|
|
/**
|
|
* gzip 압축 시 압축 효율성 테스트
|
|
*/
|
|
public function test_gzip_compression_reduces_response_size(): void
|
|
{
|
|
// Arrange: 대용량 레이아웃 생성
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$components = [];
|
|
for ($i = 0; $i < 100; $i++) {
|
|
$components[] = [
|
|
'type' => 'div',
|
|
'props' => ['class' => "component-{$i}", 'id' => "id-{$i}"],
|
|
'children' => [
|
|
[
|
|
'type' => 'span',
|
|
'props' => ['class' => 'text'],
|
|
'children' => [str_repeat("Content for item {$i}. ", 5)],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'compression-test',
|
|
'content' => [
|
|
'meta' => ['title' => 'Compression Test'],
|
|
'data_sources' => [],
|
|
'components' => $components,
|
|
],
|
|
]);
|
|
|
|
// Act: 압축 없이 요청
|
|
$responseWithoutGzip = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
$originalSize = strlen($responseWithoutGzip->getContent());
|
|
|
|
// Act: 압축 요청
|
|
$responseWithGzip = $this->withHeaders([
|
|
'Accept-Encoding' => 'gzip',
|
|
])->get("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
$compressedSize = strlen($responseWithGzip->getContent());
|
|
|
|
// Assert: 압축 후 크기가 50% 이상 감소
|
|
$this->assertLessThan($originalSize * 0.5, $compressedSize);
|
|
}
|
|
|
|
/**
|
|
* 공개 서빙 응답에서 개발자용 comment / _comment 필드가 제거되어야 합니다.
|
|
*
|
|
* 레이아웃 JSON 의 comment/_comment 는 편집기·개발자용 주석으로 런타임 렌더러가
|
|
* 사용하지 않으며, 공개 응답 크기를 키우므로 서빙 시 재귀적으로 제거된다.
|
|
*/
|
|
public function test_strips_developer_comments_from_public_serving(): void
|
|
{
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'commented',
|
|
'content' => [
|
|
'comment' => '레이아웃 최상단 개발자 주석',
|
|
'meta' => ['title' => 'Commented Layout'],
|
|
'data_sources' => [
|
|
[
|
|
'_comment' => '데이터소스 설명 주석',
|
|
'id' => 'stats',
|
|
'type' => 'api',
|
|
'endpoint' => '/api/stats',
|
|
],
|
|
],
|
|
'components' => [
|
|
[
|
|
'comment' => '컴포넌트 설명 주석',
|
|
'type' => 'div',
|
|
'props' => ['class' => 'container'],
|
|
'children' => [
|
|
[
|
|
'_comment' => '중첩 자식 주석',
|
|
'type' => 'span',
|
|
'props' => [],
|
|
'children' => ['Hello'],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
// 응답 본문 어디에도 comment / _comment 키가 없어야 함
|
|
$body = $response->getContent();
|
|
$this->assertStringNotContainsString('"comment"', $body);
|
|
$this->assertStringNotContainsString('"_comment"', $body);
|
|
|
|
// 실제 콘텐츠(컴포넌트 구조)는 보존
|
|
$data = $response->json('data');
|
|
$this->assertArrayNotHasKey('comment', $data);
|
|
$this->assertSame('div', $data['components'][0]['type']);
|
|
$this->assertArrayNotHasKey('comment', $data['components'][0]);
|
|
$this->assertArrayNotHasKey('_comment', $data['components'][0]['children'][0]);
|
|
$this->assertArrayNotHasKey('_comment', $data['data_sources'][0]);
|
|
}
|
|
|
|
/**
|
|
* 한글이 포함된 응답이 \uXXXX 이스케이프 없이 raw UTF-8 로 직렬화되어야 합니다.
|
|
*
|
|
* JSON_UNESCAPED_UNICODE 적용으로 멀티바이트 문자의 전송 크기를 줄인다.
|
|
*/
|
|
public function test_serves_korean_as_unescaped_utf8(): void
|
|
{
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'korean',
|
|
'content' => [
|
|
'meta' => ['title' => '한글 제목'],
|
|
'data_sources' => [],
|
|
'components' => [
|
|
[
|
|
'type' => 'span',
|
|
'props' => [],
|
|
'children' => ['안녕하세요'],
|
|
],
|
|
],
|
|
],
|
|
]);
|
|
|
|
// 압축 없이 raw 본문 검사 (Accept-Encoding 미지정)
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
$response->assertStatus(200);
|
|
|
|
$body = $response->getContent();
|
|
|
|
// raw UTF-8 한글이 그대로 실려야 함
|
|
$this->assertStringContainsString('한글 제목', $body);
|
|
$this->assertStringContainsString('안녕하세요', $body);
|
|
|
|
// \uXXXX 이스케이프 형태가 아니어야 함 (예: '한' = 한)
|
|
$this->assertStringNotContainsString('\\ud55c', $body);
|
|
|
|
// 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지)
|
|
$this->assertMatchesRegularExpression('/[가-힣]/u', $body);
|
|
}
|
|
|
|
/**
|
|
* 프로덕션에서 레이아웃 응답은 공개 캐시 헤더를 유지한다 (#122 작업 D — successWithCache 환경 분기 동반 효과)
|
|
*
|
|
* @effects fallback_api_serves_etag_304
|
|
*/
|
|
public function test_layout_has_public_cache_headers_in_production(): void
|
|
{
|
|
app()['env'] = 'production';
|
|
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => ['meta' => [], 'data_sources' => [], 'components' => []],
|
|
]);
|
|
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
$response->assertStatus(200);
|
|
$this->assertNotNull($response->headers->get('ETag'));
|
|
$cacheControl = (string) $response->headers->get('Cache-Control');
|
|
$this->assertStringContainsString('public', $cacheControl);
|
|
$this->assertStringContainsString('max-age=3600', $cacheControl);
|
|
}
|
|
|
|
/**
|
|
* 개발 환경에서 레이아웃 응답은 no-cache (dev 레이아웃 반영성 — #122 작업 D 환경 분기)
|
|
*
|
|
* @effects fallback_api_no_cache_in_dev
|
|
*/
|
|
public function test_layout_no_cache_in_development(): void
|
|
{
|
|
app()['env'] = 'local';
|
|
|
|
$template = Template::create([
|
|
'identifier' => 'sirsoft-admin_basic',
|
|
'vendor' => 'sirsoft',
|
|
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
'version' => '1.0.0',
|
|
'type' => 'admin',
|
|
'status' => ExtensionStatus::Active->value,
|
|
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
|
|
]);
|
|
|
|
$layout = TemplateLayout::create([
|
|
'template_id' => $template->id,
|
|
'name' => 'dashboard',
|
|
'content' => ['meta' => [], 'data_sources' => [], 'components' => []],
|
|
]);
|
|
|
|
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
|
|
|
|
$response->assertStatus(200);
|
|
$cacheControl = (string) $response->headers->get('Cache-Control');
|
|
$this->assertStringContainsString('no-cache', $cacheControl);
|
|
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
|
|
}
|
|
}
|