Files
Gnuboard7/tests/Unit/Extension/IdentityVerification/Providers/MailIdentityProviderTest.php
T
2026-07-01 10:30:32 +09:00

204 lines
8.1 KiB
PHP

<?php
namespace Tests\Unit\Extension\IdentityVerification\Providers;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Enums\IdentityVerificationStatus;
use App\Extension\IdentityVerification\Providers\MailIdentityProvider;
use App\Models\IdentityVerificationLog;
use Database\Seeders\IdentityMessageDefinitionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
/**
* MailIdentityProvider 테스트.
*
* Challenge 발행 / 검증 성공 / 잘못된 코드 / 만료 / 재시도 초과 / 취소 경로를 검증합니다.
*/
class MailIdentityProviderTest extends TestCase
{
use RefreshDatabase;
private MailIdentityProvider $provider;
protected function setUp(): void
{
parent::setUp();
Notification::fake();
Mail::fake();
// dispatchMessage 가 IdentityMessageDefinition/Template 을 조회하므로 시더 필수.
$this->seed(IdentityMessageDefinitionSeeder::class);
$this->provider = $this->app->make(MailIdentityProvider::class);
}
public function test_provider_metadata(): void
{
$this->assertSame('g7:core.mail', $this->provider->getId());
$this->assertContains('email', $this->provider->getChannels());
$this->assertTrue($this->provider->supportsPurpose('signup'));
$this->assertTrue($this->provider->supportsPurpose('sensitive_action'));
}
public function test_request_challenge_creates_log_and_sends_notification(): void
{
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'signup', 'ip_address' => '127.0.0.1'],
);
$this->assertSame('g7:core.mail', $challenge->providerId);
$this->assertSame('email', $challenge->channel);
$this->assertSame('text_code', $challenge->renderHint);
$this->assertSame(hash('sha256', 'user@example.com'), $challenge->targetHash);
$log = IdentityVerificationLog::find($challenge->id);
$this->assertNotNull($log);
$this->assertSame(IdentityVerificationStatus::Sent, $log->status);
$this->assertArrayHasKey('code_hash', $log->metadata ?? []);
}
public function test_request_challenge_password_reset_uses_link_render_hint(): void
{
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'password_reset'],
);
$this->assertSame('link', $challenge->renderHint);
$log = IdentityVerificationLog::find($challenge->id);
$this->assertArrayHasKey('link_token_hash', $log->metadata ?? []);
}
public function test_verify_with_correct_code_succeeds(): void
{
/** @var IdentityVerificationLogRepositoryInterface $repo */
$repo = $this->app->make(IdentityVerificationLogRepositoryInterface::class);
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'sensitive_action'],
);
// 테스트 목적으로 메타데이터에서 알려진 코드의 해시를 직접 주입
$knownCode = '123456';
$repo->updateById($challenge->id, [
'metadata' => ['code_hash' => password_hash($knownCode, PASSWORD_BCRYPT)],
'status' => IdentityVerificationStatus::Sent->value,
]);
$result = $this->provider->verify($challenge->id, ['code' => $knownCode]);
$this->assertTrue($result->success);
$this->assertNotNull($result->verifiedAt);
$log = IdentityVerificationLog::find($challenge->id);
$this->assertSame(IdentityVerificationStatus::Verified, $log->status);
$this->assertNotNull($log->verification_token);
}
public function test_verify_with_wrong_code_increments_attempts(): void
{
/** @var IdentityVerificationLogRepositoryInterface $repo */
$repo = $this->app->make(IdentityVerificationLogRepositoryInterface::class);
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'sensitive_action'],
);
$repo->updateById($challenge->id, [
'metadata' => ['code_hash' => password_hash('123456', PASSWORD_BCRYPT)],
'status' => IdentityVerificationStatus::Sent->value,
]);
$result = $this->provider->verify($challenge->id, ['code' => '999999']);
$this->assertFalse($result->success);
$this->assertSame('INVALID_CODE', $result->failureCode);
$log = IdentityVerificationLog::find($challenge->id);
$this->assertSame(1, $log->attempts);
}
public function test_verify_returns_max_attempts_on_final_wrong_attempt(): void
{
/** @var IdentityVerificationLogRepositoryInterface $repo */
$repo = $this->app->make(IdentityVerificationLogRepositoryInterface::class);
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'sensitive_action'],
);
// max_attempts 를 2 로 낮춰 빠르게 소진. 1회차는 INVALID_CODE, 2회차(소진)는 MAX_ATTEMPTS 안내.
$repo->updateById($challenge->id, [
'metadata' => ['code_hash' => password_hash('123456', PASSWORD_BCRYPT)],
'status' => IdentityVerificationStatus::Sent->value,
'max_attempts' => 2,
]);
// 1회차 오답 — 아직 소진 전이므로 일반 오답 안내.
$first = $this->provider->verify($challenge->id, ['code' => '999999']);
$this->assertFalse($first->success);
$this->assertSame('INVALID_CODE', $first->failureCode);
$this->assertSame('identity.errors.invalid_code', $first->failureReason);
// 2회차 오답 — 이번 시도로 max_attempts 도달(소진). 사용자에게 "최대 시도 초과 + 재요청" 안내가 도달해야 함.
$second = $this->provider->verify($challenge->id, ['code' => '999999']);
$this->assertFalse($second->success);
$this->assertSame('MAX_ATTEMPTS', $second->failureCode);
$this->assertSame('identity.errors.max_attempts', $second->failureReason);
$log = IdentityVerificationLog::find($challenge->id);
$this->assertSame(2, $log->attempts);
$this->assertSame(IdentityVerificationStatus::Failed, $log->status);
}
public function test_verify_returns_max_attempts_when_already_exhausted(): void
{
/** @var IdentityVerificationLogRepositoryInterface $repo */
$repo = $this->app->make(IdentityVerificationLogRepositoryInterface::class);
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'sensitive_action'],
);
// 이미 소진된 상태(attempts == max_attempts)에서 또 시도 — 코드 대조 전에 차단.
$repo->updateById($challenge->id, [
'metadata' => ['code_hash' => password_hash('123456', PASSWORD_BCRYPT)],
'status' => IdentityVerificationStatus::Sent->value,
'attempts' => 5,
'max_attempts' => 5,
]);
$result = $this->provider->verify($challenge->id, ['code' => '123456']);
$this->assertFalse($result->success);
$this->assertSame('MAX_ATTEMPTS', $result->failureCode);
$this->assertSame('identity.errors.max_attempts', $result->failureReason);
}
public function test_cancel_marks_status_as_cancelled(): void
{
$challenge = $this->provider->requestChallenge(
['email' => 'user@example.com'],
['purpose' => 'sensitive_action'],
);
$this->assertTrue($this->provider->cancel($challenge->id));
$log = IdentityVerificationLog::find($challenge->id);
$this->assertSame(IdentityVerificationStatus::Cancelled, $log->status);
}
public function test_request_challenge_fails_without_email(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->provider->requestChallenge([], ['purpose' => 'signup']);
}
}