공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 결함의 전면 수정. - S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단 (부트 시 확장 부재 감지 → predis 자동 폴백) - storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙) - 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control) - S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와 동일 설정(endpoint/path-style)으로 정렬 - 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정) - 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소 - env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리) - 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및 websocket 유령 설정 키 제거 - 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
377 lines
14 KiB
PHP
377 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use App\Services\DriverRegistryService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* 드라이버 탭 S3 검증 테스트 (공개 #99 / 내부 #563, A4·A7)
|
|
*
|
|
* s3_region 형식 검증 완화(리전 하드코딩 제거), s3_endpoint/s3_use_path_style
|
|
* 신규 키 검증, 드라이버 가용성 서버 게이트를 검증합니다.
|
|
*/
|
|
class SettingsControllerS3ValidationTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private User $admin;
|
|
|
|
private string $token;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->admin = $this->createAdminUser();
|
|
$this->token = $this->admin->createToken('test-token')->plainTextToken;
|
|
}
|
|
|
|
/**
|
|
* 설정 권한을 가진 관리자 사용자를 생성합니다.
|
|
*/
|
|
private function createAdminUser(): User
|
|
{
|
|
$user = User::factory()->create([
|
|
'password' => Hash::make('password123'),
|
|
]);
|
|
|
|
$permissionIds = [];
|
|
foreach (['core.settings.read', 'core.settings.update'] as $permIdentifier) {
|
|
$permission = Permission::firstOrCreate(
|
|
['identifier' => $permIdentifier],
|
|
[
|
|
'name' => json_encode(['ko' => $permIdentifier, 'en' => $permIdentifier]),
|
|
'description' => json_encode(['ko' => $permIdentifier.' 권한', 'en' => $permIdentifier.' Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => 'admin',
|
|
]
|
|
);
|
|
$permissionIds[] = $permission->id;
|
|
}
|
|
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'description' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
$adminRole->permissions()->syncWithoutDetaching($permissionIds);
|
|
$user->roles()->attach($adminRole->id);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* 인증 헤더를 부착한 요청 헬퍼입니다.
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* drivers 탭 저장의 기본 payload 를 반환합니다.
|
|
*
|
|
* @param array $overrides drivers 오버라이드 값
|
|
* @return array 요청 payload
|
|
*/
|
|
private function driversPayload(array $overrides = []): array
|
|
{
|
|
return [
|
|
'_tab' => 'drivers',
|
|
'drivers' => array_merge([
|
|
'storage_driver' => 'local',
|
|
'cache_driver' => 'file',
|
|
'session_driver' => 'file',
|
|
'queue_driver' => 'database',
|
|
'log_driver' => 'daily',
|
|
'log_level' => 'error',
|
|
'websocket_enabled' => false,
|
|
], $overrides),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 리전 형식 검증 — 유효한 값(구 화이트리스트·신규 AWS 리전·R2 auto·MinIO 커스텀)은
|
|
* 저장이 허용되어야 합니다.
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects s3_disk_resolves
|
|
*/
|
|
#[DataProvider('validRegionProvider')]
|
|
public function test_store_accepts_valid_region_shapes(string $region): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => $region,
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
]));
|
|
|
|
$response->assertStatus(200)->assertJson(['success' => true]);
|
|
}
|
|
|
|
/**
|
|
* 유효한 리전 형식 목록 (구 하드코딩 5종 밖 값 포함 — A4 회귀 가드)
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
public static function validRegionProvider(): array
|
|
{
|
|
return [
|
|
'구 화이트리스트 값' => ['ap-northeast-2'],
|
|
'구 목록 밖 AWS 리전' => ['eu-central-1'],
|
|
'Cloudflare R2 표준값' => ['auto'],
|
|
'MinIO 관례값' => ['us-east-1'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 리전 형식 검증 — 불량 형식은 422 로 거부되어야 합니다.
|
|
*
|
|
* @scenario region_shape=invalid_format,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects invalid_region_rejected
|
|
*/
|
|
#[DataProvider('invalidRegionProvider')]
|
|
public function test_store_rejects_invalid_region_shapes(string $region): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => $region,
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
]));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['drivers.s3_region']);
|
|
}
|
|
|
|
/**
|
|
* 불량 리전 형식 목록
|
|
*
|
|
* @return array<string, array{string}>
|
|
*/
|
|
public static function invalidRegionProvider(): array
|
|
{
|
|
return [
|
|
'특수문자' => ['S3!bad'],
|
|
'대문자' => ['AP-NORTHEAST-2'],
|
|
'공백 포함' => ['ap northeast 2'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* s3_endpoint 는 URL 형식이어야 합니다 (불량 형식 422).
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects endpoint_injected
|
|
*/
|
|
public function test_store_validates_s3_endpoint_url_format(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => 'auto',
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
's3_endpoint' => 'not-a-url',
|
|
]));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['drivers.s3_endpoint']);
|
|
}
|
|
|
|
/**
|
|
* s3_endpoint/s3_use_path_style 정상 값은 저장 왕복이 성립해야 합니다.
|
|
*
|
|
* @scenario region_shape=custom_minio,endpoint=path_style,driver_usability=adapter_present,attachment_disk=follow_s3
|
|
*
|
|
* @effects endpoint_injected, attachment_upload_follows_driver
|
|
*/
|
|
public function test_store_saves_s3_endpoint_and_path_style_roundtrip(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => 'us-east-1',
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
's3_endpoint' => 'http://127.0.0.1:9000',
|
|
's3_use_path_style' => true,
|
|
]));
|
|
|
|
$response->assertStatus(200)->assertJson(['success' => true]);
|
|
|
|
$show = $this->authRequest()->getJson('/api/admin/settings');
|
|
$show->assertStatus(200);
|
|
|
|
$drivers = $show->json('data.drivers');
|
|
$this->assertSame('http://127.0.0.1:9000', $drivers['s3_endpoint'] ?? null);
|
|
$this->assertTrue((bool) ($drivers['s3_use_path_style'] ?? false));
|
|
}
|
|
|
|
/**
|
|
* s3_use_path_style 은 boolean 이어야 합니다.
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=path_style,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects endpoint_injected
|
|
*/
|
|
public function test_store_validates_s3_use_path_style_boolean(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => 'auto',
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
's3_use_path_style' => 'not-bool',
|
|
]));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['drivers.s3_use_path_style']);
|
|
}
|
|
|
|
/**
|
|
* 드라이버 가용성 서버 게이트 — 사용 불능 드라이버 저장은 422 로 차단되어야 합니다 (A7).
|
|
*
|
|
* memcached PHP 확장 존재 여부는 환경 의존이므로, 확장이 있는 환경에서는
|
|
* isDriverUsable 단위 검증(DriverRegistryServiceUsabilityTest)이 판정 로직을 담당하고
|
|
* 이 테스트는 통과로 간주합니다.
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_absent,attachment_disk=local_default
|
|
*
|
|
* @effects unusable_driver_rejected
|
|
*/
|
|
public function test_store_rejects_unusable_driver(): void
|
|
{
|
|
if (extension_loaded('memcached')) {
|
|
$this->markTestSkipped('memcached 확장이 설치된 환경 — 사용 불능 상태를 만들 수 없어 단위 테스트가 판정을 담당');
|
|
}
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'cache_driver' => 'memcached',
|
|
'memcached_host' => '127.0.0.1',
|
|
'memcached_port' => 11211,
|
|
]));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['drivers.cache_driver']);
|
|
}
|
|
|
|
/**
|
|
* 드라이버 가용성 게이트의 컨트롤러↔서비스 결합을 환경 무관하게 검증합니다 (A7).
|
|
*
|
|
* 위 테스트는 확장 미설치 환경에서만 실 422 를 밟으므로, 레지스트리를 컨테이너
|
|
* partialMock 으로 교체해 "사용 불능" 판정 시 저장 요청이 422 + 사유 문구로
|
|
* 차단되는 결합점을 어느 환경에서든 단언한다.
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_absent,attachment_disk=local_default
|
|
*
|
|
* @effects unusable_driver_rejected
|
|
*/
|
|
public function test_store_rejects_unusable_driver_via_registry_gate(): void
|
|
{
|
|
$this->partialMock(DriverRegistryService::class, function ($mock) {
|
|
$mock->shouldReceive('isDriverUsable')
|
|
->andReturnUsing(fn (string $category, string $driverId) => ! ($category === 'cache' && $driverId === 'redis'));
|
|
$mock->shouldReceive('usabilityFailureReason')
|
|
->andReturn('mocked: redis client missing');
|
|
});
|
|
|
|
$response = $this->authRequest()->postJson('/api/admin/settings', $this->driversPayload([
|
|
'cache_driver' => 'redis',
|
|
'redis_host' => '127.0.0.1',
|
|
'redis_port' => 6379,
|
|
]));
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['drivers.cache_driver']);
|
|
|
|
// 사유가 422 응답 본문까지 도달해야 운영자가 원인을 알 수 있다
|
|
$messages = $response->json('errors')['drivers.cache_driver'] ?? [];
|
|
$this->assertStringContainsString('mocked: redis client missing', implode(' ', $messages));
|
|
}
|
|
|
|
/**
|
|
* 연결 테스트 요청도 동일한 리전 완화·신규 키 규칙을 적용해야 합니다 (게이트 대칭).
|
|
*
|
|
* @scenario region_shape=auto,endpoint=custom_url,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects endpoint_injected
|
|
*/
|
|
public function test_test_driver_request_accepts_relaxed_region_and_new_keys(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings/test-driver', [
|
|
'storage_driver' => 's3',
|
|
's3_bucket' => 'my-bucket',
|
|
's3_region' => 'auto',
|
|
's3_access_key' => 'key',
|
|
's3_secret_key' => 'secret',
|
|
's3_endpoint' => 'http://127.0.0.1:9',
|
|
's3_use_path_style' => true,
|
|
]);
|
|
|
|
// 검증 통과(422 아님) — 연결 자체는 도달 불가 endpoint 라 실패 결과가 반환된다
|
|
$response->assertStatus(200);
|
|
$this->assertFalse($response->json('data.results.s3.success'));
|
|
}
|
|
|
|
/**
|
|
* 연결 테스트 요청이 websocket server 3필드를 수용해야 합니다 (A6 요청부).
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects test_covers_server_websocket
|
|
*/
|
|
public function test_test_driver_request_accepts_websocket_server_fields(): void
|
|
{
|
|
$response = $this->authRequest()->postJson('/api/admin/settings/test-driver', [
|
|
'websocket_enabled' => true,
|
|
'websocket_host' => 'localhost',
|
|
'websocket_port' => 65500,
|
|
'websocket_scheme' => 'http',
|
|
'websocket_server_host' => '127.0.0.1',
|
|
'websocket_server_port' => 65501,
|
|
'websocket_server_scheme' => 'http',
|
|
'websocket_app_key' => 'k',
|
|
]);
|
|
|
|
// 검증 통과 — 서버 미기동이므로 연결 실패 결과가 반환될 뿐 422 는 아니어야 한다
|
|
$response->assertStatus(200);
|
|
$this->assertFalse($response->json('data.results.websocket.success'));
|
|
}
|
|
|
|
/**
|
|
* 비인증 요청은 401 로 차단되어야 합니다.
|
|
*
|
|
* @scenario region_shape=aws_region,endpoint=absent,driver_usability=adapter_present,attachment_disk=local_default
|
|
*
|
|
* @effects unauthenticated_returns_401
|
|
*/
|
|
public function test_store_and_test_driver_return_401_without_authentication(): void
|
|
{
|
|
$this->postJson('/api/admin/settings', $this->driversPayload())->assertStatus(401);
|
|
$this->postJson('/api/admin/settings/test-driver', ['storage_driver' => 's3'])->assertStatus(401);
|
|
}
|
|
}
|