Files
Gnuboard7/tests/Feature/Api/Admin/TemplateLayoutAttachmentControllerTest.php
T
HeuJung 5c2e42c133 fix(core): 레이아웃 첨부 URL 이 항상 프록시로 발급되던 결함 수정
공개 자산 스토리지를 설정해도 레이아웃 배경 이미지가 매번 오리진 PHP 를
거쳐 서빙되던 결함을 고친다 (https://github.com/gnuboard/g7/issues/134).
판정 기준은 운영자가 명시 선언한 core.storage.public_asset_disk 와 행 disk
의 일치다 — 디스크의 url 설정 유무로 판정하면 비공개 버킷에 공개 URL 이
설정된 구성에서 발급된 주소가 403 이 되어 그 이미지가 전부 깨진다.

전수조사에서 함께 처리한 것: 게시판 첨부 응답 url 칸(항상 null → 게이트가
살아 있는 서빙 URL), 페이지의 미배선 메서드 삭제, 확장 카테고리별 저장소
URL·경로 조회 교정, 그리고 이번 변경이 새로 만드는 고아 disk 위험 방어
(무인증 공개 서빙 라우트가 500 이 되는 것을 404 로 degrade).

문서화 하네스도 함께 보강한다. 실측을 수행하고도 이력 문서에는 판정만 남기면
대화 로그가 사라진 뒤 아무에게도 도달하지 않는데, 그 강제가 보고 형식에만
있었다.
2026-09-08 12:54:29 +09:00

426 lines
18 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionStatus;
use App\Extension\HookManager;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Template;
use App\Models\TemplateLayoutAttachment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* 템플릿 레이아웃 첨부 파일 컨트롤러 Feature 테스트
*
* golden path(업로드/조회/삭제) + 권한 경계 + 유효성 실패 + 스토리지 실삭제 검증.
*/
class TemplateLayoutAttachmentControllerTest extends TestCase
{
use RefreshDatabase;
// 같은 스위트의 다른 레이아웃 테스트(LayoutSourceMetaServingTest 등)가
// GDPR 플러그인 미들웨어(CookieConsentMiddleware → g7_gdpr_policy_versions)를
// 모든 요청에서 거치므로, 공유 migrate:fresh 가 실행 순서와 무관하게 GDPR 테이블을
// 포함하도록 동일 확장을 선언한다 (migrate:fresh 1회 함정 회피).
protected array $requiredExtensions = [
'plugins/sirsoft-gdpr',
];
private User $adminUser;
private string $adminToken;
private Template $template;
protected function setUp(): void
{
parent::setUp();
Storage::fake(config('attachment.disk', 'attachments'));
$editPermission = 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([$editPermission->id]);
$this->adminUser = User::factory()->create();
$this->adminUser->roles()->syncWithoutDetaching([$role->id]);
$this->adminToken = $this->adminUser->createToken('admin')->plainTextToken;
$this->template = Template::create([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본', 'en' => 'Basic'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본', 'en' => 'Basic'],
]);
}
/** 인증 헤더 헬퍼 */
private function authHeaders(): array
{
return ['Authorization' => "Bearer {$this->adminToken}", 'Accept' => 'application/json'];
}
public function test_upload_creates_attachment_and_stores_file(): void
{
$file = UploadedFile::fake()->image('bg.png', 100, 100);
$response = $this->withHeaders($this->authHeaders())
->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => $file,
'layout_name' => 'home',
]);
$response->assertStatus(200)
->assertJson(['success' => true])
->assertJsonPath('data.original_name', 'bg.png')
->assertJsonPath('data.layout_name', 'home');
// url 은 공개 서빙 라우트를 가리켜야 한다
$url = $response->json('data.url');
$this->assertNotNull($url);
$this->assertStringContainsString('/layout-attachments/', (string) $url);
$this->assertStringContainsString('/file', (string) $url);
$attachment = TemplateLayoutAttachment::first();
$this->assertNotNull($attachment);
$this->assertSame($this->template->id, $attachment->template_id);
$this->assertSame($this->adminUser->id, $attachment->created_by);
// 스토리지에 실제 저장됐는지 (category/path)
Storage::disk($attachment->disk)->assertExists('template-layout-attachments/'.$attachment->path);
}
public function test_upload_fires_hooks(): void
{
// Arrange
$beforeUploadFired = false;
$afterUploadFired = false;
$filterApplied = false;
HookManager::addAction('core.template_layout_attachment.before_upload', function () use (&$beforeUploadFired) {
$beforeUploadFired = true;
});
HookManager::addFilter('core.template_layout_attachment.filter_upload_file', function ($file) use (&$filterApplied) {
$filterApplied = true;
return $file;
});
HookManager::addAction('core.template_layout_attachment.after_upload', function () use (&$afterUploadFired) {
$afterUploadFired = true;
});
// Act
$response = $this->withHeaders($this->authHeaders())
->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => UploadedFile::fake()->image('hooked.png', 10, 10),
'layout_name' => 'home',
]);
// Assert
$response->assertStatus(200)->assertJson(['success' => true]);
$this->assertTrue($beforeUploadFired, 'before_upload hook should be fired');
$this->assertTrue($filterApplied, 'filter_upload_file hook should be applied');
$this->assertTrue($afterUploadFired, 'after_upload hook should be fired');
// Cleanup hooks
HookManager::clearAction('core.template_layout_attachment.before_upload');
HookManager::clearFilter('core.template_layout_attachment.filter_upload_file');
HookManager::clearAction('core.template_layout_attachment.after_upload');
}
public function test_index_lists_template_attachments(): void
{
TemplateLayoutAttachment::create([
'template_id' => $this->template->id,
'layout_name' => 'home',
'disk' => config('attachment.disk', 'attachments'),
'path' => 'sirsoft-basic/2026/05/29/a.png',
'original_name' => 'a.png',
'mime_type' => 'image/png',
'size' => 123,
'created_by' => $this->adminUser->id,
]);
$response = $this->withHeaders($this->authHeaders())
->getJson("/api/admin/templates/{$this->template->identifier}/layout-attachments");
$response->assertStatus(200)
->assertJson(['success' => true])
->assertJsonPath('data.0.original_name', 'a.png');
}
public function test_index_filters_by_layout_name(): void
{
foreach (['home', 'about'] as $layout) {
TemplateLayoutAttachment::create([
'template_id' => $this->template->id,
'layout_name' => $layout,
'disk' => 'attachments',
'path' => "p/{$layout}.png",
'original_name' => "{$layout}.png",
'mime_type' => 'image/png',
'size' => 1,
'created_by' => $this->adminUser->id,
]);
}
$response = $this->withHeaders($this->authHeaders())
->getJson("/api/admin/templates/{$this->template->identifier}/layout-attachments?layout_name=home");
$response->assertStatus(200);
$this->assertCount(1, $response->json('data'));
$this->assertSame('home.png', $response->json('data.0.original_name'));
}
public function test_destroy_removes_row_and_storage_file(): void
{
$disk = config('attachment.disk', 'attachments');
$path = 'sirsoft-basic/2026/05/29/del.png';
Storage::disk($disk)->put('template-layout-attachments/'.$path, 'data');
$attachment = TemplateLayoutAttachment::create([
'template_id' => $this->template->id,
'layout_name' => 'home',
'disk' => $disk,
'path' => $path,
'original_name' => 'del.png',
'mime_type' => 'image/png',
'size' => 4,
'created_by' => $this->adminUser->id,
]);
$response = $this->withHeaders($this->authHeaders())
->deleteJson("/api/admin/templates/layout-attachments/{$attachment->id}");
$response->assertStatus(200)->assertJson(['success' => true]);
$this->assertNull(TemplateLayoutAttachment::find($attachment->id));
// 스토리지 파일도 명시적으로 삭제됐는지 (CASCADE 미의존)
Storage::disk($disk)->assertMissing('template-layout-attachments/'.$path);
}
public function test_upload_rejects_non_image_file(): void
{
$file = UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf');
$response = $this->withHeaders($this->authHeaders())
->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => $file,
]);
$response->assertStatus(422);
$this->assertDatabaseCount('template_layout_attachments', 0);
}
public function test_upload_requires_permission(): void
{
// 권한 없는 사용자
$noPermUser = User::factory()->create();
$token = $noPermUser->createToken('np')->plainTextToken;
$response = $this->withHeaders([
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
])->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => UploadedFile::fake()->image('x.png'),
]);
$response->assertStatus(403);
}
public function test_upload_to_nonexistent_template_returns_404(): void
{
$response = $this->withHeaders($this->authHeaders())
->postJson('/api/admin/templates/nope-nope/layout-attachments', [
'file' => UploadedFile::fake()->image('x.png'),
]);
$response->assertStatus(404);
}
// ─────────────────────────────────────────────────────────────────────────
// 공개 서빙 라우트 — 발행 배경 이미지 공개 로드
// ─────────────────────────────────────────────────────────────────────────
/** 업로드된 첨부를 생성하고 모델 반환 (서빙 테스트용 헬퍼) */
private function makeStoredAttachment(?Template $template = null, string $path = 'sirsoft-basic/2026/05/29/served.png'): TemplateLayoutAttachment
{
$template ??= $this->template;
$disk = config('attachment.disk', 'attachments');
Storage::disk($disk)->put('template-layout-attachments/'.$path, 'PNGDATA');
return TemplateLayoutAttachment::create([
'template_id' => $template->id,
'layout_name' => 'home',
'disk' => $disk,
'path' => $path,
'original_name' => 'served.png',
'mime_type' => 'image/png',
'size' => 7,
'created_by' => $this->adminUser->id,
]);
}
public function test_serve_file_returns_image_publicly_without_auth(): void
{
$attachment = $this->makeStoredAttachment();
// 인증 헤더 없이(공개) 접근 — 발행 배경은 방문자에게 로드되어야 함
$response = $this->get("/api/templates/{$this->template->identifier}/layout-attachments/{$attachment->id}/file");
$response->assertStatus(200);
$this->assertStringStartsWith('image/', (string) $response->headers->get('Content-Type'));
// 캐싱 헤더(ETag) 동반
$this->assertNotNull($response->headers->get('ETag'));
}
public function test_serve_file_cross_template_returns_404(): void
{
// 다른 템플릿 소속 첨부를 본 템플릿 경로로 요청 → 404 (교차 접근 차단)
$other = Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '관리자', 'en' => 'Admin'],
]);
$attachment = $this->makeStoredAttachment($other, 'sirsoft-admin_basic/x.png');
$response = $this->getJson("/api/templates/{$this->template->identifier}/layout-attachments/{$attachment->id}/file");
$response->assertStatus(404);
}
public function test_serve_file_missing_storage_returns_404(): void
{
// DB 행은 있으나 스토리지 파일이 없는 경우 → 404
$attachment = TemplateLayoutAttachment::create([
'template_id' => $this->template->id,
'layout_name' => 'home',
'disk' => config('attachment.disk', 'attachments'),
'path' => 'sirsoft-basic/missing.png',
'original_name' => 'missing.png',
'mime_type' => 'image/png',
'size' => 1,
'created_by' => $this->adminUser->id,
]);
$response = $this->getJson("/api/templates/{$this->template->identifier}/layout-attachments/{$attachment->id}/file");
$response->assertStatus(404);
}
/**
* 공개 자산 디스크로 쓸 가짜 CDN 디스크를 등록합니다.
*
* `Storage::fake()` 는 해석된 디스크 인스턴스만 교체하고 `filesystems.disks.*`
* config 는 건드리지 않는다. 공개 자산 디스크 게이트는 그 config 존재를 보므로,
* fake 만으로는 고아 디스크로 판정되어 프록시가 나온다.
*/
private function registerFakeCdnDisk(): void
{
config(['filesystems.disks.fake_cdn' => [
'driver' => 'local',
'root' => storage_path('framework/testing/disks/fake_cdn'),
'url' => 'https://cdn.test/assets',
]]);
Storage::fake('fake_cdn', ['url' => 'https://cdn.test/assets']);
}
// ─────────────────────────────────────────────────────────────────────────
// 공개 자산 디스크 — 업로드 저장 위치와 응답 URL 형태 (공개 #134)
// ─────────────────────────────────────────────────────────────────────────
/**
* 공개 자산 디스크 미설정이면 기존 첨부 디스크에 저장되고 프록시 URL 이어야 합니다.
*
* @scenario public_asset_disk=unset,row_disk=attachments,filter_url_hook=absent
*
* @effects upload_stores_on_public_disk, proxy_url_otherwise
*/
public function test_upload_without_public_asset_disk_uses_attachment_disk(): void
{
config(['core.storage.public_asset_disk' => '']);
$response = $this->withHeaders($this->authHeaders())
->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => UploadedFile::fake()->image('bg.png', 20, 20),
'layout_name' => 'home',
]);
$response->assertStatus(200);
$attachment = TemplateLayoutAttachment::first();
$this->assertSame(config('attachment.disk', 'attachments'), $attachment->disk);
$this->assertStringContainsString('/layout-attachments/', (string) $response->json('data.url'));
$this->assertStringContainsString('/file', (string) $response->json('data.url'));
}
/**
* 공개 자산 디스크가 선언되면 그 디스크에 저장되고 직접 URL 이 발급돼야 합니다.
*
* @scenario public_asset_disk=public,row_disk=public,filter_url_hook=absent
*
* @effects upload_stores_on_public_disk, direct_url_when_row_matches_public_disk
*/
public function test_upload_with_public_asset_disk_stores_there_and_returns_direct_url(): void
{
$this->registerFakeCdnDisk();
config(['core.storage.public_asset_disk' => 'fake_cdn']);
$response = $this->withHeaders($this->authHeaders())
->postJson("/api/admin/templates/{$this->template->identifier}/layout-attachments", [
'file' => UploadedFile::fake()->image('cdn.png', 20, 20),
'layout_name' => 'home',
]);
$response->assertStatus(200);
$attachment = TemplateLayoutAttachment::first();
$this->assertSame('fake_cdn', $attachment->disk);
Storage::disk('fake_cdn')->assertExists('template-layout-attachments/'.$attachment->path);
$this->assertStringStartsWith('https://cdn.test/assets', (string) $response->json('data.url'));
}
/**
* 공개 자산 디스크를 켜도 그 이전에 올라간(다른 disk) 행은 프록시를 유지해야 합니다.
*
* @scenario public_asset_disk=public,row_disk=attachments,filter_url_hook=absent
*
* @effects proxy_url_otherwise
*/
public function test_legacy_row_keeps_proxy_url_after_enabling_public_asset_disk(): void
{
$legacy = $this->makeStoredAttachment();
$this->registerFakeCdnDisk();
config(['core.storage.public_asset_disk' => 'fake_cdn']);
$response = $this->withHeaders($this->authHeaders())
->getJson("/api/admin/templates/{$this->template->identifier}/layout-attachments");
$response->assertStatus(200);
$url = (string) $response->json('data.0.url');
$this->assertStringContainsString("/layout-attachments/{$legacy->id}/file", $url);
$this->assertStringNotContainsString('cdn.test', $url);
}
}