Files
Gnuboard7/tests/Feature/Api/Admin/SettingsStaticCacheTest.php
T
HeuJung de18159986 fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다
- config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단
- custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장
- 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제)
- 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비
- sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트
- 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
2026-09-06 15:22:52 +09:00

234 lines
9.2 KiB
PHP

<?php
namespace Tests\Feature\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* 초기 화면 정적 파일(정적 게시) 상태 조회 + 관리자 수동 복구 API 테스트 (#651 U1·U2·U6).
*
* 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다 —
* 관리자 화면의 [지금 다시 만들기] 가 그 안전망이다. 이 테스트는 그 통로(권한 경계 · 진단 결과
* 200 규약 · 버전 증가 + 게시)를 잠근다.
*/
class SettingsStaticCacheTest extends TestCase
{
use RefreshDatabase;
private const VERSION = 987650000;
private string $isolatedPublicPath;
protected function setUp(): void
{
parent::setUp();
// 실 게시 트리(public/build/ext) 격리 — 운영 중 사이트의 게시본을 건드리지 않는다
$this->isolatedPublicPath = storage_path('framework/testing/public-api-'.getmypid());
File::ensureDirectoryExists($this->isolatedPublicPath);
$this->app->usePublicPath($this->isolatedPublicPath);
ClearsTemplateCaches::resetExtensionCacheStoreMemo();
Cache::put('g7:core:ext.cache_version', self::VERSION);
Cache::forget('g7:core:ext.static.publish_failure');
Cache::lock('ext-static.publish.'.self::VERSION, 300)->forceRelease();
ExtensionStaticCacheService::resetPublishScheduleForTesting();
File::deleteDirectory(public_path('build/ext'));
}
protected function tearDown(): void
{
File::deleteDirectory(public_path('build/ext'));
File::deleteDirectory($this->isolatedPublicPath);
ExtensionStaticCacheService::resetPublishScheduleForTesting();
parent::tearDown();
}
/**
* 권한을 가진 관리자를 만들고 Bearer 토큰을 돌려줍니다.
*
* @param array<int, string> $permissions 권한 식별자 목록
* @return string plainText 토큰
*/
private function tokenWith(array $permissions): string
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permissionIds[] = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
)->id;
}
$role = Role::create([
'identifier' => 'static_cache_test_'.uniqid(),
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'description' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'is_active' => true,
]);
$role->permissions()->sync($permissionIds);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => true,
]
);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($role->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh()->createToken('test')->plainTextToken;
}
/**
* 상태 조회는 CLI 와 같은 보고서 필드를 돌려준다.
*
* @scenario permission=granted, publishable=non_production, outcome=success
*
* @effects status_endpoint_returns_report_fields
*/
public function test_status_returns_report_fields(): void
{
$response = $this->withToken($this->tokenWith(['core.settings.read']))
->getJson('/api/admin/settings/static-cache');
$response->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('data.version', self::VERSION)
->assertJsonPath('data.published', false)
->assertJsonPath('data.publishable', false)
->assertJsonStructure(['data' => [
'enabled', 'publishable', 'environment', 'version', 'published', 'files',
'published_at', 'tree_writable', 'process_user', 'failure', 'retained_versions',
]]);
// 응답 메시지가 키 그대로 새어 나가지 않는다
$this->assertStringNotContainsString('settings.', (string) $response->json('message'));
}
/**
* 프로덕션에서 재게시하면 버전이 오르고 새 버전이 게시된다.
*
* @scenario permission=granted, publishable=production_enabled, outcome=success
*
* @effects republish_bumps_version_and_publishes
*/
public function test_republish_bumps_version_and_publishes_in_production(): void
{
$this->app['env'] = 'production';
ExtensionStaticCacheService::fakeRootProcessForTesting(false);
$response = $this->withToken($this->tokenWith(['core.settings.read', 'core.settings.update']))
->postJson('/api/admin/settings/static-cache/republish');
$response->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('data.republished', true)
->assertJsonPath('data.published', true)
->assertJsonPath('data.publishable', true)
->assertJsonPath('data.previous_version', self::VERSION);
$newVersion = (int) $response->json('data.version');
$this->assertGreaterThan(self::VERSION, $newVersion, '재게시가 캐시 버전을 올리지 않았다');
$this->assertTrue((bool) $response->json('data.version_changed'));
$this->assertFileExists(public_path('build/ext/'.$newVersion.'/manifest.json'));
}
/**
* 비프로덕션에서는 bump 하지 않고 200 + `republished=false` 로 사유를 돌려준다 (진단 결과 규약).
*
* @scenario permission=granted, publishable=non_production, outcome=publish_failed_marker
*
* @effects republish_disabled_when_not_publishable
*/
public function test_republish_outside_production_is_a_diagnostic_result_not_an_error(): void
{
$response = $this->withToken($this->tokenWith(['core.settings.read', 'core.settings.update']))
->postJson('/api/admin/settings/static-cache/republish');
$response->assertOk()
->assertJsonPath('data.republished', false)
->assertJsonPath('data.publishable', false)
->assertJsonPath('data.reason', 'not_production')
->assertJsonPath('data.version', self::VERSION);
$this->assertSame(self::VERSION, (int) Cache::get('g7:core:ext.cache_version'), '게시가 쓰이지 않는 환경에서 버전을 올렸다');
}
/**
* kill-switch 가 꺼져 있으면 `enabled=false` 이고 재게시는 `disabled` 사유로 거절된다.
*
* @scenario permission=granted, publishable=kill_switch_off, outcome=publish_failed_marker
*
* @effects republish_disabled_when_not_publishable
*/
public function test_kill_switch_off_reports_disabled(): void
{
config(['core.static_cache.enabled' => false]);
$this->app['env'] = 'production';
$token = $this->tokenWith(['core.settings.read', 'core.settings.update']);
$this->withToken($token)->getJson('/api/admin/settings/static-cache')
->assertOk()
->assertJsonPath('data.enabled', false)
->assertJsonPath('data.publishable', false);
$this->withToken($token)->postJson('/api/admin/settings/static-cache/republish')
->assertOk()
->assertJsonPath('data.republished', false)
->assertJsonPath('data.reason', 'disabled');
}
/**
* 읽기 권한만으로는 재게시할 수 없다.
*
* @scenario permission=denied, publishable=production_enabled, outcome=success
*
* @effects republish_denied_without_settings_update_permission
*/
public function test_republish_requires_settings_update_permission(): void
{
$this->withToken($this->tokenWith(['core.settings.read']))
->postJson('/api/admin/settings/static-cache/republish')
->assertStatus(403);
}
/**
* 비인증 요청은 401 이다.
*
* @scenario permission=denied, publishable=non_production, outcome=success
*
* @effects republish_denied_without_settings_update_permission
*/
public function test_unauthenticated_requests_are_rejected(): void
{
$this->getJson('/api/admin/settings/static-cache')->assertStatus(401);
$this->postJson('/api/admin/settings/static-cache/republish')->assertStatus(401);
}
}