Feature (#16)
* feat: add email verification endpoints and tests Add comprehensive CI/CD pipeline using GitHub Actions - Run Pest tests on PHP 8.3 and 8.4 with MySQL 8.0 - Automated code style checks with Pint - Static analysis with Larastan/PHPStan - Parallel job execution for faster builds - Composer dependency caching - MySQL service container with health checks * feat: implement password reset flow with secure tokens , add forgot password and reset password functionality - Created password_reset_tokens table migration - Added POST /api/v1/forgot-password endpoint - Added POST /api/v1/reset-password endpoint - Both endpoints rate-limited to 6 requests per minute - Integrated with Laravel's Password facade for secure token management - Revokes all user tokens upon successful password reset - Created ForgotPasswordRequest and ResetPasswordRequest with validation - Added comprehensive test suite (6 test cases) Password reset uses signed, time-limited tokens stored in the database. All user sessions are invalidated after successful reset for security. * feat: add reusable API middleware examples , Implement three production-ready middleware patterns for APIs ForceJsonResponse: - Ensures all API responses are JSON formatted - Sets Accept: application/json header automatically - Handles non-JSON responses gracefully LogApiRequests: - Logs API requests with timestamp, method, URL, IP, user ID, status - Tracks and logs response time in milliseconds - Adds X-Response-Time header to all responses - Configurable via APP_LOG_API_REQUESTS env variable EnsureEmailVerified: - Protects routes requiring verified emails - Returns 403 with descriptive message for unverified users - Works with MustVerifyEmail contract All middleware registered as aliases in bootstrap/app.php: 'force.json', 'log.api', 'verified' These provide common API patterns that developers can apply to routes as needed. * docs: update README with new API endpoints , Update API endpoints table with email verification and password reset routes - Added 4 new endpoint rows to API documentation - Updated rate limits for protected routes (60/min -> 120/min) - Documented email verification endpoints - Documented password reset endpoints - All new endpoints properly documented with auth requirements" * Update 0001_01_01_000000_create_users_table.php * Fix test suite configuration and update route names to standard Laravel conventions * style: fix code formatting per Laravel Pint standards * refactor: revert password_reset_tokens back to users create_users migration * Update 0001_01_01_000000_create_users_table.php * fix: resolve all code style and type safety issues Pint fixes: - Fixed concat_space issues - Removed unused imports (MustVerifyEmail from EnsureEmailVerified) - Fixed not_operator_with_successor_space formatting Rector fixes: - Applied EncapsedStringsToSprintfRector for better type safety - All code quality improvements applied PHPStan fixes: - Changed all middleware return types from Response to mixed (Laravel standard) - Added Response type guard in LogApiRequests before accessing methods - Removed redundant instanceof MustVerifyEmail check (User always implements it)
This commit is contained in:
16
README.md
16
README.md
@@ -149,12 +149,16 @@ curl -X POST http://localhost:8080/api/v1/logout \
|
|||||||
|
|
||||||
### Version 1 (`/api/v1`)
|
### Version 1 (`/api/v1`)
|
||||||
|
|
||||||
| Method | Endpoint | Auth | Description | Rate Limit |
|
| Method | Endpoint | Auth | Description | Rate Limit |
|
||||||
|--------|-------------|------|--------------------------|------------|
|
|--------|------------------------------|------|-------------------------------|------------|
|
||||||
| POST | /register | No | Register new user | 5/min |
|
| POST | /register | No | Register new user | 5/min |
|
||||||
| POST | /login | No | Get authentication token | 5/min |
|
| POST | /login | No | Get authentication token | 5/min |
|
||||||
| POST | /logout | Yes | Revoke current token | 60/min |
|
| POST | /logout | Yes | Revoke current token | 120/min |
|
||||||
| GET | /me | Yes | Get current user profile | 60/min |
|
| GET | /me | Yes | Get current user profile | 120/min |
|
||||||
|
| POST | /email/verify/{id}/{hash} | Yes | Verify email address | 120/min |
|
||||||
|
| POST | /email/resend | Yes | Resend verification email | 6/min |
|
||||||
|
| POST | /forgot-password | No | Request password reset link | 6/min |
|
||||||
|
| POST | /reset-password | No | Reset password with token | 6/min |
|
||||||
|
|
||||||
## Response Format
|
## Response Format
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,20 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Controllers\Api\ApiController;
|
use App\Http\Controllers\Api\ApiController;
|
||||||
|
use App\Http\Requests\Api\V1\ForgotPasswordRequest;
|
||||||
use App\Http\Requests\Api\V1\LoginRequest;
|
use App\Http\Requests\Api\V1\LoginRequest;
|
||||||
use App\Http\Requests\Api\V1\RegisterRequest;
|
use App\Http\Requests\Api\V1\RegisterRequest;
|
||||||
|
use App\Http\Requests\Api\V1\ResendVerificationRequest;
|
||||||
|
use App\Http\Requests\Api\V1\ResetPasswordRequest;
|
||||||
|
use App\Http\Requests\Api\V1\VerifyEmailRequest;
|
||||||
use App\Http\Resources\UserResource;
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
final class AuthController extends ApiController
|
final class AuthController extends ApiController
|
||||||
{
|
{
|
||||||
@@ -23,12 +30,14 @@ final class AuthController extends ApiController
|
|||||||
'password' => Hash::make($request->password),
|
'password' => Hash::make($request->password),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
$token = $user->createToken('auth-token')->plainTextToken;
|
$token = $user->createToken('auth-token')->plainTextToken;
|
||||||
|
|
||||||
return $this->created([
|
return $this->created([
|
||||||
'user' => new UserResource($user),
|
'user' => new UserResource($user),
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
], 'User registered successfully');
|
], 'User registered successfully. Please check your email to verify your account.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function login(LoginRequest $request): JsonResponse
|
public function login(LoginRequest $request): JsonResponse
|
||||||
@@ -60,4 +69,79 @@ final class AuthController extends ApiController
|
|||||||
{
|
{
|
||||||
return $this->success(new UserResource($request->user()));
|
return $this->success(new UserResource($request->user()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function verifyEmail(VerifyEmailRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
return $this->success(message: 'Email already verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->markEmailAsVerified()) {
|
||||||
|
event(new Verified($user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success(message: 'Email verified successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resendVerificationEmail(ResendVerificationRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = User::query()->where('email', $request->email)->first();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return $this->notFound('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
return $this->error('Email already verified', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return $this->success(message: 'Verification email sent successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only('email')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status === Password::RESET_LINK_SENT) {
|
||||||
|
return $this->success(message: 'Password reset link sent to your email');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->error('Unable to send reset link', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(ResetPasswordRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user, string $password): void {
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($password),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status === Password::PASSWORD_RESET) {
|
||||||
|
return $this->success(message: 'Password reset successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->error(
|
||||||
|
match ($status) {
|
||||||
|
Password::INVALID_TOKEN => 'Invalid or expired reset token',
|
||||||
|
Password::INVALID_USER => 'User not found',
|
||||||
|
default => 'Unable to reset password',
|
||||||
|
},
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Http/Middleware/EnsureEmailVerified.php
Normal file
35
app/Http/Middleware/EnsureEmailVerified.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class EnsureEmailVerified
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure the user's email is verified before allowing access.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unauthenticated',
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->hasVerifiedEmail()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your email address is not verified. Please verify your email to continue.',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Middleware/ForceJsonResponse.php
Normal file
38
app/Http/Middleware/ForceJsonResponse.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class ForceJsonResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Ensure all responses are JSON and set proper Accept header.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$request->headers->set('Accept', 'application/json');
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
if ($response instanceof JsonResponse) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert non-JSON responses to JSON
|
||||||
|
if ($response instanceof Response) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred',
|
||||||
|
'data' => $response->getContent(),
|
||||||
|
], $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Http/Middleware/LogApiRequests.php
Normal file
49
app/Http/Middleware/LogApiRequests.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class LogApiRequests
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Log API requests for debugging and monitoring.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): mixed
|
||||||
|
{
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Ensure we have a Response object
|
||||||
|
if (! $response instanceof Response) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = round((microtime(true) - $startTime) * 1000, 2);
|
||||||
|
|
||||||
|
// Only log if enabled via config
|
||||||
|
if (config('app.log_api_requests', false)) {
|
||||||
|
Log::info('API Request', [
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'method' => $request->method(),
|
||||||
|
'url' => $request->fullUrl(),
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'status' => $response->getStatusCode(),
|
||||||
|
'duration_ms' => $duration,
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add performance header
|
||||||
|
$response->headers->set('X-Response-Time', $duration.'ms');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Api/V1/ForgotPasswordRequest.php
Normal file
25
app/Http/Requests/Api/V1/ForgotPasswordRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ForgotPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'exists:users,email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Api/V1/ResendVerificationRequest.php
Normal file
25
app/Http/Requests/Api/V1/ResendVerificationRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class ResendVerificationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email', 'exists:users,email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Api/V1/ResetPasswordRequest.php
Normal file
28
app/Http/Requests/Api/V1/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
final class ResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'token' => ['required', 'string'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'confirmed', Password::defaults()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/Api/V1/VerifyEmailRequest.php
Normal file
24
app/Http/Requests/Api/V1/VerifyEmailRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
final class VerifyEmailRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
// Allow if user is authenticated and the ID matches
|
||||||
|
return $this->user() && (int) $this->route('id') === $this->user()->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
@@ -21,7 +21,7 @@ use Laravel\Sanctum\HasApiTokens;
|
|||||||
* @property Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
*/
|
*/
|
||||||
final class User extends Authenticatable
|
final class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
use HasApiTokens;
|
use HasApiTokens;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureEmailVerified;
|
||||||
|
use App\Http\Middleware\ForceJsonResponse;
|
||||||
|
use App\Http\Middleware\LogApiRequests;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -14,7 +17,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->alias([
|
||||||
|
'force.json' => ForceJsonResponse::class,
|
||||||
|
'log.api' => LogApiRequests::class,
|
||||||
|
'verified' => EnsureEmailVerified::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -24,4 +24,20 @@ Route::middleware('throttle:auth')->group(function (): void {
|
|||||||
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'throttle:authenticated'])->group(function (): void {
|
||||||
Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout');
|
Route::post('logout', [AuthController::class, 'logout'])->name('api.v1.logout');
|
||||||
Route::get('me', [AuthController::class, 'me'])->name('api.v1.me');
|
Route::get('me', [AuthController::class, 'me'])->name('api.v1.me');
|
||||||
|
|
||||||
|
// Email verification
|
||||||
|
Route::post('email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
|
||||||
|
->middleware('signed')
|
||||||
|
->name('verification.verify');
|
||||||
|
Route::post('email/resend', [AuthController::class, 'resendVerificationEmail'])
|
||||||
|
->middleware('throttle:6,1')
|
||||||
|
->name('verification.send');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password reset routes (public with rate limiting)
|
||||||
|
Route::middleware('throttle:6,1')->group(function (): void {
|
||||||
|
Route::post('forgot-password', [AuthController::class, 'forgotPassword'])
|
||||||
|
->name('password.email');
|
||||||
|
Route::post('reset-password', [AuthController::class, 'resetPassword'])
|
||||||
|
->name('password.reset');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('Registration', function (): void {
|
|||||||
])
|
])
|
||||||
->assertJson([
|
->assertJson([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'User registered successfully',
|
'message' => 'User registered successfully. Please check your email to verify your account.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
$this->assertDatabaseHas('users', [
|
||||||
|
|||||||
159
tests/Feature/Api/V1/EmailVerificationTest.php
Normal file
159
tests/Feature/Api/V1/EmailVerificationTest.php
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Email Verification', function (): void {
|
||||||
|
it('verifies email successfully with valid link', function (): void {
|
||||||
|
Event::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson($verificationUrl);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Email verified successfully',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertNotNull($user->fresh()->email_verified_at);
|
||||||
|
Event::assertDispatched(Verified::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns success if email is already verified', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson($verificationUrl);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Email already verified',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails verification without authentication', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->postJson($verificationUrl);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails verification with invalid signature', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
// Invalid URL without signature
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson(sprintf('/api/v1/email/verify/%d/invalid-hash', $user->id));
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Resend Verification Email', function (): void {
|
||||||
|
it('resends verification email successfully', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/email/resend', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Verification email sent successfully',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails to resend if email is already verified', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/email/resend', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Email already verified',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with invalid email', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/email/resend', [
|
||||||
|
'email' => 'nonexistent@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/email/resend', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects rate limiting', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => null]);
|
||||||
|
$token = $user->createToken('test-token')->plainTextToken;
|
||||||
|
|
||||||
|
// Make 7 requests (limit is 6 per minute)
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$response = $this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
|
->postJson('/api/v1/email/resend', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last request should be rate limited
|
||||||
|
$response->assertStatus(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
tests/Feature/Api/V1/PasswordResetTest.php
Normal file
121
tests/Feature/Api/V1/PasswordResetTest.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
describe('Forgot Password', function (): void {
|
||||||
|
it('sends reset link successfully', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/forgot-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Password reset link sent to your email',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with non-existent email', function (): void {
|
||||||
|
$response = $this->postJson('/api/v1/forgot-password', [
|
||||||
|
'email' => 'nonexistent@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects rate limiting', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Make 7 requests (limit is 6 per minute)
|
||||||
|
for ($i = 0; $i < 7; $i++) {
|
||||||
|
$response = $this->postJson('/api/v1/forgot-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last request should be rate limited
|
||||||
|
$response->assertStatus(429);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Reset Password', function (): void {
|
||||||
|
it('resets password successfully with valid token', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$token = Password::createToken($user);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/reset-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'token' => $token,
|
||||||
|
'password' => 'newpassword123',
|
||||||
|
'password_confirmation' => 'newpassword123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Password reset successfully',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify password was changed
|
||||||
|
$this->assertTrue(Hash::check('newpassword123', $user->fresh()->password));
|
||||||
|
|
||||||
|
// Verify all tokens were deleted
|
||||||
|
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with invalid token', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/reset-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'token' => 'invalid-token',
|
||||||
|
'password' => 'newpassword123',
|
||||||
|
'password_confirmation' => 'newpassword123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Invalid or expired reset token',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with mismatched passwords', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$token = Password::createToken($user);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/v1/reset-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'token' => $token,
|
||||||
|
'password' => 'newpassword123',
|
||||||
|
'password_confirmation' => 'differentpassword',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with non-existent email', function (): void {
|
||||||
|
$response = $this->postJson('/api/v1/reset-password', [
|
||||||
|
'email' => 'nonexistent@example.com',
|
||||||
|
'token' => 'some-token',
|
||||||
|
'password' => 'newpassword123',
|
||||||
|
'password_confirmation' => 'newpassword123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'User not found',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user