관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다. 설정 화면(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
90 lines
3.8 KiB
PHP
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() 본문 끝을 찾지 못했습니다.");
|
|
}
|
|
}
|