Laravel Breeze標準のメール認証を有効化する中で、
EmailVerificationTest.phpへメール認証URLまわりのFeature Testを追加しました。この記事では、既存の正常系テストを土台にしながら、次のケースを1つずつ追加した過程を整理します。
- 署名なしURL
- 期限切れ署名URL
- 別ユーザー用の署名付きURL
- 認証済みユーザーの認証案内画面アクセス
- 認証済みユーザーの認証メール再送
- 認証済みユーザーの認証リンク再訪
目次
- 環境
- 前提:Laravel Breezeのメール認証ルート
- 既存テストで確認できていたこと
- 1. 署名なしURLでは認証できないことを確認する
- 2. 期限切れ署名URLでは認証できないことを確認する
- 3. 別ユーザー用の署名付きURLでは認証できないことを確認する
- 4. 認証済みユーザーは認証案内画面を再表示しない
- 5. 認証済みユーザーへ認証メールを再送しない
- 6. 認証済みユーザーが認証リンクを再訪してもVerifiedイベントを再発火しない
Notification::fake()とEvent::fake()の違いassertForbidden()とassertFalse()を両方書く理由- 今回覚えたLaravel / PHPUnitのテスト用メソッド
- 最終的なEmailVerificationTestの実行結果
- 今回のまとめ
- 関連記事
環境
- PHP 8.4
- Laravel 13
- Laravel Breeze
- PHPUnit
- Laravel Sail
- MySQL
前提:Laravel Breezeのメール認証ルート
今回のアプリでは、Laravel Breezeが生成したメール認証ルートを利用しています。
※以下の3ルートは、このプロジェクトでは
authmiddlewareグループ内に定義されています。
Route::get('verify-email', EmailVerificationPromptController::class)
->name('verification.notice');
Route::get('verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['signed', 'throttle:6,1'])
->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationNotificationController::class, 'store'])
->middleware('throttle:6,1')
->name('verification.send');
認証URLにはsignedミドルウェアが付いています。そのため、idとhashが正しいだけではなく、Laravelが生成した有効な署名付きURLであることも必要です。
参考:
既存テストで確認できていたこと
もともとのEmailVerificationTest.phpには、次のテストがありました。
email verification screen can be rendered
email verification notification can be resent with accessible status
email can be verified
email is not verified with invalid hash
つまり、認証案内画面・認証メール再送・正常なメール認証・不正hashまでは確認済みでした。
1. 署名なしURLでは認証できないことを確認する
今回わざと壊すのは署名だけです。
id → 正しい
hash → 正しい
署名 → なし
未認証ユーザーを作ります。
$user = User::factory()->create([
'email_verified_at' => null,
]);
通常のroute()で署名なしURLを作ります。
$verificationUrl = route('verification.verify', [
'id' => $user->id,
'hash' => sha1($user->email),
]);
idとhashは正しい値にして、署名だけを欠落させます。
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertForbidden();
$freshUser = $user->fresh();
$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());
assertForbidden()は403 Forbiddenを確認します。fresh()はDBから最新のモデル状態を取り直すために使います。
fresh()は型上nullになり得るため、現在のテストではassertInstanceOf()でUserモデルであることを確認してからhasVerifiedEmail()を呼び出しています。
完成したテストです。
/**
* 改ざんされた認証URLによるメール認証を防ぐため、
* 有効な署名がないURLは拒否され、未認証状態が維持されることを保証する。
*/
public function test_email_is_not_verified_without_valid_signature(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
$verificationUrl = route('verification.verify', [
'id' => $user->id,
'hash' => sha1($user->email),
]);
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertForbidden();
$freshUser = $user->fresh();
$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());
}
2. 期限切れ署名URLでは認証できないことを確認する
次は、署名自体は正しいものの有効期限が切れているURLを作ります。
temporarySignedRoute()は、有効期限付きの署名URLを生成します。
URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $user->id,
'hash' => sha1($user->email),
]
);
今回はnow()->subMinute()を指定して、すでに期限切れのURLを作ります。
/**
* 期限切れの認証URLによるメール認証を防ぐため、
* 有効期限を過ぎた署名付きURLは拒否され、未認証状態が維持されることを保証する。
*/
public function test_email_is_not_verified_with_expired_signature(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->subMinute(),
[
'id' => $user->id,
'hash' => sha1($user->email),
]
);
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertForbidden();
$freshUser = $user->fresh();
$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());
}
このテストでは、id・hash・署名は正しく、期限だけを失効させています。
3. 別ユーザー用の署名付きURLでは認証できないことを確認する
2人のユーザーを作ります。
$user = User::factory()->create([
'email_verified_at' => null,
]);
$otherUser = User::factory()->create([
'email_verified_at' => null,
]);
役割は次のとおりです。
$user → 実際にログインしてURLを踏む人
$otherUser → 認証URLの本来の持ち主
$otherUser本人用の正しい署名付きURLを作ります。
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $otherUser->id,
'hash' => sha1($otherUser->email),
]
);
そのURLへ$userとしてアクセスします。
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertForbidden();
$freshOtherUser = $otherUser->fresh();
$this->assertInstanceOf(User::class, $freshOtherUser);
$this->assertFalse($freshOtherUser->hasVerifiedEmail());
完成したテストです。
/**
* 他ユーザー用の認証URLによるなりすまし認証を防ぐため、
* 別ユーザーが有効な署名付きURLへアクセスしても拒否され、
* URL本来の所有者が未認証状態のままであることを保証する。
*/
public function test_user_cannot_verify_email_with_another_users_signed_url(): void
{
$user = User::factory()->create([
'email_verified_at' => null,
]);
$otherUser = User::factory()->create([
'email_verified_at' => null,
]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $otherUser->id,
'hash' => sha1($otherUser->email),
]
);
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertForbidden();
$freshOtherUser = $otherUser->fresh();
$this->assertInstanceOf(User::class, $freshOtherUser);
$this->assertFalse($freshOtherUser->hasVerifiedEmail());
}
4. 認証済みユーザーは認証案内画面を再表示しない
$user = User::factory()->create();
このプロジェクトのUserFactoryでは通常作成したユーザーは認証済みです。
$response = $this->actingAs($user)->get('/verify-email');
$response->assertRedirect(RouteServiceProvider::HOME);
完成したテストです。
/**
* 認証済みユーザーに不要な認証案内画面を表示しないため、
* メール認証画面へアクセスした場合はHOMEへリダイレクトされることを保証する。
*/
public function test_verified_user_is_redirected_from_email_verification_screen(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/verify-email');
$response->assertRedirect(RouteServiceProvider::HOME);
}
5. 認証済みユーザーへ認証メールを再送しない
Notification::fake()で本物の通知送信を止めます。
Notification::fake();
認証済みユーザーで再送ルートへPOSTします。
$response = $this
->actingAs($user)
->post(route('verification.send'));
認証済みなのでHOMEへ戻り、VerifyEmail通知も送られないことを確認します。
$response->assertRedirect(RouteServiceProvider::HOME);
Notification::assertNotSentTo(
$user,
VerifyEmail::class,
);
完成したテストです。
/**
* 認証済みユーザーへ不要な認証メールを再送しないため、
* 再送要求時はHOMEへリダイレクトされ、VerifyEmail通知が送信されないことを保証する。
*/
public function test_verified_user_does_not_receive_verification_notification_again(): void
{
Notification::fake();
$user = User::factory()->create();
$response = $this
->actingAs($user)
->post(route('verification.send'));
$response->assertRedirect(RouteServiceProvider::HOME);
Notification::assertNotSentTo(
$user,
VerifyEmail::class,
);
}
6. 認証済みユーザーが認証リンクを再訪してもVerifiedイベントを再発火しない
Event::fake()でイベントをテスト用にFakeへ差し替えます。
Event::fake();
認証済みユーザー本人用の正しい署名付きURLを作ります。
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $user->id,
'hash' => sha1($user->email),
]
);
そのリンクを再訪します。
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');
Event::assertNotDispatched(Verified::class);
完成したテストです。
/**
* 認証済みユーザーによる認証処理の重複実行を防ぐため、
* 認証リンクを再訪してもVerifiedイベントが再発火しないことを保証する。
*/
public function test_verified_user_can_revisit_verification_link_without_dispatching_verified_event(): void
{
$user = User::factory()->create();
Event::fake();
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
[
'id' => $user->id,
'hash' => sha1($user->email),
]
);
$response = $this->actingAs($user)->get($verificationUrl);
$response->assertRedirect(RouteServiceProvider::HOME.'?verified=1');
Event::assertNotDispatched(Verified::class);
}
Notification::fake()とEvent::fake()の違い
今回のテストでは両方使いました。
| メソッド | 対象 |
|---|---|
Notification::fake() |
Notification |
Event::fake() |
Event |
今回なら、
VerifyEmail → Notification
Verified → Event
です。
Notification::assertNotSentTo()ではメール認証通知の送信有無を確認し、Event::assertNotDispatched()では認証完了イベントの発火有無を確認します。
assertForbidden()とassertFalse()を両方書く理由
不正URLテストでは、403だけで終わらずDBの認証状態も確認しました。
$response->assertForbidden();
$freshUser = $user->fresh();
$this->assertInstanceOf(User::class, $freshUser);
$this->assertFalse($freshUser->hasVerifiedEmail());
理由は、
HTTP上は拒否された
しかしDBの状態は変わっていた
という副作用を見逃さないためです。
アクセス制御やセキュリティのテストでは、レスポンスとDB状態の両方を見ると意図が明確になります。
今回覚えたLaravel / PHPUnitのテスト用メソッド
| メソッド | 役割 |
|---|---|
route() |
名前付きルートからURLを生成する |
URL::temporarySignedRoute() |
有効期限付き署名URLを生成する |
now()->addMinutes() |
現在時刻より未来を指定する |
now()->subMinute() |
現在時刻より過去を指定する |
actingAs() |
指定ユーザーとしてログイン状態を作る |
assertForbidden() |
403 Forbiddenを確認する |
fresh() |
DBから最新のモデル状態を取り直す |
assertInstanceOf() |
値が期待したクラスのインスタンスであることを確認する |
hasVerifiedEmail() |
メール認証済みか確認する |
Notification::fake() |
NotificationをFakeへ差し替える |
Notification::assertNotSentTo() |
指定Notificationが送られていないことを確認する |
Event::fake() |
EventをFakeへ差し替える |
Event::assertNotDispatched() |
指定Eventが発火していないことを確認する |
最終的なEmailVerificationTestの実行結果
sail artisan test tests/Feature/Auth/EmailVerificationTest.php
結果:
PASS Tests\Feature\Auth\EmailVerificationTest
✓ email verification screen can be rendered
✓ email verification notification can be resent with accessible status
✓ email can be verified
✓ email is not verified with invalid hash
✓ email is not verified without valid signature
✓ email is not verified with expired signature
✓ user cannot verify email with another users signed url
✓ verified user is redirected from email verification screen
✓ verified user does not receive verification notification again
✓ verified user can revisit verification link without dispatching verified event
Tests: 10 passed (40 assertions)
正常系・不正URL・認証済みユーザーの分岐を含めて、10テストすべてPASSしました。
--
今回のまとめ
今回のFeature Test追加で、メール認証を単に「正しいURLなら認証できる」だけではなく、署名なし・期限切れ・別ユーザー用URL・認証済みユーザーの再操作まで確認できました。
特に重要だったのは、テストごとに1つだけ条件を変えることです。
署名なしテストなら、
idは正しい
hashも正しい
署名だけない
期限切れテストなら、
idは正しい
hashも正しい
署名も正しい
期限だけ切れている
という状態を作ります。
こうすることで、何が原因で拒否されたのかが明確になります。
また、不正アクセスを拒否したことだけでなく、DB上のメール認証状態が変わっていないこと、通知が再送されていないこと、イベントが再発火していないこともFeature Testで確認できました。