fix(core,admin-template): GC 스케줄 허용목록 동기 + 스토리지 잔존물 GC 신설 + 스케줄 모달 저장 회귀 수정
공개 후속 전수 조사의 실작업: - attachments:prune-orphans 관리자 예약 허용목록 등재 + 가드 테스트 도출 규칙을 런타임 validator 와 일치 (옵션 포함 키는 영원히 매칭되지 않는 죽은 항목) - layout-previews:cleanup 을 gc-schedules 가드 구간으로 편입 (+onOneServer) - storage:prune-leftovers 신설: 중단된 업데이트/설치의 _pending 스테이징·코어 임시· vendor 스테이징(3일)과 오래된 백업(30일, 최신 1개 상시 보존), 레거시 browser.log 회수 — 산출물 누적 + 회수 경로 부재( 동형) 7건 해소 - 언어팩 설치 임시 디렉토리 정리를 finally 로 이동 (설치 성공 시 100% 누수) - 브라우저 콘솔 로그를 daily(7일) 로테이션으로 전환 (단일 파일 989MB 실측) - 검수 중 발견한 스케줄 등록/수정 모달 저장 불능 수정 (Form id 부재 + 커스텀 Select/Toggle 값 미전송) + 동형 재발 차단 audit 룰 2종 신설
This commit is contained in:
@@ -261,6 +261,8 @@
|
||||
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
|
||||
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
|
||||
| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) |
|
||||
| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) |
|
||||
| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) |
|
||||
|
||||
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
|
||||
|
||||
|
||||
+3
-1
@@ -53,7 +53,7 @@
|
||||
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
|
||||
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
|
||||
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
|
||||
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
|
||||
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
|
||||
@@ -73,6 +73,8 @@
|
||||
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
|
||||
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
|
||||
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
|
||||
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
|
||||
|
||||
## [7.0.6] - 2026-08-10
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\StorageLeftoverPruneService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 스토리지 잔존물 정리 커맨드
|
||||
*
|
||||
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
|
||||
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
|
||||
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
|
||||
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
|
||||
*/
|
||||
class PruneStorageLeftoversCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'storage:prune-leftovers
|
||||
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
|
||||
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
|
||||
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
|
||||
|
||||
/**
|
||||
* 대상군 키 → 출력 라벨.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const GROUP_LABELS = [
|
||||
'staging' => '업데이트 스테이징 잔존',
|
||||
'temp' => '코어 임시 산출물',
|
||||
'vendor_bundle_staging' => 'vendor 번들 스테이징',
|
||||
'extension_backups' => '확장 백업',
|
||||
'core_backups' => '코어 백업',
|
||||
'legacy_browser_log' => '레거시 브라우저 로그',
|
||||
];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(StorageLeftoverPruneService $service): int
|
||||
{
|
||||
$days = max(0, (int) $this->option('days'));
|
||||
$backupDays = max(0, (int) $this->option('backup-days'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$result = $service->prune($days, $backupDays, $dryRun);
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach (self::GROUP_LABELS as $key => $label) {
|
||||
$paths = $result[$key] ?? [];
|
||||
$count = count($paths);
|
||||
$total += $count;
|
||||
|
||||
$this->line("{$label}: {$count}건");
|
||||
|
||||
if ($dryRun) {
|
||||
foreach ($paths as $path) {
|
||||
$this->line(" - {$path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info($dryRun
|
||||
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
|
||||
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -724,8 +724,11 @@ class LanguagePackService
|
||||
return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force);
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
} finally {
|
||||
// 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
|
||||
// catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
|
||||
File::deleteDirectory($tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,8 +779,11 @@ class LanguagePackService
|
||||
return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force);
|
||||
} catch (Throwable $e) {
|
||||
$this->cleanupPending($extractPath);
|
||||
File::deleteDirectory($tempPath);
|
||||
throw $e;
|
||||
} finally {
|
||||
// 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다.
|
||||
// catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다.
|
||||
File::deleteDirectory($tempPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* 스토리지 잔존물 정리 서비스 (`storage:prune-leftovers`)
|
||||
*
|
||||
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물과 오래된 백업본을 회수합니다.
|
||||
* 각 산출물의 생성 지점은 best-effort 정리(finally)만 가지고 있어, 강제 종료·파일
|
||||
* 잠금 환경에서 남은 잔존물은 어떤 경로도 회수하지 않았습니다 (공개이슈 #120 동형 —
|
||||
* 산출물 누적 + 회수 경로 부재). `CleanupExtensionBundlesCommand` →
|
||||
* `ExtensionBundleService` 위임 패턴을 미러합니다.
|
||||
*
|
||||
* 파괴 방지 가드:
|
||||
* - `_pending` 직하위는 타임스탬프/교체 마커 패턴 매칭 디렉토리만 대상 —
|
||||
* 다운로드 대기 정상 확장 디렉토리(접미사 없음)는 불가침.
|
||||
* - 백업은 identifier 별 최신 1개를 나이와 무관하게 상시 보존 (롤백 능력 유지).
|
||||
* - 판정은 전부 mtime 기준 — 진행 중 작업의 산출물은 신선해서 걸리지 않는다.
|
||||
*/
|
||||
class StorageLeftoverPruneService
|
||||
{
|
||||
/**
|
||||
* 업데이트 스테이징 타임스탬프 접미사 (`ExtensionPendingHelper::createUpdateStagingPath`
|
||||
* 의 `{identifier}_{Ymd_His}` 형식).
|
||||
*/
|
||||
private const STAGING_TIMESTAMP_PATTERN = '/_\d{8}_\d{6}$/';
|
||||
|
||||
/**
|
||||
* 디렉토리 교체 임시 마커 (`{identifier}_updating_{uniqid}` / `{identifier}_old_{uniqid}`).
|
||||
* `cleanupSwapLeftovers()` 는 다음 업데이트 시점에만 재시도하므로, 업데이트가 다시
|
||||
* 실행되지 않는 확장의 잔존물은 여기서만 회수된다.
|
||||
*/
|
||||
private const SWAP_LEFTOVER_PATTERN = '/_(?:updating|old)_[0-9a-f]{13}$/';
|
||||
|
||||
/**
|
||||
* 백업 디렉토리 이름 (`{identifier}_{Ymd_His}`) — ExtensionBackupHelper::createBackup.
|
||||
*/
|
||||
private const BACKUP_NAME_PATTERN = '/^(.+)_(\d{8}_\d{6})$/';
|
||||
|
||||
/**
|
||||
* 코어 백업 디렉토리 이름 (`core_{Ymd_His}`) — CoreBackupHelper::createBackup.
|
||||
*/
|
||||
private const CORE_BACKUP_NAME_PATTERN = '/^core_(\d{8}_\d{6})$/';
|
||||
|
||||
/**
|
||||
* @param string|null $baseRoot 프로젝트 루트 재정의 (테스트 격리용, 기본 base_path())
|
||||
* @param string|null $storageRoot 스토리지 루트 재정의 (테스트 격리용, 기본 storage_path())
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?string $baseRoot = null,
|
||||
private readonly ?string $storageRoot = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 잔존물을 대상군별로 정리하고 삭제(예정) 경로 목록을 돌려줍니다.
|
||||
*
|
||||
* @param int $days 임시 산출물 보존일 (mtime 기준, 경과분 삭제)
|
||||
* @param int $backupDays 백업 보존일 (최신 1개는 나이와 무관하게 보존)
|
||||
* @param bool $dryRun true 면 삭제 없이 대상 목록만 수집
|
||||
* @return array<string, list<string>> 대상군별 삭제(예정) 경로 목록
|
||||
*/
|
||||
public function prune(int $days = 3, int $backupDays = 30, bool $dryRun = false): array
|
||||
{
|
||||
$now = time();
|
||||
$cutoff = $now - $days * 86400;
|
||||
$backupCutoff = $now - $backupDays * 86400;
|
||||
|
||||
return [
|
||||
'staging' => $this->pruneUpdateStaging($cutoff, $dryRun),
|
||||
'temp' => $this->pruneCoreTemp($cutoff, $dryRun),
|
||||
'vendor_bundle_staging' => $this->pruneVendorBundleStaging($cutoff, $dryRun),
|
||||
'extension_backups' => $this->pruneExtensionBackups($backupCutoff, $dryRun),
|
||||
'core_backups' => $this->pruneCoreBackups($backupCutoff, $dryRun),
|
||||
'legacy_browser_log' => $this->pruneLegacyBrowserLog($dryRun),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* `{modules,plugins,templates}/_pending` 의 스테이징/교체 잔존물을 정리합니다.
|
||||
*
|
||||
* @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneUpdateStaging(int $cutoff, bool $dryRun): array
|
||||
{
|
||||
$pruned = [];
|
||||
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
$pendingRoot = $this->basePath($type.DIRECTORY_SEPARATOR.'_pending');
|
||||
|
||||
if (! File::isDirectory($pendingRoot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (File::directories($pendingRoot) as $dir) {
|
||||
$name = basename($dir);
|
||||
|
||||
// 타임스탬프/교체 마커가 없는 디렉토리는 다운로드 대기 정상 확장 — 불가침.
|
||||
if (! preg_match(self::STAGING_TIMESTAMP_PATTERN, $name)
|
||||
&& ! preg_match(self::SWAP_LEFTOVER_PATTERN, $name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isFresh($dir, $cutoff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pruned[] = $dir;
|
||||
|
||||
if (! $dryRun) {
|
||||
File::deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* `storage/app/temp` 직하위 전부(디렉토리·파일)를 보존일 기준으로 정리합니다.
|
||||
*
|
||||
* @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneCoreTemp(int $cutoff, bool $dryRun): array
|
||||
{
|
||||
return $this->pruneEntriesByAge(
|
||||
$this->storagePath('app'.DIRECTORY_SEPARATOR.'temp'.DIRECTORY_SEPARATOR.'*'),
|
||||
$cutoff,
|
||||
$dryRun
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* vendor 번들 스테이징(`build-*`)을 보존일 기준으로 정리합니다.
|
||||
*
|
||||
* @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneVendorBundleStaging(int $cutoff, bool $dryRun): array
|
||||
{
|
||||
return $this->pruneEntriesByAge(
|
||||
$this->storagePath('app'.DIRECTORY_SEPARATOR.'vendor-bundle-staging'.DIRECTORY_SEPARATOR.'build-*'),
|
||||
$cutoff,
|
||||
$dryRun
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 백업(`extension_backups/{type}/{identifier}_{Ymd_His}`)을 정리합니다.
|
||||
*
|
||||
* identifier 별 최신 1개는 나이와 무관하게 보존하고, 나머지 중 보존일 경과분만
|
||||
* 삭제합니다. type 디렉토리는 열거로 얻으므로 새 백업 유형(lang-packs 등)도
|
||||
* 하드코딩 없이 같은 규칙을 받습니다.
|
||||
*
|
||||
* @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneExtensionBackups(int $backupCutoff, bool $dryRun): array
|
||||
{
|
||||
$pruned = [];
|
||||
$backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'extension_backups');
|
||||
|
||||
if (! File::isDirectory($backupRoot)) {
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
foreach (File::directories($backupRoot) as $typeDir) {
|
||||
/** @var array<string, list<array{path: string, stamp: string}>> $byIdentifier */
|
||||
$byIdentifier = [];
|
||||
|
||||
foreach (File::directories($typeDir) as $dir) {
|
||||
if (! preg_match(self::BACKUP_NAME_PATTERN, basename($dir), $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$byIdentifier[$matches[1]][] = ['path' => $dir, 'stamp' => $matches[2]];
|
||||
}
|
||||
|
||||
foreach ($byIdentifier as $entries) {
|
||||
array_push($pruned, ...$this->pruneBackupSet($entries, $backupCutoff, $dryRun));
|
||||
}
|
||||
}
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 백업(`core_backups/core_{Ymd_His}`)을 정리합니다 — 최신 1개 상시 보존.
|
||||
*
|
||||
* @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneCoreBackups(int $backupCutoff, bool $dryRun): array
|
||||
{
|
||||
$backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'core_backups');
|
||||
|
||||
if (! File::isDirectory($backupRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
|
||||
foreach (File::directories($backupRoot) as $dir) {
|
||||
if (! preg_match(self::CORE_BACKUP_NAME_PATTERN, basename($dir), $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = ['path' => $dir, 'stamp' => $matches[1]];
|
||||
}
|
||||
|
||||
return $this->pruneBackupSet($entries, $backupCutoff, $dryRun);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 단일 `storage/logs/browser.log` 를 정리합니다.
|
||||
*
|
||||
* 브라우저 로그가 daily 드라이버로 전환되어 이 파일에 쓰는 코드는 더 이상 없다 —
|
||||
* 존재 자체가 잔존물이므로 나이와 무관하게 삭제합니다. 날짜별 `browser-*.log` 는
|
||||
* daily 드라이버가 보존일로 자체 정리하므로 대상이 아닙니다.
|
||||
*
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneLegacyBrowserLog(bool $dryRun): array
|
||||
{
|
||||
$path = $this->storagePath('logs'.DIRECTORY_SEPARATOR.'browser.log');
|
||||
|
||||
if (! File::exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
File::delete($path);
|
||||
}
|
||||
|
||||
return [$path];
|
||||
}
|
||||
|
||||
/**
|
||||
* 같은 백업 집합에서 최신 1개를 제외하고 보존일 경과분을 삭제합니다.
|
||||
*
|
||||
* 최신 판정은 디렉토리명 타임스탬프(`Ymd_His` — 사전순 = 시간순) 기준입니다.
|
||||
*
|
||||
* @param list<array{path: string, stamp: string}> $entries 같은 identifier 의 백업 목록
|
||||
* @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneBackupSet(array $entries, int $backupCutoff, bool $dryRun): array
|
||||
{
|
||||
if ($entries === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
usort($entries, static fn (array $a, array $b): int => strcmp($b['stamp'], $a['stamp']));
|
||||
|
||||
// 최신 1개는 롤백 자산 — 나이와 무관하게 보존.
|
||||
array_shift($entries);
|
||||
|
||||
$pruned = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($this->isFresh($entry['path'], $backupCutoff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pruned[] = $entry['path'];
|
||||
|
||||
if (! $dryRun) {
|
||||
File::deleteDirectory($entry['path']);
|
||||
}
|
||||
}
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* glob 패턴에 걸리는 항목(디렉토리·파일)을 보존일 기준으로 정리합니다.
|
||||
*
|
||||
* @param string $pattern glob 패턴
|
||||
* @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제
|
||||
* @param bool $dryRun 삭제 없이 목록만
|
||||
* @return list<string> 삭제(예정) 경로 목록
|
||||
*/
|
||||
private function pruneEntriesByAge(string $pattern, int $cutoff, bool $dryRun): array
|
||||
{
|
||||
$pruned = [];
|
||||
|
||||
foreach (File::glob($pattern) ?: [] as $entry) {
|
||||
if ($this->isFresh($entry, $cutoff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pruned[] = $entry;
|
||||
|
||||
if (! $dryRun) {
|
||||
is_dir($entry) ? File::deleteDirectory($entry) : File::delete($entry);
|
||||
}
|
||||
}
|
||||
|
||||
return $pruned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 항목의 mtime 이 보존일 이내인지 판정합니다 (mtime 조회 실패 시 보존 — fail-safe).
|
||||
*
|
||||
* @param string $path 대상 경로
|
||||
* @param int $cutoff 기준 시각 (Unix time)
|
||||
* @return bool 보존일 이내(삭제 금지)면 true
|
||||
*/
|
||||
private function isFresh(string $path, int $cutoff): bool
|
||||
{
|
||||
$mtime = @filemtime($path);
|
||||
|
||||
return $mtime === false || $mtime > $cutoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 루트 기준 경로를 만듭니다.
|
||||
*
|
||||
* @param string $path 루트 기준 상대 경로
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
private function basePath(string $path): string
|
||||
{
|
||||
return ($this->baseRoot ?? base_path()).DIRECTORY_SEPARATOR.$path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스토리지 루트 기준 경로를 만듭니다.
|
||||
*
|
||||
* @param string $path 스토리지 루트 기준 상대 경로
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
private function storagePath(string $path): string
|
||||
{
|
||||
return ($this->storageRoot ?? storage_path()).DIRECTORY_SEPARATOR.$path;
|
||||
}
|
||||
}
|
||||
@@ -137,9 +137,12 @@ class BrowserLogWriter
|
||||
return Log::channel('browser');
|
||||
}
|
||||
|
||||
// daily + 유한 보존일 — single 드라이버는 로테이션 없이 무한 append 되어
|
||||
// browser.log 가 저장소 단일 최대 파일로 자란다 (실측 989MB).
|
||||
return Log::build([
|
||||
'driver' => 'single',
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/browser.log'),
|
||||
'days' => 7,
|
||||
'level' => config('logging.level', 'debug'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ return [
|
||||
'identity:prune-logs' => ['options' => ['days']],
|
||||
'activity-log:prune' => ['options' => ['days']],
|
||||
'notification-log:prune' => ['options' => ['days']],
|
||||
// 실삭제가 기본이나 사용자 파일 파기는 커맨드 자체가 설정 토글·보존기간으로 이중 확인한다.
|
||||
'attachments:prune-orphans' => ['options' => ['dry-run', 'limit', 'days', 'scheduled']],
|
||||
// 잔존물 정리 — 최신 백업 1개 상시 보존 가드는 커맨드 내부 소유(옵션으로 무력화 불가).
|
||||
'storage:prune-leftovers' => ['options' => ['days', 'backup-days', 'dry-run']],
|
||||
|
||||
// SEO
|
||||
'seo:warmup' => ['options' => ['layout']],
|
||||
|
||||
+3
-1
@@ -86,7 +86,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: greedy
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `471`;
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `476`;
|
||||
|
||||
exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = `
|
||||
{
|
||||
@@ -254,6 +254,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구
|
||||
"modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json",
|
||||
"modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json",
|
||||
"modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json",
|
||||
"plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json",
|
||||
"plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json",
|
||||
"templates/_bundled/sirsoft-admin_basic/layouts/_admin_base.json",
|
||||
"templates/_bundled/sirsoft-admin_basic/layouts/admin_module_list.json",
|
||||
@@ -373,6 +374,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구
|
||||
"modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json",
|
||||
"modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json",
|
||||
"modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json",
|
||||
"plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json",
|
||||
"templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json",
|
||||
"templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_activity_log_list/_partial_datagrid.json",
|
||||
"templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_notification_log_list/_partial_datagrid.json",
|
||||
|
||||
@@ -1090,6 +1090,18 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>고아 첨부 정리 (미리보기)</span>
|
||||
<span class="text-[10px] opacity-60">(attachments:prune-orphans --dry-run)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('attachments:prune-orphans')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>고아 첨부 정리 (실삭제)</span>
|
||||
<span class="text-[10px] opacity-60">(attachments:prune-orphans)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('storage:prune-leftovers --dry-run')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>스토리지 잔존물 정리 (미리보기)</span>
|
||||
<span class="text-[10px] opacity-60">(storage:prune-leftovers --dry-run)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('storage:prune-leftovers')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>스토리지 잔존물 정리 (실행)</span>
|
||||
<span class="text-[10px] opacity-60">(storage:prune-leftovers)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('identity:expire-challenges')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>본인인증 challenge 만료 처리</span>
|
||||
<span class="text-[10px] opacity-60">(identity:expire-challenges)</span>
|
||||
|
||||
+6
-4
@@ -29,11 +29,8 @@ Schedule::command('dashboard:broadcast-resources')
|
||||
->withoutOverlapping(5)
|
||||
->runInBackground();
|
||||
|
||||
// 만료된 레이아웃 미리보기 정리 (30분마다)
|
||||
Schedule::command('layout-previews:cleanup')->everyThirtyMinutes();
|
||||
|
||||
/*
|
||||
| 만료 데이터 정리 (일 1회 새벽 배치, 다중 서버 1대만)
|
||||
| 만료 데이터 정리 예약 (주기는 항목별, 다중 서버 1대만)
|
||||
|
|
||||
| 시각 해석 기준은 사이트 설정 시간대(`general.timezone`)다 —
|
||||
| SettingsServiceProvider 가 `app.schedule_timezone` 을 세팅하고,
|
||||
@@ -46,6 +43,9 @@ Schedule::command('layout-previews:cleanup')->everyThirtyMinutes();
|
||||
| 읽어 대상 목록을 도출하므로, 정리 예약을 추가할 때는 반드시 이 안에 둔다.
|
||||
*/
|
||||
// gc-schedules:begin
|
||||
// 만료된 레이아웃 미리보기 정리 (30분마다)
|
||||
Schedule::command('layout-previews:cleanup')->everyThirtyMinutes()->onOneServer();
|
||||
|
||||
Schedule::command('sanctum:prune-expired', ['--hours' => 24])->dailyAt('04:00')->onOneServer();
|
||||
Schedule::command('auth:clear-resets')->dailyAt('04:05')->onOneServer();
|
||||
Schedule::command('queue:prune-failed')->dailyAt('04:10')->onOneServer();
|
||||
@@ -59,6 +59,8 @@ Schedule::command('activity-log:prune')->dailyAt('04:45')->onOneServer();
|
||||
Schedule::command('notification-log:prune')->dailyAt('04:50')->onOneServer();
|
||||
// 사용자 파일을 파기하므로 기본 꺼짐 — 커맨드가 `--scheduled` 에서 설정을 false 폴백으로 재확인한다.
|
||||
Schedule::command('attachments:prune-orphans --scheduled')->dailyAt('04:55')->onOneServer();
|
||||
// 중단된 업데이트·설치가 남긴 임시 산출물 + 오래된 백업본(최신 1개 보존) 정리
|
||||
Schedule::command('storage:prune-leftovers')->dailyAt('05:00')->onOneServer();
|
||||
|
||||
// 만료 시각이 지난 본인인증 challenge 상태 전환 (비파괴 — 물리 파기는 identity:prune-logs)
|
||||
Schedule::command('identity:expire-challenges')->hourly()->onOneServer();
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
- 목록에서 항목을 선택하면 선택 건수가 `{1}개 선택됨` 처럼 중괄호가 남은 채 표시되던 문제를 수정했습니다.
|
||||
- 목록에서 항목을 선택한 뒤 검색하거나 페이지를 넘기면, 화면에서 사라진 항목이 선택된 채로 남아 일괄 처리 대상에 포함되던 문제를 수정했습니다. 확인 창은 건수만 알려 주므로 실행 전에 알아챌 방법이 없었습니다. 이제 일괄 처리 대상은 언제나 화면에 보이면서 체크된 항목뿐입니다. 회원 목록·활동 기록·알림 발송 기록·스케줄 목록에 적용됩니다.
|
||||
- 환경설정에서 사이트 로고를 목록에서 빼도 화면에 그대로 남아, 로고 교체가 저장되지 않던 문제를 수정했습니다.
|
||||
- 스케줄 관리의 등록/수정 창에서 저장 버튼을 눌러도 아무 반응이 없던 문제를 수정했습니다. 함께, 작업 유형·실행 주기 선택란이 빈 값으로 표시되고 선택·켜기/끄기 값이 저장 요청에 실리지 않아 저장이 거절되던 문제도 수정했습니다. 실행 주기를 직접 입력(사용자 정의)하지 않은 경우에는 선택한 주기에 맞는 실행 시각이 자동으로 저장됩니다.
|
||||
|
||||
## [1.0.4] - 2026-08-10
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file admin-schedule-form-modal-submit.test.tsx
|
||||
* @description 스케줄 생성/수정 모달 — 저장 버튼의 폼 연결 회귀
|
||||
*
|
||||
* 배경:
|
||||
* footer 저장 버튼은 Form 밖에 있어 `props.form="{form id}"` (HTML form 속성)으로
|
||||
* 연결된다. 참조 대상 Form 에 `props.id` 가 없으면 브라우저는 버튼을 어떤 폼에도
|
||||
* 연결하지 않아 저장 클릭이 아무 일도 하지 않는다 — 예외도 콘솔 오류도 없이
|
||||
* 스케줄 등록/수정 저장만 조용히 불능이 된다 (menu_form 패턴 참조: 버튼
|
||||
* `form: "menu_form"` ↔ Form `id: "menu_form"`).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const formModal = require('../../layouts/partials/admin_schedule_list/_modal_form.json');
|
||||
|
||||
function collectNodes(node: any, predicate: (n: any) => boolean): any[] {
|
||||
const result: any[] = [];
|
||||
const visit = (n: any) => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (Array.isArray(n)) {
|
||||
n.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (predicate(n)) result.push(n);
|
||||
if (n.children) visit(n.children);
|
||||
};
|
||||
visit(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
describe('스케줄 폼 모달 — 저장 버튼 폼 연결', () => {
|
||||
it('submit 버튼의 form 참조마다 같은 id 의 Form 이 존재한다', () => {
|
||||
const submitButtons = collectNodes(
|
||||
formModal,
|
||||
(n) => n.name === 'Button' && n.props?.type === 'submit' && typeof n.props?.form === 'string'
|
||||
);
|
||||
expect(submitButtons.length, 'footer 저장 버튼(form 참조)이 있어야 한다').toBeGreaterThan(0);
|
||||
|
||||
const formIds = collectNodes(formModal, (n) => n.name === 'Form').map((n) => n.props?.id);
|
||||
|
||||
for (const button of submitButtons) {
|
||||
expect(
|
||||
formIds,
|
||||
`저장 버튼이 참조하는 form="${button.props.form}" 에 해당하는 Form id 가 없다 — 클릭이 무반응이 된다`
|
||||
).toContain(button.props.form);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('스케줄 폼 모달 — 폼 값 바인딩', () => {
|
||||
function submitBody(): Record<string, string> {
|
||||
const forms = collectNodes(formModal, (n) => n.name === 'Form' && Array.isArray(n.actions));
|
||||
const submit = forms[0].actions.find((a: any) => a.type === 'submit');
|
||||
const api = submit.actions.find((a: any) => a.handler === 'apiCall');
|
||||
return api.params.body;
|
||||
}
|
||||
|
||||
it('커스텀 Select 는 value 로 제어된다 (defaultValue 는 무시되어 빈 표시 + 폼 미반영)', () => {
|
||||
const selects = collectNodes(formModal, (n) => n.type === 'composite' && n.name === 'Select');
|
||||
expect(selects.length).toBe(2);
|
||||
|
||||
for (const select of selects) {
|
||||
expect(
|
||||
select.props.value,
|
||||
`Select(${select.props.name}) 는 value 로 제어해야 한다 — 커스텀 Select 는 defaultValue 를 렌더하지 않는다`
|
||||
).toBeTruthy();
|
||||
expect(select.props.defaultValue, `Select(${select.props.name}) 의 defaultValue 는 죽은 prop 이다`).toBeUndefined();
|
||||
|
||||
const change = (select.actions ?? []).find((a: any) => a.type === 'change');
|
||||
expect(change, `Select(${select.props.name}) 는 change 액션으로 상태에 기록해야 한다`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('Toggle 은 change 액션으로 boolean 을 상태에 기록한다 (자동바인딩은 "on" 문자열을 보내 422)', () => {
|
||||
const toggles = collectNodes(formModal, (n) => n.type === 'composite' && n.name === 'Toggle');
|
||||
expect(toggles.length).toBe(3);
|
||||
|
||||
for (const toggle of toggles) {
|
||||
const change = (toggle.actions ?? []).find((a: any) => a.type === 'change');
|
||||
expect(change, `Toggle(${toggle.props.name}) 에 change 액션이 없다`).toBeTruthy();
|
||||
const written = JSON.stringify(change.params);
|
||||
expect(
|
||||
written,
|
||||
`Toggle(${toggle.props.name}) 은 $event.target.checked (boolean) 를 기록해야 한다`
|
||||
).toContain('$event.target.checked');
|
||||
}
|
||||
});
|
||||
|
||||
it('submit body 의 Select/Toggle 값은 form.* 이 아니라 상태에서 읽는다', () => {
|
||||
const body = submitBody();
|
||||
|
||||
for (const field of ['type', 'frequency', 'without_overlapping', 'run_in_maintenance', 'is_active']) {
|
||||
expect(
|
||||
body[field],
|
||||
`body.${field} 가 form.* 를 읽는다 — 커스텀 Select/Toggle 값은 FormData 에 실리지 않거나 "on" 으로 실린다`
|
||||
).not.toBe(`{{form.${field}}}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('submit body 의 expression 은 비-custom 주기에서도 유효한 cron 을 보낸다', () => {
|
||||
const body = submitBody();
|
||||
// custom 이 아니면 expression 입력이 렌더되지 않아 form.expression 이 비어 서버 422 가 된다.
|
||||
expect(body.expression).not.toBe('{{form.expression}}');
|
||||
expect(body.expression).toContain("'custom'");
|
||||
});
|
||||
});
|
||||
@@ -147,7 +147,14 @@
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedSchedule": null,
|
||||
"isEditMode": false
|
||||
"isEditMode": false,
|
||||
"scheduleForm": {
|
||||
"type": "artisan",
|
||||
"frequency": "daily",
|
||||
"without_overlapping": true,
|
||||
"run_in_maintenance": false,
|
||||
"is_active": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+58
-17
@@ -16,6 +16,7 @@
|
||||
"type": "basic",
|
||||
"name": "Form",
|
||||
"props": {
|
||||
"id": "schedule_form_modal",
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
@@ -69,13 +70,23 @@
|
||||
"props": {
|
||||
"name": "type",
|
||||
"className": "w-full",
|
||||
"defaultValue": "{{_global.selectedSchedule?.type ?? 'artisan'}}",
|
||||
"value": "{{_global.scheduleForm?.type ?? 'artisan'}}",
|
||||
"options": [
|
||||
{ "label": "$t:admin.schedules.type.artisan", "value": "artisan" },
|
||||
{ "label": "$t:admin.schedules.type.shell", "value": "shell" },
|
||||
{ "label": "$t:admin.schedules.type.url", "value": "url" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"scheduleForm.type": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -160,7 +171,7 @@
|
||||
"props": {
|
||||
"name": "frequency",
|
||||
"className": "w-full",
|
||||
"defaultValue": "{{_global.selectedSchedule?.frequency ?? 'daily'}}",
|
||||
"value": "{{_global.scheduleForm?.frequency ?? 'daily'}}",
|
||||
"options": [
|
||||
{ "label": "$t:admin.schedules.frequency.everyMinute", "value": "everyMinute" },
|
||||
{ "label": "$t:admin.schedules.frequency.hourly", "value": "hourly" },
|
||||
@@ -176,7 +187,7 @@
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"scheduleFrequency": "{{$event.target.value}}"
|
||||
"scheduleForm.frequency": "{{$event.target.value}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -186,7 +197,7 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{(_global.scheduleFrequency ?? _global.selectedSchedule?.frequency) === 'custom'}}",
|
||||
"if": "{{(_global.scheduleForm?.frequency ?? 'daily') === 'custom'}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
@@ -288,9 +299,19 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "without_overlapping",
|
||||
"checked": "{{_global.selectedSchedule?.without_overlapping ?? true}}",
|
||||
"checked": "{{_global.scheduleForm?.without_overlapping ?? true}}",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"scheduleForm.without_overlapping": "{{$event.target.checked}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -322,9 +343,19 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "run_in_maintenance",
|
||||
"checked": "{{_global.selectedSchedule?.run_in_maintenance ?? false}}",
|
||||
"checked": "{{_global.scheduleForm?.run_in_maintenance ?? false}}",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"scheduleForm.run_in_maintenance": "{{$event.target.checked}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -356,9 +387,19 @@
|
||||
"name": "Toggle",
|
||||
"props": {
|
||||
"name": "is_active",
|
||||
"checked": "{{_global.selectedSchedule?.is_active ?? true}}",
|
||||
"checked": "{{_global.scheduleForm?.is_active ?? true}}",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"scheduleForm.is_active": "{{$event.target.checked}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -386,14 +427,14 @@
|
||||
"body": {
|
||||
"name": "{{form.name}}",
|
||||
"description": "{{form.description}}",
|
||||
"type": "{{form.type}}",
|
||||
"type": "{{_global.scheduleForm?.type ?? 'artisan'}}",
|
||||
"command": "{{form.command}}",
|
||||
"frequency": "{{form.frequency}}",
|
||||
"expression": "{{form.expression}}",
|
||||
"frequency": "{{_global.scheduleForm?.frequency ?? 'daily'}}",
|
||||
"expression": "{{(_global.scheduleForm?.frequency ?? 'daily') === 'custom' ? form.expression : ((_global.scheduleForm?.frequency ?? 'daily') === 'everyMinute' ? '* * * * *' : ((_global.scheduleForm?.frequency ?? 'daily') === 'hourly' ? '0 * * * *' : ((_global.scheduleForm?.frequency ?? 'daily') === 'weekly' ? '0 0 * * 0' : ((_global.scheduleForm?.frequency ?? 'daily') === 'monthly' ? '0 0 1 * *' : '0 0 * * *'))))}}",
|
||||
"timeout": "{{form.timeout}}",
|
||||
"without_overlapping": "{{form.without_overlapping}}",
|
||||
"run_in_maintenance": "{{form.run_in_maintenance}}",
|
||||
"is_active": "{{form.is_active}}"
|
||||
"without_overlapping": "{{_global.scheduleForm?.without_overlapping ?? true}}",
|
||||
"run_in_maintenance": "{{_global.scheduleForm?.run_in_maintenance ?? false}}",
|
||||
"is_active": "{{_global.scheduleForm?.is_active ?? true}}"
|
||||
}
|
||||
},
|
||||
"onSuccess": [
|
||||
|
||||
+8
-1
@@ -1753,7 +1753,14 @@
|
||||
"params": {
|
||||
"target": "global",
|
||||
"selectedSchedule": "{{$args[1]}}",
|
||||
"isEditMode": true
|
||||
"isEditMode": true,
|
||||
"scheduleForm": {
|
||||
"type": "{{$args[1].type ?? 'artisan'}}",
|
||||
"frequency": "{{$args[1].frequency ?? 'daily'}}",
|
||||
"without_overlapping": "{{!!$args[1].without_overlapping}}",
|
||||
"run_in_maintenance": "{{!!$args[1].run_in_maintenance}}",
|
||||
"is_active": "{{!!$args[1].is_active}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* sirsoft-admin_basic — 스케줄 등록/수정 모달 저장 회귀.
|
||||
*
|
||||
* 결함: footer 저장 버튼이 `form="schedule_form_modal"` 로 폼과 연결되는데 Form 에 id 가
|
||||
* 없어 클릭이 무반응이었고(예외·콘솔 오류 없음), 커스텀 Select 는 defaultValue 를
|
||||
* 렌더하지 않아 작업 유형·실행 주기가 빈 값으로 표시되며 저장 요청에 실리지 않았다.
|
||||
* Toggle 자동바인딩은 "on" 문자열을 보내 boolean 검증에서 422 가 됐다 — 세 결함이
|
||||
* 겹쳐 이 모달로는 스케줄을 한 건도 등록할 수 없었다. 전부 브라우저에서만 드러나는
|
||||
* 결함이라 단위 테스트(JSON 구조 단언)와 별개로 실제 저장 왕복을 고정한다.
|
||||
*
|
||||
* @scenario schedule-form-modal-save
|
||||
* @axes leg=create
|
||||
* @effects schedule_modal_save_creates_row
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { issueToken, authenticatePage } from '../../fixtures/admin-template-auth';
|
||||
|
||||
test.describe('@sirsoft-admin_basic 스케줄 폼 모달 저장', () => {
|
||||
// @scenario leg=create
|
||||
// @effects schedule_modal_save_creates_row
|
||||
test('등록 모달에서 저장하면 예약이 생성되어 목록에 나타난다 (원복 포함)', async ({ page }) => {
|
||||
const token = issueToken(
|
||||
'core.schedules.read',
|
||||
'core.schedules.create',
|
||||
'core.schedules.update',
|
||||
'core.schedules.delete'
|
||||
);
|
||||
await authenticatePage(page, token);
|
||||
await page.goto('/admin/schedules');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
const name = `E2E 스케줄 저장 회귀 ${Date.now()}`;
|
||||
|
||||
await page.getByRole('button', { name: /스케줄 등록/ }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 커스텀 Select 가 value 로 제어되어 기본값이 표시되어야 한다 (빈 표시 회귀).
|
||||
await expect(dialog.getByRole('button', { name: 'Artisan' })).toBeVisible();
|
||||
await expect(dialog.getByRole('button', { name: '매일' })).toBeVisible();
|
||||
|
||||
await dialog.locator('input[name="name"]').fill(name);
|
||||
await dialog.locator('input[name="command"]').fill('cache:clear');
|
||||
|
||||
const createResponse = page.waitForResponse(
|
||||
(res) => res.url().includes('/api/admin/schedules') && res.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
);
|
||||
await dialog.getByRole('button', { name: '저장' }).click();
|
||||
|
||||
// 저장 버튼이 폼과 연결되지 않으면 POST 자체가 발생하지 않는다 (무반응 회귀).
|
||||
const response = await createResponse;
|
||||
expect(response.status(), '스케줄 생성 요청이 거절되었다').toBe(201);
|
||||
|
||||
const created = (await response.json()) as { data: { id: number } };
|
||||
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
||||
await expect(page.getByText(name)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// 원복 — 검증용 예약 삭제 (페이지 인증 컨텍스트의 실제 API 경로)
|
||||
const deleteStatus = await page.evaluate(async (id) => {
|
||||
const res = await fetch(`/api/admin/schedules/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
return res.status;
|
||||
}, created.data.id);
|
||||
expect(deleteStatus, '검증용 예약 원복(삭제)이 실패했다').toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ class CoreScheduleRegistrationTest extends TestCase
|
||||
public function test_core_gc_schedules_are_registered(): void
|
||||
{
|
||||
$expected = [
|
||||
'layout-previews:cleanup' => '*/30 * * * *',
|
||||
'sanctum:prune-expired' => '0 4 * * *',
|
||||
'auth:clear-resets' => '5 4 * * *',
|
||||
'queue:prune-failed' => '10 4 * * *',
|
||||
@@ -53,6 +54,8 @@ class CoreScheduleRegistrationTest extends TestCase
|
||||
'identity:prune-logs' => '40 4 * * *',
|
||||
'activity-log:prune' => '45 4 * * *',
|
||||
'notification-log:prune' => '50 4 * * *',
|
||||
'attachments:prune-orphans' => '55 4 * * *',
|
||||
'storage:prune-leftovers' => '0 5 * * *',
|
||||
'identity:expire-challenges' => '0 * * * *',
|
||||
];
|
||||
|
||||
@@ -194,6 +197,7 @@ class CoreScheduleRegistrationTest extends TestCase
|
||||
*
|
||||
* 손으로 적은 목록은 예약이 늘어도 그대로라 전수를 증명하지 못합니다 —
|
||||
* 다중 서버 1대 실행(`onOneServer`)으로 선언된 artisan 예약을 그대로 읽습니다.
|
||||
* 예약 문자열의 옵션은 런타임 validator(`parseArtisanCommand`)와 동일하게 명령명만 남깁니다.
|
||||
*
|
||||
* @return array<int, string> 커맨드 이름 목록
|
||||
*/
|
||||
@@ -213,7 +217,10 @@ class CoreScheduleRegistrationTest extends TestCase
|
||||
|
||||
preg_match_all("/Schedule::command\(\s*'([^']+)'/", $region, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1] ?? []));
|
||||
return array_values(array_unique(array_map(
|
||||
static fn (string $command): string => (string) strtok($command, ' '),
|
||||
$matches[1] ?? []
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\DevTools;
|
||||
|
||||
use App\Support\DevTools\BrowserLogWriter;
|
||||
use Illuminate\Http\Request;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 브라우저 콘솔 로그 파일 로테이션 회귀 테스트.
|
||||
*
|
||||
* 배경:
|
||||
* `BrowserLogWriter::channel()` 이 `single` 드라이버로 로거를 만들면
|
||||
* `storage/logs/browser.log` 단일 파일에 로테이션 없이 무한 append 된다 —
|
||||
* 개발 도구를 켜 둔 사이트에서 이 파일이 저장소 단일 최대 파일(수백 MB)로
|
||||
* 자라도 어떤 경로도 회수하지 않는다. 날짜별 파일 + 유한 보존일을 고정한다.
|
||||
*
|
||||
* @scenario vector=browser_log_write, actor_permission=debug_gate_enabled
|
||||
*
|
||||
* @effects browser_logs_rotate_daily_with_finite_retention
|
||||
*/
|
||||
class BrowserLogRotationTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 브라우저 로그는 날짜별 파일에 기록되고, 단일 browser.log 에는 더 이상 쓰지 않는다.
|
||||
*/
|
||||
public function test_browser_logs_are_written_to_a_dated_file(): void
|
||||
{
|
||||
$datedPath = storage_path('logs/browser-'.now()->format('Y-m-d').'.log');
|
||||
$singlePath = storage_path('logs/browser.log');
|
||||
|
||||
$datedExistedBefore = file_exists($datedPath);
|
||||
// 레거시 단일 파일은 수백 MB 일 수 있다 — 내용을 읽지 않고 크기만 대조한다.
|
||||
$singleSizeBefore = file_exists($singlePath) ? filesize($singlePath) : null;
|
||||
|
||||
$marker = 'browser-log-rotation-regression-'.uniqid();
|
||||
|
||||
try {
|
||||
$request = Request::create('/_boost/browser-logs', 'POST', [
|
||||
'logs' => [
|
||||
['type' => 'log', 'data' => [$marker], 'url' => 'https://example.test/'],
|
||||
],
|
||||
]);
|
||||
|
||||
BrowserLogWriter::handle($request);
|
||||
|
||||
$this->assertFileExists($datedPath, '브라우저 로그가 날짜별 파일에 기록되지 않았다');
|
||||
$this->assertStringContainsString(
|
||||
$marker,
|
||||
$this->tail($datedPath),
|
||||
'날짜별 파일 꼬리에서 이번 기록을 찾지 못했다'
|
||||
);
|
||||
|
||||
clearstatcache();
|
||||
$singleSizeAfter = file_exists($singlePath) ? filesize($singlePath) : null;
|
||||
$this->assertSame(
|
||||
$singleSizeBefore,
|
||||
$singleSizeAfter,
|
||||
'레거시 단일 browser.log 에 여전히 기록이 추가되고 있다'
|
||||
);
|
||||
} finally {
|
||||
if (! $datedExistedBefore && file_exists($datedPath)) {
|
||||
@unlink($datedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 로거는 로테이션 핸들러를 쓰고 보존일이 유한해야 한다 (0 = 무제한 금지).
|
||||
*/
|
||||
public function test_browser_log_channel_uses_rotating_handler_with_finite_retention(): void
|
||||
{
|
||||
$method = new \ReflectionMethod(BrowserLogWriter::class, 'channel');
|
||||
/** @var LoggerInterface $logger */
|
||||
$logger = $method->invoke(null);
|
||||
|
||||
$handlers = $logger->getLogger()->getHandlers();
|
||||
$this->assertNotEmpty($handlers, '로그 핸들러를 확인할 수 없다');
|
||||
|
||||
$rotating = null;
|
||||
foreach ($handlers as $handler) {
|
||||
if ($handler instanceof RotatingFileHandler) {
|
||||
$rotating = $handler;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull($rotating, '브라우저 로그 채널이 로테이션(daily) 핸들러를 쓰지 않는다');
|
||||
|
||||
$maxFiles = new \ReflectionProperty(RotatingFileHandler::class, 'maxFiles');
|
||||
$this->assertGreaterThan(
|
||||
0,
|
||||
$maxFiles->getValue($rotating),
|
||||
'보존일이 무제한(0)이다 — 날짜별 파일이 무한 누적된다'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 꼬리(마지막 16KB)를 읽습니다 — 큰 로그 파일 전체 적재 방지.
|
||||
*
|
||||
* @param string $path 대상 파일 경로
|
||||
* @return string 꼬리 내용
|
||||
*/
|
||||
private function tail(string $path): string
|
||||
{
|
||||
$size = (int) filesize($path);
|
||||
$handle = fopen($path, 'rb');
|
||||
|
||||
if ($handle === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
fseek($handle, max(0, $size - 16384));
|
||||
|
||||
return (string) stream_get_contents($handle);
|
||||
} finally {
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
@@ -768,6 +769,51 @@ class LanguagePackServiceTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 설치가 성공해도 다운로드 임시 디렉토리(storage/app/temp/{uuid})가 남지 않는다.
|
||||
*
|
||||
* 임시 디렉토리 삭제가 catch 경로에만 있으면 실패 시에는 정리되고 성공 시에는
|
||||
* 100% 잔존한다 — 예외도 로그도 없이 설치할 때마다 ZIP 사본이 쌓이는 누수다.
|
||||
* 성공 경로를 실제로 통과시켜 잔존물이 0건임을 고정한다.
|
||||
*
|
||||
* @scenario vector=install_from_url, actor_permission=cli_system
|
||||
*
|
||||
* @effects successful_install_leaves_no_temp_directory
|
||||
*/
|
||||
public function test_install_from_url_success_leaves_no_temp_directory(): void
|
||||
{
|
||||
$identifier = 'tmpleak-core-ja';
|
||||
$zipPath = tempnam(sys_get_temp_dir(), 'lp_zip');
|
||||
$zip = new \ZipArchive;
|
||||
$zip->open($zipPath, \ZipArchive::OVERWRITE);
|
||||
$zip->addFromString(
|
||||
'language-pack.json',
|
||||
(string) json_encode($this->coreJaManifest($identifier, 'tmpleak', '1.0.0'), JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
$zip->close();
|
||||
|
||||
Http::fake(['https://packs.example.com/*' => Http::response((string) file_get_contents($zipPath), 200)]);
|
||||
|
||||
$tempRoot = storage_path('app/temp');
|
||||
$before = File::isDirectory($tempRoot) ? File::directories($tempRoot) : [];
|
||||
|
||||
try {
|
||||
$pack = $this->service->installFromUrl('https://packs.example.com/pack.zip', null);
|
||||
|
||||
$this->assertSame($identifier, $pack->identifier);
|
||||
|
||||
$after = File::isDirectory($tempRoot) ? File::directories($tempRoot) : [];
|
||||
$this->assertSame(
|
||||
[],
|
||||
array_values(array_diff($after, $before)),
|
||||
'설치가 성공했는데 storage/app/temp 에 다운로드 임시 디렉토리가 남았다'
|
||||
);
|
||||
} finally {
|
||||
@unlink($zipPath);
|
||||
File::deleteDirectory(base_path('lang-packs/'.$identifier));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 ja 번들 픽스처용 manifest 를 만듭니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Services\StorageLeftoverPruneService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 스토리지 잔존물 정리 서비스 회귀 테스트 (`storage:prune-leftovers`)
|
||||
*
|
||||
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(#120 동형 — 산출물 누적 +
|
||||
* 회수 경로 부재)을 mtime 기준으로 회수하되, 파괴 방지 가드(정상 대기 확장 불가침 ·
|
||||
* 최신 백업 상시 보존 · dry-run 무삭제)를 함께 고정한다.
|
||||
*
|
||||
* 픽스처는 실제 저장소 경로가 아닌 격리 루트에 만든다 — 서비스 생성자의 루트 주입이
|
||||
* 그 격리 지점이다.
|
||||
*/
|
||||
class StorageLeftoverPruneServiceTest extends TestCase
|
||||
{
|
||||
/** @var string 픽스처 격리 루트 */
|
||||
private string $fixtureRoot;
|
||||
|
||||
/** @var string base_path 대응 루트 */
|
||||
private string $baseRoot;
|
||||
|
||||
/** @var string storage_path 대응 루트 */
|
||||
private string $storageRoot;
|
||||
|
||||
private StorageLeftoverPruneService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fixtureRoot = storage_path('framework/testing/leftover-prune-'.uniqid());
|
||||
$this->baseRoot = $this->fixtureRoot.DIRECTORY_SEPARATOR.'base';
|
||||
$this->storageRoot = $this->fixtureRoot.DIRECTORY_SEPARATOR.'storage';
|
||||
|
||||
File::ensureDirectoryExists($this->baseRoot);
|
||||
File::ensureDirectoryExists($this->storageRoot);
|
||||
|
||||
$this->service = new StorageLeftoverPruneService($this->baseRoot, $this->storageRoot);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
File::deleteDirectory($this->fixtureRoot);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 보존일이 지난 스테이징·임시·vendor 번들 잔존물만 삭제되고, 신선한 것은 남는다.
|
||||
*
|
||||
* @scenario leftover_target=update_staging, leftover_mode=real_delete, leftover_guard=aged_out
|
||||
*
|
||||
* @effects aged_leftovers_are_pruned_after_retention, fresh_leftovers_are_kept
|
||||
*/
|
||||
public function test_aged_leftovers_are_pruned_and_fresh_ones_are_kept(): void
|
||||
{
|
||||
// 업데이트 스테이징 (타임스탬프 접미사) + 교체 임시 마커
|
||||
$agedStaging = $this->makeDir('base/modules/_pending/acme-shop_20260701_010101', 5);
|
||||
$agedSwap = $this->makeDir('base/modules/_pending/acme-shop_updating_0123456789abc', 5);
|
||||
$freshStaging = $this->makeDir('base/plugins/_pending/acme-pay_20260817_010101', 0);
|
||||
|
||||
// 코어 임시 (디렉토리 + 파일 혼재)
|
||||
$agedTempDir = $this->makeDir('storage/app/temp/module_update_deadbeef00000', 5);
|
||||
$agedTempFile = $this->makeFile('storage/app/temp/core_remote_changelog.md', 5);
|
||||
$freshTempDir = $this->makeDir('storage/app/temp/plugin_update_cafebabe00000', 0);
|
||||
|
||||
// vendor 번들 스테이징
|
||||
$agedBuild = $this->makeDir('storage/app/vendor-bundle-staging/build-0011223344556.77889900', 5);
|
||||
$freshBuild = $this->makeDir('storage/app/vendor-bundle-staging/build-aabbccddeeff0.11223344', 0);
|
||||
|
||||
$result = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
|
||||
$this->assertDirectoryDoesNotExist($agedStaging);
|
||||
$this->assertDirectoryDoesNotExist($agedSwap);
|
||||
$this->assertDirectoryExists($freshStaging, '보존일 이내 스테이징이 삭제되었다');
|
||||
|
||||
$this->assertDirectoryDoesNotExist($agedTempDir);
|
||||
$this->assertFileDoesNotExist($agedTempFile);
|
||||
$this->assertDirectoryExists($freshTempDir, '보존일 이내 임시 디렉토리가 삭제되었다');
|
||||
|
||||
$this->assertDirectoryDoesNotExist($agedBuild);
|
||||
$this->assertDirectoryExists($freshBuild, '보존일 이내 번들 스테이징이 삭제되었다');
|
||||
|
||||
$this->assertCount(2, $result['staging']);
|
||||
$this->assertCount(2, $result['temp']);
|
||||
$this->assertCount(1, $result['vendor_bundle_staging']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업은 보존일 경과분만 삭제하되, identifier 별 최신 1개는 나이와 무관하게 보존한다.
|
||||
*
|
||||
* @scenario leftover_target=extension_backups, leftover_mode=real_delete, leftover_guard=latest_backup_kept
|
||||
*
|
||||
* @effects latest_backup_is_always_kept
|
||||
*/
|
||||
public function test_latest_backup_is_kept_regardless_of_age(): void
|
||||
{
|
||||
// 확장 백업 — 같은 identifier 의 오래된 2개: 최신(40일)은 보존, 그 이전(50일)은 삭제
|
||||
$extOldest = $this->makeDir('storage/app/extension_backups/modules/acme-shop_20260601_010101', 50);
|
||||
$extNewest = $this->makeDir('storage/app/extension_backups/modules/acme-shop_20260708_010101', 40);
|
||||
// 다른 identifier 의 보존일 이내 백업은 최신이 아니어도 남는다
|
||||
$extRecentA = $this->makeDir('storage/app/extension_backups/modules/acme-blog_20260810_010101', 7);
|
||||
$extRecentB = $this->makeDir('storage/app/extension_backups/modules/acme-blog_20260815_010101', 2);
|
||||
|
||||
// 코어 백업 — 최신 1개 보존, 그 이전 삭제
|
||||
$coreOldest = $this->makeDir('storage/app/core_backups/core_20260601_020202', 50);
|
||||
$coreNewest = $this->makeDir('storage/app/core_backups/core_20260708_020202', 40);
|
||||
|
||||
$result = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
|
||||
$this->assertDirectoryDoesNotExist($extOldest);
|
||||
$this->assertDirectoryExists($extNewest, '나이와 무관하게 최신 백업 1개는 보존해야 한다 (롤백 능력)');
|
||||
$this->assertDirectoryExists($extRecentA, '보존일 이내 백업은 최신이 아니어도 남아야 한다');
|
||||
$this->assertDirectoryExists($extRecentB);
|
||||
|
||||
$this->assertDirectoryDoesNotExist($coreOldest);
|
||||
$this->assertDirectoryExists($coreNewest, '코어도 최신 백업 1개는 상시 보존해야 한다');
|
||||
|
||||
$this->assertSame([$extOldest], $result['extension_backups']);
|
||||
$this->assertSame([$coreOldest], $result['core_backups']);
|
||||
}
|
||||
|
||||
/**
|
||||
* `_pending` 직하위의 정상 대기 확장 디렉토리(타임스탬프 접미사 없음)는 불가침이다.
|
||||
*
|
||||
* @scenario leftover_target=update_staging, leftover_mode=real_delete, leftover_guard=normal_pending_untouched
|
||||
*
|
||||
* @effects normal_pending_extension_dirs_are_untouched
|
||||
*/
|
||||
public function test_normal_pending_extension_directories_are_untouched(): void
|
||||
{
|
||||
// 다운로드 대기 정상 확장 — 오래되어도 삭제 대상이 아니다
|
||||
$pendingModule = $this->makeDir('base/modules/_pending/acme-shop', 90);
|
||||
$pendingTemplate = $this->makeDir('base/templates/_pending/acme-admin_basic', 90);
|
||||
|
||||
$result = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
|
||||
$this->assertDirectoryExists($pendingModule, '정상 대기 확장 디렉토리가 삭제되었다');
|
||||
$this->assertDirectoryExists($pendingTemplate);
|
||||
$this->assertSame([], $result['staging']);
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run 은 아무것도 삭제하지 않고 삭제 대상 목록만 돌려준다.
|
||||
*
|
||||
* @scenario leftover_target=core_temp, leftover_mode=dry_run, leftover_guard=aged_out
|
||||
*
|
||||
* @effects dry_run_deletes_nothing
|
||||
*/
|
||||
public function test_dry_run_deletes_nothing_and_reports_targets(): void
|
||||
{
|
||||
$agedStaging = $this->makeDir('base/modules/_pending/acme-shop_20260701_010101', 5);
|
||||
$agedTemp = $this->makeDir('storage/app/temp/ext_zip_0123456789abc', 5);
|
||||
$legacyLog = $this->makeFile('storage/logs/browser.log', 1);
|
||||
|
||||
$result = $this->service->prune(days: 3, backupDays: 30, dryRun: true);
|
||||
|
||||
$this->assertDirectoryExists($agedStaging, 'dry-run 이 스테이징을 실제로 삭제했다');
|
||||
$this->assertDirectoryExists($agedTemp, 'dry-run 이 임시 디렉토리를 실제로 삭제했다');
|
||||
$this->assertFileExists($legacyLog, 'dry-run 이 레거시 로그를 실제로 삭제했다');
|
||||
|
||||
$this->assertSame([$agedStaging], $result['staging']);
|
||||
$this->assertSame([$agedTemp], $result['temp']);
|
||||
$this->assertSame([$legacyLog], $result['legacy_browser_log']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정리 직후 재실행은 아무것도 삭제하지 않는다 (멱등).
|
||||
*
|
||||
* @scenario leftover_target=vendor_bundle_staging, leftover_mode=real_delete, leftover_guard=idempotent_second_run
|
||||
*
|
||||
* @effects leftover_prune_is_idempotent
|
||||
*/
|
||||
public function test_second_run_prunes_nothing(): void
|
||||
{
|
||||
$this->makeDir('base/modules/_pending/acme-shop_20260701_010101', 5);
|
||||
$this->makeDir('storage/app/temp/module_update_deadbeef00000', 5);
|
||||
$this->makeDir('storage/app/vendor-bundle-staging/build-0011223344556.77889900', 5);
|
||||
$this->makeDir('storage/app/extension_backups/modules/acme-shop_20260601_010101', 50);
|
||||
$this->makeDir('storage/app/extension_backups/modules/acme-shop_20260708_010101', 40);
|
||||
$this->makeDir('storage/app/core_backups/core_20260601_020202', 50);
|
||||
$this->makeDir('storage/app/core_backups/core_20260708_020202', 40);
|
||||
$this->makeFile('storage/logs/browser.log', 1);
|
||||
|
||||
$first = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
$second = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
|
||||
$this->assertGreaterThan(0, array_sum(array_map('count', $first)), '1회차가 아무것도 지우지 않으면 멱등 검증이 무의미하다');
|
||||
$this->assertSame(0, array_sum(array_map('count', $second)), '2회차가 무언가를 또 지웠다 — 멱등이 아니다');
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 단일 browser.log 는 나이와 무관하게 삭제하되, 날짜별 로그는 건드리지 않는다.
|
||||
*
|
||||
* @scenario leftover_target=legacy_browser_log, leftover_mode=real_delete, leftover_guard=aged_out
|
||||
*
|
||||
* @effects legacy_browser_log_is_removed
|
||||
*/
|
||||
public function test_legacy_browser_log_is_removed_but_dated_logs_are_kept(): void
|
||||
{
|
||||
$legacy = $this->makeFile('storage/logs/browser.log', 0);
|
||||
$dated = $this->makeFile('storage/logs/browser-2026-08-17.log', 10);
|
||||
|
||||
$result = $this->service->prune(days: 3, backupDays: 30, dryRun: false);
|
||||
|
||||
$this->assertFileDoesNotExist($legacy, '레거시 단일 browser.log 는 writer 가 사라졌으므로 무조건 삭제한다');
|
||||
$this->assertFileExists($dated, '날짜별 로그는 daily 드라이버가 자체 정리한다 — 대상이 아니다');
|
||||
$this->assertSame([$legacy], $result['legacy_browser_log']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 픽스처 디렉토리를 만들고 mtime 을 지정 일수만큼 과거로 되돌립니다.
|
||||
*
|
||||
* 내부에 파일을 하나 두어 실제 잔존물 형태(비어 있지 않은 디렉토리)를 재현하고,
|
||||
* 내용 생성이 디렉토리 mtime 을 갱신하므로 touch 는 마지막에 수행한다.
|
||||
*
|
||||
* @param string $relative 픽스처 루트 기준 상대 경로
|
||||
* @param int $ageDays 현재로부터 며칠 전의 mtime 으로 둘지
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
private function makeDir(string $relative, int $ageDays): string
|
||||
{
|
||||
$path = $this->fixtureRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
File::ensureDirectoryExists($path);
|
||||
file_put_contents($path.DIRECTORY_SEPARATOR.'payload.txt', 'leftover');
|
||||
|
||||
$mtime = time() - $ageDays * 86400;
|
||||
touch($path.DIRECTORY_SEPARATOR.'payload.txt', $mtime);
|
||||
touch($path, $mtime);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 픽스처 파일을 만들고 mtime 을 지정 일수만큼 과거로 되돌립니다.
|
||||
*
|
||||
* @param string $relative 픽스처 루트 기준 상대 경로
|
||||
* @param int $ageDays 현재로부터 며칠 전의 mtime 으로 둘지
|
||||
* @return string 절대 경로
|
||||
*/
|
||||
private function makeFile(string $relative, int $ageDays): string
|
||||
{
|
||||
$path = $this->fixtureRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
File::ensureDirectoryExists(dirname($path));
|
||||
file_put_contents($path, 'leftover');
|
||||
touch($path, time() - $ageDays * 86400);
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,16 @@ axes:
|
||||
row_age: [within_retention, beyond_retention]
|
||||
schedule_axis: [code_registered, admin_registered]
|
||||
timezone_setting: [configured, empty]
|
||||
# 스토리지 잔존물 GC (#120 후속 — storage:prune-leftovers): 대상군 × dry-run × 보존 가드
|
||||
leftover_target:
|
||||
- update_staging
|
||||
- core_temp
|
||||
- vendor_bundle_staging
|
||||
- extension_backups
|
||||
- core_backups
|
||||
- legacy_browser_log
|
||||
leftover_mode: [real_delete, dry_run]
|
||||
leftover_guard: [aged_out, fresh_kept, latest_backup_kept, normal_pending_untouched, idempotent_second_run]
|
||||
|
||||
exclusions:
|
||||
- { target: identity_challenges, row_age: beyond_retention, reason: "상태 전환만 수행하며 삭제하지 않음 — 만료 여부는 expires_at 이 판정" }
|
||||
@@ -65,11 +75,29 @@ effects:
|
||||
- automated_prune_emits_its_own_hooks
|
||||
- retention_floor_is_owned_by_the_domain_layer
|
||||
- large_purge_is_split_into_batches
|
||||
# 스토리지 잔존물 GC (#120 후속)
|
||||
- aged_leftovers_are_pruned_after_retention
|
||||
- fresh_leftovers_are_kept
|
||||
- latest_backup_is_always_kept
|
||||
- normal_pending_extension_dirs_are_untouched
|
||||
- dry_run_deletes_nothing
|
||||
- leftover_prune_is_idempotent
|
||||
- legacy_browser_log_is_removed
|
||||
- browser_logs_rotate_daily_with_finite_retention
|
||||
- successful_install_leaves_no_temp_directory
|
||||
|
||||
# E2E axis 없음 (스토리지 잔존물 GC): 브라우저 표면은 dev-dashboard 의 실행 버튼뿐이고
|
||||
# blade 는 frontend-change-requires-e2e 룰 대상 경로가 아니다. 판정(패턴 매칭·mtime·
|
||||
# 보존 가드)은 전부 서버측 파일시스템 연산이라 서버측 단위 테스트가 같은 것을 더 촘촘히
|
||||
# 고정한다. 대시보드 버튼 노출 자체는 CoreScheduleRegistrationTest 가 소스 대조로 단언한다.
|
||||
|
||||
test_files:
|
||||
- tests/Feature/Console/CoreScheduleRegistrationTest.php
|
||||
- tests/Feature/Console/DataRetentionCommandsTest.php
|
||||
- tests/Unit/Repositories/DeletesInBatchesTest.php
|
||||
- tests/Unit/Services/StorageLeftoverPruneServiceTest.php
|
||||
- tests/Feature/DevTools/BrowserLogRotationTest.php
|
||||
- tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php
|
||||
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Console/PruneExpiredTempOrdersCommandTest.php
|
||||
|
||||
rules_layer_coverage:
|
||||
|
||||
Reference in New Issue
Block a user