7.0.11 인스톨러·코어 업데이트 변경(e60a82d73)에 대해 과거 회귀 22건을 부류별로 대조한
결과, 신규 노출면 1건과 테스트 위생 1건이 나와 인터뷰 결정대로 조치했다.
1. argv 채널의 SAPI 게이트 — CGI/FPM 은 register_argc_argv=On 이면 $_SERVER['argv'] 를
쿼리스트링을 '+' 로 쪼개 채우므로(`GET /?x+core:update` → argv[1]==='core:update', php-cgi
실측) 비인증 웹 요청이 업데이트 트리로 판정되어 bootstrap/app.php 자가 치유가 요청마다
패키지 매니페스트를 지우고 다시 만들었다. CoreUpdateContext 와 bootstrap/app.php 복제본
모두 argv 를 cli·phpdbg 에서만 읽는다. env 플래그 채널은 웹에서 주입할 수 없으므로 그대로
두어 웹 요청 안에서 시작하는 업데이트 흐름(7.1.0)에 영향이 없다. 동형성 테스트에 SAPI 축을
더했다.
2. 매니페스트 삭제 실패 기록 — PackageManifestCacheHelper::clear 가 지우지 못한 파일의
경로를 돌려주고, spawn 직전 호출부가 업그레이드 로그·콘솔에 경고로 남긴다. 권한·소유권
불일치면 자식의 자가 치유도 같은 이유로 실패해 증상은 제보와 같은 「Class not found」 인데,
이 경고가 원인이 권한이라는 유일한 흔적이다.
3. 테스트 격리 — tests/bootstrap.php 가 APP_PACKAGES_CACHE/APP_SERVICES_CACHE 를 테스트
전용 경로로 돌린다. proc_open 으로 자식을 띄우는 기존 테스트 2종의 자식이 개발 클론의
실제 bootstrap/cache 매니페스트를 지우고 다시 쓰던 것(stat 실측)을 부모·자식 함께 막는다.
관리자 [시스템 최적화] 경로(withPreservedContainer 파사드 복원의 미실측 형제 호출처)는
임시 설치본에서 API 로 실측했다 — 200, 설정·라우트 캐시 재생성, 후속 요청 200, 로그 오류 0.
4. 트러블슈팅 사례 ↔ 회귀 테스트 앵커 계약 — 신규 사례는 헤딩에 <!-- case:{영역}-{번호} -->
앵커를 달고 같은 문자열을 그 사례를 잠그는 회귀 테스트에도 남겨야 한다. 사례 번호는
문서마다 1부터 재시작하고 병합으로 중복되므로(이번 리베이스에서도 우리 사례가 develop 과
같은 29 였다가 31 로 밀렸다), 개수만 대조하면 다른 사례를 덮는 테스트도 초록이 된다.
그런데 판정기 check-troubleshooting-test-coverage.cjs 를 부르는 지점이 저장소에 하나도
없었다 — 스크립트 자체 주석에만 실행법이 적혀 있어 아무도 부르지 않으면 영원히 돌지
않았고, 그 사이 위반이 9건 쌓였다(backend 26~31, cache 17~19). 돌지 않는 대조는 아무것도
잠그지 못하므로 위반 해소와 실행 지점 부여를 함께 한다.
9건 전부에 앵커를 부착하고(각 사례가 선언한 회귀 테스트 중 가장 구체적인 파일에 배치,
한 파일이 두 사례에 선언된 경우는 갈라 배치), stop-guard 7.2 에 앵커 계약 + 미커버
baseline ratchet 두 축으로 등록했다. 트러블슈팅 사례 추가 프로토콜에 6단계를
더하고 coverage 에 troubleshooting-case-anchor-contract(manual-only, 전용 판정기 위임)를
등재했다. 판정기 종료코드 1 → 0, 미커버 건수는 전 문서 baseline 그대로다.
456 lines
17 KiB
PHP
456 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Settings;
|
|
|
|
use App\Support\EnvPriority;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* [case:backend-26] `.env` 키 단위 우선 규약의 계약 테스트.
|
|
*
|
|
* 이 결함군은 오류를 남기지 않는다 — 매핑이 뒤처지거나 배선이 빠지면 잠금이 조용히
|
|
* 미발동하고, 그 상태는 "그 키가 `.env` 에 없다"와 화면상 구분되지 않는다. 그래서
|
|
* 판정을 행위가 아니라 **구조**(맵 패리티 · 캡처 일치 · 배선 실존)로 고정한다.
|
|
*
|
|
* @effects explicit_env_skips_injection, empty_string_env_not_explicit
|
|
*/
|
|
class EnvPriorityContractTest extends TestCase
|
|
{
|
|
/**
|
|
* 주입 대상 카테고리의 모든 defaults 키는 MAP 이나 EXEMPT 에 등재되어 있습니다.
|
|
*
|
|
* Provider 에 새 주입이 추가되면 이 단언이 red 가 되어, 그 키가 `.env` 대응을 갖는지
|
|
* 판단하도록 강제합니다. 등재를 잊으면 그 키는 영원히 settings 승으로 남습니다.
|
|
*
|
|
* @effects map_parity_covers_every_injected_default_key
|
|
*/
|
|
public function test_모든_주입_카테고리_키가_맵이나_면제목록에_등재된다(): void
|
|
{
|
|
$defaults = $this->loadDefaults();
|
|
$unregistered = [];
|
|
|
|
foreach (EnvPriority::INJECTED_CATEGORIES as $category) {
|
|
foreach (array_keys($defaults[$category] ?? []) as $key) {
|
|
$storageKey = $category.'.'.$key;
|
|
|
|
if (! array_key_exists($storageKey, EnvPriority::MAP)
|
|
&& ! array_key_exists($storageKey, EnvPriority::EXEMPT)) {
|
|
$unregistered[] = $storageKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$unregistered,
|
|
'EnvPriority::MAP 또는 EXEMPT 에 등재되지 않은 설정 키: '.implode(', ', $unregistered)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* MAP 의 모든 settings 키는 defaults.json 에 실존합니다.
|
|
*
|
|
* 반대 방향 — 오타나 사라진 키가 맵에 남아 있으면 그 항목은 영원히 잠기지 않습니다.
|
|
*
|
|
* @effects map_parity_covers_every_injected_default_key
|
|
*/
|
|
public function test_맵의_모든_키가_defaults에_실존한다(): void
|
|
{
|
|
$defaults = $this->loadDefaults();
|
|
$missing = [];
|
|
|
|
foreach (array_keys(EnvPriority::MAP + EnvPriority::EXEMPT) as $storageKey) {
|
|
[$category, $key] = explode('.', $storageKey, 2);
|
|
|
|
if (! array_key_exists($key, $defaults[$category] ?? [])) {
|
|
$missing[] = $storageKey;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$missing,
|
|
'defaults.json 에 없는 키가 맵/면제목록에 남아 있습니다: '.implode(', ', $missing)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* MAP 의 모든 config 키는 실존하는 config 경로입니다.
|
|
*
|
|
* 오타난 config 키는 표시값이 조용히 null 이 되고, 문서 근거도 함께 어긋납니다.
|
|
* 런타임에 생성되는 키(`RUNTIME_CREATED_CONFIG_KEYS`)만 사유와 함께 제외합니다.
|
|
*
|
|
* @effects locked_plain_shows_effective_value
|
|
*/
|
|
public function test_맵의_config_키가_실존한다(): void
|
|
{
|
|
$missing = [];
|
|
|
|
foreach (EnvPriority::MAP as $storageKey => $entry) {
|
|
foreach ($entry['config'] as $configKey) {
|
|
if (in_array($configKey, EnvPriority::RUNTIME_CREATED_CONFIG_KEYS, true)) {
|
|
continue;
|
|
}
|
|
|
|
if (! config()->has($configKey)) {
|
|
$missing[] = $storageKey.' → '.$configKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$missing,
|
|
'실존하지 않는 config 키: '.implode(', ', $missing)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `config/env-priority.php` 의 캡처 대상이 `EnvPriority::envVars()` 와 일치합니다.
|
|
*
|
|
* 캡처 목록이 뒤처지면 그 env 변수는 명시해도 잠기지 않습니다 — 오류 없이.
|
|
*
|
|
* @effects explicit_env_skips_injection
|
|
*/
|
|
public function test_캡처_대상이_맵의_env_변수와_일치한다(): void
|
|
{
|
|
$source = (string) file_get_contents(config_path('env-priority.php'));
|
|
|
|
$this->assertStringContainsString(
|
|
'EnvPriority::envVars()',
|
|
$source,
|
|
'config/env-priority.php 는 캡처 대상을 EnvPriority::envVars() 에서 도출해야 합니다 (목록 중복 기재 금지).'
|
|
);
|
|
|
|
// 실제 평가 결과가 맵의 변수만 다루는지도 확인한다 (SSoT 도출이 형식만 남는 것 차단).
|
|
$captured = require config_path('env-priority.php');
|
|
|
|
$this->assertIsArray($captured['explicit']);
|
|
$this->assertSame(
|
|
[],
|
|
array_diff(array_keys($captured['explicit']), EnvPriority::envVars()),
|
|
'캡처 결과에 맵 밖의 env 변수가 섞였습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 스위치는 `config('env-priority.enabled')` 만으로 해석됩니다.
|
|
*
|
|
* @effects switch_off_behaves_identically
|
|
*/
|
|
public function test_스위치가_config_로만_해석된다(): void
|
|
{
|
|
$source = (string) file_get_contents(app_path('Support/EnvPriority.php'));
|
|
$stripped = $this->stripComments($source);
|
|
|
|
$this->assertStringNotContainsString(
|
|
'env(',
|
|
$stripped,
|
|
'EnvPriority 안에서 env() 를 읽으면 config:cache 환경에서 null 로 고정되어 판정이 영구 미발동합니다.'
|
|
);
|
|
|
|
config(['env-priority.enabled' => true]);
|
|
$this->assertTrue(EnvPriority::enabled());
|
|
|
|
config(['env-priority.enabled' => false]);
|
|
$this->assertFalse(EnvPriority::enabled());
|
|
}
|
|
|
|
/**
|
|
* `SettingsServiceProvider` 의 모든 주입 지점에 잠금 필터가 배선되어 있습니다.
|
|
*
|
|
* 한 곳이라도 빠지면 그 카테고리만 조용히 `.env` 를 계속 덮습니다.
|
|
*
|
|
* 모집단은 `getCategory('<카테고리>')` 호출부에서 **도출한다** — 메서드 이름을 손으로
|
|
* 열거하면 새 주입 메서드가 늘어도 케이스 없이 통과한다(오늘 우연히 일치할 뿐이다).
|
|
* EXEMPT 키만 읽어 필터가 no-op 인 지점은 그 메서드 안의 `audit:allow` 사유로 면제한다.
|
|
*
|
|
* @effects explicit_env_skips_injection
|
|
*/
|
|
public function test_provider_주입_지점에_잠금_필터가_배선된다(): void
|
|
{
|
|
$sites = $this->injectionSites();
|
|
|
|
// 하한 — 도출식이 죽으면 이 단언이 "위반 0" 으로 위장된다.
|
|
$this->assertNotEmpty(
|
|
$sites,
|
|
"getCategory('<카테고리>') 주입 지점을 하나도 도출하지 못했습니다 — 호출 형태가 바뀌었다면 이 도출식도 함께 갱신해야 합니다."
|
|
);
|
|
|
|
$missing = [];
|
|
|
|
foreach ($sites as $site) {
|
|
if (str_contains($site['body'], 'audit:allow env-priority-filter-wiring')) {
|
|
continue;
|
|
}
|
|
|
|
if (! str_contains($site['body'], "EnvPriority::filterLocked('{$site['category']}'")) {
|
|
$missing[] = "{$site['method']}() → {$site['category']}";
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$missing,
|
|
'잠금 필터가 배선되지 않은 주입 지점: '.implode(', ', $missing)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `INJECTED_CATEGORIES` 가 소스에서 도출한 주입 카테고리 집합과 일치합니다.
|
|
*
|
|
* 이 상수는 맵 패리티 검사의 모집단이다. 손으로 적힌 채 뒤처지면 새 카테고리의 키가
|
|
* MAP/EXEMPT 등재 검사를 통째로 비껴가고, 그 상태는 "이상 0건" 과 구분되지 않는다.
|
|
*
|
|
* @effects map_parity_covers_every_injected_default_key
|
|
*/
|
|
public function test_주입_카테고리_상수가_소스_도출_결과와_일치한다(): void
|
|
{
|
|
$derived = array_values(array_unique(array_column($this->injectionSites(), 'category')));
|
|
sort($derived);
|
|
|
|
$declared = EnvPriority::INJECTED_CATEGORIES;
|
|
sort($declared);
|
|
|
|
$this->assertNotEmpty($derived, '주입 카테고리를 하나도 도출하지 못했습니다.');
|
|
$this->assertSame(
|
|
$derived,
|
|
$declared,
|
|
'EnvPriority::INJECTED_CATEGORIES 가 Provider 의 실제 주입 카테고리와 어긋납니다 — '
|
|
.'누락된 카테고리의 키는 MAP/EXEMPT 등재 검사를 받지 않습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `defaults.json` 이 민감으로 표시한 키는 MAP 에서도 민감으로 표시됩니다.
|
|
*
|
|
* 표시값 오버레이는 MAP 의 `sensitive` 플래그만 보고 건너뛴다. 그런데 민감 여부의
|
|
* 본래 출처는 `frontend_schema` 라, 두 곳이 갈라지면 그 키의 `.env` 비밀값이
|
|
* 관리자 응답에 조용히 실린다 — 오류도 로그도 남지 않는다.
|
|
*
|
|
* 반대 방향(MAP 만 민감)은 과하게 가리는 쪽이라 안전측이므로 검사하지 않는다.
|
|
*
|
|
* @effects locked_sensitive_hides_effective_value
|
|
*/
|
|
public function test_스키마가_민감으로_표시한_키는_맵에서도_민감이다(): void
|
|
{
|
|
$schema = json_decode(
|
|
(string) file_get_contents(config_path('settings/defaults.json')),
|
|
true
|
|
)['frontend_schema'] ?? [];
|
|
|
|
$schemaSensitive = [];
|
|
|
|
foreach ($schema as $category => $definition) {
|
|
foreach (($definition['fields'] ?? []) as $key => $field) {
|
|
if (($field['sensitive'] ?? false) === true) {
|
|
$schemaSensitive[] = $category.'.'.$key;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 하한 — 스키마 형태가 바뀌어 0건이 되면 이 검사는 아무것도 재지 않는다.
|
|
$this->assertNotEmpty(
|
|
$schemaSensitive,
|
|
'frontend_schema 에서 민감 키를 하나도 도출하지 못했습니다 — 스키마 형태가 바뀌었다면 이 도출식도 함께 갱신해야 합니다.'
|
|
);
|
|
|
|
$unflagged = [];
|
|
|
|
foreach ($schemaSensitive as $storageKey) {
|
|
if (! array_key_exists($storageKey, EnvPriority::MAP)) {
|
|
continue; // 잠금 대상이 아니면 오버레이 자체가 없다
|
|
}
|
|
|
|
if (! EnvPriority::isSensitive($storageKey)) {
|
|
$unflagged[] = $storageKey;
|
|
}
|
|
}
|
|
|
|
$this->assertSame(
|
|
[],
|
|
$unflagged,
|
|
"defaults.json 이 민감으로 표시했으나 EnvPriority::MAP 에 'sensitive' => true 가 없는 키: "
|
|
.implode(', ', $unflagged).' — 이 키들은 잠기면 `.env` 값이 화면에 노출됩니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* testing 격리 가드가 런타임 `env('APP_ENV')` 로 되돌아가지 않습니다.
|
|
*
|
|
* config:cache 환경에서 env() 는 null 이라 가드가 통째로 무력해집니다.
|
|
*
|
|
* @effects switch_off_behaves_identically
|
|
*/
|
|
public function test_provider_가_런타임_env_로_환경을_판별하지_않는다(): void
|
|
{
|
|
$source = (string) file_get_contents(app_path('Providers/SettingsServiceProvider.php'));
|
|
$stripped = $this->stripComments($source);
|
|
|
|
$this->assertStringNotContainsString(
|
|
"env('APP_ENV')",
|
|
$stripped,
|
|
"환경 판별은 app()->environment() 로 합니다 — env('APP_ENV') 는 config:cache 에서 null 입니다."
|
|
);
|
|
|
|
$this->assertStringNotContainsString(
|
|
"env('G7_ENV_PRIORITY')",
|
|
$stripped,
|
|
'스위치는 config(env-priority.enabled) 로만 읽습니다.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* `.env.example` 은 스위치를 주석 상태로 안내하고, 테스트용 env 는 활성 라인을 두지 않습니다.
|
|
*
|
|
* @effects env_examples_document_env_priority_switch
|
|
*/
|
|
public function test_env_예시가_스위치를_주석으로_안내한다(): void
|
|
{
|
|
$example = (string) file_get_contents(base_path('.env.example'));
|
|
|
|
$this->assertStringContainsString('G7_ENV_PRIORITY', $example, '.env.example 에 스위치 안내가 필요합니다.');
|
|
$this->assertMatchesRegularExpression(
|
|
'/^\s*#\s*G7_ENV_PRIORITY=/m',
|
|
$example,
|
|
'.env.example 의 스위치는 주석 상태여야 합니다 (기본 OFF).'
|
|
);
|
|
$this->assertDoesNotMatchRegularExpression(
|
|
'/^\s*G7_ENV_PRIORITY=/m',
|
|
$example,
|
|
'.env.example 에 활성 스위치 라인을 두면 신규 설치가 즉시 잠깁니다.'
|
|
);
|
|
|
|
// `.env.testing.example` 은 저장소 추적 파일이라 반드시 존재한다 — 부재를 건너뛰면
|
|
// 그 축이 공허 통과한다. 안내 문구가 사라지면 "왜 여기엔 스위치가 없는가" 의 근거가
|
|
// 사라져, 다음 사람이 디버깅 편의로 활성 라인을 넣게 된다.
|
|
$testingExamplePath = base_path('.env.testing.example');
|
|
$this->assertFileExists($testingExamplePath, '.env.testing.example 은 저장소 추적 파일입니다.');
|
|
$this->assertStringContainsString(
|
|
'G7_ENV_PRIORITY',
|
|
(string) file_get_contents($testingExamplePath),
|
|
'.env.testing.example 에 스위치를 켜지 않는 사유 안내가 필요합니다.'
|
|
);
|
|
|
|
foreach (['.env.testing.example', '.env.testing'] as $file) {
|
|
$path = base_path($file);
|
|
|
|
if (! file_exists($path)) {
|
|
continue;
|
|
}
|
|
|
|
$this->assertDoesNotMatchRegularExpression(
|
|
'/^\s*G7_ENV_PRIORITY=/m',
|
|
(string) file_get_contents($path),
|
|
"{$file} 에 활성 스위치 라인을 두면 테스트가 개발 머신의 .env 상태에 좌우됩니다."
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `defaults.json` 의 defaults 블록을 읽습니다.
|
|
*
|
|
* @return array<string, array<string, mixed>> 카테고리별 기본값
|
|
*/
|
|
private function loadDefaults(): array
|
|
{
|
|
$raw = (string) file_get_contents(config_path('settings/defaults.json'));
|
|
|
|
return json_decode($raw, true)['defaults'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* `SettingsServiceProvider` 의 settings 카테고리 주입 지점을 소스에서 도출합니다.
|
|
*
|
|
* `getCategory('<카테고리>')` 호출부를 찾고 그것을 감싸는 메서드를 역으로 특정한다.
|
|
* 메서드 이름 목록을 손으로 들고 있으면 새 주입이 늘어도 검사가 그것을 보지 못하므로,
|
|
* 모집단은 언제나 소스에서 파생한다. audit 룰 `env-priority-filter-wiring` 과 같은 도출식이다.
|
|
*
|
|
* @return list<array{category: string, method: string, body: string}> 주입 지점 목록
|
|
*/
|
|
private function injectionSites(): array
|
|
{
|
|
$source = (string) file_get_contents(app_path('Providers/SettingsServiceProvider.php'));
|
|
|
|
preg_match_all("/getCategory\(\s*'([a-z_]+)'\s*\)/", $source, $matches, PREG_OFFSET_CAPTURE);
|
|
|
|
$sites = [];
|
|
$seen = [];
|
|
|
|
foreach ($matches[1] as $index => $capture) {
|
|
$category = $capture[0];
|
|
$offset = $matches[0][$index][1];
|
|
|
|
if (! preg_match_all('/function\s+([A-Za-z0-9_]+)\s*\(/', substr($source, 0, $offset), $heads, PREG_OFFSET_CAPTURE)) {
|
|
continue;
|
|
}
|
|
|
|
$last = array_key_last($heads[1]);
|
|
$method = $heads[1][$last][0];
|
|
$body = $this->extractMethodBody($source, $method);
|
|
|
|
$key = $method.':'.$category;
|
|
|
|
if ($body === '' || isset($seen[$key])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$key] = true;
|
|
$sites[] = ['category' => $category, 'method' => $method, 'body' => $body];
|
|
}
|
|
|
|
return $sites;
|
|
}
|
|
|
|
/**
|
|
* PHP 소스에서 주석을 제거합니다 ("하면 안 되는 것" 설명문 오탐 차단).
|
|
*
|
|
* @param string $source 원본 소스
|
|
* @return string 주석이 제거된 소스
|
|
*/
|
|
private function stripComments(string $source): string
|
|
{
|
|
return (string) preg_replace(['#/\*.*?\*/#s', '#//[^\n]*#'], '', $source);
|
|
}
|
|
|
|
/**
|
|
* 소스에서 지정 메서드의 본문을 잘라냅니다 (중괄호 균형 기준).
|
|
*
|
|
* @param string $source 원본 소스
|
|
* @param string $method 메서드명
|
|
* @return string 메서드 본문 (찾지 못하면 빈 문자열)
|
|
*/
|
|
private function extractMethodBody(string $source, string $method): string
|
|
{
|
|
$signature = 'function '.$method.'(';
|
|
$start = strpos($source, $signature);
|
|
|
|
if ($start === false) {
|
|
return '';
|
|
}
|
|
|
|
$open = strpos($source, '{', $start);
|
|
|
|
if ($open === false) {
|
|
return '';
|
|
}
|
|
|
|
$depth = 0;
|
|
$length = strlen($source);
|
|
|
|
for ($i = $open; $i < $length; $i++) {
|
|
if ($source[$i] === '{') {
|
|
$depth++;
|
|
} elseif ($source[$i] === '}') {
|
|
$depth--;
|
|
|
|
if ($depth === 0) {
|
|
return substr($source, $open, $i - $open + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
}
|