mailProvider = $this->makeFakeProvider('g7:core.mail'); $this->externalProvider = $this->makeFakeProvider('plugin:fake_external'); /** @var IdentityVerificationManager $manager */ $manager = $this->app->make(IdentityVerificationManager::class); $manager->register($this->mailProvider); $manager->register($this->externalProvider); } protected function tearDown(): void { /** @var IdentityVerificationManager $manager */ $manager = $this->app->make(IdentityVerificationManager::class); $manager->unregister('g7:core.mail'); $manager->unregister('plugin:fake_external'); parent::tearDown(); } /** * Mode A: Service::start 에 명시한 providerId 가 Manager 해석에 그대로 반영. */ public function test_mode_a_explicit_provider_id_is_used_for_challenge(): void { /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); $challenge = $service->start( purpose: 'signup', target: ['email' => 'a@example.com'], context: ['ip_address' => '127.0.0.1'], providerId: 'plugin:fake_external', ); $this->assertSame('plugin:fake_external', $challenge->providerId); } /** * Mode A: providerId = null 이면 Manager fallback (default = g7:core.mail). */ public function test_mode_a_null_provider_id_falls_back_to_default(): void { /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); $challenge = $service->start( purpose: 'signup', target: ['email' => 'a@example.com'], context: ['ip_address' => '127.0.0.1'], ); $this->assertSame('g7:core.mail', $challenge->providerId); } /** * Mode A: 미등록 providerId 도 silent fallback (예외 throw 없음). */ public function test_mode_a_unregistered_provider_id_falls_back_silently(): void { /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); $challenge = $service->start( purpose: 'signup', target: ['email' => 'a@example.com'], context: ['ip_address' => '127.0.0.1'], providerId: 'plugin:nonexistent', ); $this->assertSame('g7:core.mail', $challenge->providerId); } /** * Mode A: 등록되었지만 해당 purpose 미지원인 provider 도 silent fallback. * * 운영자가 정책/요청에 plugin:password_only 를 지정했지만 그 provider 가 signup 을 지원하지 * 않는 경우, 예외 대신 mail (코어 기본) 로 fallback 되어야 한다. */ public function test_mode_a_provider_not_supporting_purpose_falls_back_silently(): void { // signup 은 지원하지 않고 password_reset 만 지원하는 가짜 provider 추가 등록. $passwordOnly = $this->makeFakeProvider('plugin:password_only', supportedPurposes: ['password_reset']); /** @var IdentityVerificationManager $manager */ $manager = $this->app->make(IdentityVerificationManager::class); $manager->register($passwordOnly); try { /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); $challenge = $service->start( purpose: 'signup', target: ['email' => 'a@example.com'], context: ['ip_address' => '127.0.0.1'], providerId: 'plugin:password_only', ); $this->assertSame( 'g7:core.mail', $challenge->providerId, 'signup 을 지원하지 않는 명시 provider 는 silent fallback 되어 mail 이 선택되어야 한다', ); } finally { $manager->unregister('plugin:password_only'); } } /** * Mode B: signup_after_create 정책의 provider_id 가 Listener → Service::start → Manager 로 전파. * * AuthService 전체 가입 흐름 대신 Listener::handle 을 직접 호출하여 * provider_id 전파만 검증한다 (가입 흐름 검증은 Auth 도메인 테스트 책임). */ public function test_mode_b_policy_provider_id_propagates_through_listener(): void { $policy = $this->createSignupAfterCreatePolicy('plugin:fake_external'); $user = $this->createUser(); /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); /** @var IdentityPolicyService $policyService */ $policyService = $this->app->make(IdentityPolicyService::class); $listener = new InitiateIdentityChallengeAfterRegister($service, $policyService); $listener->handle($user, [ 'signup_stage' => 'after_create', 'ip_address' => '127.0.0.1', 'user_agent' => 'phpunit', ]); $log = \App\Models\IdentityVerificationLog::query() ->where('user_id', $user->id) ->orderByDesc('id') ->first(); $this->assertNotNull($log, '정책이 enabled 면 challenge log 가 생성되어야 함'); $this->assertSame('plugin:fake_external', $log->provider_id); unset($policy); } /** * Mode B: 정책 provider_id = null 이면 Manager fallback (BC). */ public function test_mode_b_null_policy_provider_id_falls_back_to_default(): void { $this->createSignupAfterCreatePolicy(null); $user = $this->createUser(); /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); /** @var IdentityPolicyService $policyService */ $policyService = $this->app->make(IdentityPolicyService::class); $listener = new InitiateIdentityChallengeAfterRegister($service, $policyService); $listener->handle($user, [ 'signup_stage' => 'after_create', 'ip_address' => '127.0.0.1', 'user_agent' => 'phpunit', ]); $log = \App\Models\IdentityVerificationLog::query() ->where('user_id', $user->id) ->orderByDesc('id') ->first(); $this->assertNotNull($log); $this->assertSame('g7:core.mail', $log->provider_id); } /** * Mode B: 정책 disabled 면 challenge 발행 자체 미수행 (BC 회귀 차단). */ public function test_mode_b_disabled_policy_skips_challenge(): void { $this->createSignupAfterCreatePolicy('plugin:fake_external', enabled: false); $user = $this->createUser(); /** @var IdentityVerificationService $service */ $service = $this->app->make(IdentityVerificationService::class); /** @var IdentityPolicyService $policyService */ $policyService = $this->app->make(IdentityPolicyService::class); $listener = new InitiateIdentityChallengeAfterRegister($service, $policyService); $listener->handle($user, [ 'signup_stage' => 'after_create', 'ip_address' => '127.0.0.1', 'user_agent' => 'phpunit', ]); $count = \App\Models\IdentityVerificationLog::query() ->where('user_id', $user->id) ->count(); $this->assertSame(0, $count); } /** * signup_after_create 정책을 DB 에 생성. */ private function createSignupAfterCreatePolicy(?string $providerId, bool $enabled = true): IdentityPolicy { return IdentityPolicy::query()->updateOrCreate( ['key' => 'core.auth.signup_after_create'], [ 'scope' => 'hook', 'target' => 'core.auth.after_register', 'purpose' => 'signup', 'provider_id' => $providerId, 'enabled' => $enabled, 'applies_to' => 'self', 'fail_mode' => 'block', 'grace_minutes' => 0, 'priority' => 100, 'conditions' => null, 'source_type' => IdentityPolicySourceType::Core, 'source_identifier' => 'core', ], ); } /** * 테스트용 활성 사용자를 생성합니다. */ private function createUser(): User { return User::factory()->create([ 'email' => 'a@example.com', ]); } /** * 모든 purpose (또는 일부 purpose) 를 지원하는 간단한 fake provider. * * @param string $id provider 식별자 * @param array|null $supportedPurposes null 이면 모든 purpose 지원, 배열이면 해당 항목만 지원 */ private function makeFakeProvider(string $id, ?array $supportedPurposes = null): IdentityVerificationInterface { return new class($id, $supportedPurposes) implements IdentityVerificationInterface { /** * @param array|null $supportedPurposes */ public function __construct(private string $id, private ?array $supportedPurposes = null) {} public function getId(): string { return $this->id; } public function getLabel(): string { return $this->id; } public function getChannels(): array { return ['email']; } public function getChannelLabels(): array { return ['email' => 'Email']; } public function getRenderHint(): string { return 'text_code'; } public function supportsPurpose(string $purpose): bool { if ($this->supportedPurposes === null) { return true; } return in_array($purpose, $this->supportedPurposes, true); } public function isAvailable(): bool { return true; } public function requestChallenge(User|array $target, array $context = []): VerificationChallenge { $targetHash = is_array($target) && ! empty($target['email']) ? hash('sha256', mb_strtolower((string) $target['email'])) : ($target instanceof User ? hash('sha256', mb_strtolower((string) $target->email)) : str_repeat('0', 64)); $userId = $target instanceof User ? $target->id : null; $log = \App\Models\IdentityVerificationLog::query()->create([ 'provider_id' => $this->id, 'purpose' => $context['purpose'] ?? 'signup', 'channel' => 'email', 'target_hash' => $targetHash, 'user_id' => $userId, 'status' => \App\Enums\IdentityVerificationStatus::Sent->value, 'expires_at' => Carbon::now()->addMinutes(15), 'origin_type' => $context['origin_type'] ?? null, 'origin_identifier' => $context['origin_identifier'] ?? null, 'origin_policy_key' => $context['origin_policy_key'] ?? null, 'metadata' => [], 'properties' => [], ]); return new VerificationChallenge( id: (string) $log->id, providerId: $this->id, purpose: $context['purpose'] ?? 'signup', channel: 'email', targetHash: $targetHash, expiresAt: $log->expires_at, renderHint: 'text_code', ); } public function verify(string $challengeId, array $input, array $context = []): VerificationResult { return VerificationResult::success( challengeId: $challengeId, providerId: $this->id, verifiedAt: Carbon::now(), ); } public function cancel(string $challengeId): bool { return true; } public function getSettingsSchema(): array { return []; } public function withConfig(array $config): static { return $this; } }; } }