Files
Gnuboard7/tests/Feature/Identity/CoreIdentityPolicyLifecycleTest.php
2026-05-11 11:29:41 +09:00

344 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Identity;
use App\Enums\IdentityVerificationStatus;
use App\Exceptions\IdentityVerificationRequiredException;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Services\IdentityPolicyService;
use Database\Seeders\IdentityPolicySeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Notification;
use Tests\Support\Identity\PolicyLifecycleTestHelpers;
use Tests\Support\Identity\TestIdentityProvider;
use Tests\TestCase;
/**
* 코어 IDV 정책 챌린지/검증 라이프사이클 (Part B-3).
*
* 7단계 통합:
* 1. 보호된 API 호출 → HTTP 428 + payload 검증
* 2. POST /api/identity/challenges → challenge 발급 (TestIdentityProvider 결정적 코드)
* 3. challenge 코드 획득 (TestIdentityProvider::FIXED_CODE)
* 4. POST /api/identity/challenges/{id}/verify → verification_token 발급, identity_verification_logs.verified_at 기록
* 5. 원래 보호 API 재시도 → 통과 (정책에 의한 428 미발생)
* 6. grace_minutes 윈도우 내 재호출 → 통과
* 7. grace_minutes 경과 후 재호출 → 다시 428
*
* 메일 회로 자체는 `IdentityPolicyMailFakeSmokeTest` 가 별도 검증.
*/
class CoreIdentityPolicyLifecycleTest extends TestCase
{
use PolicyLifecycleTestHelpers, RefreshDatabase;
private IdentityPolicyService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = $this->app->make(IdentityPolicyService::class);
$this->seed(RolePermissionSeeder::class);
$this->seed(IdentityPolicySeeder::class);
$this->registerTestIdentityProvider();
Notification::fake();
}
/**
* signup_before_submit — HTTP route 레이어 라이프사이클.
*
* /api/auth/register 가 정책 enabled 시 IDV 를 강제하고, challenge → verify 후에는
* 정책 미들웨어를 통과해 회원가입이 정상 진행되어야 한다.
*/
public function test_signup_before_submit_full_http_lifecycle(): void
{
$policy = $this->enableSignupPolicy('core.auth.signup_before_submit');
// 1단계 — 보호된 register 호출은 428 (정책 위반)
$email = 'newuser_'.uniqid().'@example.test';
$payload = $this->assertProtectedRouteIssues428(
'POST',
'/api/auth/register',
$this->guestToken(),
'core.auth.signup_before_submit',
'signup',
[
'name' => 'Lifecycle Tester',
'nickname' => 'lt_'.uniqid(),
'email' => $email,
'password' => 'StrongPass!23',
'password_confirmation' => 'StrongPass!23',
'agreement' => true,
],
);
$this->assertSame('signup', $payload['purpose']);
// 2~4단계 — 게스트로 challenge 발급 + verify (Mode B 가입 흐름)
$issued = $this->issueAndVerifyChallengeAsGuest($email, 'signup');
$this->assertNotNull($issued['challenge_id']);
// 5단계 — verified 토큰을 함께 보내서 register 재시도 시 정책이 통과되어야 함.
// (IdentityPolicyService::resolveTargetHash 가 게스트의 input.email 을 기준으로 verified 로그 조회)
$retry = $this->postJson('/api/auth/register', [
'name' => 'Lifecycle Tester',
'nickname' => 'lt_'.uniqid(),
'email' => $email,
'password' => 'StrongPass!23',
'password_confirmation' => 'StrongPass!23',
'agreement' => true,
]);
// verify 직후라 정책 미들웨어는 통과해야 한다 — 응답 코드는 register 비즈니스 로직 결과에 따라
// 200/201 또는 검증 실패 시 422 가 될 수 있으나 428 은 안 나야 한다.
$this->assertNotSame(
428,
$retry->getStatusCode(),
'verify 직후 register 재시도는 정책 미들웨어를 통과해야 함 (현재 status: '.$retry->getStatusCode().')',
);
}
/**
* Service-level 라이프사이클 — password_change (grace=5).
*
* 1) enforce throws (이력 없음)
* 2) verified 로그 기록 (challenge → verify 결과를 fixture 로 시뮬레이션)
* 3) enforce passes (grace 윈도우 내)
* 4) Carbon::setTestNow 로 grace 경과 시뮬레이션
* 5) enforce throws 재발생
*/
public function test_password_change_service_lifecycle_with_grace_window(): void
{
$user = User::factory()->create();
$policy = $this->enableServicePolicy('core.profile.password_change');
// 1단계 — 인증 이력 없을 때 enforce 는 throw
try {
$this->service->enforce($policy, $user, []);
$this->fail('인증 이력 없을 때 enforce 는 throw 해야 함');
} catch (IdentityVerificationRequiredException $e) {
$this->assertSame('core.profile.password_change', $e->policyKey);
}
// 2단계 — 결정적 challenge 발급 + verify (Service 레이어로 직접 호출)
$logRepository = $this->app->make(\App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface::class);
$provider = new TestIdentityProvider($logRepository);
$challenge = $provider->requestChallenge($user, ['purpose' => 'sensitive_action']);
$result = $provider->verify($challenge->id, ['code' => TestIdentityProvider::FIXED_CODE]);
$this->assertTrue($result->success, 'TestIdentityProvider verify 는 결정적 코드로 성공해야 함');
// 3단계 — verified 직후 enforce 는 grace 윈도우로 통과
$this->service->enforce($policy, $user, []);
$this->assertTrue(true, 'verified 직후 enforce 는 throw 없이 통과해야 함');
// 4~5단계 — grace_minutes (5) + 1분 경과 후 enforce 는 다시 throw
Carbon::setTestNow(Carbon::now()->addMinutes(6));
try {
$this->service->enforce($policy, $user->fresh(), []);
$this->fail('grace 경과 후에는 enforce 가 다시 throw 해야 함');
} catch (IdentityVerificationRequiredException $e) {
$this->assertSame('core.profile.password_change', $e->policyKey);
} finally {
Carbon::setTestNow();
}
}
/**
* 코어 9개 정책 + 모듈 8개 정책 전수 Service-level 라이프사이클 매트릭스.
*
* 각 정책마다:
* 1. enforce throws (no verification)
* 2. TestIdentityProvider 로 challenge 발급 + verify
* 3. enforce skips (verified 직후)
* 4. grace > 0 정책 → Carbon::setTestNow grace+1 분 시뮬레이션
* 5. grace > 0 정책 → enforce throws again
*
* @dataProvider policyLifecycleProvider
*/
public function test_policy_full_service_lifecycle(
string $policyKey,
string $purpose,
string $userType,
int $graceMinutes,
?array $contextHint = null,
?string $sourceType = 'core',
?array $declaredOverride = null,
): void {
if ($sourceType !== 'core') {
$this->markTestSkipped("module/plugin 정책은 해당 모듈 테스트에서 검증");
}
$user = $userType === 'admin' ? $this->makeAdminUser() : User::factory()->create();
$policy = $this->enableServicePolicy($policyKey);
$context = $contextHint ?? [];
if ($declaredOverride !== null) {
// signup_before_submit 와 같이 conditions 매칭이 필요한 정책은 컨텍스트 보충 (enforce 자체는 conditions 무시)
$context = array_merge($context, $declaredOverride);
}
// 1. 인증 이력 없을 때 enforce 는 throw
try {
$this->service->enforce($policy, $user, $context);
$this->fail("정책 '{$policyKey}' 가 인증 이력 없을 때 throw 해야 함");
} catch (IdentityVerificationRequiredException $e) {
$this->assertSame($policyKey, $e->policyKey);
}
// 2. challenge 발급 + verify
$logRepository = $this->app->make(\App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface::class);
$provider = new TestIdentityProvider($logRepository);
$challenge = $provider->requestChallenge($user, ['purpose' => $purpose]);
$result = $provider->verify($challenge->id, ['code' => TestIdentityProvider::FIXED_CODE]);
$this->assertTrue($result->success, "TestIdentityProvider verify 실패 (policyKey: {$policyKey})");
// 3. verified 직후 enforce 는 통과
$this->service->enforce($policy, $user->fresh(), $context);
$this->assertTrue(true, "정책 '{$policyKey}' verified 직후 enforce 통과");
// 4~5. grace > 0 정책만 grace 윈도우 만료 후 다시 throw
if ($graceMinutes > 0) {
Carbon::setTestNow(Carbon::now()->addMinutes($graceMinutes + 1));
try {
$this->service->enforce($policy, $user->fresh(), $context);
$this->fail("정책 '{$policyKey}' grace+1 분 경과 후 throw 해야 함");
} catch (IdentityVerificationRequiredException $e) {
$this->assertSame($policyKey, $e->policyKey);
} finally {
Carbon::setTestNow();
}
}
}
public static function policyLifecycleProvider(): array
{
// 코어 9개 정책 — module/plugin 정책은 각자 모듈 테스트에서 동일 매트릭스 커버.
return [
'signup_before_submit (self, grace=0)' => [
'core.auth.signup_before_submit', 'signup', 'self', 0, ['signup_stage' => 'before_submit', 'http_method' => 'POST'],
],
'signup_after_create (self, grace=0)' => [
'core.auth.signup_after_create', 'signup', 'self', 0, ['signup_stage' => 'after_create'],
],
'password_reset (both/self, grace=0)' => [
'core.auth.password_reset', 'password_reset', 'self', 0,
],
'password_change (self, grace=5)' => [
'core.profile.password_change', 'sensitive_action', 'self', 5,
],
'contact_change (self, grace=5)' => [
'core.profile.contact_change', 'sensitive_action', 'self', 5, ['changed_fields' => ['email']],
],
'account_withdraw (self, grace=0)' => [
'core.account.withdraw', 'sensitive_action', 'self', 0,
],
'app_key_regenerate (admin, grace=0)' => [
'core.admin.app_key_regenerate', 'sensitive_action', 'admin', 0,
],
'user_delete (admin, grace=0)' => [
'core.admin.user_delete', 'sensitive_action', 'admin', 0,
],
'extension_uninstall (admin, grace=0)' => [
'core.admin.extension_uninstall', 'sensitive_action', 'admin', 0,
],
];
}
private function makeAdminUser(): User
{
$admin = User::factory()->create(['is_super' => true]);
$adminRole = \App\Models\Role::where('identifier', 'admin')->first();
if ($adminRole) {
$admin->roles()->attach($adminRole->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
return $admin->fresh();
}
/**
* password_reset — HTTP route 레이어 라이프사이클 (게스트, both 적용).
*/
public function test_password_reset_route_returns_428_when_enabled(): void
{
$this->enableSignupPolicy('core.auth.password_reset');
$email = 'reset_'.uniqid().'@example.test';
// user 가 존재해야 AuthService::resetPassword 가 token 검증을 거쳐 hook 까지 도달.
// 직전 회귀: 명시 미들웨어 등록 제거 후 자동 매핑은 hook scope 정책을 처리하지 않으므로,
// controller → AuthService 까지 진입해야 hook listener 가 enforce 한다.
\App\Models\User::factory()->create(['email' => $email]);
// 비밀번호 재설정 토큰 1건 시드
\DB::table('password_reset_tokens')->insert([
'email' => $email,
'token' => bcrypt('valid-token'),
'created_at' => now(),
]);
$response = $this->withHeaders(['Accept' => 'application/json'])
->postJson('/api/auth/reset-password', [
'email' => $email,
'token' => 'valid-token',
'password' => 'NewStrong!23',
'password_confirmation' => 'NewStrong!23',
]);
$this->assertSame(428, $response->getStatusCode(), 'enabled 시 password_reset 정책은 428 발동');
$payload = $response->json('verification');
$this->assertIsArray($payload);
$this->assertSame('core.auth.password_reset', $payload['policy_key']);
$this->assertSame('password_reset', $payload['purpose']);
}
private function enableSignupPolicy(string $key): IdentityPolicy
{
$policy = IdentityPolicy::where('key', $key)->first();
$this->assertNotNull($policy);
$policy->enabled = true;
$policy->provider_id = TestIdentityProvider::ID;
$policy->save();
return $policy->fresh();
}
private function enableServicePolicy(string $key): IdentityPolicy
{
return $this->enableSignupPolicy($key);
}
private function guestToken(): string
{
// 라우트 미들웨어가 'optional.sanctum' 으로 게스트도 허용 — 빈 문자열로 헤더만 형성
return '';
}
/**
* 게스트 흐름 (Mode B) 에서 challenge 발급 후 결정적 코드로 검증.
*/
private function issueAndVerifyChallengeAsGuest(string $email, string $purpose): array
{
$issue = $this->postJson('/api/identity/challenges', [
'purpose' => $purpose,
'target' => ['email' => $email],
]);
$issue->assertStatus(201);
$challengeId = $issue->json('data.id');
$this->assertNotNull($challengeId);
$verify = $this->postJson('/api/identity/challenges/'.$challengeId.'/verify', [
'code' => TestIdentityProvider::FIXED_CODE,
]);
$verify->assertStatus(200);
return ['challenge_id' => $challengeId];
}
}