manager = new IdentityVerificationManager(); } public function test_register_and_get_provider(): void { $provider = $this->makeProvider('dummy', true, true); $this->manager->register($provider); $this->assertTrue($this->manager->has('dummy')); $this->assertSame($provider, $this->manager->get('dummy')); } public function test_unregister_removes_provider(): void { $provider = $this->makeProvider('dummy', true, true); $this->manager->register($provider); $this->manager->unregister('dummy'); $this->assertFalse($this->manager->has('dummy')); } public function test_get_unknown_throws(): void { $this->expectException(InvalidArgumentException::class); $this->manager->get('nonexistent'); } public function test_all_runs_through_filter_hook(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $this->manager->register($mail); $injected = $this->makeProvider('plugin:fake', true, true); $filter = function (array $providers) use ($injected) { $providers[$injected->getId()] = $injected; return $providers; }; HookManager::addFilter('core.identity.registered_providers', $filter); try { $all = $this->manager->all(); $this->assertArrayHasKey('g7:core.mail', $all); $this->assertArrayHasKey('plugin:fake', $all); } finally { // 테스트 격리 — 동일 PHP 프로세스 내 후속 테스트가 본 filter 의 영향을 받지 않도록 정리. HookManager::removeFilter('core.identity.registered_providers', $filter); } } public function test_resolve_for_purpose_returns_provider_supporting_purpose(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $this->manager->register($mail); $resolved = $this->manager->resolveForPurpose('signup'); $this->assertSame('g7:core.mail', $resolved->getId()); } public function test_resolve_for_purpose_falls_back_when_default_unsupported(): void { $unsupported = $this->makeProvider('g7:core.mail', true, false); $supporter = $this->makeProvider('plugin:kcp', true, true); $this->manager->register($unsupported); $this->manager->register($supporter); $resolved = $this->manager->resolveForPurpose('signup'); $this->assertSame('plugin:kcp', $resolved->getId()); } /** * 명시 $providerId 가 등록 + purpose 지원 시 settings/default 보다 우선 적용 (0번 우선순위, refs #275). */ public function test_resolve_for_purpose_prefers_explicit_provider_id(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $external = $this->makeProvider('plugin:fake_external', true, true); $this->manager->register($mail); $this->manager->register($external); // settings.identity.purpose_providers.signup = mail 로 강제 — 명시 providerId 가 이걸 덮어야 한다. config(['settings.identity.purpose_providers.signup' => 'g7:core.mail']); $resolved = $this->manager->resolveForPurpose('signup', 'plugin:fake_external'); $this->assertSame('plugin:fake_external', $resolved->getId()); } /** * 명시 $providerId 가 미등록이면 silent fallback (기존 우선순위 체인 진행, refs #275). */ public function test_resolve_for_purpose_falls_back_when_explicit_provider_unregistered(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $this->manager->register($mail); $resolved = $this->manager->resolveForPurpose('signup', 'plugin:nonexistent'); $this->assertSame('g7:core.mail', $resolved->getId()); } /** * 명시 $providerId 가 등록되어 있어도 supportsPurpose() false 면 silent fallback (refs #275). */ public function test_resolve_for_purpose_falls_back_when_explicit_provider_does_not_support_purpose(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $passwordOnly = $this->makeProvider('plugin:password_only', true, false); $this->manager->register($mail); $this->manager->register($passwordOnly); $resolved = $this->manager->resolveForPurpose('signup', 'plugin:password_only'); $this->assertSame('g7:core.mail', $resolved->getId()); } /** * $providerId = null (기본값) 시 기존 우선순위 유지 — BC 회귀 차단 (refs #275). */ public function test_resolve_for_purpose_with_null_provider_id_preserves_legacy_order(): void { $mail = $this->makeProvider('g7:core.mail', true, true); $external = $this->makeProvider('plugin:fake_external', true, true); $this->manager->register($mail); $this->manager->register($external); // default = g7:core.mail (Manager 의 defaultId) — null providerId 이면 default 로 해석되어야 한다. $resolved = $this->manager->resolveForPurpose('signup', null); $this->assertSame('g7:core.mail', $resolved->getId()); } /** * IdentityVerificationInterface 더블을 생성합니다. */ private function makeProvider(string $id, bool $available, bool $supportsAll): IdentityVerificationInterface { return new class($id, $available, $supportsAll) implements IdentityVerificationInterface { public function __construct( private string $id, private bool $available, private bool $supportsAll, ) {} 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 { return $this->supportsAll; } public function isAvailable(): bool { return $this->available; } public function requestChallenge(User|array $target, array $context = []): VerificationChallenge { return new VerificationChallenge( id: 'dummy-id', providerId: $this->id, purpose: $context['purpose'] ?? 'sensitive_action', channel: 'email', targetHash: str_repeat('0', 64), expiresAt: Carbon::now()->addMinutes(15), 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; } }; } }