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

256 lines
9.9 KiB
PHP

<?php
namespace Modules\Sirsoft\Board\Tests\Unit;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Models\NotificationDefinition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Board\Services\BoardPermissionService;
use Modules\Sirsoft\Board\Services\BoardSettingsService;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
/**
* 게시판 단건 설정 저장(setSetting)의 파이프라인 경유 / 캐시 정합 테스트 (공개 #114 동종)
*
* 이커머스와 같은 결함에 더해 한 단계 더 나쁜 상태였다 — 저장 후 자기 캐시(`$settings`)를
* 비우지 않아, 같은 요청 안에서 곧이은 조회가 저장 전 값을 반환했다. 기존 테스트는
* `clearCache()` 를 수동으로 끼워 넣어 그 결함을 우회하고 있었다.
*
* 벌크 저장의 boolean backfill 은 폼 Toggle-OFF 미전송 대응이라 단건 저장에 그대로 태우면
* 안 된다 — 제출하지 않은 boolean 이 전부 false 로 박제되어 기본값 true 인 항목이 뒤집힌다.
* 그래서 공통 본문만 공유하고 backfill 여부만 갈라 놓는다.
*/
class BoardSetSettingPipelineTest extends ModuleTestCase
{
private BoardSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$permissionService = $this->createMock(BoardPermissionService::class);
$notificationDefinitionRepository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$notificationDefinitionRepository->method('getByExtension')->willReturn(new Collection);
$this->service = new BoardSettingsService($permissionService, $notificationDefinitionRepository);
$this->storagePath = storage_path('framework/testing/modules/sirsoft-board/settings');
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::deleteDirectory($this->storagePath);
}
parent::tearDown();
}
/**
* 카테고리 저장 파일을 그대로 읽습니다.
*
* @param string $category 카테고리명
* @return array 저장 파일의 디코드 결과 (파일 부재 시 빈 배열)
*/
private function savedFile(string $category): array
{
$path = $this->storagePath.'/'.$category.'.json';
if (! File::exists($path)) {
return [];
}
return json_decode(File::get($path), true) ?? [];
}
/**
* 단건 저장 직후 같은 인스턴스의 조회가 신값을 반환한다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects single_key_save_invalidates_service_cache
*/
public function test_single_key_save_invalidates_cache_without_manual_clear(): void
{
// 조회로 캐시를 채운 뒤 저장
$this->assertNotSame(31, $this->service->getSetting('basic_defaults.per_page'));
$this->service->setSetting('basic_defaults.per_page', 31);
$this->assertSame(
31,
$this->service->getSetting('basic_defaults.per_page'),
'저장 후에도 조회가 저장 전 값을 반환했습니다 (캐시 무효화 누락).'
);
}
/**
* 단건 저장은 미제출 boolean 을 false 로 박제하지 않는다.
*
* 벌크 저장의 backfill 은 폼 전체 제출을 전제로 한 보정이므로, 부분 저장에 태우면
* 기본값 true 인 항목이 통째로 뒤집힌다.
*
* @scenario save_path=single_key
*
* @effects single_key_save_does_not_backfill_booleans
*/
public function test_single_key_save_does_not_backfill_unsubmitted_booleans(): void
{
$this->service->setSetting('basic_defaults.per_page', 25);
$saved = $this->savedFile('basic_defaults');
$this->assertArrayNotHasKey(
'use_comment',
$saved,
'제출하지 않은 boolean 이 단건 저장으로 박제되었습니다.'
);
$this->assertTrue(
$this->service->getSetting('basic_defaults.use_comment'),
'기본값 true 인 boolean 설정이 단건 저장으로 뒤집혔습니다.'
);
}
/**
* 벌크 저장의 boolean backfill 은 그대로 유지된다. (비회귀 pin)
*
* @scenario save_path=bulk
*
* @effects bulk_save_backfills_unsubmitted_booleans
*/
public function test_bulk_save_still_backfills_unsubmitted_booleans(): void
{
$this->service->saveSettings(['basic_defaults' => ['per_page' => 25]]);
$saved = $this->savedFile('basic_defaults');
$this->assertArrayHasKey('use_comment', $saved, '벌크 저장의 boolean backfill 이 사라졌습니다.');
$this->assertFalse($saved['use_comment']);
}
/**
* 신고 정책 단건 저장도 알림 강제 활성 규칙을 거친다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_forces_report_notification_flags(): void
{
$this->service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => false,
'notify_author_on_report_action' => false,
]);
$saved = $this->savedFile('report_policy');
$this->assertTrue(
$saved['notify_admin_on_report'] ?? false,
'신고 알림 강제 활성 규칙이 단건 저장 경로에서 적용되지 않았습니다.'
);
$this->assertTrue($saved['notify_author_on_report_action'] ?? false);
$this->assertSame(7, $saved['auto_hide_threshold'] ?? null);
}
/**
* 신고 정책 단건 저장은 알림 정의의 활성 상태까지 동기화한다.
*
* 저장 파일만 보면 강제 활성 규칙이 통과한 것처럼 보인다. 그러나 알림 정의가 비활성인
* 채로 남으면 `NotificationHookListener` 가 훅을 구독하지 않아 설정은 켜져 있는데
* 알림만 오지 않는다 — 화면에도 로그에도 흔적이 없다.
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_syncs_notification_definition_status(): void
{
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
$definition->is_active = false;
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
$repository->expects($this->once())
->method('update')
->with($definition, ['is_active' => true]);
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
$service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => false,
]);
}
/**
* 이미 원하는 상태인 알림 정의는 다시 쓰지 않는다. (불필요 쓰기 차단)
*
* @scenario save_path=single_key
*
* @effects report_notification_flags_forced_on_single_key_save
*/
public function test_single_key_save_skips_notification_definition_update_when_already_active(): void
{
$definition = new NotificationDefinition(['type' => 'report_received_admin']);
$definition->is_active = true;
$repository = $this->createMock(NotificationDefinitionRepositoryInterface::class);
$repository->method('getByExtension')->willReturn(new Collection([$definition]));
$repository->expects($this->never())->method('update');
$service = new BoardSettingsService($this->createMock(BoardPermissionService::class), $repository);
$service->setSetting('report_policy', [
'auto_hide_threshold' => 7,
'notify_admin_on_report' => true,
]);
}
/**
* 단건 저장도 defaults 스키마 기준 숫자 정규화를 거친다. (실패-먼저)
*
* @scenario save_path=single_key
*
* @effects numeric_string_normalized_on_single_key_save
*/
public function test_single_key_save_normalizes_numeric_strings(): void
{
$this->service->setSetting('basic_defaults.per_page', '42');
$saved = $this->savedFile('basic_defaults');
$this->assertSame(42, $saved['per_page'] ?? null, '숫자 문자열이 정규화되지 않고 저장되었습니다.');
}
/**
* 테스트 실행 중에는 운영 설정 경로를 쓰지 않는다. (실패-먼저)
*
* @effects board_settings_storage_isolated_in_tests
*/
public function test_storage_path_is_isolated_during_tests(): void
{
// audit:allow extension-storage-path-hand-assembled 운영 경로를 **의도적으로** 가리킨다 —
// 이 단언의 대상은 "테스트가 운영 파일을 건드리지 않았는가" 이므로 해석기를 쓰면 검사가 성립하지 않는다.
$productionPath = storage_path('app/modules/sirsoft-board/settings/basic_defaults.json');
// 운영 파일의 존재 여부·내용을 그대로 스냅샷 (환경마다 상태가 다르므로 변화 없음만 단언)
$before = File::exists($productionPath) ? File::get($productionPath) : null;
$this->service->setSetting('basic_defaults.per_page', 33);
$this->assertFileExists(
$this->storagePath.'/basic_defaults.json',
'테스트 격리 경로에 저장되지 않았습니다.'
);
$after = File::exists($productionPath) ? File::get($productionPath) : null;
$this->assertSame($before, $after, '테스트가 운영 설정 파일을 생성/변경했습니다.');
}
}