KISA 제보 취약점(KVE-2026-1914/1919/2019/2029/2041/2042/2043/2044)과 그 수정 과정에서 드러난 자격증명 전송로 결함을 함께 해소한다. globalHeaders 는 데이터소스와 apiCall 핸들러에만 적용되는데, 코어 ApiClient 를 직접 부르는 경로들이 그 사실을 모른 채 게이트된 엔드포인트를 호출하고 있었다. 서버는 정당한 사용자를 거부하고 화면은 이미 버튼을 내준 뒤라, 예외도 로그도 없이 그 자리만 비는 형태로만 드러났다. 전송로 10축을 전수 열거해 6건을 고치고, 같은 실수가 반복되지 않도록 규정과 coverage 에 등재했다. 아웃바운드 프록시가 사이트 자기 자신으로 가는 내부 요청까지 가로채 저장이 수십 초씩 걸리던 문제도 함께 고쳤다. 실패가 폴백으로 삼켜져 화면에는 지연으로만 나타났다.
301 lines
12 KiB
PHP
301 lines
12 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api\Admin;
|
|
|
|
use App\Enums\ExtensionOwnerType;
|
|
use App\Enums\IdentityVerificationStatus;
|
|
use App\Enums\PermissionType;
|
|
use App\Extension\HookManager;
|
|
use App\Models\IdentityPolicy;
|
|
use App\Models\IdentityVerificationLog;
|
|
use App\Models\Permission;
|
|
use App\Models\Role;
|
|
use App\Models\User;
|
|
use Database\Seeders\IdentityPolicySeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Admin UserController 삭제 테스트
|
|
*
|
|
* 관리자/슈퍼관리자 사용자 삭제 시 구체적 에러 메시지 응답을 검증합니다.
|
|
*/
|
|
class UserControllerDeleteTest 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(['is_super' => true]);
|
|
|
|
$permissions = [];
|
|
foreach (['core.users.read', 'core.users.delete'] as $identifier) {
|
|
$permissions[] = Permission::firstOrCreate(
|
|
['identifier' => $identifier],
|
|
[
|
|
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => PermissionType::Admin,
|
|
]
|
|
);
|
|
}
|
|
|
|
$adminRole = Role::firstOrCreate(
|
|
['identifier' => 'admin'],
|
|
[
|
|
'name' => json_encode(['ko' => '관리자', 'en' => 'Administrator']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'is_active' => true,
|
|
]
|
|
);
|
|
|
|
foreach ($permissions as $permission) {
|
|
if (! $adminRole->permissions()->where('permissions.id', $permission->id)->exists()) {
|
|
$adminRole->permissions()->attach($permission->id);
|
|
}
|
|
}
|
|
|
|
$user->roles()->attach($adminRole->id, [
|
|
'assigned_at' => now(),
|
|
'assigned_by' => null,
|
|
]);
|
|
|
|
return $user->fresh();
|
|
}
|
|
|
|
/**
|
|
* 인증된 요청 헬퍼 메서드
|
|
*/
|
|
private function authRequest(): static
|
|
{
|
|
return $this->withHeaders([
|
|
'Authorization' => 'Bearer '.$this->token,
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 슈퍼 관리자 삭제 시 구체적 에러 메시지 응답
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 슈퍼 관리자 삭제 시 422 상태 코드와 구체적 에러 메시지 반환
|
|
*/
|
|
public function test_delete_super_admin_returns_specific_error_message(): void
|
|
{
|
|
$superAdmin = User::factory()->create(['is_super' => true]);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$superAdmin->uuid}");
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonPath('success', false);
|
|
$response->assertJsonPath('message', __('exceptions.cannot_delete_super_admin'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// 관리자 삭제: 권한/스코프 시스템에 위임
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 회귀 — 훅 정책에서 throw 되는 428 응답에도 return_request 가 포함되어야 한다.
|
|
*
|
|
* 사례: IDV 모달이 verify 성공 후 return_request.url 에 토큰을 부착해 재실행해야 하는데,
|
|
* Listener 가 context.return_request 를 누락해 응답이 null 로 나가 → 인터셉터가 재시도를
|
|
* 시작하지 못함 → 사용자가 인증을 마쳐도 삭제가 진행되지 않고 본인확인 토스트만 반복.
|
|
*
|
|
* 미들웨어(scope=route) 는 return_request 를 채우지만 Listener(scope=hook) 는 누락하던 결함.
|
|
*/
|
|
public function test_hook_policy_428_response_includes_return_request_for_retry(): void
|
|
{
|
|
$this->seed(IdentityPolicySeeder::class);
|
|
IdentityPolicy::where('key', 'core.admin.user_delete')->update(['enabled' => true]);
|
|
|
|
$target = User::factory()->create(['is_super' => false]);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$target->uuid}");
|
|
|
|
$response->assertStatus(428);
|
|
$response->assertJsonPath('verification.policy_key', 'core.admin.user_delete');
|
|
$response->assertJsonPath('verification.return_request.method', 'DELETE');
|
|
$this->assertStringContainsString(
|
|
"/api/admin/users/{$target->uuid}",
|
|
(string) $response->json('verification.return_request.url'),
|
|
'return_request.url 가 원 요청 URL 이어야 retry 가 가능함'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 회귀 — 삭제 정책 활성 + verification_token 부착 시 HTTP 흐름 전체에서 정상 삭제.
|
|
*
|
|
* 사례: 관리자가 사용자 삭제 클릭 → 428 → IDV 모달에서 인증 → token 부착 retry →
|
|
* 그래도 "본인 확인이 필요합니다" 토스트가 다시 뜨고 삭제 안 되던 결함.
|
|
*
|
|
* 검증: verification_token 우회가 라우트 미들웨어 + 훅 리스너 양쪽 모두에서 일관 동작해야
|
|
* end-to-end DELETE 가 통과하고 사용자가 실제로 삭제되는지 확인.
|
|
*/
|
|
public function test_delete_with_verification_token_passes_through_hook_policy(): void
|
|
{
|
|
$this->seed(IdentityPolicySeeder::class);
|
|
IdentityPolicy::where('key', 'core.admin.user_delete')->update(['enabled' => true]);
|
|
|
|
$target = User::factory()->create(['is_super' => false]);
|
|
$token = 'tok-'.bin2hex(random_bytes(8));
|
|
|
|
IdentityVerificationLog::create([
|
|
'id' => Str::uuid()->toString(),
|
|
'provider_id' => 'g7:core.mail',
|
|
'purpose' => 'sensitive_action',
|
|
'channel' => 'email',
|
|
'user_id' => $this->admin->id,
|
|
'target_hash' => hash('sha256', mb_strtolower($this->admin->email)),
|
|
'status' => IdentityVerificationStatus::Verified->value,
|
|
'verification_token' => $token,
|
|
'render_hint' => 'text_code',
|
|
'attempts' => 0,
|
|
'max_attempts' => 5,
|
|
'verified_at' => now()->subSeconds(2),
|
|
'expires_at' => now()->addMinutes(15),
|
|
'created_at' => now()->subSeconds(2),
|
|
'updated_at' => now()->subSeconds(2),
|
|
]);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$target->uuid}?verification_token={$token}");
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertJsonPath('success', true);
|
|
$this->assertDatabaseMissing('users', ['id' => $target->id]);
|
|
}
|
|
|
|
/**
|
|
* 슈퍼관리자(is_super=true) 가 admin 권한을 가진 일반 관리자 계정을 삭제하면 성공한다.
|
|
*
|
|
* 회귀: 과거 UserService::deleteUser 가 target.isAdmin() 만 보고 무조건 차단해
|
|
* 슈퍼관리자도 다른 관리자를 지울 수 없던 결함. 권한/스코프 검증은 PermissionMiddleware
|
|
* 가 담당하며, Service 는 시스템 불변식인 슈퍼관리자 보호만 유지한다.
|
|
*/
|
|
public function test_super_admin_can_delete_admin_user(): void
|
|
{
|
|
$adminPermission = Permission::create([
|
|
'identifier' => 'core.admin.delete.test',
|
|
'name' => json_encode(['ko' => '관리자 권한', 'en' => 'Admin Permission']),
|
|
'extension_type' => ExtensionOwnerType::Core,
|
|
'extension_identifier' => 'core',
|
|
'type' => PermissionType::Admin,
|
|
]);
|
|
|
|
$role = Role::create([
|
|
'identifier' => 'test-admin-delete',
|
|
'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']),
|
|
]);
|
|
$role->permissions()->attach($adminPermission->id);
|
|
|
|
$adminUser = User::factory()->create(['is_super' => false]);
|
|
$adminUser->roles()->attach($role->id);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$adminUser->uuid}");
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertJsonPath('success', true);
|
|
$this->assertDatabaseMissing('users', ['id' => $adminUser->id]);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 일반 사용자 삭제 성공
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 일반 사용자 삭제 시 성공 메시지 반환
|
|
*/
|
|
public function test_delete_regular_user_returns_success(): void
|
|
{
|
|
$regularUser = User::factory()->create(['is_super' => false]);
|
|
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$regularUser->uuid}");
|
|
|
|
$response->assertStatus(200);
|
|
$response->assertJsonPath('success', true);
|
|
$response->assertJsonPath('message', __('user.delete_success'));
|
|
}
|
|
|
|
// ========================================================================
|
|
// 삭제 실패 응답 — placeholder 미노출 + 예외 원문 비노출
|
|
// ========================================================================
|
|
|
|
/**
|
|
* 회귀 — 삭제 실패 시 응답 message 에 `:error` placeholder 가 그대로 남지 않고,
|
|
* 동시에 예외 원문(내부 사정)이 사용자 응답에 실리지 않아야 한다.
|
|
*
|
|
* 이력: #415 는 토스트에 `사용자 삭제에 실패했습니다: :error` 가 그대로 노출되던 것을
|
|
* 고치면서 구체 사유를 message 에 실었다. 이후 보안 정정(#577)이 그 방향을 뒤집어,
|
|
* UserService::deleteUser 는 원본 예외를 로그로만 남기고 응답에는 일반 안내만 싣는다.
|
|
* 두 요구는 양립한다 — placeholder 가 남지 않으면서 원문도 새지 않으면 된다.
|
|
*
|
|
* 검증: message 에 `:error` 가 없고, 훅이 던진 예외 원문도 응답 어디에도 없다.
|
|
* 그리고 사용자가 읽을 수 있는 일반 안내 문구가 온다.
|
|
*/
|
|
public function test_delete_failure_message_has_no_placeholder_and_no_raw_exception_detail(): void
|
|
{
|
|
$target = User::factory()->create(['is_super' => false]);
|
|
|
|
$hookName = 'core.user.before_delete';
|
|
$reason = '연결된 외부 데이터가 있어 삭제할 수 없습니다 (regression-marker)';
|
|
|
|
// UserService::deleteUser 의 before_delete 훅에서 예외를 던져 삭제 실패를 강제.
|
|
// Service 가 이를 잡아 __('user.delete_failed', ['error' => ...]) ValidationException 으로 변환.
|
|
HookManager::addAction($hookName, function () use ($reason) {
|
|
throw new RuntimeException($reason);
|
|
});
|
|
|
|
try {
|
|
$response = $this->authRequest()
|
|
->deleteJson("/api/admin/users/{$target->uuid}");
|
|
} finally {
|
|
HookManager::clearAction($hookName);
|
|
}
|
|
|
|
$response->assertStatus(422);
|
|
$response->assertJsonPath('success', false);
|
|
|
|
$message = (string) $response->json('message');
|
|
$body = (string) $response->getContent();
|
|
|
|
// 핵심 1: placeholder 가 그대로 노출되면 안 된다 (#415 회귀 방지)
|
|
$this->assertStringNotContainsString(':error', $message, 'message 에 미치환 :error 가 남으면 안 된다');
|
|
$this->assertStringNotContainsString(':error', $body, '응답 어디에도 미치환 :error 가 남으면 안 된다');
|
|
|
|
// 핵심 2: 예외 원문(내부 사정)이 응답에 실리면 안 된다 (#577 보안 정정)
|
|
$this->assertStringNotContainsString(
|
|
$reason,
|
|
$body,
|
|
'예외 원문이 응답에 실리면 안 된다 — 원본은 로그로만 남긴다'
|
|
);
|
|
|
|
// 사용자가 읽을 수 있는 일반 안내가 온다
|
|
$this->assertSame(__('user.delete_failed'), $message);
|
|
}
|
|
}
|