Files
Gnuboard7/tests/Support/Identity/PolicyLifecycleTestHelpers.php
T
2026-05-11 11:29:41 +09:00

213 lines
7.5 KiB
PHP

<?php
namespace Tests\Support\Identity;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\IdentityPolicy;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Carbon;
/**
* IDV 정책 라이프사이클 통합 테스트 헬퍼.
*
* Part B-3 — 보호 API → 428 → challenge 발급 → verify → 재시도 → grace 윈도우 → grace 경과 후 차단
* 7단계를 일관된 헬퍼 메서드 호출로 검증한다.
*
* 인증코드는 결정적(`TestIdentityProvider::FIXED_CODE`) 으로 발급되어 메일 인터셉트 의존이 없다.
* 메일 회로 자체 검증은 `IdentityPolicyMailFakeSmokeTest` 가 별도로 수행 (`Mail::fake()` + `MailIdentityProvider`).
*/
trait PolicyLifecycleTestHelpers
{
/**
* TestIdentityProvider 를 IDV 매니저에 등록하고 기본 프로바이더로 지정한다.
*
* 각 테스트 setUp() 또는 trait 의 setUpPolicyLifecycle() 자동 후크에서 호출.
*/
protected function registerTestIdentityProvider(): void
{
$manager = $this->app->make(IdentityVerificationManager::class);
$logRepository = $this->app->make(IdentityVerificationLogRepositoryInterface::class);
$provider = new TestIdentityProvider($logRepository);
$manager->register($provider);
config(['settings.identity.default_provider' => TestIdentityProvider::ID]);
}
/**
* 일반 사용자(자기 자신) 권한 토큰 발급.
*/
protected function actingAsRegularUser(?array $attributes = null): array
{
$user = User::factory()->create($attributes ?? []);
$token = $user->createToken('test-user')->plainTextToken;
return ['user' => $user->fresh(), 'token' => $token];
}
/**
* 관리자 사용자 + 관리자 역할 권한 토큰 발급.
*/
protected function actingAsAdminUser(?array $attributes = null): array
{
$user = User::factory()->create(array_merge(['is_super' => true], $attributes ?? []));
$adminRole = Role::where('identifier', 'admin')->first();
if ($adminRole) {
$user->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
$user = $user->fresh();
$token = $user->createToken('test-admin')->plainTextToken;
return ['user' => $user, 'token' => $token];
}
/**
* 1단계: 보호된 API 호출이 정책 위반으로 HTTP 428 을 반환하는지 검증.
* payload 의 policy_key/purpose/challenge_start_url 도 함께 점검.
*/
protected function assertProtectedRouteIssues428(
string $method,
string $uri,
string $token,
string $expectedPolicyKey,
string $expectedPurpose,
array $body = [],
): array {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->json($method, $uri, $body);
$response->assertStatus(428);
// ResponseHelper::identityRequired() 가 만드는 428 응답 구조 — verification 키 하위
$payload = $response->json('verification');
$this->assertIsArray($payload, '428 응답에는 verification 객체가 포함되어야 함');
$this->assertSame($expectedPolicyKey, $payload['policy_key'] ?? null, '428 payload.policy_key 가 기대값과 일치해야 함');
$this->assertSame($expectedPurpose, $payload['purpose'] ?? null, '428 payload.purpose 가 기대값과 일치해야 함');
$this->assertSame('/api/identity/challenges', $payload['challenge_start_url'] ?? null);
return $payload;
}
/**
* 2~4단계: challenge 발급 → 결정적 코드로 verify → verification_token 회수.
*/
protected function issueAndVerifyChallenge(
string $token,
string $purpose,
?array $target = null,
): array {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->postJson('/api/identity/challenges', array_filter([
'purpose' => $purpose,
'target' => $target,
]));
$response->assertStatus(201);
$challengeId = $response->json('data.id');
$this->assertNotNull($challengeId, 'challenge id 가 응답에 포함되어야 함');
$verifyResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->postJson('/api/identity/challenges/'.$challengeId.'/verify', [
'code' => TestIdentityProvider::FIXED_CODE,
]);
$verifyResponse->assertStatus(200);
return [
'challenge_id' => $challengeId,
'verified_at' => Carbon::now(),
];
}
/**
* 5단계: 인증 직후 보호된 API 재시도가 통과하는지 검증.
*/
protected function assertProtectedRoutePassesAfterVerification(
string $method,
string $uri,
string $token,
array $body = [],
int $expectedStatus = 200,
): \Illuminate\Testing\TestResponse {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
])->json($method, $uri, $body);
$response->assertStatus($expectedStatus);
return $response;
}
/**
* 6단계: grace_minutes 윈도우 내(예: grace-1분) 재호출 시 통과 검증.
*/
protected function assertGraceWindowSkips(
IdentityPolicy $policy,
callable $protectedCall,
): void {
Carbon::setTestNow(Carbon::now()->addMinutes(max(1, $policy->grace_minutes - 1)));
try {
$response = $protectedCall();
$this->assertTrue(
$response->getStatusCode() < 400 || $response->getStatusCode() === 422,
'grace 윈도우 내 재호출은 정책에 의한 428 이 발생하면 안 됨 (현 상태: '.$response->getStatusCode().')',
);
$this->assertNotSame(428, $response->getStatusCode(), 'grace 윈도우 내에는 428 이 발생하지 않아야 함');
} finally {
Carbon::setTestNow();
}
}
/**
* 7단계: grace_minutes 경과 후(예: grace+1분) 다시 428 반환 검증.
*/
protected function assertGraceWindowExpires(
IdentityPolicy $policy,
callable $protectedCall,
): void {
Carbon::setTestNow(Carbon::now()->addMinutes($policy->grace_minutes + 1));
try {
$response = $protectedCall();
$this->assertSame(428, $response->getStatusCode(), 'grace 윈도우 경과 후에는 428 이 다시 발생해야 함');
} finally {
Carbon::setTestNow();
}
}
/**
* applies_to 우회: 정책의 applies_to 와 다른 사용자 유형은 정책 영향을 받지 않아야 한다.
*/
protected function assertAppliesToBypass(
string $method,
string $uri,
string $bypassToken,
array $body = [],
): void {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$bypassToken,
'Accept' => 'application/json',
])->json($method, $uri, $body);
$this->assertNotSame(
428,
$response->getStatusCode(),
'applies_to 우회 대상은 정책 발동을 받으면 안 됨 (현 상태: '.$response->getStatusCode().')',
);
}
}