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 그대로다.
156 lines
6.1 KiB
PHP
156 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Helpers;
|
|
|
|
use App\Extension\Helpers\FilePermissionHelper;
|
|
use App\Extension\Helpers\SettingsMigrator;
|
|
use Illuminate\Support\Facades\File;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* [case:backend-28] SettingsMigrator owner 상속 회귀 테스트.
|
|
*
|
|
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때
|
|
* `SettingsMigrator::writeJsonFile` 가 만드는 *.json 파일이 root 소유로 영구 잔존하는
|
|
* 회귀 차단:
|
|
*
|
|
* - 변경 1 로 storage/app/{modules,plugins} 가 chown 비대상이 된 후, settings *.json
|
|
* 파일에 후속 PHP-FPM 이 update 시도 시 쓰기 실패하는 케이스 차단
|
|
* - writeJsonFile 후 부모 디렉토리(예: storage/app/modules/{id}/settings/) 의 owner/group
|
|
* 을 상속하여 PHP-FPM 시드 시점 owner 가 PHP-FPM 이라면 자동 일치
|
|
*
|
|
* 본 테스트는 `FilePermissionHelper::inheritOwnershipFromParent` 가 public static 으로
|
|
* 노출되어 외부(SettingsMigrator) 가 호출 가능한지 검증.
|
|
*/
|
|
class SettingsMigratorOwnershipTest extends TestCase
|
|
{
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
private array $tempDirs = [];
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
foreach ($this->tempDirs as $dir) {
|
|
if (File::isDirectory($dir)) {
|
|
File::deleteDirectory($dir);
|
|
}
|
|
}
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
private function createTempDir(): string
|
|
{
|
|
$dir = storage_path('test_settings_owner_'.uniqid());
|
|
File::ensureDirectoryExists($dir);
|
|
$this->tempDirs[] = $dir;
|
|
|
|
return $dir;
|
|
}
|
|
|
|
/**
|
|
* inheritOwnershipFromParent 가 public static 으로 노출되어야 함.
|
|
*
|
|
* 변경 7-(a): `SettingsMigrator::writeJsonFile` 같은 외부 호출처가 본 helper 를
|
|
* 사용해 부모 owner/group 을 상속하도록 노출.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_inherit_ownership_from_parent_is_public(): void
|
|
{
|
|
$reflection = new \ReflectionMethod(FilePermissionHelper::class, 'inheritOwnershipFromParent');
|
|
|
|
$this->assertTrue(
|
|
$reflection->isPublic(),
|
|
'FilePermissionHelper::inheritOwnershipFromParent 가 public 이어야 외부 호출 가능 (SettingsMigrator 등)',
|
|
);
|
|
$this->assertTrue($reflection->isStatic(), 'static 메서드여야 함');
|
|
}
|
|
|
|
/**
|
|
* inheritOwnershipFromParent — 부모 디렉토리 stat 기반으로 owner/group 적용.
|
|
*
|
|
* POSIX 환경 전용. 일반 user 권한에서는 다른 user 로 chown 시도가 실패할 수 있으나,
|
|
* 자기 자신 owner 인 경우 chown 자체가 발생하지 않으므로 멱등.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_inherit_ownership_from_parent_idempotent_for_self_owner(): void
|
|
{
|
|
if (DIRECTORY_SEPARATOR !== '/' || ! function_exists('chown')) {
|
|
$this->markTestSkipped('POSIX 환경 전용 (Windows 자동 스킵)');
|
|
}
|
|
|
|
$parent = $this->createTempDir();
|
|
$child = $parent.'/child.json';
|
|
file_put_contents($child, '{}');
|
|
|
|
$parentOwnerBefore = fileowner($parent);
|
|
$childOwnerBefore = fileowner($child);
|
|
|
|
// 자기 자신 owner 면 부모와 자식 owner 가 동일 → 멱등
|
|
FilePermissionHelper::inheritOwnershipFromParent($child);
|
|
|
|
$this->assertSame($parentOwnerBefore, fileowner($parent));
|
|
$this->assertSame($childOwnerBefore, fileowner($child));
|
|
$this->assertSame($parentOwnerBefore, fileowner($child), '자식 owner 가 부모와 동일');
|
|
}
|
|
|
|
/**
|
|
* settings 디렉토리 생성도 파일과 대칭으로 부모 소유권을 상속한다 (#651 B1).
|
|
*
|
|
* 파일(`writeJsonFile`)만 상속하고 디렉토리(`makeDirectory`)는 상속하지 않던 비대칭 — sudo
|
|
* 업그레이드 스텝이 디렉토리를 root 로 만들면 `storage/app/{modules,plugins}` 는 restore_ownership
|
|
* 의도적 제외 경로라 되돌려지지 않고, 이후 웹 프로세스의 그 모듈 설정 저장이 영구 실패한다.
|
|
*
|
|
* @effects settings_directory_seed_inherits_ownership
|
|
*/
|
|
public function test_settings_migrator_make_directory_invokes_inherit_ownership(): void
|
|
{
|
|
$body = (string) file_get_contents((new \ReflectionClass(SettingsMigrator::class))->getFileName());
|
|
|
|
$pos = strpos($body, 'File::makeDirectory($settingsDir');
|
|
$this->assertNotFalse($pos, 'SettingsMigrator 의 settings 디렉토리 생성 지점을 찾지 못했다');
|
|
|
|
$window = implode("\n", array_slice(explode("\n", substr($body, $pos)), 0, 4));
|
|
|
|
$this->assertStringContainsString(
|
|
'FilePermissionHelper::inheritOwnershipFromParent($settingsDir)',
|
|
$window,
|
|
'SettingsMigrator: settings 디렉토리 생성 뒤 부모 소유권 상속이 없다 (파일만 상속하는 비대칭)'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* SettingsMigrator::writeJsonFile 호출 후 *.json 의 owner 가 부모와 일치.
|
|
*
|
|
* `SettingsMigrator` 는 protected 메서드 + non-static 이라 직접 호출이 어려우므로,
|
|
* 핵심 통합 보장 = "writeJsonFile 메서드 안에서 inheritOwnershipFromParent 호출" 을
|
|
* reflection 으로 검증.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function test_settings_migrator_write_json_file_invokes_inherit_ownership(): void
|
|
{
|
|
$reflection = new \ReflectionClass(SettingsMigrator::class);
|
|
$method = $reflection->getMethod('writeJsonFile');
|
|
$body = file_get_contents($method->getFileName());
|
|
|
|
// writeJsonFile 메서드 내부에 inheritOwnershipFromParent 호출이 존재해야 함
|
|
$startLine = $method->getStartLine();
|
|
$endLine = $method->getEndLine();
|
|
$methodBody = implode("\n", array_slice(
|
|
explode("\n", $body),
|
|
$startLine - 1,
|
|
$endLine - $startLine + 1
|
|
));
|
|
|
|
$this->assertStringContainsString(
|
|
'inheritOwnershipFromParent',
|
|
$methodBody,
|
|
'SettingsMigrator::writeJsonFile 가 sudo update 시 root 로 만든 *.json 의 owner 를 부모로 상속해야 함 (FilePermissionHelper::inheritOwnershipFromParent 호출)',
|
|
);
|
|
}
|
|
}
|