https://github.com/gnuboard/g7/issues/125 — 상품 상세설명을 HTML 로 저장할 때 HTMLPurifier 가 모듈 vendor 폴더 안에 정의 캐시를 만들려다 실패해 저장이 매번 500 으로 끝나던 문제를 고친다. vendor 를 읽기 전용으로 두는 표준 배포에서 그 쓰기는 예외가 아니라 PHP 경고로 나오고 Laravel 이 이를 ErrorException 으로 승격시킨다. 캐시는 설정 해시당 1회만 기록되므로 캐시가 영영 생기지 않아 재시도해도 같은 결과였다. 캐시 경로를 storage 아래로 옮기고, 그 경로마저 확보하지 못하면 캐시만 끄고 정화는 그대로 수행한다 — 캐시는 성능 장치이고 정화는 보안 장치라, 전자의 실패가 후자를 건너뛰게 만들면 안 된다. 저장은 성공하므로 운영자에게 도달하는 흔적이 로그 하나뿐이라 error 수준으로 남긴다 (출하 기본 로그 수준이 error 라 warning 은 기본 설치 상태에서 파일에 남지 않는다). 그 과정에서 갈라져 있던 두 축을 코어 한 곳으로 모은다. - 쓰기 디렉토리 확보: 억제 생성·chmod·setgid·소유권 상속·쓰기 판정 절차가 정적 게시와 정의 캐시 두 곳에 서로 다른 하드닝으로 복제돼 있었다(억제 mkdir·setgid·clearstatcache 가 사본마다 한쪽씩 빠져 있었다). FilePermissionHelper 의 ensureWritableDirectory 와 hardenDirectory 로 통합하고, 실패 사유는 out 파라미터로 올려 정책(조용한 성능 저하 대 시끄러운 실패)은 호출부가 정하게 둔다. - 확장 저장 경로: storage_path('app/modules/…') 손조립이 30곳에 흩어져 있어 테스트 격리 분기를 넣으려면 사본마다 복제해야 했고, 한 곳만 빠뜨려도 그 확장의 테스트가 운영 설정 파일을 덮어쓴다. 디스크 root 를 단일 출처로 읽는 ExtensionStoragePath 로 전환하고 테스트 분기는 config/filesystems.php 한 줄에서 끝낸다. 함께 고친 것 - 테스트가 운영 라우트 캐시로 부팅해 확장 allowlist 가 라우트 축에서 통째로 무력화되던 문제. 삭제가 아니라 경로를 돌린다 — 라우트 캐시는 확장 작업 전까지 재생성되지 않아, 삭제하면 운영 사이트가 그때까지 라우트 파일 스캔 경로로 떨어진다. - PHPUnit 프로세스가 확장 vendor 의 제3자 composer 패키지를 오토로드하지 않아 그 패키지를 쓰는 코드 경로가 통째로 테스트 불가였던 문제. 확장 자신의 오토로더를 그대로 쓰면 활성 디렉토리가 _bundled 를 이기고 base path 유추까지 깨지므로, 생성된 맵에서 제3자 항목만 골라 별도 로더로 등록한다. - 게시 폴더가 setgid 를 갖지 않아, 명령줄과 웹이 번갈아 만든 하위 폴더를 다른 쪽이 쓰지 못하던 문제. - 관리자 템플릿이 HTML 정화 라이브러리를 직접 지정하지 않아 전이 의존으로 딸려온 구버전이 쓰이던 문제. 동반 산출물 - 규정 표(·AGENTS.md) 6행 + storage-driver/service-repository/testing-guide 문서 - audit 룰 2종 + coverage 6항목. 저장소가 이미 전량 전환돼 전수 실행이 공허 통과하므로 판정식은 픽스처 36건이 잠근다 - INSTALL.md 에 설치 후 파일 권한 절 추가 (vendor 쓰기 권한 불요를 명시)
497 lines
17 KiB
PHP
497 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Extension\PluginManager;
|
|
use App\Models\Permission;
|
|
use App\Models\Plugin;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use App\Support\ExtensionStoragePath;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\File;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* PluginSettingsController API 테스트
|
|
*
|
|
* 플러그인 설정 조회, 수정, 레이아웃 조회 API 엔드포인트를 테스트합니다.
|
|
* 설정은 파일 기반으로 storage/app/plugins/{identifier}/settings/setting.json에 저장됩니다.
|
|
*/
|
|
class PluginSettingsControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
private PluginManager $pluginManager;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
$this->pluginManager = app(PluginManager::class);
|
|
}
|
|
|
|
/**
|
|
* 관리자 역할 생성 및 할당
|
|
*
|
|
* @param array $permissions 사용자에게 부여할 권한 식별자 목록
|
|
*/
|
|
private function createAdminUser(array $permissions = ['core.plugins.read', 'core.plugins.update']): User
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
// 권한 생성
|
|
$permissionIds = [];
|
|
foreach ($permissions as $permIdentifier) {
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
[
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
// 고유한 식별자로 테스트용 역할 생성
|
|
$roleIdentifier = 'admin_settings_test_'.uniqid();
|
|
$testRole = Role::create([
|
|
'identifier' => $roleIdentifier,
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'description' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Administrator']),
|
|
'is_active' => true,
|
|
]);
|
|
|
|
// admin 역할도 추가 (admin 미들웨어 통과용)
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
// 테스트용 역할에 권한 할당
|
|
$testRole->permissions()->sync($permissionIds);
|
|
|
|
// 사용자에게 admin 역할과 테스트용 역할 모두 할당
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
$user->roles()->attach($testRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* 인증된 요청 헬퍼 메서드
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 플러그인 생성 (DB 레코드만)
|
|
*/
|
|
private function createTestPlugin(string $identifier = 'test-settings-plugin'): Plugin
|
|
{
|
|
return Plugin::create([
|
|
'identifier' => $identifier,
|
|
'vendor' => 'test',
|
|
'name' => json_encode(['ko' => '테스트 플러그인', 'en' => 'Test Plugin']),
|
|
'version' => '1.0.0',
|
|
'description' => json_encode(['ko' => '테스트용 플러그인', 'en' => 'Test Plugin']),
|
|
'status' => 'active',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 테스트용 설정 파일 생성
|
|
*/
|
|
private function createTestSettingsFile(string $identifier, array $settings): void
|
|
{
|
|
$settingsDir = ExtensionStoragePath::plugin($identifier, 'settings');
|
|
$settingsPath = $settingsDir.'/setting.json';
|
|
|
|
if (! File::isDirectory($settingsDir)) {
|
|
File::makeDirectory($settingsDir, 0755, true);
|
|
}
|
|
|
|
File::put($settingsPath, json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
}
|
|
|
|
/**
|
|
* 테스트용 설정 파일 삭제
|
|
*/
|
|
private function cleanupTestSettings(string $identifier): void
|
|
{
|
|
$pluginDir = ExtensionStoragePath::plugin($identifier);
|
|
if (File::isDirectory($pluginDir)) {
|
|
File::deleteDirectory($pluginDir);
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// 인증/권한 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 인증 없이 설정 조회 시 401 반환
|
|
*/
|
|
public function test_show_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/plugins/test-plugin/settings');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 권한 없이 설정 조회 시 403 반환
|
|
*/
|
|
public function test_show_returns_403_without_permission(): void
|
|
{
|
|
// 권한 없는 관리자 생성
|
|
$user = User::factory()->create();
|
|
$adminRole = Role::where('identifier', 'admin')->first();
|
|
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->getJson('/api/admin/plugins/test-plugin/settings');
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* 인증 없이 설정 업데이트 시 401 반환
|
|
*/
|
|
public function test_update_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->putJson('/api/admin/plugins/test-plugin/settings', [
|
|
'key' => 'value',
|
|
]);
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
/**
|
|
* 권한 없이 설정 업데이트 시 403 반환
|
|
*/
|
|
public function test_update_returns_403_without_permission(): void
|
|
{
|
|
// read 권한만 있는 관리자 생성
|
|
$user = $this->createAdminUser(['core.plugins.read']);
|
|
$token = $user->createToken('test-token')->plainTextToken;
|
|
|
|
$response = $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$token,
|
|
'Accept' => 'application/json',
|
|
])->putJson('/api/admin/plugins/test-plugin/settings', [
|
|
'key' => 'value',
|
|
]);
|
|
|
|
$response->assertStatus(403);
|
|
}
|
|
|
|
/**
|
|
* 인증 없이 레이아웃 조회 시 401 반환
|
|
*/
|
|
public function test_layout_returns_401_without_authentication(): void
|
|
{
|
|
$response = $this->getJson('/api/admin/plugins/test-plugin/settings/layout');
|
|
|
|
$response->assertStatus(401);
|
|
}
|
|
|
|
// ========================================================================
|
|
// show() 테스트 - 설정 조회
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 존재하지 않는 플러그인 설정 조회 시 404 반환
|
|
*/
|
|
public function test_show_returns_404_for_nonexistent_plugin(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/plugins/nonexistent-plugin/settings');
|
|
|
|
$response->assertStatus(404)
|
|
->assertJson(['success' => false]);
|
|
}
|
|
|
|
/**
|
|
* 실제 설치된 Daum 우편번호 플러그인 설정 조회 테스트
|
|
*/
|
|
public function test_show_returns_settings_for_installed_plugin(): void
|
|
{
|
|
// Daum 우편번호 플러그인이 설치되어 있는지 확인
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
// DB에 플러그인 레코드가 있는지 확인
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/plugins/sirsoft-daum_postcode/settings');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson(['success' => true]);
|
|
|
|
// 기본 설정값이 반환되는지 확인
|
|
$data = $response->json('data');
|
|
$this->assertIsArray($data);
|
|
}
|
|
|
|
// ========================================================================
|
|
// update() 테스트 - 설정 업데이트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 존재하지 않는 플러그인 설정 업데이트 시 에러 반환
|
|
*/
|
|
public function test_update_returns_error_for_nonexistent_plugin(): void
|
|
{
|
|
$response = $this->authRequest()->putJson('/api/admin/plugins/nonexistent-plugin/settings', [
|
|
'key' => 'value',
|
|
]);
|
|
|
|
// 플러그인 인스턴스가 없으면 저장 실패
|
|
$response->assertStatus(500)
|
|
->assertJson(['success' => false]);
|
|
}
|
|
|
|
/**
|
|
* 실제 설치된 플러그인 설정 업데이트 테스트
|
|
*/
|
|
public function test_update_saves_settings_for_installed_plugin(): void
|
|
{
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
// DB에 플러그인 레코드 생성
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
// 플러그인이 선언한 설정 스키마의 실제 키를 사용한다.
|
|
// 스키마 밖의 키는 검증을 통과하지 못해 저장되지 않는다.
|
|
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
|
'display_mode' => 'popup',
|
|
'popup_width' => 640,
|
|
]);
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson(['success' => true]);
|
|
|
|
// 파일에서 값 확인
|
|
$settingsPath = ExtensionStoragePath::plugin('sirsoft-daum_postcode', 'settings').'/setting.json';
|
|
// 저장이 200 으로 끝났으므로 파일은 반드시 있어야 한다 — `File::exists` 조건 안에
|
|
// 단언을 두면 경로가 어긋났을 때 검사가 조용히 공허해진다.
|
|
$this->assertFileExists($settingsPath);
|
|
|
|
$savedContent = json_decode(File::get($settingsPath), true);
|
|
$this->assertSame('popup', $savedContent['display_mode'] ?? null);
|
|
$this->assertSame(640, $savedContent['popup_width'] ?? null);
|
|
|
|
// 정리
|
|
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
|
}
|
|
|
|
/**
|
|
* 스키마에 없는 필드는 저장되지 않음 (mass-assignment 방지)
|
|
*
|
|
* 과거 구현은 `validated()` 가 비면 `$request->all()` 로 폴백했다. 폴백의 명분이던
|
|
* "PluginManager 미등록 플러그인" 은 `PluginSettingsService::save()` 가 이미 false 로
|
|
* 차단하므로 도달할 수 없고, 실제로는 스키마 밖 키만 담긴 요청이 그대로 설정 파일에
|
|
* 병합되는 경로로만 동작했다.
|
|
*/
|
|
public function test_update_ignores_fields_outside_settings_schema(): void
|
|
{
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
// 스키마에 정의되지 않은 키만 담아 전송 → validated() 가 빈 배열이 되는 조건
|
|
$this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
|
'injected_field' => 'evil',
|
|
'another_unknown' => ['nested' => true],
|
|
]);
|
|
|
|
$settingsPath = ExtensionStoragePath::plugin('sirsoft-daum_postcode', 'settings').'/setting.json';
|
|
// 스키마 밖 키만 담긴 요청은 저장 자체가 일어나지 않을 수 있다. 그 경우에도 단언은
|
|
// 수행한다 — 조건 안에 두면 파일이 없을 때 검사가 0건이 되어 공허하게 통과한다.
|
|
$savedContent = File::exists($settingsPath)
|
|
? (json_decode(File::get($settingsPath), true) ?? [])
|
|
: [];
|
|
|
|
$this->assertArrayNotHasKey('injected_field', $savedContent);
|
|
$this->assertArrayNotHasKey('another_unknown', $savedContent);
|
|
|
|
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
|
}
|
|
|
|
// ========================================================================
|
|
// layout() 테스트 - 레이아웃 조회
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 존재하지 않는 플러그인 레이아웃 조회 시 404 반환
|
|
*/
|
|
public function test_layout_returns_404_for_nonexistent_plugin(): void
|
|
{
|
|
$response = $this->authRequest()->getJson('/api/admin/plugins/nonexistent-plugin/settings/layout');
|
|
|
|
$response->assertStatus(404)
|
|
->assertJson(['success' => false]);
|
|
}
|
|
|
|
/**
|
|
* Daum 우편번호 플러그인 레이아웃 조회 성공
|
|
*/
|
|
public function test_layout_returns_layout_for_daum_postcode_plugin(): void
|
|
{
|
|
$layoutPath = base_path('plugins/sirsoft-daum_postcode/resources/layouts/settings.json');
|
|
|
|
if (! file_exists($layoutPath)) {
|
|
$this->markTestSkipped('Daum postcode plugin layout file not found');
|
|
}
|
|
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
// DB에 플러그인 레코드 생성
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/plugins/sirsoft-daum_postcode/settings/layout');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'data' => [
|
|
'version',
|
|
'meta',
|
|
'schema',
|
|
],
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 응답 구조 테스트
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 설정 조회 응답 구조 검증
|
|
*/
|
|
public function test_show_response_structure(): void
|
|
{
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
// DB에 플러그인 레코드 생성
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
$response = $this->authRequest()->getJson('/api/admin/plugins/sirsoft-daum_postcode/settings');
|
|
|
|
$response->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'success',
|
|
'message',
|
|
'data',
|
|
])
|
|
->assertJson([
|
|
'success' => true,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 설정 업데이트 응답에 업데이트된 설정 포함
|
|
*/
|
|
public function test_update_response_includes_updated_settings(): void
|
|
{
|
|
$pluginInstance = $this->pluginManager->getPlugin('sirsoft-daum_postcode');
|
|
|
|
if (! $pluginInstance) {
|
|
$this->markTestSkipped('sirsoft-daum_postcode plugin not installed');
|
|
}
|
|
|
|
// DB에 플러그인 레코드 생성
|
|
$plugin = Plugin::where('identifier', 'sirsoft-daum_postcode')->first();
|
|
if (! $plugin) {
|
|
$this->createTestPlugin('sirsoft-daum_postcode');
|
|
}
|
|
|
|
$response = $this->authRequest()->putJson('/api/admin/plugins/sirsoft-daum_postcode/settings', [
|
|
'display_mode' => 'popup',
|
|
]);
|
|
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'success' => true,
|
|
]);
|
|
|
|
// 응답에 업데이트된 설정이 포함되어 있는지 확인
|
|
$data = $response->json('data');
|
|
$this->assertIsArray($data);
|
|
$this->assertArrayHasKey('display_mode', $data);
|
|
$this->assertSame('popup', $data['display_mode']);
|
|
|
|
// 정리
|
|
$this->cleanupTestSettings('sirsoft-daum_postcode');
|
|
}
|
|
}
|