Files
HeuJung 85e266d361 fix(core,extensions): 확장 쓰기 경로 공통화 · HTMLPurifier 정의 캐시 storage 이전
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 쓰기 권한 불요를 명시)
2026-08-28 15:22:22 +09:00

185 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Upgrade;
use App\Extension\UpgradeContext;
use App\Support\ExtensionStoragePath;
use App\Upgrades\Data\Ext\Plugins\SirsoftTosspayments\V1_0_1\Migrations\BackfillTossPgProvider;
use Illuminate\Support\Facades\File;
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
/**
* 저장된 주문설정의 토스 주문서형 결제수단 PG 고정 백필.
*
* 주문서형 결제수단(toss_*)은 `pg_provider: null` 로 등록되어 저장 파일에 그대로
* 영속화됐고, 관리자 주문설정 화면이 다른 간편결제와 달리 "PG 고정" 배지 대신 빈 PG
* 선택 셀렉트를 그렸다. 백필은 자기 접두사 수단만, 멱등하게 현재 선언과 일치시킨다.
*
* @scenario method_kind=extension, capability_declared=declared, capability=pg_locked
*
* @effects upgrade_step_backfills_settings_file, saved_null_pg_provider_self_healed
*
* @group payment
* @group upgrade
*/
class BackfillTossPgProviderTest extends PluginTestCase
{
private string $settingsPath;
private bool $hadOriginalSettings = false;
private ?string $originalSettings = null;
protected function setUp(): void
{
parent::setUp();
require_once base_path('plugins/_bundled/sirsoft-tosspayments/upgrades/data/1.0.1/migrations/BackfillTossPgProvider.php');
$this->settingsPath = ExtensionStoragePath::module('sirsoft-ecommerce', 'settings').'/order_settings.json';
$this->hadOriginalSettings = File::exists($this->settingsPath);
$this->originalSettings = $this->hadOriginalSettings ? File::get($this->settingsPath) : null;
File::ensureDirectoryExists(dirname($this->settingsPath));
}
protected function tearDown(): void
{
if ($this->hadOriginalSettings && $this->originalSettings !== null) {
File::put($this->settingsPath, $this->originalSettings);
} else {
File::delete($this->settingsPath);
}
parent::tearDown();
}
public function test_backfills_pg_declaration_for_toss_methods(): void
{
// 결함 시절의 저장 상태 재현: pg_provider=null, 능력 키 부재
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'tosspayments'],
['id' => 'toss_tosspay', 'pg_provider' => null, 'is_active' => true],
['id' => 'toss_naverpay', 'pg_provider' => null, 'is_active' => true],
]);
$this->runMigration();
$methods = $this->readMethods();
foreach (['toss_tosspay', 'toss_naverpay'] as $id) {
$method = $methods[$id];
$this->assertSame('tosspayments', $method['pg_provider'], "{$id} 의 PG 가 고정되어야 한다");
$this->assertTrue($method['pg_locked']);
$this->assertTrue($method['needs_pg']);
$this->assertSame('pg', $method['refund_method']);
// 관리자가 설정한 값은 보존한다.
$this->assertTrue($method['is_active']);
}
}
public function test_does_not_touch_other_plugins_or_builtin_methods(): void
{
// 각 플러그인은 자기 접두사 수단만 백필한다 (다른 PG 의 수단을 건드리면 안 된다).
$this->writeSettings([
['id' => 'card', 'pg_provider' => 'tosspayments'],
['id' => 'dbank', 'pg_provider' => ''],
['id' => 'kginicis_naverpay', 'pg_provider' => null],
['id' => 'toss_card', 'pg_provider' => null],
]);
$this->runMigration();
$methods = $this->readMethods();
$this->assertSame('tosspayments', $methods['card']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['card']);
$this->assertSame('', $methods['dbank']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['dbank']);
// 다른 PG 플러그인의 수단은 그 플러그인의 스텝이 처리한다.
$this->assertNull($methods['kginicis_naverpay']['pg_provider']);
$this->assertArrayNotHasKey('pg_locked', $methods['kginicis_naverpay']);
$this->assertSame('tosspayments', $methods['toss_card']['pg_provider']);
}
public function test_is_idempotent(): void
{
$this->writeSettings([
['id' => 'toss_card', 'pg_provider' => null],
]);
$this->runMigration();
$first = File::get($this->settingsPath);
$this->runMigration();
$second = File::get($this->settingsPath);
$this->assertSame($first, $second, '재실행해도 결과가 달라지지 않아야 한다');
}
public function test_skips_when_settings_file_is_missing(): void
{
File::delete($this->settingsPath);
$this->runMigration();
$this->assertFalse(File::exists($this->settingsPath));
}
public function test_skips_when_settings_json_is_malformed(): void
{
File::put($this->settingsPath, '{ not valid json');
$this->runMigration();
// 손상된 파일을 덮어써서 더 망가뜨리지 않는다.
$this->assertSame('{ not valid json', File::get($this->settingsPath));
}
/**
* 테스트용 주문설정 파일을 기록합니다.
*
* @param array<int, array<string, mixed>> $paymentMethods 결제수단 배열
*/
private function writeSettings(array $paymentMethods): void
{
File::put($this->settingsPath, json_encode([
'payment_methods' => $paymentMethods,
], JSON_THROW_ON_ERROR));
}
/**
* 저장 파일의 결제수단을 ID 키 맵으로 읽습니다.
*
* @return array<string, array<string, mixed>> 결제수단 ID => 설정
*/
private function readMethods(): array
{
$settings = json_decode(File::get($this->settingsPath), true, flags: JSON_THROW_ON_ERROR);
$keyed = [];
foreach ($settings['payment_methods'] as $method) {
$keyed[$method['id']] = $method;
}
return $keyed;
}
/**
* 백필 마이그레이션을 실행합니다.
*/
private function runMigration(): void
{
(new BackfillTossPgProvider)->run(new UpgradeContext(
fromVersion: '1.0.0',
toVersion: '1.0.1',
currentStep: '1.0.1',
));
}
}