Files
Gnuboard7/tests/Unit/Extension/ExtensionUpdateStatusCacheSymmetryTest.php
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00

90 lines
3.8 KiB
PHP

<?php
namespace Tests\Unit\Extension;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Tests\TestCase;
/**
* 확장 업데이트의 상태 복원 ↔ 캐시 무효화 대칭성 테스트
*
* 업데이트는 시작 시 상태를 `updating` 으로 바꾼다. 그 창에서 누군가 활성 확장 목록을 읽으면
* 그 확장이 빠진 목록이 캐시(기본 TTL 하루)에 남는다. 성공 경로는 마지막에 캐시를 비우지만,
* 실패 경로가 상태만 되돌리고 캐시를 비우지 않으면 확장은 정상인데 그 관리자 화면만
* 하루 동안 404 가 된다 — 운영자가 원인을 짐작하기 어렵고 스스로 회복되지도 않는다.
*
* 상태를 되돌리는 지점마다 캐시 무효화가 따라붙는지 구조로 고정한다.
*/
class ExtensionUpdateStatusCacheSymmetryTest extends TestCase
{
/**
* 상태 복원 횟수만큼 캐시 무효화가 존재하는지 검증합니다.
*
* @param string $relativePath 검사 대상 매니저 파일 경로
* @param string $invalidateCall 해당 매니저의 캐시 무효화 호출 문자열
*/
#[Test]
#[TestWith(['app/Extension/ModuleManager.php', 'updateModule', 'invalidateModuleStatusCache()'])]
#[TestWith(['app/Extension/PluginManager.php', 'updatePlugin', 'invalidatePluginStatusCache()'])]
public function every_status_restore_is_followed_by_cache_invalidation(string $relativePath, string $method, string $invalidateCall): void
{
$body = $this->updateMethodSource(base_path($relativePath), $method);
$restores = preg_match_all("/'status'\s*=>\s*\\\$previousStatus/", $body);
$invalidations = substr_count($body, $invalidateCall);
$this->assertGreaterThan(0, $restores, "{$relativePath} 에서 상태 복원 지점을 찾지 못했습니다 — 검사가 무력화됐습니다.");
$this->assertGreaterThanOrEqual(
$restores,
$invalidations,
"{$relativePath}: 상태 복원 {$restores}회에 비해 캐시 무효화가 {$invalidations}회뿐입니다 — "
.'복원했는데 비우지 않은 경로가 있으면 그 확장 화면이 캐시 만료까지 404 로 남습니다.'
);
}
/**
* 업데이트 메서드 본문을 주석 제거 후 반환합니다.
*
* 주석을 남겨 두면 설명 문장에 들어 있는 호출 표기가 검사를 통과시킨다.
*
* @param string $path 대상 파일 절대 경로
* @param string $method 대상 메서드명 (updateModule / updatePlugin)
* @return string 주석이 제거된 메서드 본문
*/
private function updateMethodSource(string $path, string $method): string
{
$this->assertFileExists($path);
$stripped = '';
foreach (token_get_all(file_get_contents($path)) as $token) {
if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) {
continue;
}
$stripped .= is_array($token) ? $token[1] : $token;
}
$start = strpos($stripped, "public function {$method}(");
$this->assertNotFalse($start, "{$path} 에서 {$method}() 메서드를 찾지 못했습니다.");
// 중괄호 균형으로 메서드 본문 끝을 찾는다
$open = strpos($stripped, '{', $start);
$depth = 0;
$length = strlen($stripped);
for ($i = $open; $i < $length; $i++) {
if ($stripped[$i] === '{') {
$depth++;
} elseif ($stripped[$i] === '}') {
$depth--;
if ($depth === 0) {
return substr($stripped, $open, $i - $open + 1);
}
}
}
$this->fail("{$path} 의 update() 본문 끝을 찾지 못했습니다.");
}
}