https://github.com/gnuboard/g7/issues/128 (제보자 glitter-gim) 과 그 부수의무 전수조사에서 확정한 결함군을 닫는다. ## 디버그 라우트 게이트 (F1~F5) 게이트가 핸들러 안에 흩어져 있어 8개 라우트 중 3개에만 붙어 있었다. 빠진 쪽에 `File::cleanDirectory(storage/debug-dump)` 를 수행하는 `DELETE clear` 가 있었고, production·APP_DEBUG=false 에서도 미인증 200 으로 덤프 전체가 지워졌다. GET 4종은 User SPA catch-all 이 등록 순서상 앞서 가려 주고 있었을 뿐이라, 보호를 우연에 맡긴 구조였다. 게이트를 `bootstrap/app.php` 의 devtools 래퍼 한 곳(`debug.gate` 그룹 미들웨어)으로 올린다. 새 라우트는 자동으로 덮이고, 부착 지점이 하나라 훼손이 곧바로 드러난다. `withRouting(channels:)` 인자가 함께 등록하던 게이트 없는 `/broadcasting/auth` 는 제거하고, 채널 정의 로드만 `BroadcastServiceProvider` 로 옮겨 웹소켓 킬스위치 우회로를 없앤다. catch-all 제외 패턴에는 예약 프리픽스를 전수 추가했다. ## 확장 자산 CSS 상대 참조 (범위 추가 — 결정) `asset_url_mode=extensionless` 에서 자산 URL 이 쿼리 형태가 되면 브라우저의 기준 디렉토리가 `/api/{타입}/assets/` 로 잡혀, CSS 안의 `url('./woff2/f.woff2')` 가 존재하지 않는 주소가 된다. 사용자 템플릿 웹폰트 1건과 관리자 템플릿 국기 아이콘 약 500건이 이 상태였다. 병합 CSS 번들은 더 나빴다 — 상대 참조를 가진 확장을 번들에서 통째로 제외하고 있었는데, 번들 URL 이 내려오면 프론트는 개별 로딩을 아예 타지 않으므로 제외는 곧 그 확장 스타일이 하나도 적용되지 않음을 뜻했다. 둘 다 서빙 시점에 절대 자산 URL 로 치환한다. 최종 URL 은 런타임 모드와 캐시 버전이 정하므로 빌드 시점에는 알 수 없고, 동봉 자산은 제3자 산출물이라 원본을 손대면 상류 갱신마다 재작업이 된다. 개별 서빙 3경로와 병합 번들이 같은 해석 규칙을 공유한다 — 갈라지면 한쪽만 고쳐진 채 남는다. 이 결함군은 공통적으로 예외도 서버 로그 흔적도 남기지 않는다. 열린 엔드포인트가 정상 응답하는 것, 404 로 글꼴이 기본 서체가 되는 것이 유일한 증상이다. ## 회귀 잠금 되돌림 red 를 모집단 전원에 대해 실측했다 — 그룹 미들웨어 제거, 개별 게이트 재주입, `channels:` 복원, catch-all 예외 제거, 컨트롤러 3원의 `fileResponse` 회귀, 번들 치환 제거가 각각 어느 테스트를 red 로 만드는지 확인하고 원상 복구했다. 게이트 부착 축은 행위 테스트로 대체할 수 없다 — 가려진 라우트는 행위상 "막힌 것" 과 구분되지 않으므로, `gatherMiddleware` 를 직접 보는 등록 계약 테스트를 둔다. 치환 배선도 같은 이유로 라우트 이름 규약에서 모집단을 도출하는 계약 검사를 둔다(손으로 적은 목록은 네 번째 경로가 생기는 순간 조용히 낡는다).
549 lines
18 KiB
PHP
549 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Module;
|
|
|
|
use App\Enums\ExtensionStatus;
|
|
use App\Models\Module;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ModuleAssetServingTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private Module $activeModule;
|
|
|
|
private string $testModulePath;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// 테스트용 활성화된 모듈 생성
|
|
$this->activeModule = Module::factory()->create([
|
|
'identifier' => 'test-module',
|
|
'status' => ExtensionStatus::Active->value,
|
|
]);
|
|
|
|
// 테스트용 모듈 디렉토리 생성
|
|
$this->testModulePath = base_path('modules/test-module');
|
|
if (! file_exists($this->testModulePath.'/dist/js')) {
|
|
mkdir($this->testModulePath.'/dist/js', 0755, true);
|
|
}
|
|
if (! file_exists($this->testModulePath.'/dist/css')) {
|
|
mkdir($this->testModulePath.'/dist/css', 0755, true);
|
|
}
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// 테스트용 파일 및 디렉토리 정리
|
|
if (file_exists($this->testModulePath)) {
|
|
$this->deleteDirectory($this->testModulePath);
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
/**
|
|
* 디렉토리 재귀 삭제 헬퍼 메서드
|
|
*/
|
|
private function deleteDirectory(string $dir): bool
|
|
{
|
|
if (! file_exists($dir)) {
|
|
return true;
|
|
}
|
|
|
|
if (! is_dir($dir)) {
|
|
return unlink($dir);
|
|
}
|
|
|
|
foreach (scandir($dir) as $item) {
|
|
if ($item == '.' || $item == '..') {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return rmdir($dir);
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모듈의 JS 파일 서빙 성공
|
|
*/
|
|
public function test_serves_js_file_from_active_module(): void
|
|
{
|
|
// Arrange
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test module");');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$response->assertHeader('Content-Type', 'application/javascript');
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모듈의 CSS 파일 서빙 성공
|
|
*/
|
|
public function test_serves_css_file_from_active_module(): void
|
|
{
|
|
// Arrange
|
|
$cssPath = $this->testModulePath.'/dist/css/module.css';
|
|
file_put_contents($cssPath, '.module { color: blue; }');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/css/module.css');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$this->assertTrue(
|
|
str_starts_with($response->headers->get('Content-Type'), 'text/css'),
|
|
'Content-Type should start with text/css'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 비활성화 모듈 접근 시 404 반환
|
|
*/
|
|
public function test_returns_404_for_inactive_module(): void
|
|
{
|
|
// Arrange
|
|
Module::factory()->create([
|
|
'identifier' => 'inactive-module',
|
|
'status' => ExtensionStatus::Inactive->value,
|
|
]);
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/inactive-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
// 검증 실패로 인한 리다이렉트 또는 404 허용
|
|
$this->assertContains($response->status(), [302, 404]);
|
|
}
|
|
|
|
/**
|
|
* 존재하지 않는 파일 접근 시 404 반환
|
|
*/
|
|
public function test_returns_404_for_nonexistent_file(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/nonexistent.js');
|
|
|
|
// Assert
|
|
// 검증 실패로 인한 리다이렉트 또는 404 허용
|
|
$this->assertContains($response->status(), [302, 404]);
|
|
}
|
|
|
|
/**
|
|
* Path Traversal 공격 차단 - 기본 패턴 (../)
|
|
*/
|
|
public function test_blocks_path_traversal_attack(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/../../.env');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* Path Traversal 공격 차단 - 백슬래시 패턴 (..\)
|
|
*/
|
|
public function test_blocks_path_traversal_with_backslash(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/..%5c..%5cconfig.php');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* Path Traversal 공격 차단 - URL 인코딩 패턴 (%2e%2e%2f)
|
|
*/
|
|
public function test_blocks_url_encoded_path_traversal(): void
|
|
{
|
|
$patterns = [
|
|
'%2e%2e%2f',
|
|
'%2e%2e/',
|
|
'%2e%2e%5c',
|
|
'..%2f',
|
|
'..%5c',
|
|
'.%2e/',
|
|
];
|
|
|
|
foreach ($patterns as $pattern) {
|
|
$response = $this->get("/api/modules/assets/test-module/{$pattern}secret.txt");
|
|
$this->assertContains($response->status(), [302, 422], "Pattern {$pattern} should be blocked");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 절대 경로 차단 - Windows 경로
|
|
*/
|
|
public function test_blocks_windows_absolute_path(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/C:%5cWindows%5cSystem32%5cconfig.ini');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* 절대 경로 차단 - Linux 경로
|
|
*/
|
|
public function test_blocks_linux_absolute_path(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module//etc/passwd');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* NULL 바이트 공격 차단
|
|
*/
|
|
public function test_blocks_null_byte_attack(): void
|
|
{
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/malicious.php%00.js');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* 불허 파일 타입 차단 - PHP
|
|
*/
|
|
public function test_blocks_disallowed_file_types(): void
|
|
{
|
|
// Arrange
|
|
$phpPath = $this->testModulePath.'/malicious.php';
|
|
file_put_contents($phpPath, '<?php echo "hack"; ?>');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/malicious.php');
|
|
|
|
// Assert
|
|
$this->assertContains($response->status(), [302, 422]);
|
|
}
|
|
|
|
/**
|
|
* 불허 파일 타입 차단 - 실행 파일들
|
|
*/
|
|
public function test_blocks_various_disallowed_file_types(): void
|
|
{
|
|
$disallowedExtensions = ['exe', 'sh', 'bat', 'dll', 'so', 'py', 'rb', 'pl', 'asp', 'aspx', 'jsp', 'cgi'];
|
|
|
|
foreach ($disallowedExtensions as $ext) {
|
|
$response = $this->get("/api/modules/assets/test-module/malicious.{$ext}");
|
|
$this->assertContains($response->status(), [302, 422], "Extension .{$ext} should be blocked");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 허용된 파일 타입 확인
|
|
*/
|
|
public function test_allows_all_whitelisted_file_types(): void
|
|
{
|
|
$allowedFiles = [
|
|
'app.js' => 'application/javascript',
|
|
'style.css' => 'text/css',
|
|
'data.json' => 'application/json',
|
|
'image.png' => 'image/png',
|
|
'photo.jpg' => 'image/jpeg',
|
|
'icon.svg' => 'image/svg+xml',
|
|
'font.woff' => 'font/woff',
|
|
'font.woff2' => 'font/woff2',
|
|
];
|
|
|
|
foreach ($allowedFiles as $filename => $expectedMimeType) {
|
|
$filePath = $this->testModulePath.'/dist/'.$filename;
|
|
file_put_contents($filePath, 'test content');
|
|
|
|
$response = $this->get("/api/modules/assets/test-module/dist/{$filename}");
|
|
$response->assertStatus(200, "File {$filename} should be allowed");
|
|
|
|
$actualMimeType = $response->headers->get('Content-Type');
|
|
if ($filename === 'style.css') {
|
|
$this->assertTrue(
|
|
str_starts_with($actualMimeType, $expectedMimeType),
|
|
"MIME type for {$filename} should start with {$expectedMimeType}"
|
|
);
|
|
} else {
|
|
$this->assertEquals(
|
|
$expectedMimeType,
|
|
$actualMimeType,
|
|
"MIME type for {$filename} should be {$expectedMimeType}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐싱 헤더 정상 설정
|
|
*/
|
|
public function test_sets_caching_headers(): void
|
|
{
|
|
// Arrange
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test");');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$cacheControl = $response->headers->get('Cache-Control');
|
|
$this->assertNotNull($cacheControl, 'Cache-Control header should be present');
|
|
}
|
|
|
|
/**
|
|
* ETag 헤더 생성 확인
|
|
*/
|
|
public function test_generates_etag_header(): void
|
|
{
|
|
// Arrange
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test");');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$etag = $response->headers->get('ETag');
|
|
$this->assertNotNull($etag, 'ETag header should be present');
|
|
$this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $etag, 'ETag should be a 32-character MD5 hash');
|
|
}
|
|
|
|
/**
|
|
* ETag 매칭 시 304 Not Modified 응답
|
|
*/
|
|
public function test_returns_304_when_etag_matches(): void
|
|
{
|
|
// Arrange
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test");');
|
|
|
|
$firstResponse = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
$etag = $firstResponse->headers->get('ETag');
|
|
|
|
// Act
|
|
$response = $this->withHeaders([
|
|
'If-None-Match' => $etag,
|
|
])->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(304);
|
|
$this->assertEmpty($response->getContent(), '304 response should have empty body');
|
|
}
|
|
|
|
/**
|
|
* 프로덕션 환경에서 immutable 캐싱 정책 적용
|
|
*/
|
|
public function test_applies_immutable_caching_in_production(): void
|
|
{
|
|
// Arrange
|
|
app()['env'] = 'production';
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test");');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$cacheControl = $response->headers->get('Cache-Control');
|
|
$this->assertStringContainsString('immutable', $cacheControl, 'Production should use immutable caching');
|
|
$this->assertStringContainsString('public', $cacheControl);
|
|
$this->assertStringContainsString('max-age=31536000', $cacheControl);
|
|
}
|
|
|
|
/**
|
|
* 개발 환경에서 no-cache 정책 적용
|
|
*/
|
|
public function test_applies_no_cache_in_development(): void
|
|
{
|
|
// Arrange
|
|
app()['env'] = 'local';
|
|
$jsPath = $this->testModulePath.'/dist/js/module.iife.js';
|
|
file_put_contents($jsPath, 'console.log("test");');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$cacheControl = $response->headers->get('Cache-Control');
|
|
$this->assertStringContainsString('no-cache', $cacheControl, 'Development should use no-cache');
|
|
}
|
|
|
|
/**
|
|
* 중첩 경로 처리 테스트
|
|
*/
|
|
public function test_handles_nested_path(): void
|
|
{
|
|
// Arrange
|
|
$nestedPath = $this->testModulePath.'/dist/js/components/Button.js';
|
|
mkdir(dirname($nestedPath), 0755, true);
|
|
file_put_contents($nestedPath, 'export default Button;');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/dist/js/components/Button.js');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$response->assertHeader('Content-Type', 'application/javascript');
|
|
}
|
|
|
|
/**
|
|
* Source Map 파일 서빙 거부 테스트
|
|
*
|
|
* 소스맵은 원본 코드 전문을 담고 있어 배포 환경에서 노출되면 안 된다.
|
|
* 파일이 디스크에 실재하더라도(= 파일 부재로 인한 실패가 아님) 허용 확장자
|
|
* 목록에서 제외되어 서빙이 거부되어야 한다.
|
|
*/
|
|
public function test_rejects_sourcemap_file(): void
|
|
{
|
|
// Arrange: 맵 파일이 실제로 존재하는 상태를 만든다
|
|
$mapPath = $this->testModulePath.'/dist/js/module.iife.js.map';
|
|
file_put_contents($mapPath, '{"version":3,"sources":[],"mappings":""}');
|
|
$this->assertFileExists($mapPath);
|
|
|
|
// Act: JSON 클라이언트 (에러 본문 확인용)
|
|
$json = $this->getJson('/api/modules/assets/test-module/dist/js/module.iife.js.map');
|
|
|
|
// Assert: 허용 확장자 검증에서 거부 — 맵 내용이 절대 응답에 실리지 않는다
|
|
$json->assertStatus(422);
|
|
$json->assertJsonValidationErrors('path');
|
|
$this->assertStringNotContainsString('mappings', $json->getContent());
|
|
|
|
// Act: 브라우저 요청 (Accept 헤더 없음) — 서빙되지 않음을 확인
|
|
$browser = $this->get('/api/modules/assets/test-module/dist/js/module.iife.js.map');
|
|
|
|
// Assert: 200 서빙이 아니어야 한다 (검증 실패 → 리다이렉트)
|
|
$this->assertNotSame(200, $browser->getStatusCode());
|
|
}
|
|
|
|
/**
|
|
* 운영자가 넣은 `custom/` 자산도 서빙된다.
|
|
*
|
|
* 모듈 자산 경로는 확장 루트 기준이라 `custom/` 이 별도 분기 없이 해석된다
|
|
* (템플릿만 `dist/` 하드코딩이라 분기가 필요했다). 그 사실을 실제 HTTP 로 고정한다 —
|
|
* 서비스 반환값만 보면 라우트·FormRequest 게이트를 통과하는지가 남는다.
|
|
*
|
|
* @scenario custom_source=convention_scan, custom_asset=css
|
|
*
|
|
* @effects custom_asset_loaded_after_extension_bundles
|
|
*/
|
|
public function test_serves_operator_custom_css_from_active_module(): void
|
|
{
|
|
// Arrange
|
|
mkdir($this->testModulePath.'/custom', 0755, true);
|
|
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/custom/custom.css');
|
|
|
|
// Assert
|
|
$response->assertStatus(200);
|
|
$this->assertTrue(
|
|
str_starts_with($response->headers->get('Content-Type'), 'text/css'),
|
|
'Content-Type should start with text/css'
|
|
);
|
|
|
|
// dist/ 의 동명 파일이 아니라 custom/ 의 파일이어야 한다 — 내용으로 확인한다.
|
|
// CSS 응답은 상대 참조 치환을 거치므로 본문이 버퍼에 실린다(BinaryFileResponse 아님).
|
|
// 파일 경로 대신 내용을 보는 편이 "무엇이 나갔는가" 를 직접 재는 것이기도 하다.
|
|
$this->assertStringContainsString(
|
|
'.operator { color: red; }',
|
|
$response->getContent(),
|
|
'custom/ 의 파일이 서빙되어야 한다'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 비활성 모듈의 `custom/` 은 서빙되지 않는다.
|
|
*
|
|
* 비활성 확장의 자산이 계속 응답하면 "껐는데 스타일이 남아 있다" 가 된다.
|
|
*
|
|
* @scenario custom_source=convention_scan
|
|
*
|
|
* @effects inactive_extension_custom_not_served
|
|
*/
|
|
public function test_returns_404_for_custom_asset_of_inactive_module(): void
|
|
{
|
|
// Arrange
|
|
mkdir($this->testModulePath.'/custom', 0755, true);
|
|
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
|
|
|
|
$this->activeModule->update(['status' => ExtensionStatus::Inactive->value]);
|
|
|
|
// Act
|
|
$response = $this->get('/api/modules/assets/test-module/custom/custom.css');
|
|
|
|
// Assert
|
|
$response->assertStatus(404);
|
|
}
|
|
|
|
/**
|
|
* `custom/` 도 확장자 화이트리스트를 그대로 받는다 — 신규 확장자를 열지 않는다.
|
|
*
|
|
* @scenario custom_source=convention_scan, custom_asset=static_file
|
|
*
|
|
* @effects custom_disallowed_extension_blocked
|
|
*/
|
|
public function test_blocks_disallowed_file_type_under_custom(): void
|
|
{
|
|
// Arrange
|
|
mkdir($this->testModulePath.'/custom', 0755, true);
|
|
file_put_contents($this->testModulePath.'/custom/notes.txt', 'not an asset');
|
|
|
|
// Act
|
|
$response = $this->getJson('/api/modules/assets/test-module/custom/notes.txt');
|
|
|
|
// Assert: FormRequest 의 확장자 게이트가 거부한다 (422)
|
|
$response->assertStatus(422);
|
|
$response->assertJsonValidationErrors('path');
|
|
$this->assertStringNotContainsString('not an asset', $response->getContent());
|
|
}
|
|
|
|
/**
|
|
* `custom/` 자산도 ETag 로 304 를 돌려준다 — 운영자 파일이라고 캐시 계약이 다르지 않다.
|
|
*
|
|
* @scenario custom_source=convention_scan, custom_asset=css
|
|
*
|
|
* @effects custom_asset_url_busts_on_file_change
|
|
*/
|
|
public function test_returns_304_for_custom_asset_when_etag_matches(): void
|
|
{
|
|
// Arrange
|
|
mkdir($this->testModulePath.'/custom', 0755, true);
|
|
file_put_contents($this->testModulePath.'/custom/custom.css', '.operator { color: red; }');
|
|
|
|
$first = $this->get('/api/modules/assets/test-module/custom/custom.css');
|
|
$first->assertStatus(200);
|
|
|
|
$etag = $first->headers->get('ETag');
|
|
$this->assertNotNull($etag, 'custom 자산에도 ETag 가 있어야 한다');
|
|
|
|
// Act
|
|
$second = $this->get(
|
|
'/api/modules/assets/test-module/custom/custom.css',
|
|
['If-None-Match' => $etag]
|
|
);
|
|
|
|
// Assert
|
|
$second->assertStatus(304);
|
|
}
|
|
}
|